Skip to content

Backend architecture

Modgud is not classically layered (Domain → Application → Infrastructure). Instead, the core features are organised as vertical slices, with additional IdP-specific layers on top.

Project layout

src/dotnet/
├── Modgud.Authentication/   ← Slice (Login, 2FA, OIDC, GDPR, Sessions)
├── Modgud.Authorization/    ← Slice (Groups, Roles, Permissions)
├── Modgud.Domain/           ← Realm, OAuth, LoginProvider domain
├── Modgud.Application/      ← DTOs, service interfaces
├── Modgud.Infrastructure/   ← OpenIddict stores, tenancy, realm cache, Wolverine handlers
├── Modgud.Api/              ← Minimal API endpoints, middleware, setup, SignalR hub
├── Modgud.Api.Tests/        ← Integration tests (Testcontainers + PostgreSQL)
└── Common/                       ← Shared utilities (PathHelper, Optional<T>, ...)

Component diagram

Request lifecycle

Browser → ASP.NET Core
  ↓ UseRouting
  ↓ UseMiddleware<RealmMiddleware>          ← sets HttpContext.Items["TenantId"]
  ↓ UseSession
  ↓ UseAuthentication                        ← cookie auth
  ↓ UseAuthorization
  ↓ UseMiddleware<TwoFactorEnforcementMW>    ← blocks users without 2FA at level ≥ 1
  ↓ Endpoint routing
  ↓ Endpoint with RequiresPermission(...)    ← per-resource gating
  ↓ Handler
       ↓ IDocumentSession                    ← TenantedSessionFactory reads TenantId
       ↓ Marten query against tenant DB
       ↓ Response

TenantedSessionFactory is registered as a Marten ISessionFactory (AddMarten(...).BuildSessionsWith<TenantedSessionFactory>()), so every IDocumentSession/IQuerySession injection is automatically tenant-scoped.

Wolverine CQRS

CQRS commands and queries are dispatched via Wolverine's IMessageBus:

csharp
var result = await _messageBus.InvokeAsync<ErrorOr<UserDto>>(
    new CreateUserCommand(...));

Handlers are auto-discovered. Modgud runs with DurabilityMode.Solo (in-memory, local) — no external message broker required. The Marten outbox is still active for event side-effects: SignalR notifications fire after SaveChangesAsync via ProjectionSideEffects.

Codegen runs with TypeLoadMode.Auto — Wolverine/Marten generated classes are pre-generated at build time to save cold-start time and avoid Roslyn compilation at runtime.

Marten usage

Modgud uses three Marten patterns:

1. Document storage

Classic Marten document store for ephemeral or security-sensitive data — no event sourcing.

DocumentContents
ApplicationUserASP.NET Identity user
UserSecurityDataPassword hash, TOTP key, recovery codes, passkey credentials
UserSessionActive login session
EmailOtpChallenge, MagicLinkChallenge, WebAuthnChallengeEphemeral challenges
IdpConfigOIDC IdP configuration
OpenIddictAuthorizationDocument, OpenIddictTokenDocumentOAuth tokens + authorizations

2. Inline projections (*State)

Synchronous within the SaveChanges transaction. Guarantee that the next read after a write sees the new state. Used for validation and identity stores.

ProjectionWhat it holds
OAuthApplicationStateOpenIddict application state
OAuthScopeStateOpenIddict scope state
OAuthApiStateAPI resource state
LoginProviderStateInternal/external login provider state

3. Event-sourced aggregates

OAuth domain aggregates are fully event-sourced via Marten:

AggregateEvents
OAuthApplicationAggregateCreated, Updated, Deleted, Renamed, ...
OAuthScopeAggregateCreated, ResourcesChanged, ...
OAuthApiAggregateCreated, Updated, Scopes-Changed, ...
LoginProviderAggregateCreated, Updated, Disabled, ...

User events are emitted by the Authentication slice (UserCreated, UserUpdated, UserPasswordChanged, UserLoggedIn, ...). The slice itself stores identity through the ApplicationUser document; the events are kept separately for audit and for the PrincipalProjection (see Authorization slice).

OpenIddict stores

Modgud implements all four OpenIddict stores as Marten-backed stores, in Modgud.Infrastructure/OpenIddict/:

StoreBacking
MartenApplicationStoreOAuthApplicationState inline projection (event-sourced via aggregate)
MartenScopeStoreOAuthScopeState inline projection (event-sourced via aggregate)
MartenAuthorizationStoreOpenIddictAuthorizationDocument (direct storage)
MartenTokenStoreOpenIddictTokenDocument (direct storage)

Plus two pipeline hooks:

  • RealmIssuerHandler — overwrites context.Issuer with the per-request BaseUri (= realm domain). This way every realm has its own discovery document.
  • AccessTokenTypeHandler — switches between reference tokens and JWT per client.

Setup bootstrap

Program.cs runs an explicit bootstrap path at startup (before app.Run()):

  1. Create master DB (raw SQL, because Marten can't do this while the connection hangs on a missing DB)
  2. Apply Marten schema (Storage.ApplyAllConfiguredChangesToDatabaseAsync) → realms.mt_tenant_databases is created
  3. Register system tenant (tenancy.AddDatabaseRecordAsync("system", masterCs))
  4. Apply Marten schema again → per-tenant tables for the system tenant
  5. Seed system realm document (EnsureSystemRealmExistsAsync)
  6. Seed default OAuth scopes + internal login provider (OAuthRealmSeeder.SeedAsync)
  7. Warm up RealmCache

Only after this does Kestrel start listening.

Recovery CLI

The Authentication slice ships a break-glass CLI. Instead of starting Kestrel, the image can run in the container with the recover subcommand:

bash
dotnet Modgud.Api.dll recover list
dotnet Modgud.Api.dll recover reset-2fa <username>
dotnet Modgud.Api.dll recover set-email <username> <email>
dotnet Modgud.Api.dll recover magic-link <username>
dotnet Modgud.Api.dll recover rebuild-projections

Helps with lockouts: all 2FA lost, no admin left, projection corrupted — all solvable via container exec.

Frontend integration

The Vue frontend lives at src/frontend-vue/ and is served from the container as static wwwroot/ content via app.UseSpaUI(). The SignalR hub is mounted at /signalr/ui (MapHARRRController<UIHub>).

See the repo-only Vue frontend notes for the slice-internal detail.

Testing

Integration tests (Modgud.Api.Tests) use:

  • Testcontainers — PostgreSQL in Docker, started automatically on test runs
  • WebApplicationFactory — in-process hosting of the API with cookie auth
  • Per-test-class DB isolation — each test class gets its own DB
  • Shared PostgreSQL container — one container instance for all test collections, parallelised
  • WireMock — fake OIDC server for external login tests
  • Pre-generated Wolverine/Marten code (TypeLoadMode.Auto) — eliminates Roslyn compilation at runtime

Released under the Apache-2.0 License.