Skip to content

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.AbpModule with 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.AppHost project 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-migrator waits for the database (WaitFor(db)), takes the Default connection string, and — when pointed at a shared DB — runs in validate-only mode (VALIDATE_MIGRATIONS_ONLY=true).
  • auth waits for redis, rabbitmq and db, then waits for the migrator to complete (WaitForCompletionIf(!skip, migrator)) before it starts.
  • api waits for auth, 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 for auth and api.

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:0services:{name}:http:0ConnectionStrings:{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 references Hub.UI and Pricing.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.Public exists 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 custom VersionTracker mechanism.
  • Org scoping uses a custom IQueryFilter system, not ABP IDataFilter — with both an auto-applied (global query filter) path and a manual FilterAll() path. Forgetting FilterAll on the manual path silently leaks cross-org data.
  • The Default context opts into a .NET 10 / EF 10 toggle, UseParameterizedCollectionMode(ParameterTranslationMode.Parameter), and the provider plus the ArithAbortConnectionInterceptor must be configured in a single Configure action (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.