Architecture overview
GrandNode is an ASP.NET Core application backed by MongoDB. The source is one solution, GrandNode.slnx, split into layers that depend only inward: the domain knows nothing about the database, the business layer knows nothing about HTTP, and the core never references a plugin. This article is the map you need before you change or extend anything.
Solution layout
Everything lives under src/:
| Folder | What it contains |
|---|---|
src/Core | Grand.Domain (entities and settings classes, no dependencies), Grand.Data (the IRepository<T> abstraction with MongoDB and LiteDB implementations), Grand.Infrastructure (startup, plugin and module loading, caching, migrations infrastructure), Grand.Mediator, Grand.Mapping, Grand.SharedKernel. |
src/Business | Grand.Business.Core holds the interfaces, commands and queries; Grand.Business.Catalog, .Checkout, .Customers, .Marketing, .Messages, .Cms, .Common, .Authentication and .Storage hold the implementations. |
src/Web | Grand.Web (the storefront and the host process), Grand.Web.Admin (administrator panel), Grand.Web.Store (store manager panel), Grand.Web.Vendor (vendor panel), Grand.Web.AdminShared (models, validators and mappings shared by the panels), Grand.Web.Common (base controllers, tag helpers, themes, middleware) and Grand.SharedUIResources (panel assets). |
src/Modules | Application modules switched on in configuration: Grand.Module.Installer, Grand.Module.Migration, Grand.Module.ScheduledTasks and Grand.Module.Api. |
src/Plugins | Installable providers and themes: Payments.*, Shipping.*, Tax.*, Widgets.*, DiscountRules.*, Authentication.*, ExchangeRate.*, Theme.*. |
src/Tests | MSTest projects, one per production project (Grand.Business.Catalog.Tests, Grand.Web.Admin.Tests and so on). |
src/Aspire | A .NET Aspire host that starts MongoDB, Redis and two replicas of the web app for local multi-instance testing. |
Modules and plugins are not referenced by Grand.Web. Each one builds itself into src/Web/Grand.Web/Modules/<name> or src/Web/Grand.Web/Plugins/<SystemName>, and the host discovers them at startup.
Layering
Grand.Web / Grand.Module.Api HTTP, controllers, view models
Grand.Business.* use cases, services, validators
Grand.Domain entities (no dependencies)
Grand.Data / Grand.Infrastructure data access, caching, startup
Grand.SharedKernel primitives shared by every layer
- Controllers hold no business logic. They read the input, send a mediator request and return a result.
- Business services depend on
IRepository<T>, never on a MongoDB collection. - Service registrations live in an
IStartupApplicationclass in the project that owns them, not inProgram.cs. - A core, business or web project never references a plugin. The core defines an interface; a plugin registers an implementation.
Startup and IStartupApplication
Program.cs in Grand.Web is deliberately short. StartupBase scans the loaded assemblies, including installed plugins and enabled modules, for classes implementing IStartupApplication and runs them ordered by Priority:
public interface IStartupApplication
{
void ConfigureServices(IServiceCollection services, IConfiguration configuration);
void Configure(WebApplication application, IWebHostEnvironment webHostEnvironment);
int Priority { get; }
bool BeforeConfigure { get; }
}
Lower priorities run first. The core uses negative values for URL rewriting and forwarded headers, 0 for core services, 500 for authentication and the context middleware and 1000 for endpoint routing. Plugins conventionally use 10. Configure runs in two passes: every startup with BeforeConfigure = true first, then the rest.
On each request the ContextMiddleware resolves the current store first and then the current customer, language and currency, and exposes them through IContextAccessor (StoreContext and WorkContext). Code that runs outside a request, such as scheduled tasks and migrations, has no work context and must receive the store or customer explicitly.
Data access: IRepository
All persistence goes through IRepository<T> from Grand.Data. It exposes Table as an IQueryable<T> for LINQ queries and async writes such as InsertAsync, UpdateAsync, DeleteAsync, plus partial updates (UpdateField, IncField, AddToCollectionField) that change one field without rewriting the document. The MongoDB implementation is the default; a LiteDB implementation exists for embedded scenarios.
var query = _productRepository.Table
.Where(p => p.Published && p.VendorId == vendorId)
.OrderBy(p => p.DisplayOrder);
return await _productRepository.PagedAsync(query, pageIndex, pageSize);
The repository also offers async helpers that work for both providers: ToListAsync, FirstOrDefaultAsync, CountAsync, AnyAsync and PagedAsync.
MongoDB documents use PascalCase field names (Name, not name), which matters when you query the database directly in mongosh.
Mediator: commands, queries and events
Grand.Mediator is an in-house mediator with a MediatR-compatible API (IRequest, IRequestHandler, INotification, INotificationHandler, IMediator); the MediatR package itself is not used. Request types that cross projects are defined in Grand.Business.Core; their handlers live in the business project that owns the feature. The storefront has its own view-model requests in Grand.Web/Features/Handlers and Grand.Web/Commands/Handler.
public class GetSuggestedProductsQuery : IRequest<IList<Product>>
{
public string[] CustomerTagIds { get; set; }
public int ProductsNumber { get; set; }
}
// in a controller or another handler
var products = await _mediator.Send(new GetSuggestedProductsQuery {
CustomerTagIds = customer.CustomerTags.ToArray(), ProductsNumber = 4 });
After every write, services publish an entity event so other code can react without the service knowing about it:
await _repository.UpdateAsync(entity);
await _cache.RemoveByPrefix(CacheKey.TAXCATEGORIES_PATTERN_KEY);
await _mediator.EntityUpdated(entity);
Handlers implement INotificationHandler<EntityUpdated<T>> (or EntityInserted, EntityDeleted). Plugins use the same mechanism to hook into the platform, for example to react when an order is placed.
Caching
ICacheBase is the only caching abstraction. It is an in-memory cache, optionally synchronised across instances through Redis publish/subscribe (Redis:RedisPubSubEnabled in appsettings.json). Caching belongs in business services, around the repository call, never in controllers or handlers. Keys are constants on the CacheKey class and must include every variable that changes the result, above all the store id and language id.
public async Task<IList<TaxCategory>> GetAllTaxCategories(string storeId = "")
{
var key = string.Format(CacheKey.TAXCATEGORIES_ALL_KEY, storeId);
return await _cacheBase.GetAsync(key, async () =>
{
var query = _taxCategoryRepository.Table.AsQueryable();
if (!string.IsNullOrEmpty(storeId))
query = query.Where(tc => tc.StoreId == storeId || string.IsNullOrEmpty(tc.StoreId));
return await _taxCategoryRepository.ToListAsync(query.OrderBy(tc => tc.DisplayOrder));
});
}
Every insert, update and delete clears the family prefix with RemoveByPrefix, otherwise the storefront keeps serving stale data.
From request to page
Controller action
-> IMediator.Send(query) Grand.Web/Features/Handlers
-> business service Grand.Business.*
-> ICacheBase.GetAsync Grand.Infrastructure/Caching
-> IRepository<T> -> MongoDB Grand.Data
-> View(model)
-> theme view locations (IThemeView), widget zones
Related
- Development setup
- Writing a plugin
- Creating a theme
- The AI Agent Kit — the repository's
.ai/knowledgefolder goes deeper on every section above.
Question not answered here? Ask the community on GitHub Discussions ↗