Architecture¶
High-level view¶
The Cargonerds solution is an ABP Framework layered modular monolith targeting .NET 10. There are two complementary structuring forces at work, and keeping them separate is the key to understanding the whole system:
- ABP defines how the code is structured. Every part of the application is an
ABP module — a class
deriving from
Volo.Abp.Modularity.AbpModulewith a[DependsOn(...)]attribute. ABP walks this dependency graph from a single root module per host, topologically sorts it, and runs each module's lifecycle hooks in order. Within each module the code follows the standard ABP layering (Domain.Shared, Domain, Application.Contracts, Application, EntityFrameworkCore, HttpApi, UI). - .NET Aspire defines how the system runs. The
Cargonerds.AppHostproject is the Aspire orchestrator: it declares the infrastructure resources (SQL Server, Redis, RabbitMQ) and the service projects, wires connection strings and environment variables between them, sequences their startup, and provides a dashboard with health, logs, metrics and traces. Locally the resources run as Docker containers; published to Azure they map to Azure SQL, Azure Cache for Redis and a RabbitMQ container.
The core application lives under src/. Two reusable ABP modules under modules/ extend it:
Hub (shipments, organisations, documents, orders, tracking) and Pricing (quotations, offers,
margins). Pricing builds on Hub's domain, and the Cargonerds shell depends on both plus the
full ABP commercial module suite (Identity Pro, OpenIddict Pro, SaaS, CmsKit Pro, Chat, GDPR, and
more).
Three module families, one composition
The [DependsOn] graph chains consistently as Pricing → Hub → ABP at every layer. The
Cargonerds.* variant of each layer pulls in the Pricing* and Hub* projects of that same
layer plus the matching ABP commercial modules. Diamond dependencies (Hub is reached via both
Pricing and Cargonerds) are de-duplicated by ABP automatically. See the
Modules Overview for the per-module breakdown.
flowchart TB
subgraph shell["Cargonerds shell (src/)"]
CApp["CargonerdsApplicationModule"]
CEf["CargonerdsEntityFrameworkCoreModule"]
CHost["CargonerdsHttpApiHostModule (root)"]
end
subgraph pricing["Pricing module (modules/pricing/)"]
PApp["PricingApplicationModule"]
PEf["PricingEntityFrameworkCoreModule"]
end
subgraph hub["Hub module (modules/hub/)"]
HApp["HubApplicationModule"]
HEf["HubEntityFrameworkCoreModule"]
end
subgraph abp["ABP commercial suite"]
Abp["Identity Pro · OpenIddict Pro · SaaS · CmsKit Pro · Chat · GDPR · …"]
end
CHost --> CApp & CEf
CApp --> PApp --> HApp --> Abp
CEf --> PEf --> HEf --> Abp
CApp --> Abp
Orchestrated resources¶
The AppHost (src/Cargonerds.AppHost/Program.cs) builds the entire resource graph in a single
top-level program and ends with builder.Build().EnsureDockerRunningIfLocalDebug().Run() — the last
call is a Nextended.Aspire helper that fails fast with a clear message if Docker is not running
during local debugging. Resource names come from CargonerdsConsts.Aspire.Service
(src/Cargonerds.Domain.Shared/CargonerdsConsts.cs).
| Aspire resource | Backed by | Role |
|---|---|---|
sqlserver / Default |
Azure SQL Server (container spark-db locally) |
Primary relational database |
redis |
Azure Redis (container spark-redis) |
Distributed cache; ships with RedisInsight + Redis Commander |
messaging |
RabbitMQ (container spark-rabbitmq) |
Distributed event bus / messaging |
db-migrator |
Cargonerds.DbMigrator |
Applies EF Core migrations and seeds data |
auth |
Cargonerds.AuthServer |
OpenIddict OAuth 2.0 / OIDC server |
api |
Cargonerds.HttpApi.Host |
REST + OData API host |
admin |
Cargonerds.Blazor |
Blazor WebAssembly admin UI |
frontends (e.g. realtime) |
frontend/* Next.js apps |
Customer-facing UI, auto-discovered by AddAllFrontends() |
documentation |
this MkDocs site (Dockerfile) | Documentation, started on demand |
The four .NET service projects are registered through one local helper rather than AddProject<T>
directly:
var migrator = builder.AddProjectWithDefaults<Cargonerds_DbMigrator>(CargonerdsConsts.Aspire.Service.Migrator);
var authServer = builder.AddProjectWithDefaults<Cargonerds_AuthServer>(CargonerdsConsts.Aspire.Service.Auth);
var apiHost = builder.AddProjectWithDefaults<Cargonerds_HttpApi_Host>(CargonerdsConsts.Aspire.Service.Api);
var adminFrontend = builder.AddProjectWithDefaults<Cargonerds_Blazor>(CargonerdsConsts.Aspire.Service.Admin);
AddProjectWithDefaults is AddProject<TProject>(service.Name, launchProfile) plus it always sets
USE_ASPIRE_CONFIG=true, which is what makes each child load its appsettings.aspire.json (the
consumer half of service discovery — see below). The Cargonerds_* symbols are the Aspire
source-generated Projects.* metadata types.
Web.Public is not orchestrated
The repository contains a Cargonerds.Web.Public project (an ABP module with its own root
module), but it is not registered in the AppHost. The active user interfaces are the Blazor
admin app and the Next.js realtime frontend.
flowchart TB
subgraph Orchestrator
AppHost["Cargonerds.AppHost<br/>.NET Aspire"]
end
subgraph Services
Auth["auth<br/>AuthServer / OpenIddict"]
Api["api<br/>HttpApi.Host"]
Admin["admin<br/>Blazor WASM"]
Realtime["realtime<br/>Next.js"]
Migrator["db-migrator"]
end
subgraph Infrastructure
Db[("SQL Server<br/>Default")]
Redis[("Redis")]
Mq[("RabbitMQ<br/>messaging")]
end
AppHost -.orchestrates.-> Auth & Api & Admin & Realtime & Migrator
Migrator --> Db
Auth --> Db
Auth --> Redis
Auth --> Mq
Api --> Db
Api --> Redis
Api --> Mq
Admin -->|HTTP/proxies| Api
Admin -->|OIDC| Auth
Realtime -->|REST/OData| Api
Realtime -->|OIDC| Auth
Orchestration: start order and service discovery¶
Aspire does more than launch processes — the AppHost expresses the dependencies and readiness gates between resources, and injects how each service finds the others. This is the part of the architecture that is easiest to get wrong when running services standalone, so it is worth understanding concretely.
Start order and readiness gates¶
Each service declares what it must wait for. The waits are conditioned by two developer-speedup
toggles read once at the top of Program.cs (EnvVars.SkipWaitingInBackend,
EnvVars.ExplicitFrontendStart):
db-migratorwaits for the database (WaitFor(db)), takes theDefaultconnection string, and — when pointed at a shared DB — runs in validate-only mode (VALIDATE_MIGRATIONS_ONLY=true).authwaits forredis,rabbitmqanddb, then waits for the migrator to complete (WaitForCompletionIf(!skip, migrator)) before it starts.apiwaits forauth, for the three infrastructure resources, and for the migrator to complete. In run mode (unless skipping) it also adds an HTTP readiness probe against the ABP endpoint/api/abp/application-configuration.admin(Blazor) waits forauthandapi.
The local WaitFor(params IResourceBuilder<IResource>?[]) helper is null-tolerant — it filters
nulls before aggregating — so passing optional or absent resources is safe.
Service discovery (producer ↔ consumer)¶
The AppHost is the producer side. After all resources are declared, it fans references out so every service knows the URL of every other service:
var projects = new List<IResourceBuilder<ProjectResource>> { authServer, apiHost, adminFrontend };
IResourceBuilder<IResourceWithEndpoints>[] servicesWithEndpoints = [.. frontends];
IResourceBuilder<IResourceWithConnectionString>[] servicesWithConnectionString = [rabbitmq, redis];
foreach (IResourceBuilder<ProjectResource> project in projects.Append(migrator))
{
project.WithServiceReference(projects); // project → project
project.WithServiceReference(servicesWithEndpoints); // project → frontends
foreach (var service in servicesWithConnectionString)
project.WithReference(service); // rabbitmq + redis connection strings
}
WithServiceReference sets an env var services__{targetName}__https__0 (run mode → the target's
first existing endpoint; publish mode → https://{targetName}.{DEPLOYMENT_DOMAIN}).
The consumer side is a committed appsettings.aspire.json per service, which references other
services by name token. For Cargonerds.HttpApi.Host:
{ "App": { "SelfUrl": "{api}", "CorsOrigins": "{realtime},{admin}", "Realtime": "{realtime}" },
"AuthServer": { "Authority": "{auth}", "WellKnownConfigAddress": "{auth}/.well-known/openid-configuration" },
"Redis": { "Configuration": "{redis}" },
"RabbitMQ":{ "Connections": { "Default": { "HostName": "{messaging}", "Override": true } } } }
At child startup a custom ServiceDiscoveryConfigurationProvider
(src/Cargonerds.ServiceDefaults/) rewrites each {name} to the first of
services:{name}:https:0 → services:{name}:http:0 → ConnectionStrings:{name}.
Token resolution is fail-fast
If a {name} token has no matching endpoint or connection string, the provider throws an
InvalidOperationException that lists the available services. Adding a token but forgetting
to wire the resource in the AppHost breaks startup, not just that one feature. Likewise,
standalone runs of a service must set USE_ASPIRE_CONFIG=true themselves, or the child loads
default ABP config that is not Aspire-compatible.
What every host inherits: AddServiceDefaults()¶
Cargonerds.Blazor, Cargonerds.AuthServer and Cargonerds.HttpApi.Host call
builder.AddServiceDefaults() (src/Cargonerds.ServiceDefaults/HostApplicationBuilderExtensions.cs)
as the first builder step, before the ABP module bootstrap. So Aspire's configuration and
telemetry are layered in under ABP's configuration system. The extension does, in order:
builder.AddExtraConfigFiles(); // multi-layer config (aspire / azure / white-label / spark-env)
builder.AddServiceDiscoveryConfiguration(); // the {name}-token provider above
builder.ConfigureOpenTelemetry(); // logging, metrics, tracing
builder.Services.AddServiceDiscovery();
builder.Services.ConfigureHttpClientDefaults(http =>
{
http.AddStandardResilienceHandler(); // retries, circuit breaker, timeouts
http.AddServiceDiscovery(); // resolve service names in HttpClients
});
DbMigrator is the exception
Cargonerds.DbMigrator does not call AddServiceDefaults(); it re-implements the config
layering by hand and therefore has no OpenTelemetry, resilience or health endpoints from the
shared library. It is a generic Host worker whose completion gates auth and api. See
Aspire integration for the full orchestration detail.
Layered architecture¶
ABP divides each module into well-defined layers with strict dependency rules. The descriptions
below apply to the main Cargonerds solution and to the Hub and Pricing modules alike. See
Layered Architecture for the dependency rules and the
ABP DDD building blocks
reference.
.Domain.Shared¶
Constants, enums, error codes, settings keys and localization resources that must be shared across
layers. It has no dependencies and is referenced by every other project. Examples here:
CargonerdsConsts, ConnectionStringNames, MultiTenancyConsts.
.Domain¶
The heart of the application:
entities and aggregate roots,
value objects,
domain services,
domain events and
repository interfaces.
Depends only on .Domain.Shared.
.Application.Contracts¶
Application service interfaces, DTOs and permission definitions. Separating the contract from the implementation lets it be shared with client applications.
.Application¶
Implements the application service interfaces. Application services orchestrate domain objects and
repositories and return DTOs. In the custom layers, object mapping uses
Mapperly
(source-generated) rather than AutoMapper. Depends on .Application.Contracts and .Domain.
.EntityFrameworkCore¶
The EF Core data-access layer: the DbContext and repository implementations. The main
Cargonerds.EntityFrameworkCore project references the Hub.EntityFrameworkCore and
Pricing.EntityFrameworkCore modules. This is where CargonerdsDbContext (the Default database)
is configured — see Data flow below for how the three contexts
relate.
.DbMigrator¶
A console host (Aspire resource db-migrator) that creates the database, applies migrations and
seeds initial data. Run automatically by the AppHost on startup; its completion gates the API and
Auth servers.
.HttpApi / .HttpApi.Host¶
.HttpApi defines API controllers, but most are generated from application services by ABP's
auto API controllers.
Cargonerds.HttpApi.Host is the runnable ASP.NET Core host (api) that exposes them. It is the
root module for the API tier and its ConfigureServices delegates to focused private helpers
(ConfigureAuthentication, ConfigureApiKeyAuthentication, ConfigureSwagger, ConfigureCache,
ConfigureCors, ConfigureHealthChecks, and more), wired alongside Cargonerds.ServiceDefaults.
It explicitly generates the auto-API controllers for all three application assemblies:
options.ConventionalControllers.Create(typeof(CargonerdsApplicationModule).Assembly);
options.ConventionalControllers.Create(typeof(HubApplicationModule).Assembly);
options.ConventionalControllers.Create(typeof(PricingApplicationModule).Assembly);
.HttpApi.Client¶
Strongly typed C#
dynamic client proxies
for the HTTP APIs (RemoteServiceName = "Default"), consumed by the Blazor client and other .NET
callers.
UI¶
- Blazor (
Cargonerds.Blazor+Cargonerds.Blazor.Client) — the Blazor WebAssembly admin UI (admin). The client project referencesHub.UIandPricing.Blazor.WebAssembly, so module pages surface inside the admin shell. - Next.js (
frontend/realtime) — the customer-facing React app, consuming the same API and authenticating via the AuthServer. It shares components through@cargonerds/ui-library(frontend/ui-library). Cargonerds.Web.Publicexists in the repo but is not currently part of the orchestrated system.
.ServiceDefaults¶
A shared library whose AddServiceDefaults extension registers service discovery, resilience
policies, health checks and OpenTelemetry (see above).
Referenced by each service host for consistent behaviour.
Data flow: a request, end to end¶
The persistence layer is where the modular composition becomes most concrete. The solution uses
three EF Core DbContexts, each bound to a different connection string. Understanding which one
serves a request is essential.
| DbContext | Connection string | Holds | Notes |
|---|---|---|---|
CargonerdsDbContext |
Default (ConnectionStringNames.SparkDb) |
Identity Pro, SaaS, Permission/Feature/Setting management, AuditLogging, OpenIddict Pro, CmsKit Pro, BlobStoring, plus app tables (ClientSettings, ApiKeys, Notifications, OrgUnit extensions) | The only context with EF migrations |
HubDbContext |
Hub (ConnectionStringNames.HubDb) |
The large domain DB: Shipments, Consols, Containers, Organizations, Invoices, Orders, Tracking, Customs, and the Pricing entities (120+ DbSets) |
Pooled + second-level cached; no EF migrations |
PricingDbContext |
Pricing (PricingDbProperties.ConnectionStringName) |
Essentially empty — OnModelCreating only calls builder.ConfigurePricing() |
Pricing entities are physically owned by HubDbContext |
CargonerdsDbContext folds several ABP module databases into one physical DB using
[ReplaceDbContext] so
cross-module repository JOINs work:
[ReplaceDbContext(typeof(IIdentityProDbContext))]
[ReplaceDbContext(typeof(ISaasDbContext))]
[ReplaceDbContext(typeof(IPermissionManagementDbContext))]
[ConnectionStringName(ConnectionStringNames.SparkDb)]
public class CargonerdsDbContext
: AbpDbContext<CargonerdsDbContext>,
ISaasDbContext, IIdentityProDbContext, IPermissionManagementDbContext
ConfigureHub() is deliberately commented out
In CargonerdsDbContext.OnModelCreating, builder.ConfigurePricing(); is called but
//builder.ConfigureHub(); is commented out. The Hub model is not mapped into the Default
context — Hub entities are reached only through HubDbContext. Consequently the Hub schema has
no EF migrations and is managed outside EF Core; the migrator only migrates the Default
database. See Entity Framework and
Migrations.
A typical authenticated read request flows like this:
sequenceDiagram
participant C as Client (Blazor / Next.js)
participant Auth as auth (OpenIddict)
participant Api as api (HttpApi.Host)
participant App as Application service
participant Repo as Repository (EfCoreRepository)
participant Hub as HubDbContext (pooled + L2 cache)
participant Def as CargonerdsDbContext (Default)
C->>Auth: OIDC login → access token
C->>Api: REST/OData request (Bearer token / API key)
Note over Api: Middleware: localization, multi-tenancy,<br/>UnitOfWork, dynamic claims, filter context
Api->>App: Auto-API controller → app service
App->>Repo: query via repository interface
Repo->>Hub: shipments/orgs (Hub conn)
Repo->>Def: identity/permissions (Default conn)
Hub-->>Repo: cached or live rows
Def-->>Repo: rows
Repo-->>App: entities
App-->>Api: DTOs (Mapperly)
Api-->>C: JSON
The API host's OnApplicationInitialization builds the middleware pipeline that frames every
request: UseAbpRequestLocalization, UseMultiTenancy, UseUnitOfWork, UseDynamicClaims, a
custom FilterContextMiddleware, OData routing and Swagger UI.
Hub's performance-tuned read path¶
HubEntityFrameworkCoreModule.ConfigureServices registers HubDbContext twice — once via
AddAbpDbContext<HubDbContext> for ABP repositories and Unit of Work, and once via
AddDbContextPool<HubDbContext>(poolSize: 256) for a pooled, second-level-cached read path:
context.Services.AddEFSecondLevelCache(options =>
{
options
.UseMemoryCacheProvider()
.UseCacheKeyPrefix("EF_")
.UseDbCallsIfCachingProviderIsDown(TimeSpan.FromMinutes(1));
options.CacheAllQueries(CacheExpirationMode.Absolute, TimeSpan.FromMinutes(1));
});
context.Services.AddDbContextPool<HubDbContext>(
(sp, opts) =>
{
var cs = sp.GetRequiredService<IConfiguration>().GetConnectionString(ConnectionStringNames.HubDb);
opts.UseSqlServer(cs, sql => { sql.EnableRetryOnFailure(); sql.CommandTimeout(180); });
opts.AddInterceptors(/* SecondLevelCache, CommandLogging, FacetJoin, ArithAbort, Recompile */);
},
poolSize: 256
);
Hub-specific gotchas
- Every Hub query is cached for 60 s by default (
CacheAllQueries, Absolute 1 min). Stale reads are expected unless invalidated via the customVersionTrackermechanism. - Org scoping uses a custom
IQueryFiltersystem, not ABPIDataFilter— with both an auto-applied (global query filter) path and a manualFilterAll()path. ForgettingFilterAllon the manual path silently leaks cross-org data. - The
Defaultcontext opts into a .NET 10 / EF 10 toggle,UseParameterizedCollectionMode(ParameterTranslationMode.Parameter), and the provider plus theArithAbortConnectionInterceptormust be configured in a singleConfigureaction (a split causes "No database provider configured").
Full detail lives in Entity Framework.
Summary¶
Responsibilities are cleanly separated along two axes. ABP structures the code: business rules
in the domain layer, orchestration in the application layer, persistence behind EF Core repositories,
and presentation in the Blazor and Next.js clients — all composed from the Cargonerds, Hub and
Pricing module families via [DependsOn]. .NET Aspire structures the runtime: it declares the
infrastructure and service resources, sequences their startup, injects service discovery and
connection strings, and observes everything through a single dashboard.
For the dependency rules see Layered Architecture; for the orchestration internals see Aspire integration; for the persistence model see Entity Framework. For deployment details see the Deployment guide, and for the module-specific breakdown see the Modules Overview.