The riskiest part of an expand-only migration is often not adding the new column. It is teaching every reader what the new null means.
I recently reviewed an EF Core change that introduced an explicit lifecycle state. The safe schema move was straightforward: add the state as nullable, leave existing rows untouched, and interpret null as the legacy active state. Newer application versions could write an explicit value while older versions continued to work with rows they already understood.
That avoided a mass update during deployment. It also created a less visible obligation: the application now had two representations of one business state.
Why the nullable expansion helps
A backfill and a new NOT NULL constraint in one deployment can be expensive and operationally awkward. It may lock a large table, make rollback harder, or create a window where different application versions disagree about the schema.
An expand-only change reduces that coupling:
- Add the nullable column.
- Keep legacy rows valid.
- Teach new readers to understand both representations.
- Move writes to the explicit representation.
- Backfill and contract later, once every reader is ready.
This is a useful compatibility pattern. It does not prove zero downtime by itself, but it gives mixed application versions a smaller, more manageable contract.
The hidden cost: two truths in the data
Suppose null and Active both mean active during the transition. A query that checks only State == Active silently loses every legacy row. A query that checks only “not deleted” may keep explicitly transitioned rows in an active workflow.
The dangerous part is not usually the first query. It is the fifteenth one, written months later by someone who never saw the migration plan.
That is why the compatibility meaning should not live in comments scattered across handlers, controllers, and background jobs. It needs one executable definition.
A deliberately generic example might look like this:
public static IQueryable<Record> WhereActive(
this IQueryable<Record> source)
=> source.Where(record =>
!record.IsDeleted &&
(record.State == null ||
record.State == RecordState.Active));
The important detail is not the syntax. It is that the transitional business rule has a name, one implementation, and a place where tests can pin its behaviour.
Write the truth table before the migration
The committed tests I reviewed covered the cases that matter:
| Stored representation | Expected meaning |
|---|---|
Legacy null
|
Active |
| Explicit active value | Active |
| Explicit transitioned value | Inactive |
| Soft-deleted row | Inactive |
That table is small, but it prevents several classes of drift. It also makes idempotency easier to reason about. If a legacy null already means active, “correcting” it to active may be a no-op rather than a new transition with a duplicate audit record.
There is a useful testing caveat here. The evidence I reviewed strongly pinned application semantics using in-memory tests. Those tests are valuable, but they do not prove provider-specific SQL translation, indexing behaviour, or migration performance. For a production rollout, I would add at least one relational-provider test that inspects the generated query and applies the migration to a representative database.
Make the temporary state visibly temporary
Nullable is doing two jobs during this rollout:
- database nullability;
- compatibility with the legacy model.
Those are not the same thing. If the second job is not documented and retired, null can become a permanent third state that every future feature must remember.
The migration plan should therefore include the contraction, not just the expansion:
- measure how many legacy rows remain;
- move every writer to explicit values;
- backfill in bounded batches;
- verify no reader depends on the legacy representation;
- add the constraint only after the data and deployed code are ready;
- simplify the predicate once
nullno longer carries business meaning.
This costs more than a one-shot migration. For a period, the code must support dual semantics, the test matrix is larger, and ad hoc queries are riskier. In return, the rollout avoids coupling a schema rewrite, application deployment, and data correction into one irreversible event.
A practical review checklist
Before approving an expand-only state migration, I now ask:
- What does
nullmean to legacy and new code? - Is that meaning centralised in one reusable predicate or specification?
- Do tests cover legacy, explicit, transitioned, and deleted states?
- Has the query been exercised against the real database provider?
- Can repeated commands remain idempotent across both representations?
- What metric shows that the compatibility state is disappearing?
- Which later deployment performs the backfill and
NOT NULLcontraction?
The broader lesson is simple: a nullable column can be a safe rollout mechanism, but it is not a complete domain model. The schema change creates a temporary compatibility contract. The engineering work is making that contract explicit, executable, observable, and disposable.
What transitional representation in your system is still waiting for its contraction step?
Top comments (0)