Entity Framework Core migrations
Statesman's two Entity Framework Core packages — Statesman.Persistence.EntityFrameworkCore (the
ledger) and Statesman.Outbox.EntityFrameworkCore (the outbox cursor) — are provider-neutral and ship
no migration. ROADMAP 0.3 Phase 14 adds six small, opt-in packages that each ship exactly one
generated Initial migration for one context, on one engine. Owning your migration yourself, by
subclassing the base context, stays the supported default. Nothing on this page changes that; it
documents the alternative.
What ships, and what does not
Six packages, one Initial migration each:
| Package | Extension method |
|---|---|
Statesman.Persistence.EntityFrameworkCore.Sqlite |
UseStatesmanLedgerSqliteMigrations() |
Statesman.Persistence.EntityFrameworkCore.SqlServer |
UseStatesmanLedgerSqlServerMigrations() |
Statesman.Persistence.EntityFrameworkCore.PostgreSQL |
UseStatesmanLedgerPostgreSqlMigrations() |
Statesman.Outbox.EntityFrameworkCore.Sqlite |
UseStatesmanOutboxSqliteMigrations() |
Statesman.Outbox.EntityFrameworkCore.SqlServer |
UseStatesmanOutboxSqlServerMigrations() |
Statesman.Outbox.EntityFrameworkCore.PostgreSQL |
UseStatesmanOutboxPostgreSqlMigrations() |
Each is opt-in: a consumer who never calls one of these methods sees exactly the behaviour these packages had before they existed. No migration is discovered, none is applied, and nothing here is required to use either core package.
The SerializerEnvelope migration
ROADMAP 0.3 Phase 22 adds one nullable EnvelopeJson column to each ledger entity
(StatesmanLedgerHead and StatesmanLedgerRecord), and one generated migration per ledger package to
carry it:
| Package | Migration id |
|---|---|
Statesman.Persistence.EntityFrameworkCore.Sqlite |
20260915204340_SerializerEnvelope |
Statesman.Persistence.EntityFrameworkCore.SqlServer |
20260915204350_SerializerEnvelope |
Statesman.Persistence.EntityFrameworkCore.PostgreSQL |
20260915204358_SerializerEnvelope |
Each of these ids is, exactly like Initial's, a permanent public contract: never renamed, removed, or
regenerated. The three outbox packages ship no matching migration — StatesmanOutboxCursorDbContext's
model does not change this phase, measured by Statesman.Outbox.EntityFrameworkCore.Tests staying
total: 28 failed: 0 on every engine with both ledger entities changed — so generating one there would
be an empty, purposeless migration.
Registering a shipped migration
Three calls, in this order — the provider, then the migrations extension, then Migrate() or
MigrateAsync():
services.AddDbContextFactory<AppStateDbContext>(options =>
options.UseSqlite(connectionString).UseStatesmanLedgerSqliteMigrations());
// At startup, once:
await using var context = await factory.CreateDbContextAsync();
await context.Database.MigrateAsync();
The migrations call comes after the provider call: UseStatesmanLedgerSqliteMigrations() reads
the RelationalOptionsExtension the provider call already configured off the builder, re-adds it with
the migrations assembly and history table name applied, and replaces IMigrationsAssembly. Calling it
before a provider has been selected throws InvalidOperationException rather than silently doing
nothing.
The outbox packages follow the identical shape, against StatesmanOutboxCursorDbContext and
UseStatesmanOutboxSqliteMigrations() / UseStatesmanOutboxSqlServerMigrations() /
UseStatesmanOutboxPostgreSqlMigrations().
The subclass rule, and why the package can honour it
Every shipped migration targets the base context — StatesmanLedgerDbContext or
StatesmanOutboxCursorDbContext — never a consumer's subclass, because a subclass's exact type is
not known when the migration is generated. Entity Framework Core discovers migrations by exact
[DbContext] type equality, so without help a migration generated against the base context is
invisible to a subclass: Database.Migrate() would return normally having created only the history
table, and the shipped tables would simply not exist.
Each of the six packages installs a subclass-tolerant IMigrationsAssembly replacement that relaxes
that check to declared.IsAssignableFrom(runningContextType), so a derived context still gets the
shipped migration applied. This is what lets every existing consumer — which subclasses the base
context, as every test and every other documentation page in this repository does — take a shipped
package with no other change.
A subclass that adds to the model is rejected, loudly, by Entity Framework Core itself:
InvalidOperationException: An error was generated for warning
'Microsoft.EntityFrameworkCore.Migrations.PendingModelChangesWarning': The model for context
'AppDbContext' has pending changes. Add a new migration before updating the database.
The subclass-tolerant assembly makes the shipped migration reach the subclass; it does not — and must
not — let a subclass silently add tables or columns the shipped migration knows nothing about. A
consumer who genuinely wants shipped tables beside their own suppresses this with
ConfigureWarnings(w => w.Ignore(RelationalEventId.PendingModelChangesWarning)) and owns the
consequence: with the warning ignored, Migrate() proceeds without applying anything for the
subclass's own additions, so those additions are the consumer's responsibility to migrate themselves.
Adopting a migration for a database created by EnsureCreated
A database that was created with EnsureCreated() has no migration-history row, so pointing it at a
shipped migration and calling Migrate() fails on the very first table:
Microsoft.Data.Sqlite.SqliteException: SQLite Error 1: 'table "StatesmanHeads" already exists'.
Both core packages ship BaselineAsync(DbContext, CancellationToken = default) for exactly this case
— Statesman.StatesmanLedgerMigrations.BaselineAsync and
Statesman.Outbox.EntityFrameworkCore.StatesmanOutboxMigrations.BaselineAsync. It writes the
migrations-history table and one history row per shipped migration id, inside one transaction run
through the context's own execution strategy, without touching any other table. It returns bool:
true when it wrote the history rows, false when the history already had rows (a no-op), so it is
safe to call unconditionally at startup. It throws InvalidOperationException when no shipped
migration was discovered at all, because that means the options are misconfigured and silently writing
an empty history table would bake the misconfiguration in.
The history row it writes records ProductInfo.GetVersion() — the running Entity Framework Core
assembly's version — the same source Migrator.ApplyMigration uses when it writes a history row for a
migration applied through Migrate(). A baselined row is therefore indistinguishable from one
Migrate() would have written itself.
Baselining an empty database is a mistake, not a shortcut. BaselineAsync does not check whether
the tables it is baselining actually exist — it trusts the caller. Running it against a database that
was never EnsureCreated-initialized tells Entity Framework Core the shipped tables are already
present when they are not, and every later read or write against them fails. Call it only against a
database you know was created by EnsureCreated, immediately before the first Migrate() call.
The two history table names
A shipped migration's history table is not the default __EFMigrationsHistory:
| Context | History table |
|---|---|
StatesmanLedgerDbContext |
__StatesmanLedgerMigrationsHistory |
StatesmanOutboxCursorDbContext |
__StatesmanOutboxMigrationsHistory |
Two names, one per context, because the ledger and the outbox cursor are independently adoptable and a
consumer may point both at the same database. __EFMigrationsHistory is shared per database, so a
consumer running their own migrations beside a shipped set in one database needs the shipped set's
history kept in its own table, or the two histories interleave.
Per-engine column types
This is the section that answers "why six packages, not three, or one." The three engines share no
type mapping this model uses, so the same Initial migration generated against the same base model
produces different column types on each engine. From the ledger's four tables
(StatesmanHeads, StatesmanLeases, StatesmanRecords, StatesmanSequences), as generated:
| Property | SQLite | SQL Server | PostgreSQL |
|---|---|---|---|
Root (max 128) |
TEXT |
nvarchar(128) |
character varying(128) |
Path (max 512) |
TEXT |
nvarchar(512) |
character varying(512) |
Partition (max 256) |
TEXT |
nvarchar(256) |
character varying(256) |
Revision |
INTEGER |
bigint |
bigint |
GlobalPosition |
INTEGER |
bigint |
bigint |
Operation (int) |
INTEGER |
int |
integer |
Status (int) |
INTEGER |
int |
integer |
ValueType (max 1024) |
TEXT |
nvarchar(1024) |
character varying(1024) |
SchemaVersion (int) |
INTEGER |
int |
integer |
Payload (nullable) |
BLOB |
varbinary(max) |
bytea |
OccurredAt |
TEXT |
datetimeoffset |
timestamp with time zone |
FreshUntil / ServeUntil (nullable) |
TEXT |
datetimeoffset |
timestamp with time zone |
Source (max 256) |
TEXT |
nvarchar(256) |
character varying(256) |
CorrelationId / CausationId / ErrorJson (nullable) |
TEXT |
nvarchar(max) |
text |
MetadataJson (required, unbounded) |
TEXT |
nvarchar(max) |
text |
StatesmanLeases.LeaseId (max 256, PK) |
TEXT |
nvarchar(256) |
character varying(256) |
StatesmanLeases.Token (max 64) |
TEXT |
nvarchar(64) |
character varying(64) |
StatesmanLeases.ExpiresAt |
TEXT |
datetimeoffset |
timestamp with time zone |
StatesmanSequences.Name (max 128, PK) |
TEXT |
nvarchar(128) |
character varying(128) |
StatesmanSequences.Value |
INTEGER |
bigint |
bigint |
Primary keys are identical in shape across all three engines: StatesmanHeads on
(Root, Path, Partition), StatesmanRecords on (Root, Path, Partition, Revision) plus a unique
index on GlobalPosition, StatesmanLeases on LeaseId, StatesmanSequences on Name.
The outbox's single StatesmanOutboxCursors table, keyed on OutboxId:
| Property | SQLite | SQL Server | PostgreSQL |
|---|---|---|---|
OutboxId (max 256, PK) |
TEXT |
nvarchar(256) |
character varying(256) |
Position |
INTEGER |
bigint |
bigint |
The two engine limitations, restated
Shipping a migration does not change either of these; it only changes where an operator first encounters them.
SQL Server's 900-byte clustered-key warning. StatesmanRecords' composite primary key
(Root, Path, Partition, Revision) can exceed SQL Server's 900-byte clustered-index key limit at
its declared maximum lengths. CREATE TABLE succeeds with a warning either way; an operator who
adopts the shipped SQL Server migration now sees that same warning in migration output, where before
it appeared only in EnsureCreated output. Same warning, same reason, unchanged by shipping a
migration — see the providers page for the full
explanation, and the spec's pre-Phase-12 addendum decision 9 for why it is not re-opened.
PostgreSQL's microsecond timestamptz truncation. PostgreSQL stores timestamptz to the
microsecond while a .NET tick is 100 ns, so OccurredAt, FreshUntil, ServeUntil, and the lease
table's ExpiresAt lose their last tick digit on that engine. This is a property of the column type
PostgreSQL maps DateTimeOffset to, not of how the table was created, and shipping a migration does
not change it — see the spec's pre-Phase-12 addendum decision 12.
Neither limitation is changed by shipping migrations. See spec section "Phase 14 — Entity Framework Core migrations as code," item 7 ("Pre-Phase-12 addendum decisions 9 and 12 are not re-opened"), for the full ruling.
The versioning policy
A shipped migration is a permanent public contract. Five rules govern every future change to any of the six packages:
- Append-only. A shipped migration id is never renamed, removed, or regenerated. A consumer's history table holds it, and changing it strands every database that already applied it.
- Every model change adds a migration to all six packages, each with a working
Down. The six packages generate different SQL from the same model, so a change landing in five of them is a broken sixth — the drift gate (below) fails per package, not once for the whole model. Amended (Phase 22, 2026-09-15): a model change adds a migration to the three packages for the context whose model actually changed, not to all six. Measured: Phase 22 changed onlyStatesmanLedgerDbContext's model, generatedSerializerEnvelopein the three ledger packages only, and left the three outbox packages untouched —Statesman.Outbox.EntityFrameworkCore.Testsstayedtotal: 28 failed: 0on every engine — and the drift gate fired per package for that context alone, never for the outbox's three packages, which had nothing to drift against. - The six ids never align. A migration id is a UTC timestamp taken at generation, so no two packages' ids match even for the same logical change, and no document or code may imply they do.
- A
ProductVersionmove is a committed diff.modelBuilder.HasAnnotation("ProductVersion", …)tracks the Entity Framework Core version at generation time. An Entity Framework Core upgrade that changes generated output surfaces as a snapshot diff to review and commit, not to regenerate away. - Generated files are committed verbatim. What
dotnet efwrites is what ships; nobody hand-edits a generated migration, designer, or snapshot file.
Authoring a new migration, once a model change lands in both core packages:
dotnet tool restore
dotnet ef migrations add <Name> --project src/Statesman.Persistence.EntityFrameworkCore.Sqlite/Statesman.Persistence.EntityFrameworkCore.Sqlite.csproj --output-dir Migrations
repeated for each of the other five packages, substituting its own --project. dotnet tool restore
reads .config/dotnet-tools.json, which pins dotnet-ef to the same Entity Framework Core version the
packages reference, so the command is reproducible across machines. Each of the six packages also
references Microsoft.EntityFrameworkCore.Design for this purpose, with PrivateAssets="all" — that
reference never reaches a consumer; it exists only so dotnet ef can run against the project at
authoring time.
The drift gate
There is no CI job that opens a database connection to check for model drift; the check needs none.
EntityFrameworkMigrationDriftTests (for the ledger, in Statesman.EntityFrameworkCore.Tests) and
EntityFrameworkOutboxMigrationDriftTests (for the outbox, in
Statesman.Outbox.EntityFrameworkCore.Tests) each assert, per engine, that the migrations assembly is
non-empty, the model snapshot is not null, and Database.HasPendingModelChanges() is false — against
an unreachable connection string, because HasPendingModelChanges() is a design-time diff against the
snapshot and never contacts a server.
When one of these tests goes red, it means the context's model has moved since the shipped migration was generated. The failure message says exactly what to do:
StatesmanOutboxCursorDbContext's model has drifted from this package's shipped migration. Add a migration to ALL SIX packages -- three engines times two contexts -- withdotnet tool restore && dotnet ef migrations add--project --output-dir Migrations . Never edit or remove a shipped migration: its id is a permanent public contract.
Amended (Phase 22, 2026-09-15): the ledger test's own message no longer reads this way — see the
versioning policy's amendment above. EntityFrameworkMigrationDriftTests' message now says to add a
migration to the three packages for the context whose model changed, and explains why (Phase 22
measured that a change to StatesmanLedgerDbContext leaves StatesmanOutboxCursorDbContext's three
packages entirely unaffected). The quoted text above is now EntityFrameworkOutboxMigrationDriftTests'
message verbatim, unchanged this phase because nothing in Phase 22 gave it a reason to change: the
outbox test still names StatesmanOutboxCursorDbContext and still says "ALL SIX", which stays literally
true only because that context's model did not move this phase — the two tests' wording is no longer
uniform, and a future change to either context should follow whichever test actually fires rather than
assume both messages still say the same thing. Follow whichever message fires literally: generate a new
migration in the three packages it names, never edit or remove the one that already shipped.