DB Migration Strategy¶
Purpose¶
This document defines how we design and apply Entity Framework Core (EF Core) database migrations in a zero-downtime environment. Unlike previous approaches, migrations must now be safe to run while older application versions are still in production.
Key Principles¶
- Backward Compatibility First — every database change must be compatible with both:
- the current (old) application version, and
- the new application version.
- No Breaking Changes in a Single Release — avoid changes that require all application instances to be updated simultaneously.
- Expand → Migrate → Contract Pattern — all schema changes must follow this pattern:
- Expand: add new structures without removing old ones.
- Migrate: update application code to use new structures.
- Contract: remove old structures in a later release.
Migration Strategy¶
Phase 1: Expand (Safe Changes Only)¶
Allowed operations:
- Add new tables
- Add nullable columns
- Add columns with default values
- Add indexes
- Add new foreign keys (nullable first)
Avoid:
- Dropping columns/tables
- Renaming columns (use add + copy instead)
- Dropping indexes before a replacement is in place
- Making nullable columns non-nullable immediately
Example:
// Add new column (nullable)
migrationBuilder.AddColumn<string>(
name: "NewColumn",
table: "Orders",
nullable: true);
Phase 2: Application Update¶
Deploy a new version of the application that:
- writes to both old and new schema (dual write), and
- reads from the old schema OR supports fallback logic.
Example strategy: write to both OldColumn and NewColumn; read from NewColumn if available,
otherwise fall back.
Phase 3: Data Migration¶
Backfill data safely. Options:
- Background job
- Script executed separately
- Incremental migration
Example:
Important:
- Must be idempotent.
- Must not lock large tables for long periods.
Phase 4: Switch Read Path¶
Update the application to:
- read only from the new schema, and
- stop depending on the old schema.
Phase 5: Contract (Cleanup)¶
Only after all instances run the new version:
- remove old columns, and
- remove fallback logic.
Example:
Handling Common Changes¶
Marking Fields for Deletion (Required Process)¶
Before removing any column from the database, the corresponding property in code must be explicitly marked as deprecated using a custom attribute.
Attribute definition — developers must use a shared attribute, e.g.:
Rules:
- Properties marked with
[MarkedForDeletion]must beprivate(or at mostprotectedif required by EF Core). - Classes marked with
[MarkedForDeletion]must beinternal.
Analyzer enforcement — a Roslyn analyzer enforces the following:
- Any usage of a marked property or class results in a compile-time error.
- Usage is not allowed even within the same class.
- Allowed only in:
- EF Core model configuration (expression trees), and
- migration or data backfill code (optional exception).
This guarantees that the field is truly no longer used and that removal is safe in a later release.
Removing a Column (Step-by-Step)¶
Columns must never be removed in a single release.
- Stop using the column — update application logic to no longer read/write the column, make sure the column is used in neither the frontend nor the backend, and introduce replacement fields if needed.
-
Mark for deletion (code-only release):
Ensure there are no remaining usages (enforced by the analyzer) and the application runs without the field.
-
Deploy and observe — run the system in production and verify no runtime dependency exists.
- Remove from database — create a migration to drop the column and remove the property from code.
Adding a Column with Computed / Derived Values¶
When introducing a column whose value is derived from existing data, a multi-release approach is required.
-
Expand (add column as nullable):
The column must be nullable; no existing data is modified yet.
-
Application update — the new application version writes values for all NEW records; existing records remain
NULL. -
Data backfill (separate release) — create a migration or background process to compute values for existing data:
Requirements: must be idempotent, must run safely on large datasets, avoid long locks.
-
Enforce NOT NULL constraint — after ALL data is populated, make the property non-nullable and remove null checks from code. Only safe once no
NULLvalues remain. - Finalize usage — the application fully relies on the new column; old data dependencies are removed if applicable.
Renaming a Column¶
Do not use rename directly. Instead:
- Add the new column.
- Copy the data.
- Update the app.
- Remove the old column later.
Changing a Column Type¶
- Add a new column with the new type.
- Backfill the data.
- Update the app.
- Remove the old column.
Making a Column Non-Nullable¶
- Ensure all rows have values.
- Add default handling in the app.
- Apply a migration to make it
NOT NULL.
Dropping a Column¶
Only after no application version uses it anymore.
Deployment Strategy¶
A typical safe multi-step rollout:
- Deploy migration (expand).
- Deploy application (dual write).
- Run data migration.
- Deploy application (new read path).
- Deploy cleanup migration.
EF Core Specific Guidelines¶
Migration Immutability (Critical Rule)¶
Once a migration has been merged into main, it must not be:
- edited,
- deleted, or
- reordered.
This applies especially after it has been applied to any shared environment.
Exception — only allowed if the migration is broken and cannot be applied, AND it has NOT been applied to any shared or production environment.
Why this matters — changing or removing migrations can lead to:
- schema drift between environments,
- failed deployments,
- inconsistent migration history, and
- runtime errors due to mismatched schemas.
Instead of modifying existing migrations, always create a new migration to fix or adjust behavior.
Migration Anti-Patterns (Do NOT Do This)¶
Dropping a column directly
Why it breaks: older application versions may still read/write the column, causing immediate runtime failures during rolling deployments.
Renaming columns using EF rename
Why it breaks: the old application version still expects the old name, resulting in missing-column errors.
Making a nullable column non-nullable immediately
Why it breaks: existing rows may contain NULL values; the migration fails or the application
crashes on insert/update.
Removing or editing existing migrations
Why it breaks: environments may already have applied the old version; the migration history becomes inconsistent.
Writing data only to the new column without dual write after renaming a column
Why it breaks: the old application version still reads the old column, leading to inconsistent or missing data.
Large blocking updates in one transaction
Why it breaks: long-running locks can block production traffic and cause timeouts and outages.
Pull Request Checklist¶
Every PR containing migrations must include the following checklist. In this repo the bot posts it
as a comment on any PR that touches Migrations/ or a *ModelSnapshot.cs, gated by the
Database migration checklist check (see
Development Workflow).
General
- Migration follows Expand → Migrate → Contract pattern
- Migration is backward compatible with the currently released version of SPARK
- No columns / tables are dropped or modified in a way that the currently released version of SPARK cannot handle
- Columns and tables are only dropped if they have been marked for deletion in an already released version of SPARK
Schema Changes
- New columns are nullable or have safe defaults — this is not relevant if the column is introduced in the Hub and not used by SPARK. As the Hub processing is stopped during the release, it can be assured that no new data is added during the migration. If you are not sure, use the two-step release version.
- No columns/tables are dropped prematurely, only after being attributed with
MarkedForDeletionin a previous release - No direct renames (use add + copy instead)
Data Safety
- Data migrations are idempotent (can be applied multiple times without changing the outcome)
- Large updates are batched or safe for production
- No long-running locks expected
Marked for Deletion
- Fields to be removed are marked with
[MarkedForDeletion] - No remaining usages (verified by analyzer)
EF Core Specific
- Auto-generated migration reviewed manually
- No unintended data loss operations
- Existing migrations were NOT modified or deleted
Breaking Changes
- If tables/columns are dropped, a new PBI was created and linked for the next sprint
- If changes require multiple releases, this is planned for the next release cycle (release first release, merge PRs for second release, second release)
Summary¶
Zero-downtime migrations require discipline:
- Never break existing behavior.
- Introduce changes gradually.
- Remove old structures only after it is safe.
Following this approach ensures continuous availability and safe deployments.