Skip to content

Persistence (Marten)

Modgud uses Marten as a document DB and event store on top of PostgreSQL. Marten manages its own schema — no manual EF Core migrations.

Multi-tenant setup

Marten MasterTableTenancy with database-per-tenant. Details: Multi-tenancy / Realms.

Schema management

Marten runs with AutoCreate.CreateOrUpdate. On boot:

csharp
await store.Storage.ApplyAllConfiguredChangesToDatabaseAsync();

That creates or updates all tables, indexes, functions and projection tables. After a code change to documents/aggregates: just restart — Marten detects the schema drift and applies it.

Development vs production

In production you should set AutoCreate.None and apply schema changes explicitly via await store.Storage.ApplyAllConfiguredChangesToDatabaseAsync() in a controlled migration phase — otherwise a multi-pod deployment race-conditions on schema apply.

Three Marten patterns

1. Document storage

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

DocumentContentsIndexes
ApplicationUserASP.NET Identity userNormalizedUserName (unique), NormalizedEmail
ApplicationRoleIdentity roleNormalizedName (unique)
UserSecurityDataPassword hash, TOTP key, recovery codes, passkey credentialsSame id as the user
UserSessionActive session tracking (UAParser)UserId, LastActiveAt
EmailOtpChallenge6-digit OTP hash + expiryUserId
MagicLinkChallengeToken hash + expiryUserId
WebAuthnChallengePasskey ceremony stateTTL ~5 min
IdpConfigOIDC IdP config (without secret)Per realm
IdpSecretOIDC client secret (separate)Per IdpConfig
OpenIddictAuthorizationDocumentOAuth consent recordsApplicationId, Subject
OpenIddictTokenDocumentReference tokens, refresh tokensApplicationId, Subject, ReferenceId
AuthLogDocumentAuth events (login, logout, failures)TTL 7 days
UserDeletionStateGDPR delete workflow stateUserId
UserChangeRequestProfile self-service pending changesPer (UserId, Type)
Principal (polymorphic)Person + Group + ServiceAccountmt_doc_type discriminator
PermissionRoleRBAC role definitionsPer realm
Realm (in IGlobalStore)Tenant metadata in master DBSchema global

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 for the OpenIddict stores.

ProjectionAggregateUsed by
OAuthApplicationStateProjectionOAuthApplicationStateOAuthApplicationAggregateMartenApplicationStore (OpenIddict)
OAuthScopeStateProjectionOAuthScopeStateOAuthScopeAggregateMartenScopeStore (OpenIddict)
OAuthApiStateProjectionOAuthApiStateOAuthApiAggregateAPI resource management
LoginProviderStateProjectionLoginProviderStateLoginProviderAggregateLogin provider resolution
PrincipalProjectionBasePrincipal (polymorphic)abstract — app extensionAuthorization slice
PermissionRoleProjectionPermission role aggregateAuthorization slice
IdpConfigProjectionIdpConfigIdpConfig aggregateOIDC login
ExternalIdentityLinkProjection(no aggregate, plain doc apply)OIDC login

3. Async read models (*ListReadModel, *DetailsReadModel)

Async projections running in a background daemon (DaemonMode.HotCold); denormalised views for API responses. In tests they run inline for deterministic behaviour.

ProjectionPurpose
UserListReadModelAdmin user grid
UserDetailsReadModelAdmin user details
GroupListReadModel, GroupDetailsReadModelAdmin group views
RoleListReadModelAdmin role grid

Event-stream example

User lifecycle (written by the Authentication slice):

Stream: <userId>
  v1: UserCreated         { UserId, UserName, Email, ... }
  v2: UserPasswordChanged { UserId }
  v3: UserLoggedIn        { UserId, IpAddress, OccurredAt }
  v4: UserNameChanged     { UserId, NewFirstName, NewLastName }
  v5: UserTwoFactorEnabled { UserId }
  v6: UserLoggedIn        { UserId, IpAddress, OccurredAt }
  ...

