Keystone Diagrams
Architecture Diagram
This reflects a clean layered architecture:
- Web: API entry point and HTTP concerns
- UseCases: application logic and orchestration
- Core: domain model, aggregates, value objects
- SharedKernel: common contracts and foundational abstractions
- Infrastructure: persistence, security, external integrations
- Tests: validation at unit, integration, functional, and architecture levels
CQRS/DDD Architecture Diagram
This version reflects the project’s current structure:
Keystone.API.Web.csprojis the presentation layerKeystone.API.UseCases.csprojaligns with application-layer command/query handlingKeystone.API.Core.csprojrepresents the DDD domain modelKeystone.API.Infrastructure.csprojhandles persistence, identity, and integrationsKeystone.API.SharedKernel.csprojsupports shared contracts and common abstractions
Bounded Context Diagram
Key DDD alignment notes from the actual code:
- Aggregate root:
User.csis the single consistency boundary —ExternalLoginandPasswordResetTokenare only ever mutated through it (e.g.LinkExternalLogin,RequestPasswordReset), never directly. - Value object:
UserIdstrongly types identity instead of a raw Guid. - **Invariants enforced in the domain, not application layer — e.g. "cannot remove the only credential" and "unverified external identity can't create a user" are guarded inside User, throwing DomainException.
- Specifications (UserByEmailSpec, UserByExternalLoginSpec, UserByPasswordResetTokenHashSpec) act as the query-side contract into the aggregate, keeping persistence-ignorant query logic in the domain via Ardalis.Specification.
- Application layer = context boundary: everything under UseCases/Features/Users (Login, RegisterWithPassword, ExternalLogin, ProvisionAccount, PasswordReset, UnlinkExternalLogin) is the only sanctioned entry point into this bounded context — no other layer touches User directly.
- Shared Kernel (IAggregateRoot,
EntityBase<TId>, IRepository, IUnitOfWork, domain events) is intentionally shared across bounded contexts as common DDD tactical-pattern infrastructure, not context-specific logic. - Anti-corruption boundary: Infrastructure (EF Core mapping, KeystoneDbContext, MediatorDomainEventPublisher) translates the pure domain model to/from persistence and external identity providers (OpenIddict) without leaking those concerns into Core.
ER Diagram: Data Model
Notes on this model, straight from User.cs, ExternalLogin.cs, PasswordResetToken.cs, and UserConfiguration.cs:
- User is the only aggregate root; ExternalLogin and PasswordResetToken are owned children with no independent lifecycle (OnDelete(Cascade), both constructed via internal factory methods only callable from User).
- Email on User has a unique index — one account per email.
- A user can have multiple external logins (one per provider — enforced in code, not the DB) and multiple password reset tokens (history is kept; old ones get marked used rather than deleted).
- UserId is a value-object wrapper around Guid, converted at the EF layer via HasConversion.
Request Pipeline Flow — Endpoint → Aggregate → Persistence
Key things this second diagram captures from the real code:
- Every endpoint under Endpoints/Account/ is a thin adapter — it constructs a command/query and calls mediator.Send, nothing more (verified directly in LoginEndpoint.cs, ProvisionAccountEndpoint.cs, etc.).
- All state changes route through named behaviors on User (RegisterWithPassword, LinkExternalLogin, RemoveExternalLogin, RequestPasswordReset, ConfirmPasswordReset) — no handler mutates ExternalLogin/PasswordResetToken directly.
- Query-side reads go through Ardalis Specification classes rather than ad-hoc LINQ, keeping the repository generic.
- TransactionBehavior (via Mediator pipeline behaviors in Keystone.API.SharedKernel) wraps command handling in a unit of work, and domain events raised on User are dispatched by MediatorDomainEventPublisher after the transaction commits.
- ExternalLoginCallbackEndpoint/ConfirmLinkEndpoint are the OpenIddict/OIDC federation path; RequestPasswordReset/ConfirmPasswordReset share the same aggregate but a distinct token/security service (Sha256PasswordResetTokenGenerator).
Extended Request Pipeline FLow
Key details this adds, straight from the code:
RegisterEndpoint vs ProvisionAccountEndpoint — same domain outcome (User.RegisterWithPassword), but deliberately separate front doors per the comments in RegisterEndpoint.cs and ProvisionAccountEndpoint.cs:
| RegisterEndpoint | ProvisionAccountEndpoint | |
|---|---|---|
| Route | POST /account/register | POST /v1/accounts/provision |
| Auth | AllowAnonymous() | ApiKeyAuthenticationHandler scheme |
| Caller | End user via browser | Command Center (trusted service) |
| Handler | RegisterWithPasswordCommand | ProvisionAccountCommand |
| Idempotency | Errors 409 on duplicate email | Returns WasAlreadyRegistered instead of failing |
| Trust tier | Public, presumably rate-limited/anti-bot candidate | Exempt from those — single caller acting for many real users |
Connect/AuthorizeEndpoint and Connect/TokenEndpoint bypass the UseCases/CQRS layer entirely — they're OpenIddict's designated "passthrough" hooks:
- AuthorizeEndpoint (GET /connect/authorize) — OpenIddict middleware already validated client_id/redirect_uri/PKCE/scopes before this runs; the endpoint just checks the LoginCookie auth scheme. No cookie → redirect to /account/login (re-entering the normal flow above); valid cookie → builds a ClaimsPrincipal from the subject claim and calls SignInAsync, which OpenIddict turns into an authorization code.
- TokenEndpoint (POST /connect/token) — handles two grant types: authorization_code (re-signs-in with the principal OpenIddict already persisted against the code) and client_credentials (builds a principal representing the client application itself, via IOpenIddictApplicationManager, with no end user involved at all).
- Neither endpoint touches User, Repository, or the Mediator pipeline — token/code issuance is entirely OpenIddict's own state machine plus ASP.NET Core's cookie/claims authentication, only crossing into the CQRS/domain layer indirectly via the /account/login redirect when no session cookie exists yet.