Skip to content

Architecture

Sergio Hernandez edited this page Jul 24, 2026 · 1 revision

Architecture

Every TrackHub backend service is built the same way: Clean Architecture in four layers, a custom CQRS mediator with a fixed behavior pipeline, and a composition root that wires them in a documented order. Learning one service teaches you all of them.

Related pages: Inter-Service Communication · Database · Coding Standards


Clean Architecture layers

graph LR
    web["Web<br/><i>GraphQL resolvers, REST endpoints,<br/>middleware, Program.cs</i>"]
    app["Application<br/><i>Commands / Queries,<br/>validators, behaviors</i>"]
    domain["Domain<br/><i>Entities, interfaces,<br/>value objects, events</i>"]
    infra["Infrastructure<br/><i>EF Core, external clients,<br/>reader/writer implementations</i>"]

    web --> app
    web --> infra
    app --> domain
    infra --> app
    infra --> domain
Loading
Layer Purpose Depends on Never depends on
Domain Business logic, entities, interfaces, value objects, domain events (Interfaces, Models, Records, Enums, Helpers, Resources) Common.Domain Application, Infrastructure, Web
Application Use cases (commands/queries), validation, DTO mapping, pipeline behaviors Domain, Common.Application, Common.Mediator Infrastructure, Web
Infrastructure Data access (EF Core), external service clients, repository implementations Domain, Application, Common.Infrastructure Web
Web GraphQL resolvers, Minimal APIs, middleware, Program.cs composition root Application, Infrastructure, Common.Web

Additional project types:

  • Workers — the Router's SyncWorker is the only standalone worker process. Every other recurring job is a hosted service inside its own service's web host (see the background job catalog).
  • DBInitializer — database seeding (Manager, Security).
  • ClientSeeder — OAuth client registration (AuthorityServer).

Naming

Every assembly and root namespace is TrackHub.{Service}.{Layer}. Repository folder names keep their historical spellings (TrackHubSecurity, TrackHubRouter, TrackHubCommon), but the assemblies inside do not.

Service Assemblies
Manager TrackHub.Manager.{Domain,Application,Infrastructure,Web}
Telemetry TrackHub.Telemetry.{Layer}
Security TrackHub.Security.{Layer}
Router TrackHub.Router.{Layer}, plus TrackHub.Router.SyncWorker
Reporting TrackHub.Reporting.{Layer}
Geofencing TrackHub.Geofencing.{Layer}
TripManagement TrackHub.TripManagement.{Layer}
AuthorityServer TrackHub.AuthorityServer.{Layer}, plus TrackHub.AuthorityServer.ClientSeeder

Consumer client projects inside Infrastructure are named for their target: TrackHub.{Service}.Infrastructure.{TargetService}Api — for example TrackHub.Router.Infrastructure.ManagerApi.

Type naming: {Feature}{Action}Command / {Feature}{Action}Query (readonly record structs), {Name}Handler, {Name}Validator, {Entity}Reader : I{Entity}Reader, {Entity}Writer : I{Entity}Writer, {Entity}Dto for input and {Entity}Vm for output. GraphQL resolvers are partial Query / Mutation classes; REST endpoints are {Feature} : EndpointGroupBase with routes /api/{GroupClassName}/....


CQRS and the mediator

TrackHub uses its own mediator (Common.Mediator), not MediatR. The contracts are IRequest<TResult> / IRequest markers, IRequestHandler<TRequest,TResult> / IRequestHandler<TRequest>, a MediatorDispatcher : ISender, IPublisher, and Unit for void results. Every command and query is a readonly record struct.

[Authorize(Resource = Resources.Users, Action = Actions.Write)]
public readonly record struct CreateUserCommand(CreateUserDto User) : IRequest<UserVm>;

GraphQL resolvers and REST endpoints contain no business logic — they only dispatch through ISender.

Behavior pipeline

Behaviors are registered by AddApplicationServices(assembly). (AddBasicApplicationServices registers validation and exception handling only, for hosts that do not need the full chain.)

graph TB
    r["Request"] --> b1["1. Logging"]
    b1 --> b2["2. Validation<br/><i>FluentValidation</i>"]
    b2 --> b3["3. Authorization<br/><i>[Authorize]</i>"]
    b3 --> b4["4. Account Scope<br/><i>tenant isolation</i>"]
    b4 --> b5["5. Caching<br/><i>[Caching]</i>"]
    b5 --> b6["6. Rate Limiting<br/><i>[RateLimiting]</i>"]
    b6 --> b7["7. Unhandled Exception"]
    b7 --> h["Handler"]
