TIMEWARP SKILL — the golden aggregate-root pattern: typed id, Entity<TId> base, fail-closed Create, named mutations with no public setters, a private nested Invariants validator, and save-time enforcement via DomainInvariantsGuard/AggregateDbContext. Invoke before adding or reviewing an IAggregateRoot, or when TWA0011/TWA0012 fire. WHEN: add an aggregate, IAggregateRoot, aggregate root, TWA0011, TWA0012, Invariants validator, typed id.

Maintained in timewarp-architecture · Canonical file: SKILL.md

Install

With TimeWarp.Skills:

dnx TimeWarp.Skills -- add tw-aggregate-pattern https://timewarp.software/

Add a harness target and skills sync (see the TimeWarp.Skills page). Or copy the SKILL.md into your agent's skills directory.


Aggregate pattern (TWA0011/0012)

An aggregate is a domain entity that is the consistency boundary for a set of invariants. Every aggregate root in this repository follows the same golden pattern. This skill is the pattern SSOT, including the persistence walkthrough (EF mapping → host registration → SaveChanges → tests).

Detection — when to invoke

Signal How to find it
Adding a new IAggregateRoot any domain type that owns its own consistency boundary
TWA0011 / TWA0012 diagnostic analyzer output names the aggregate type
"Where does the invariants check run?" save path, not construction
Reviewing a Create/mutation method for a domain type fail-closed construction check

The golden pattern

Placement

An aggregate's domain type is <name>-domain.cs in its owning slice, and its typed id is <name>-id-domain.cs alongside it — both follow the <name>[-<function>]-<layer>.cs filename grammar for the domain layer. See tw-feature-placement for the full grammar, registry, and use-case-folder rules; this skill covers the aggregate's internal shape, not where the file lives.

Enforcement map

Rule Requires Why
TWA0011 An IAggregateRoot must declare a nested Invariants : AbstractValidator<T> Fail-closed: no validator means DomainInvariantsGuard cannot check the aggregate at save time
TWA0012 That nested Invariants must be private Keeps it out of AddValidatorsFromAssemblyContaining auto-registration — it is a save-time domain check, not a request validator

Exemplar

web/features/profile/profile-domain.cs + profile-id-domain.cs — read both before adding a new aggregate. Its EF mapping (profile-entity-type-configuration-infrastructure.cs — table/schema profiles, TypedId key conversion) is applied by PostgresDbContext via ApplyConfigurationsFromAssembly. Version's .IsConcurrencyToken() is supplied for free by the AggregateDbContext Version convention — an aggregate's own mapping does not declare it.

Persistence golden path (Postgres + EF)

The template's default durable path is Postgres-only state-store EF. No SQL Server dual story, no event sourcing as the default, no in-app EnsureCreated. Hosts inherit AggregateDbContext so SaveChanges enforcement is not sealed inside one product context. Npgsql stays host-only.

Add an aggregate — walkthrough

Prerequisites: the postgres template flag is on (default).

1. Domain

Place <name>-domain.cs and <name>-id-domain.cs under web/features/<slice>/. Follow The golden pattern above. Application code never writes Version — it is store-owned.

2. Infrastructure mapping

Add <name>-entity-type-configuration-infrastructure.cs in the same slice:

Concern What to do
Table + schema ToTable("orders", "orders") — schema-per-slice on the single host context
Key HasKey(e => e.Id)
TypedId HasConversion(id => id.Value, v => OrderId.From(v))
Concurrency nothing — AggregateDbContext configures Version for every IAggregateRoot

Do not call .IsConcurrencyToken() yourself on an aggregate mapping.

3. Host registration

On PostgresDbContext: add public DbSet<Order> Orders => Set<Order>();, keep base.OnModelCreating and ApplyConfigurationsFromAssembly. Override OnConfigureConventions (not sealed ConfigureConventions) for TypedId conventions. Feature *-infrastructure.cs files compile into web-infrastructure — do not hand-register each IEntityTypeConfiguration.

4. Application

Load → named mutation → SaveChangesAsync. Do not call DomainInvariantsGuard yourself. Invalid state fails closed before SQL. Concurrent writers surface as DbUpdateConcurrencyException.

5. Tests

Layer What
Domain unit Create/guards/mutations (profile-tests.cs)
Model mapping Schema, TypedId, concurrency token (no live DB)
SaveChanges hook Version bump, child→root (foundation-infrastructure-tests)
Postgres integration Database.Migrate, round-trip, concurrent update on an ephemeral DB

6. Store port vs direct DbContext

Use When
Direct PostgresDbContext / DbSet<T> Product aggregate owned by this host; Profile teaching path
Port (IPrincipalStore, …) Multi-backend seam; in-memory + EF must share snapshot-on-get and CAS semantics

Identity Principal/Credential are not IAggregateRoot. The store owns optimistic concurrency (EntityVersion.Next + ConcurrencyConflictException). Host mapping still sets .IsConcurrencyToken() as a DB race belt, but AggregateDbContext does not auto-increment Version for those types — that avoids a double-bump.

7. Schema evolution

After editing the model or an IEntityTypeConfiguration:

dev db add-migration <NameYourChange>

That wraps dotnet tool restore plus:

dotnet ef migrations add <NameYourChange> \
  --project source/container-apps/web/projects/web-infrastructure/web-infrastructure.csproj \
  --startup-project source/container-apps/web/projects/web-server/web-server.csproj \
  --context PostgresDbContext \
  --output-dir ../../platform/postgres/migrations \
  --namespace TimeWarp.Architecture.Persistence.Migrations

Do not kebab-rename EF scaffold files. Removing a mapped entity requires a migration that drops the unused tables — never an out-of-band DROP against __EFMigrationsHistory.

Checklist