-
Notifications
You must be signed in to change notification settings - Fork 1
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
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
| 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
SyncWorkeris 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).
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}/....
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.
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"]
| 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)orGuid.Parse(user.Id). Service-client tokens carry the client id as their subject, which is not a GUID, and the resultingFormatExceptionsurfaces to callers as a masked "Unexpected Execution Error". Always useGuid.TryParse(user.Id, out var id) ? id : throw new UnauthorizedAccessException().
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). |
-
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…Masterservice 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.RequestAccountResolversearches the request root first, then recurses breadth-first at most two levels into complex members. It descends only into types declared in aTrackHub*orCommon.*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-levelAccountIdalways wins over a nested one. New account-scoped requests should still carry a top-levelAccountId. - Handler unit tests do not run the pipeline. The behavior tests in
Common.Application.Tests/Behaviors/AccountScopeBehaviorTestsare the only proof this works.
grep -r AllowCrossAccount is the complete inventory of deliberate cross-tenant surfaces, and every entry is a review item.
Reader/Writer separation: interfaces in Domain/Interfaces, implementations in Infrastructure/{DbContext}/, registered Scoped.
ApplicationDbContext : IApplicationDbContext is configured with:
-
NoTrackingby default,SplitQuery -
AuditableEntityInterceptor(created/modified columns) andDispatchDomainEventsInterceptor(domain events published after successful persistence) - Npgsql dynamic JSON; NetTopologySuite for spatial data (Geofencing, TripManagement)
-
IEntityTypeConfiguration<T>classes discovered viaApplyConfigurationsFromAssembly -
configurationBuilder.UseUtcTimestamps()inConfigureConventions
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.
-
ApplicationDbContextisNoTrackingby default. A row fetched for mutation must beAttached or its changes are silently discarded atSaveChangesAsync. EF InMemory defaults toTrackAll, so unit tests will not catch the omission. -
PostgreSQL
jsoncolumns have no equality operator.transporter_position.attributesis mappedHasColumnType("json"), so anyDistinct(),GroupBy()or set operation over the entity (or a projection including the column) fails at runtime with error42883. De-duplicate withEXISTSor key-based predicates instead. EF InMemory does not catch this — only real PostgreSQL does. -
Never apply
OrderBy/ThenByafter 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.
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.
UseHeaderPropagation
→ UseCors("AllowFrontend")
→ UseHsts
→ UseHealthChecks("/health")
→ UseHttpsRedirection
→ UseAuthentication
→ UseAuthorization
→ UseExceptionHandler
→ MapGraphQL (+ MapEndpoints(assembly) where REST exists)
Two details are load-bearing:
-
UseAuthenticationandUseAuthorizationare explicit in every service. Never rely onWebApplication's auto-insertion. -
UseCorsruns beforeUseHealthChecks. The public status page probes each service's/healthcross-origin with no token; inverting these two makes a service's tile permanently unreadable.
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=Developmenton a spare port to unmask it.
| 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 isAlwaysEnabledFeatureFlagServiceviaTryAddScoped, so a service that does not register its ownIFeatureFlagServicesilently ignores every[RequireFeature]attribute.
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.
Platform
- Architecture
- Technology
- Inter-Service Communication
- Database
- Security and Identity
- User Permissions Overview
Services
- Manager
- Telemetry
- Router
- Adding a Provider
- Geofencing
- Trip Management
- Reporting
- Common Library
- Frontend
Engineering
User docs
- User Guide (ships in the app)