Loading
Behavior Purpose
LoggingBehavior Logs the request lifecycle at Debug. Resolves the caller's display name only when Debug logging is enabled (it costs an identity round-trip) and only for user principals — service-client tokens carry a non-GUID subject and must never fail the pipeline.
ValidationBehavior / GraphQLValidationBehavior FluentValidation integration.
AuthorizationBehavior Enforces [Authorize(Resource, Action, PrincipalTypes)]. See Security and Identity.
AccountScopeBehavior Central multi-tenant isolation — see below.
CachingBehavior Distributed cache via [Caching]. Requires services.AddDistributedMemoryCache() in every service's Application DI; a missing registration fails every request with a masked DI error.
RateLimitingBehavior Token bucket via [RateLimiting(PermitLimit, WindowSeconds)].
UnhandledExceptionBehavior Catches and logs unhandled exceptions.

Handler idiom caution. Never write new Guid(user.Id) or Guid.Parse(user.Id). Service-client tokens carry the client id as their subject, which is not a GUID, and the resulting FormatException surfaces to callers as a masked "Unexpected Execution Error". Always use Guid.TryParse(user.Id, out var id) ? id : throw new UnauthorizedAccessException().


Tenant scoping (fail-closed)

AccountScopeBehavior runs immediately after authorization in every mediator-based service and is fail-closed: a request that resolves no AccountId is denied unless the caller carries an account scope or the request type declares why it needs none. [Authorize] answers "may this caller do this?" and says nothing about which tenant was named — that is this behavior's job, and it is never hand-rolled in handlers.

Every request falls into exactly one category:

Category Marker Meaning
Account-bearing (none) An AccountId is within the resolver's reach. The behavior enforces it equals the principal's account.
Caller-scoped (none) The account is derived from IUser / ICurrentPrincipal. Passes only when the principal actually has an account; a global service identity is denied.
Handler-enforced [AccountScopeEnforcedInHandler] Keyed/by-id requests whose ownership is enforced by the reader or writer they delegate to. Documentation and coverage only — it does not admit account-less principals.
Platform-owned [PlatformScoped("justification")] Data with no tenant dimension: the seeded RBAC catalog, the toll catalog, platform status and announcements, geocoding-provider and transporter-type registries, the report catalog, and login/token primitives.
Deliberate cross-tenant [AllowCrossAccount("justification")] The only opt-out, reserved for audited cross-tenant surfaces (global service identities, Administrator master consoles).

Rules that are easy to get wrong

  • Never put [AllowCrossAccount] on a request an ordinary user can reach. The attribute is per request type, so it hands cross-tenant reach to every caller of that type. If an internal service call breaks under fail-closed and the request is also user-reachable, thread the caller's account through, or split a …Master service twin.
  • [PlatformScoped]'s justification is a positive claim naming the platform owner. "I could not find the account" never qualifies.
  • [Caching] never combines with caller-scoped or handler-enforced requests. The cache key is built from request fields only, so the cached response would leak across accounts. The per-service coverage gate fails the build on this combination.
  • The guard also sees a nested AccountId. RequestAccountResolver searches the request root first, then recurses breadth-first at most two levels into complex members. It descends only into types declared in a TrackHub* or Common.* assembly, and never into a collection — a batch names zero or many accounts, not one, so a batching request is a cross-account surface that must declare itself. A root-level AccountId always wins over a nested one. New account-scoped requests should still carry a top-level AccountId.
  • Handler unit tests do not run the pipeline. The behavior tests in Common.Application.Tests/Behaviors/AccountScopeBehaviorTests are the only proof this works.

grep -r AllowCrossAccount is the complete inventory of deliberate cross-tenant surfaces, and every entry is a review item.


Data access

Reader/Writer separation: interfaces in Domain/Interfaces, implementations in Infrastructure/{DbContext}/, registered Scoped.

ApplicationDbContext : IApplicationDbContext is configured with:

  • NoTracking by default, SplitQuery
  • AuditableEntityInterceptor (created/modified columns) and DispatchDomainEventsInterceptor (domain events published after successful persistence)
  • Npgsql dynamic JSON; NetTopologySuite for spatial data (Geofencing, TripManagement)
  • IEntityTypeConfiguration<T> classes discovered via ApplyConfigurationsFromAssembly
  • configurationBuilder.UseUtcTimestamps() in ConfigureConventions

Mapping between entities and DTO/VM records is manual, via static extension methods. There is no AutoMapper.

New by-id readers and writers extend the service's AccountScopedDataAccess base and call RequireAccountAccess / RequireAccountWriteAccess on the loaded row's owning account — never an inline ad-hoc if.