PrincipalProjectionBase (abstract) consumes these events and writes them into the mt_doc_principal table as the Person subclass. That's the bridge to the Authorization slice: the slice needs Person records for email routing and membership predicates, the app fills them from the events.

Security data separation

Security-sensitive data does NOT land in the event stream. Instead of UserPasswordChanged(UserId, NewPasswordHash) there's UserPasswordChanged(UserId) and the hash is written in parallel into UserSecurityData (plain document, same id).

Same approach for:

DataWhere
Password hashUserSecurityData.PasswordHash
TOTP authenticator keyUserSecurityData.AuthenticatorKey
Recovery codesUserSecurityData.RecoveryCodes
Passkey credentials (public key, sign count)StoredPasskeyCredential (separate doc, per user)
OIDC client secretIdpSecret (separate doc, per IdpConfig)

The benefit: GDPR erase and stream replay are safe — no re-applying of masked hashes.

Indexes and filtered unique constraints

Soft-delete is everywhere — but usernames/emails must be reusable after a soft-delete. Solution: filtered unique indexes with PostgreSQL partial indexes:

csharp
schema.For<ApplicationUser>()
    .UniqueIndex(UniqueIndexType.DuplicatedField, "NormalizedUserName",
        u => u.NormalizedUserName)
    .Where(u => u.IsDeleted == false || u.IsDeleted == null);

In SQL:

sql
CREATE UNIQUE INDEX ... ON mt_doc_applicationuser
  ((data ->> 'NormalizedUserName'))
  WHERE (data ->> 'IsDeleted')::boolean IS NOT TRUE;

This way usernames/emails can be reused immediately after soft-delete without colliding with active users.

GDPR via Marten

Data masking

csharp
options.Events.AddMaskingRuleForProtectedInformation<UserCreated>(x =>
    new UserCreated(x.UserId, "[DELETED]", "[DELETED]", null, null, null));

options.Events.AddMaskingRuleForProtectedInformation<UserLoggedIn>(x =>
    new UserLoggedIn(x.UserId, "[DELETED-IP]", x.OccurredAt));

Only takes effect when the stream is archived (ArchiveStream) — live events are not touched.

Stream archival

In the GDPR confirm-delete flow:

csharp
session.Events.ArchiveStream(userId);
await session.SaveChangesAsync();
// Archived events are gone from normal read-model queries.
// Compliance queries (Events.QueryAllRawEvents()) still see them — masked.

Serialization

Marten is configured with System.Text.Json:

csharp
options.UseSystemTextJsonForSerialization(configure: o =>
{
    o.PropertyNamingPolicy = null;     // Exact property names — no camelCase
    o.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
    o.Converters.Add(new JsonStringEnumConverter());
});

Enums are stored as strings (readable in the DB inspector).

Important tables per tenant DB

TableContents
mt_eventsEvent store (all domain events, JSON data)
mt_streamsStream metadata (aggregate id, version, type)
mt_doc_applicationuserIdentity user documents
mt_doc_usersecuritydataPassword hashes, TOTP keys etc.
mt_doc_principalPolymorphic: Person + Group + ServiceAccount
mt_doc_permissionroleRBAC roles
mt_doc_oauthapplicationstateOpenIddict application inline projection
mt_doc_oauthscopestateOpenIddict scope inline projection
mt_doc_oauthapistateAPI resource inline projection
mt_doc_loginproviderstateLogin provider inline projection
mt_doc_openiddicttokendocumentReference tokens, refresh tokens
mt_doc_openiddictauthorizationdocumentOAuth authorizations (consent records)
mt_doc_idpconfigOIDC IdP configurations
mt_doc_authlogdocumentAuth events (7-day retention)
mt_doc_usersessionActive sessions

In the master DB additionally:

TableContents
realms.mt_tenant_databasesMarten tenant registry
global.mt_doc_realmRealm documents

Released under the Apache-2.0 License.