Architecture
Heimdall is a layered (DDD-flavoured) solution with CQRS in the application layer. Reads and writes travel different objects, through different mediators, into different handlers.
The layers
graph TB
subgraph P["Presentation — ArturRios.Heimdall.WebApi"]
CTRL[Controllers]
SEC[Security: token issuers, mappers, filters]
MAIL[Email senders]
end
subgraph A["Application"]
subgraph CMD["ArturRios.Heimdall.Command"]
CH[Command handlers]
CV[FluentValidation validators]
CS[Services: token, 2FA, email verification]
AUD[Auditing decorator]
end
subgraph QRY["ArturRios.Heimdall.Query"]
QH[Query handlers]
HC[Health checks]
end
subgraph SHR["ArturRios.Heimdall.Shared"]
MSG[Messages + status-code maps]
OWN[IScopeOwnershipChecker, IActorAccessor]
end
end
subgraph D["Domain — ArturRios.Heimdall.Domain"]
ENT[Entities + Roles enum]
end
subgraph I["Infrastructure — ArturRios.Heimdall.Data"]
CTX[AppDbContext]
MAPS[Entity maps]
MIG[Migrations]
SEED[DatabaseSeeder]
end
CTRL --> CMD & QRY
CTRL --> MSG
CMD & QRY --> SHR
CMD & QRY --> ENT
CMD & QRY -->|repository interfaces| I
I --> ENT
SEC -.implements.-> CS
MAIL -.implements.-> CSThe arrows only ever point inwards or sideways: Domain depends on nothing, and Application
depends on Infrastructure only through repository interfaces (IAsyncRepository<T, long>,
IAsyncReadOnlyRepository<T, long>) that Infrastructure implements. Presentation supplies the concrete
adapters for the abstractions the Application layer declares — IAuthTokenIssuer,
IEmailVerificationSender, IGoogleIdTokenVerifier — which is why swapping Mailgun for logging, or
the real Google verifier for the refusing one, is a start-up registration decision and nothing else.
| Project | Responsibility |
|---|---|
ArturRios.Heimdall.Domain | Entities and the Roles enum. No behaviour beyond data shape. |
ArturRios.Heimdall.Command | Write side: commands, validators, handlers, application services, auditing. |
ArturRios.Heimdall.Query | Read side: queries, validators, handlers, health checks, output DTOs. |
ArturRios.Heimdall.Shared | Cross-cutting contracts and the message/status-code maps both sides use. |
ArturRios.Heimdall.Data | AppDbContext, entity maps, migrations, seeding. |
ArturRios.Heimdall.WebApi | Controllers, JWT issuing and validation, filters, email and Google adapters, start-up. |
CQRS: two mediators, two shapes
classDiagram
class Controller {
-CommandMediator commandMediator
-QueryMediator queryMediator
}
class CommandMediator {
+ExecuteCommandAsync~TCommand, TOutput~(command) DataOutput~TOutput~
}
class QueryMediator {
+ExecuteQueryAsync~TQuery, TOutput~(query) DataOutput~TOutput~
+ExecutePaginatedQueryAsync~TQuery, TOutput~(query) PaginatedOutput~TOutput~
}
class ICommandHandlerAsync~TCommand, TOutput~ {
<<interface>>
+HandleAsync(command, cancellationToken) DataOutput~TOutput~
}
class IQueryHandlerAsync~TQuery, TOutput~ {
<<interface>>
+HandleAsync(query, cancellationToken) DataOutput~TOutput~
}
class IPaginatedQueryHandlerAsync~TQuery, TOutput~ {
<<interface>>
+HandleAsync(query, cancellationToken) PaginatedOutput~TOutput~
}
class AuditingCommandHandler~TCommand, TOutput~ {
-ICommandHandlerAsync inner
-IAuditLogWriter auditLogWriter
+HandleAsync(command, cancellationToken) DataOutput~TOutput~
}
class ConcreteCommandHandler {
-IValidator~TCommand~ validator
-IAsyncRepository~T~ repository
+HandleAsync(command, cancellationToken) DataOutput~TOutput~
}
Controller --> CommandMediator
Controller --> QueryMediator
CommandMediator --> ICommandHandlerAsync
QueryMediator --> IQueryHandlerAsync
QueryMediator --> IPaginatedQueryHandlerAsync
ICommandHandlerAsync <|.. AuditingCommandHandler
ICommandHandlerAsync <|.. ConcreteCommandHandler
AuditingCommandHandler o-- ConcreteCommandHandler : decoratesA controller action is deliberately thin — bind, dispatch, resolve:
[HttpPost]
[RoleRequirement((int)Roles.SystemAdmin)]
public async Task<ActionResult<DataOutput<CreateScopeCommandOutput?>>> Create(
[FromBody] CreateScopeCommand command)
{
var result = await commandMediator
.ExecuteCommandAsync<CreateScopeCommand, CreateScopeCommandOutput>(command);
return result.ToActionResult(statusMap: ScopeMessageMap.StatusCodes);
}
No business rule lives in a controller. The handler returns a DataOutput<T> carrying data,
messages, and errors; Util.WebApi’s ToActionResult turns that into an HTTP status using the per-area
message map — a dictionary from message text to status code — so “which error is a 404 and which
is a 409” is a single table per area rather than scattered return NotFound() calls.
Auditing by decoration
Every write handler is registered through AddAuditedCommandHandler, which registers the concrete
handler and then registers the interface as an AuditingCommandHandler wrapping it (NFR-09):
services.AddScoped<THandler>();
services.AddScoped<ICommandHandlerAsync<TCommand, TOutput>>(provider =>
new AuditingCommandHandler<TCommand, TOutput>(
provider.GetRequiredService<THandler>(),
provider.GetRequiredService<IAuditLogWriter>(),
provider.GetRequiredService<ILogger<AuditingCommandHandler<TCommand, TOutput>>>()));
The decorator writes one AuditLog row per successful command, resolving the target from the
output’s Id/PublicId by reflection and the actor from IActorAccessor (the current request’s
identity). A failure to write the audit entry is logged as a warning and never fails the request the
user already succeeded at.
The concrete THandler registration exists only so the decorator factory can resolve it. Resolving
THandler directly silently bypasses auditing — always depend on
ICommandHandlerAsync<TCommand, TOutput>.
See Audit logging for the sequence.
How a failure becomes a status
ToActionResult resolves the HTTP status from the envelope’s first error (or, on success, its
first message) against the map the controller passes it, falling back to 200 on success and 400 on
failure. Each use case owns a *MessageMap naming its own outcomes — InvalidCredentials is a 401,
EmailAlreadyExists a 409, and so on.
Persistence failures are not the use case’s vocabulary, and until recently they had none of their own: a repository handed back the provider’s text, which named the violated index, the conflicting value, or the column and its type. Nothing stable could be keyed off that, so every one of them fell through to the 400 default — a duplicate that lost a race and a database that had gone away were both reported as bad requests, and the message leaked schema detail to the caller.
ArturRios.Data.Relational.Core (since 4.0.0) classifies each failure into one of five fixed, caller-safe
messages instead, reading the provider text only to decide which. DataAccessMessageMap folds four
of them into every use case’s map:
| Classified failure | Status |
|---|---|
| Unique violation | 409 Conflict |
| Other integrity rule (FK, NOT NULL, CHECK) | 409 Conflict |
| Concurrency conflict | 409 Conflict |
| Transient — the store is unavailable | 503 Service Unavailable |
The fifth, the unclassified A data-access error occurred., is deliberately left unmapped and keeps
the 400 default. It covers both causes a caller can fix — a value longer than its column, where a
validator failed to bound it — and causes only an operator can, so neither 400 nor 500 fits all of
it, and 400 is the safer of the two.
A use case’s own entries always win over these, so a map assembled this way can never override the vocabulary the use case defines.
The HTTP request pipeline
graph LR
REQ([Request]) --> FWD["Forwarded headers<br/>trusted proxies only"]
FWD --> HTTPS[HTTPS redirect]
HTTPS --> TRACE[TraceActivityMiddleware]
TRACE --> EX[ExceptionMiddleware]
EX --> CORS["CORS<br/>configured origins only"]
CORS --> AUTHN[AuthenticationMiddleware]
AUTHN --> RL[Rate limiter<br/>credential-checking endpoints only]
RL --> MFA[MfaPendingGuardFilter]
MFA --> SRO[SubjectRightsOnlyGuardFilter]
SRO --> LIVE[ActorLivenessFilter]
LIVE --> ROLE["RoleRequirement /<br/>AllowAnonymous"]
ROLE --> CTRL[Controller action]
CTRL --> MED[Mediator]
MED --> H[Handler]
H --> DB[(PostgreSQL)]
H --> RES[DataOutput]
RES --> RR["ToActionResult<br/>+ message map"]
RR --> RESP([HTTP response])Everything from TraceActivityMiddleware to AuthenticationMiddleware is Util.WebApi’s standard
pipeline, which Startup derives from WebApiStartup to get. What it has no place for is added
around it: the metrics scrape branch (on its own port, ahead of everything — see
Operations
), forwarded headers and HTTPS redirection go in front through a startup
filter, and the rate limiter goes after it. TraceActivityMiddleware logs every request with the
caller’s IP address — the real caller’s when HEIMDALL_TRUSTED_PROXIES names the proxy in front of
the API, the proxy’s otherwise.
Six things about this pipeline are worth knowing before reading any handler:
Authentication reads no database. AddTokenAuthentication<IdentityUserMapper> is configured with
JwtValidationMode.ClaimsOnly and TokenSource.Header: the IdentityUser is rebuilt from the
token’s claims alone, and nothing is looked up while the token is validated. The library’s validator
checks the signature (HS256 only) and lifetime; PersonTokenValidator takes its place, wrapping it,
and also requires HEIMDALL_AUTH_TOKEN_ISSUER and exactly one of the three person audiences
(NFR-30), so a correctly signed token Heimdall did not issue for itself is refused like a forged
one.
But the identity it names is checked. ActorLivenessFilter runs globally, right after the
challenge-token guard, and refuses a token whose person or Google User is absent or logically
deleted (FR-AU-05, FR-GO-12). Without it, ClaimsOnly meant a token kept working for its
whole lifetime after the account behind it was deleted — and the handlers compensated unevenly:
ScopeOwnershipChecker excluded a deleted Scope Admin, while every System Admin bypass and every
“acting on yourself” branch trusted the role claim alone, leaving the protection in place for the
lesser role and absent for the greater one. It costs one indexed read per authenticated request, and
two for a Google User, since the token does not say which table its subject lives in. A restricted
identity (NFR-24) is refused the same way — except on the four actions marked
[DataSubjectRight] (export, erasure request, restriction, lifting one’s own restriction), where a
restricted or suspended subject still reaches the handler and the use case’s own flows answer.
Single-use state is spent in the database, not in memory. The repositories write whole rows —
read, change, save — so two requests that read the same recovery code, token, challenge or counter
before either saved would both act on it. IAtomicWrites (Domain, implemented in Data on
ExecuteUpdate) makes each of those a single conditional UPDATE … WHERE whose affected-row count
says who won, and charges every guess budget before the comparison it guards.
One class owns both directions of the claims. IdentityUserMapper writes the claims when a token
is issued and reads them when one is validated, so the two cannot drift. Every claim value is a
PublicId; an internal bigint never reaches a token (NFR-15).
A challenge token is inert everywhere but one endpoint. MfaPendingGuardFilter is registered
globally and rejects any request whose identity carries MfaPending with a 401 (FR-2F-10,
NFR-17). The second-factor endpoint itself is unaffected because it is [AllowAnonymous] and
takes the challenge token as a body field, never as a bearer credential. The token also carries an
audience of its own, HEIMDALL_AUTH_TOKEN_AUDIENCE + .2fa-challenge, so a consumer that pins the
person audience refuses it, and IdentityUserMapper reads mfaPending only under that audience.
A subject-rights-only token is confined to four actions. A restricted identity that signs in
(NFR-24, UC-11 AF-11i, UC-25 AF-25e) is issued a token carrying the subjectRightsOnly claim
and no scope, under its own audience, HEIMDALL_AUTH_TOKEN_AUDIENCE + .subject-rights, which
consumers pinning the person audience refuse. SubjectRightsOnlyGuardFilter, registered globally
after the challenge-token guard, answers it 403 on every action not marked [DataSubjectRight] —
on its claims alone, before the liveness check’s read. The liveness check would refuse it there too
while the restriction stands; the guard keeps it confined after the restriction is lifted, so a lift
never turns it into a full token.
Authorization
Role checks are declarative, via [RoleRequirement] on the action:
[HttpDelete("{id:guid}/hard")]
[RoleRequirement((int)Roles.SystemAdmin)]
Ownership checks — “is this Scope Admin an owner of this scope?” — cannot be expressed in an
attribute, so they live behind IScopeOwnershipChecker, resolved inside the handler. A handful of
endpoints carry no role attribute on purpose: GET /api/scopes/{scopeId}/google-users/{id}, for
example, must admit the Google User themselves, whose token is User-role, so any attribute strong
enough to exclude other users would exclude the actor the use case grants.
The complete matrix — every action against every role — is §7 of the System Requirements Document , summarised per endpoint in the API reference .
Validation
Input validation is FluentValidation, one validator per command or query, registered alongside its handler (NFR-10). Where a command has no validator it is a documented decision, not an omission — the start-up registrations carry the reason inline. The two recurring reasons:
- Nothing to validate. Every field is a typed route value already constrained by the route
(
{id:guid}), or the request carries no caller-supplied input at all because the subject comes from the bearer token. - The rule needs the database. Which second factor is required depends on the stored
AppEnabled/EmailEnabledof the person’s configuration — a read only the handler can make.
Every paginated list query does have a validator, so page size and filters are checked before a query reaches the database.
Persistence
AppDbContext applies one entity map per entity (PersonDbMap, ScopeDbMap, …) rather than
annotating the domain classes, keeping storage concerns out of the domain. Handlers never touch the
context: they depend on IAsyncRepository<T, long> for writes and IAsyncReadOnlyRepository<T, long> for reads,
the latter exposing Query() so a handler can compose an IQueryable and let the database do the
filtering.
EF diagnostics — sensitive data logging and detailed errors — are enabled outside Production only; they would otherwise print parameter values including password hashes, salts, and email addresses.
Where to go next
- Domain model — entities, relationships, and deletion cascades.
- Flows — the same pipeline traced end-to-end for specific use cases.
- Operations — migrations, health checks, logging, and rate limiting.