Persistence gotchas

  • ApplicationDbContext is NoTracking by default. A row fetched for mutation must be Attached or its changes are silently discarded at SaveChangesAsync. EF InMemory defaults to TrackAll, so unit tests will not catch the omission.
  • PostgreSQL json columns have no equality operator. transporter_position.attributes is mapped HasColumnType("json"), so any Distinct(), GroupBy() or set operation over the entity (or a projection including the column) fails at runtime with error 42883. De-duplicate with EXISTS or key-based predicates instead. EF InMemory does not catch this — only real PostgreSQL does.
  • Never apply OrderBy/ThenBy after projecting into a DTO or VM record struct. Npgsql cannot translate an ordering whose key is a member of a constructor-built record struct; it throws at query time and surfaces as Unexpected Execution Error. Order on the entity columns inside the query expression. EF InMemory evaluates this client-side and the unit tests pass — this class of bug has reached production before.

See Database for schema ownership, conventions and the timestamp policy.


Composition root

Program.cs follows a fixed order in every service:

AddApplicationServices
  → AddApplicationDbContext
  → AddInfrastructureServices
  → AddApp{Service}Context
  → AddWebServices
  → AddHealthChecks
  → AddTrackHubGraphQLServer<Query, Mutation>(builder.Environment.IsDevelopment())

The GraphQL server chain lives once, in Common.Web:

AddGraphQLServer()
    .AddAuthorization()
    .AddMaxExecutionDepthRule(15)
    .AddErrorFilter<TrackHubGraphQLErrorFilter>()
    .ModifyRequestOptions(...)
    .AddQueryType<TQuery>()
    .AddMutationType<TMutation>()

Per-service deviations chain onto the returned builder — the Router appends GeocodingErrorFilter, OperatorSyncErrorFilter and ProviderCapabilityErrorFilter. All six GraphQL services use it; Reporting is REST-only and does not.

Middleware order

UseHeaderPropagation
  → UseCors("AllowFrontend")
  → UseHsts
  → UseHealthChecks("/health")
  → UseHttpsRedirection
  → UseAuthentication
  → UseAuthorization
  → UseExceptionHandler
  → MapGraphQL  (+ MapEndpoints(assembly) where REST exists)

Two details are load-bearing:

  • UseAuthentication and UseAuthorization are explicit in every service. Never rely on WebApplication's auto-insertion.
  • UseCors runs before UseHealthChecks. The public status page probes each service's /health cross-origin with no token; inverting these two makes a service's tile permanently unreadable.

Exception handling

CustomExceptionHandler : IExceptionHandler maps exceptions to HTTP status codes:

Exception Status
ValidationException 400
NotFoundException 404
UnauthorizedAccessException 401
ForbiddenAccessException 403
TooManyRequestsException 429
anything else 500

On the GraphQL side, TrackHubGraphQLErrorFilter maps:

Exception GraphQL error code
ForbiddenAccessException FORBIDDEN (with requiredResource / requiredAction extensions)
FeatureDisabledException FEATURE_DISABLED
UnauthorizedAccessException UNAUTHORIZED
ProviderCapabilityNotSupportedException (Router) PROVIDER_CAPABILITY_NOT_SUPPORTED (with protocol / capability extensions)

Never throw a raw Exception. Use the typed exceptions above so callers get an actionable code.

Debugging a masked error. Outside Development, unhandled exceptions surface as "Unexpected Execution Error". Check the producing service's log, or run the deployed binary with ASPNETCORE_ENVIRONMENT=Development on a spare port to unmask it.


Cross-cutting attributes

Attribute Enforced by
[Authorize(Resource, Action[, PrincipalTypes])] AuthorizationBehavior
[AllowCrossAccount("why")] / [PlatformScoped("why")] / [AccountScopeEnforcedInHandler] AccountScopeBehavior
[Caching(SlidingExpiration = …)] CachingBehavior
[RateLimiting(PermitLimit, WindowSeconds)] RateLimitingBehavior
[RequireFeature(FeatureKeys.X)] IFeatureFlagService
AbstractValidator<T> ValidationBehavior
BaseAuditableEntity AuditableEntityInterceptor
entity.AddDomainEvent(...) DispatchDomainEventsInterceptor

[RequireFeature] fails open unless the service overrides the flag service. Common's default registration is AlwaysEnabledFeatureFlagService via TryAddScoped, so a service that does not register its own IFeatureFlagService silently ignores every [RequireFeature] attribute.


File organization

Application/{Feature}/Commands|Queries/{Name}.cs   ← command + handler + validator co-located
Infrastructure/{DbContext}/{Entity}Reader|Writer.cs
Infrastructure/{DbContext}/Configurations/{Entity}Configuration.cs
Domain/Interfaces|Models/...
Web/GraphQL/Query|Mutation/{Feature}.cs
Web/Endpoints/{Feature}.cs
{Layer}/DependencyInjection.cs

One public class per file. Two IEntityTypeConfiguration<T> classes go in two files. The sanctioned exceptions are the CQRS feature file (command + its handler + its validator) and grouped DTO/VM contract files holding several related records.

Clone this wiki locally