| name | managing-shared-database-schema |
| description | Governs a SQL database that two hosts read and write at the same time during a side-by-side migration. Use when a legacy .NET Framework app and a new .NET app share one database, point at the same connection string, or must both stay live while the schema changes. Covers which toolchain owns schema changes, expand-then-contract (additive-only) evolution, dual-write and backfill ordering, deployment ordering, and rollback rules. Triggers for "shared database", "who runs migrations", "dual write", "zero downtime schema change", or "add a column safely". Applies to EF6, EF Core, Dapper, ADO.NET, DACPAC/SqlPackage, DbUp and Flyway, and explains how EF6's __MigrationHistory and EF Core's __EFMigrationsHistory coexist without merging. Also use before deleting a Migrations folder, before enabling Database.Migrate() at startup, or when asked whether renaming or dropping a column is safe while the old app is live.
|
| metadata | {"traits":".NET|CSharp|VisualBasic|DotNetCore","discovery":"lazy"} |
Managing a Shared Database During Side-by-Side Operation
Overview
In a side-by-side migration, the legacy .NET Framework host and the new .NET host both run
against one SQL database for weeks or months. Every schema change in that window must be
tolerated by code that is still running and by code that is being deployed — simultaneously.
This skill is ORM-agnostic. The rules apply equally to EF6, EF Core, Dapper, raw ADO.NET,
and SQL-first tooling. Where a rule needs a concrete binding, the EF6 and EF Core forms are
given side by side.
Read ref/worked-example.md before making any schema change. It carries the full
column-rename walkthrough with the exact ordering. The phase skeleton below is a summary,
not a substitute.
Scope
In scope: one database, two or more live application hosts, a bounded migration window.
Out of scope: ETL and data warehousing, database-per-service decomposition, read replicas
and sharding, cross-database distributed transactions.
Related skills:
migrating-ef6-code-first-to-ef-core — the EF6→EF Core code migration. Its teardown steps
are for full cutover only and must not run while both hosts are live.
migrating-ef-dbcontext — DbContext registration and DI. Its startup-migration sample is
gated on the ownership decision made here.
migrating-aspnet-framework-to-core — the side-by-side task structure that creates this
window.
Window admission checklist
Do not open the shared-database window until all five hold:
If any check fails, fix it before proceeding. Opening the window with two hosts able to issue
DDL is the failure mode this skill exists to prevent.
Step 1: Schema-writer inventory
Produce a written table in task.md. Do not proceed on recollection or assumption.
Search the whole solution — both hosts, and any jobs, tools or deployment scripts:
| What to search for | Indicates |
|---|
DbMigrator, migrate.exe, Update-Database | EF6 migration runner |
Database.Migrate(), dotnet ef database update | EF Core migration runner |
EnsureCreated(), CreateDatabaseIfNotExists, DropCreateDatabase* | implicit schema creation |
SqlPackage, .sqlproj, .dacpac | SQL-first deployment |
DbUp, Flyway, Liquibase, RoundhousE | third-party migrator |
CREATE TABLE, ALTER TABLE in .sql files or embedded strings | ad-hoc DDL |
connection-string names shared across *.config and appsettings*.json | the shared database itself |
Record one row per finding, each citing path:line:
| Writer | Toolchain | Where it runs | Evidence |
|---|
MyApp.MigrationRunner | EF6 DbMigrator | scheduled job | src/Jobs/Migrate.cs:42 |
Do not select an ownership case until the table has at least one evidenced row, or an
explicit "none found — asked the user and they confirmed X".
Step 2: Decide who owns schema changes
This is a procedure, not a lookup. Follow it in order.
- Build the inventory (Step 1).
- If more than one DDL-capable writer type appears — stop and ask the user who owns the
pen. Do not infer it. Two writers with two ledgers is the root cause of nearly every
failure in this window.
- If exactly one appears — that is Case A. Still enforce a single deployable runner.
- Choose Case C only when the user confirms SQL-first ownership, or when the window is
long enough that neither ORM should hold the pen.
Record the decision and its rationale in task.md. Every later step depends on it.
Case A — one toolchain, one runner (most common)
Both web hosts are inert with respect to schema. One deployable migrator applies changes.
- No startup migration in either host.
- One runner, one place, one schedule.
- Concurrency is the residual risk. EF6's
DbMigrator takes no distributed lock.
EF Core 9+ does take one (__EFMigrationsLock) — but the two do not coordinate with
each other. Two concurrent runners can interleave DDL.
- They can be made to coordinate. On SQL Server, EF Core's lock is an
sp_getapplock
taken on the resource name __EFMigrationsLock. A non-EF-Core runner that takes an
exclusive sp_getapplock on that same resource name therefore serializes against
dotnet ef database update as well as against itself. This is an EF Core implementation
detail, not a documented contract — confirm it for your EF Core version before relying on
it, and keep "one runner" as the primary control rather than the lock.
- Serialize explicitly. Either run migrations from a single scheduled job that cannot overlap
itself, or wrap the runner in an application lock:
DECLARE @lock int;
EXEC @lock = sp_getapplock @Resource = 'schema-migration', @LockMode = 'Exclusive',
@LockOwner = 'Session', @LockTimeout = 60000;
IF @lock < 0
THROW 50000, 'Could not acquire the schema-migration lock; another runner holds it.', 1;
EXEC sp_releaseapplock @Resource = 'schema-migration', @LockOwner = 'Session';
Two details decide whether this actually protects anything:
- Check the return value.
sp_getapplock does not raise an error on failure — it
returns a negative value (-1 timeout, -2 cancelled, -3 deadlock victim, -999 argument
error). An EXEC whose result nobody inspects therefore fails open: the timeout elapses,
the batch continues, and the migration runs with no lock at all — precisely the case this is
meant to prevent.
@LockOwner = 'Session' binds the lock to the connection that took it, and that
connection must stay open for the whole run. The migration itself may execute on a different
connection — the lock provides mutual exclusion between runners, not between connections —
which is what makes this usable with EF6's DbMigrator, since it opens its own connection.
What breaks the guarantee is closing the lock-holding connection early, or a runner that
deploys without taking the lock at all. Use @LockOwner = 'Transaction' only if the
migration genuinely runs inside that transaction.
Case B — two toolchains, one holds the pen
Default: EF6 keeps the pen for the duration of the window.
Two reasons, both concrete:
- Convention drift. EF6 names constraints
FK_dbo.Posts_dbo.Blogs_BlogId; EF Core names
them FK_Posts_Blogs_BlogId. EF Core scaffolds a migration by diffing its model against
its own snapshot, never against the live database, so it cannot see those legacy names.
An unbaselined first migration therefore emits CREATE for the entire model — every object
it "creates" already exists — and once baselined, subsequent migrations emit ALTER/DROP
against EF Core-convention names that are not what the database actually calls them.
- Variable isolation. Moving schema deployment from
DbMigrator/migrate.exe to
dotnet ef during the window conflates an infrastructure migration with an application
migration, on the host still serving production traffic.
When to override the default. Hand the pen to EF Core instead only when all three hold —
and record the reason in task.md:
- The Framework host is feature-frozen: kept alive to serve traffic, not developed
against. Authoring every schema change in a codebase no one is working in is real friction,
and this is the case the default gets wrong.
- EF Core's model has been reverse-engineered from the live database with
Scaffold-DbContext, so it already carries the legacy constraint names (see the bootstrap
rule below). This is a model bootstrap and nothing more: Scaffold-DbContext produces
entity classes and a DbContext. It creates no migration, no ModelSnapshot.cs and no
__EFMigrationsHistory row, so it does not baseline the ledger.
- The switch happens once, at a planned checkpoint — never per change — and the Step 3
handover procedure (scaffold the baseline, review it, register it with the history-table
script) completes within that same checkpoint. EF Core must not own the pen for even
one change while its ledger is still empty: its first
migrations add would diff against
an empty snapshot and emit CreateTable for the entire model.
If any of the three fails, EF6 keeps the pen. Either way the handover is a one-time event:
alternating writers is prohibited (Step 3).
Rules:
- The non-owner never scaffolds a migration. It models the schema read-only.
- If EF Core must eventually take the pen, bootstrap it with
Scaffold-DbContext against the
live database. That emits explicit HasConstraintName(...) calls pinning EF Core to the
legacy names, neutralising the convention diff. It does not register a ledger baseline —
that is Step 3's handover procedure, and both are required before EF Core owns the pen.
- Pin delete behaviour explicitly — "we emit no DDL" does not make it moot.
DeleteBehavior
governs two separate things: the FK clause EF Core would write in a migration, and what EF
Core does at runtime to dependents it has loaded. The non-owner emits no migration, so the
first is irrelevant — which is precisely why it gets skipped, and why the second then bites. A
required (non-nullable) FK defaults to DeleteBehavior.Cascade, so deleting a principal makes
EF Core issue DELETE for each tracked child. Against a live FK declared NO ACTION those
child deletes are perfectly legal and succeed, so the rows vanish — while the EF6 host doing
the same delete gets an FK violation. One database, two behaviours, and no error anywhere to
reveal it. Mirror the live constraint: WillCascadeOnDelete(false) or NO ACTION means
.OnDelete(DeleteBehavior.Restrict). Read the real FK; never accept the convention default.
Case C — neither ORM owns; SQL-first
Both ORMs are frozen as read-projections of a schema owned by DACPAC/SqlPackage, DbUp or
similar. That system carries its own authoritative ledger. Neither Add-Migration nor
dotnet ef migrations add is run at all.
Step 3: Migration ledgers coexist — they never merge
EF6 and EF Core write different tables:
| EF6 | EF Core |
|---|
| Table | dbo.__MigrationHistory | dbo.__EFMigrationsHistory |
| Model snapshot | gzipped EDMX in the Model column | <DbContext>ModelSnapshot.cs in source |
They never collide — which is worse than colliding. Each runner scaffolds its next
migration by diffing against its own snapshot, so EF6's Add-Migration happily re-adds a
column EF Core already created, and vice versa.
Rules:
- Never point EF Core at EF6's history table.
- Never copy rows between the two tables. The formats are not interchangeable.
- Preserve EF6's ledger read-only — the Framework host still needs it.
- Handover (at the moment EF Core takes ownership of schema deployment — by default at
cutover, and during the window only under the Step 2 override): scaffold an EF Core baseline
migration. It will contain the full
CreateTable set for the whole model — a first
migration diffs against an empty snapshot, so an empty Up() is not something you can wait
for, and an instruction to "verify it contains no operations" can never be satisfied. Review
it to confirm it describes the schema that already exists, then register it without
executing it: generate dotnet ef migrations script --idempotent and apply only its
history-table parts — the conditional CREATE TABLE __EFMigrationsHistory block (an
EF6-only database has no such table, so the insert alone fails) and the
INSERT INTO __EFMigrationsHistory row. Do not use dotnet ef database update — that
command executes the migration; EF Core has no --fake.
- Where several
DbContext types share one database, each has its own history rows. Decide
behaviour per context, not per database.
- Changing owner requires a fresh baseline checkpoint. Alternating writers is prohibited.
Precondition: the EF6 host must call Database.SetInitializer<MyContext>(null).
An app that keeps MigrateDatabaseToLatestVersion runs DbMigrator.Update() on first
context use, which applies any pending EF6 migration automatically at startup — a second
uncoordinated schema writer that deploys on process start rather than on your schedule.
Nulling the initializer also bypasses the model-compatibility check
(Database.CompatibleWithModel), which the initializer pipeline invokes. Be clear about what
that check does not do: it compares the current model against the model hash stored in
__MigrationHistory, so it reacts to your code changing, not to the physical schema
advancing underneath it. Additive DDL applied out of band does not trip it.
With the initializer nulled, EF6 silently tolerates added columns and tables, because it
emits explicit column lists rather than SELECT *. It fails only when a column it knows
about is dropped or renamed, or when a NOT NULL column without a default is added and EF6
inserts a row.
Step 4: Expand-then-contract — additive only
Inside the window, schema changes are additive only. Contraction happens after the legacy
host is retired.
Prohibited while both hosts are live:
| Change | Why it breaks |
|---|
| Rename a column or table | legacy host's explicit column list stops resolving |
| Drop a column or table | same, immediately |
Narrow a type (nvarchar(200)→(50), bigint→int) | legacy writes overflow |
| Change a primary key or clustered index | rewrites the table; blocks both hosts |
| Add a UNIQUE constraint or unique index on a column a host already writes | the legacy host keeps writing duplicates |
Create a non-clustered index without ONLINE = ON | takes a lock for the build's duration |
Add NOT NULL without a default | legacy INSERT omits the column and fails |
The unique-index row applies to columns that are already being written. A new column is
the different case: once the Step 5 dual-write is deployed to every writer, they all populate
it, so a filtered unique index becomes legal at the Enforce phase — see Step 5 phase 6 and
ref/worked-example.md Phase 6, which shows why the same index is illegal at expand time.
Adding a NOT NULL column with a default is metadata-only — and therefore safe — only when
all four hold:
- SQL Server 2012 or later, Enterprise edition (Evaluation, and Developer on 2022 and
earlier, are Enterprise-equivalent; SQL Server 2025 Standard Developer is not — that
release splits Developer into Enterprise Developer and Standard Developer, and the latter
carries Standard's limits), or Azure SQL. On Standard this is a full table rewrite under a
Sch-M lock regardless of everything below — the most common way this step goes wrong is
assuming otherwise. Validating on a Standard Developer box and concluding production will
behave the same way is the version of that mistake this note exists to prevent.
- The default is a runtime constant — an expression producing the same value for every
row, such as
0, '', or GETDATE(). Not NEWID() or NEWSEQUENTIALID(), and not a
scalar UDF; those force a full rewrite.
- The column's type is not
varchar(max), nvarchar(max), varbinary(max), xml,
text, ntext, image, hierarchyid, geometry, geography, or a CLR UDT. These are
excluded from the online path outright, independently of the other conditions.
- Adding it does not push the table's maximum possible row size past 8,060 bytes.
If any fail, treat it as a rewrite: add the column nullable, backfill in batches, then enforce.
Safe — but only under these conditions:
| Change | Condition |
|---|
| Add a nullable column | unconditional |
| Add a new table | unconditional |
| Add a default constraint | unconditional — it does not touch existing rows |
Add a non-clustered index ONLINE = ON | Enterprise or Azure SQL only. Still takes a short lock at the start and a Sch-M lock to finish, and needs log and tempdb headroom. On Standard there is no online path, so defer the index to contraction. |
| Widen a type | only when the widening is genuinely metadata-only. varchar(50)→varchar(100) is; int→bigint and varchar→nvarchar rewrite the whole table. int→bigint is also an application break: as soon as the new host writes a value beyond Int32, the legacy host's mapping overflows reading it back. |
Step 5: Phase skeleton
Every non-trivial change follows this order. Ordering is not stylistic — inverting steps 2
and 3 loses data.
- Expand — add the new nullable column/table. Deploy alone.
- Dual-write — deploy to every writer, so new writes populate old and new.
- Backfill — batched, restartable, race-safe; only rows the dual-write did not cover.
- Verify — old and new agree continuously; no unmigrated writer remains.
- Read switch — read new, fall back to old. Deploy. Soak.
- Enforce — add the final constraint (a filtered unique index, or
NOT NULL where the
value is genuinely mandatory). Keep dual-writing: enforcement does not require the old
write to stop, and stopping it early strands every old reader you have not yet retired.
- Contract — stop the old write and drop the old column, only after every old
consumer is retired. These belong together: from the moment old writes stop, any surviving
reader of the old column is silently reading stale data, and a rollback to a pre-dual-write
revision can no longer be trusted.
Backfilling before the dual-write is deployed loses every row written between the two — this
is the single most common way to corrupt data in this window. See ref/worked-example.md.
Step 6: Deployment ordering
Schema first, always. Every schema change must be independently deployable and must not
require a simultaneous code deployment.
Per change:
| PR | Contains | Done when |
|---|
| PR1 | DDL only | new column exists; both hosts still healthy |
| PR2 | dual-write in every writer | new column populated on every new write |
| PR3 | backfill job | COUNT(*) WHERE new IS NULL AND old IS NOT NULL = 0 |
| PR4 | read switch | new path serving reads; error rate flat |
| PR5 | constraint DDL only | constraint enforced; dual-write still running |
| PR6 | contract (post-retirement): stop the old write, drop the read fallback, unmap the property from every model | deployed and soaked, before any DROP |
| PR7 | contract DDL | old column dropped |
PR6 and PR7 are separate deployments on purpose: dropping the column in the same unit that
stops writing it leaves no window in which to discover a consumer the retirement inventory
missed. Unmapping the property belongs in PR6 for a harder reason — EF emits every mapped
scalar in its SELECT list, so a model still mapping a dropped column fails every read of
that entity, not only the paths that use it.
Never combine DDL and application logic in one deployable unit.
Step 7: Rollback contract
Invariant: every schema state must be tolerated by all four of {old Framework code, new
Framework code, old Core code, new Core code}.
- Code rolls back. Schema does not. A rolled-back deployment must run against the
advanced schema — which is exactly what additive-only guarantees.
- Down-migrations are banned in the window.
Down() methods are not tested against live
data and typically drop columns the other host is still reading.
- Recovery = roll forward. If a schema change is wrong, deploy a compensating additive
migration, or perform a controlled manual DBA intervention with a verified backup — never
an automated
Down().
Step 8: Runtime safety across two stacks
One schema, two ORMs, different conventions. Check each:
- Concurrency tokens. A
rowversion/timestamp column must be mapped as a concurrency
token in both stacks (EF6 [Timestamp] / IsRowVersion(); EF Core IsRowVersion()),
or one host silently overwrites the other's changes.
- Convention drift against one schema:
decimal — EF6 defaults to decimal(18,2); EF Core requires explicit HasPrecision.
Mismatch silently truncates.
string — EF6 defaults to nvarchar(max)/Unicode; confirm EF Core's HasMaxLength
and IsUnicode match the actual column.
DateTime — datetime vs datetime2 precision differs; round-tripping can shift values.
- Cascade deletes — EF6 and EF Core default differently, and the non-owner emitting no
DDL does not make this cosmetic: the default still drives runtime deletes. See Case B.
- Index and FK naming — see Case B.
- Transactions.
TransactionScope with MSDTC promotion has limited support on
Microsoft.Data.SqlClient / modern .NET. Prefer a single local transaction per operation;
where cross-resource atomicity is genuinely needed, use an outbox.
- Cache invalidation. Each host has its own in-memory cache. A write by one is invisible
to the other's cache. Shorten TTLs, or use a shared/distributed cache, for data both mutate.
- Drift fingerprint. Before each advance, re-check the live schema against both models —
the window is long enough for out-of-band changes to appear.
Step 9: Detection and gating
Apply this skill when either holds:
Upgrade Options > Project Structure > Project Approach is Side-by-side, or
- the schema-writer inventory shows two or more hosts on one connection string.
Standalone fallback — no upgrade options available: treat as shared if two projects
resolve the same server+database, or if a connection string name is duplicated across a
*.config and an appsettings*.json.
Fail closed. If the connection strings are tokenized, injected at deploy time, or pulled
from Key Vault so sharing cannot be proven — ask the user. Do not assume they are separate.
Decomposition Rules
(Read by the task-breaker when this skill is attached to a task being decomposed. This exact
heading is the one both task-breaker and task-executor scan for — do not rename it.)
Restate the pin on every data-touching subtask. A subtask's task.md is written from the
breakdown alone — it is not merged with its parent — so a #skill: pin in the parent
description does not reach the child. When breaking down a task that carries
#skill:managing-shared-database-schema, copy that pin verbatim into the description of every
subtask whose scope includes a DbContext, an entity model, a connection string, a migration,
a repository, or raw SQL. Without it the subtask that actually writes the schema is the one
agent in the run that cannot see this skill.
Do not create a subtask that performs a contraction. Rename-column, drop-column,
narrow-type, add-unique-constraint and "clean up the old column" are not valid subtasks inside
the window, no matter how naturally they fall out of the parent's scope. If the parent's work
implies one, split it into an expand subtask now and a contraction item recorded for after the
Framework host is retired.
Sequence dual-write before backfill. If the breakdown produces both, the dual-write subtask
must be ordered first and marked as a dependency of the backfill subtask. Reversing them loses
every row written between the two deployments.
Success criteria
- Schema-writer inventory written to
task.md, every row citing path:line
- Exactly one schema owner chosen, recorded, and justified
- No
Database.Migrate(), EnsureCreated(), or migration-applying initializer in either host
- EF6
Migrations/ folder and Configuration class intact if EF6 owns the pen
- The two migration ledgers untouched by each other — no merging, no row copying
- Every schema change in the window additive-only
- Dual-write deployed before any backfill runs
- No contraction DDL while any old consumer remains
- Rollback verified: previous code revision runs against the advanced schema