title: Read-time schema translation type: pattern created: 2026-04-24 updated: 2026-04-24 tags: [schema-migration, read-time, translation, adapter-layer, preference-migration, zero-downtime, rollback-safe, backward-compatibility] sources: [2026-03-19-slack-how-slack-rebuilt-notifications] related: concepts/read-time-preference-translation, patterns/expand-migrate-contract, patterns/feature-flagged-dual-implementation, concepts/backward-compatibility, concepts/coupled-vs-decoupled-database-schema-app-deploy, systems/slack-notifications-2-0
Read-time schema translation¶
Read-time schema translation is a zero-migration pattern for evolving the semantics of an existing schema: add an adapter layer at every read site that translates legacy stored values into new-schema values on the fly. Stored bytes are never rewritten; all behavioural change lives in the translator.
Canonicalised from Slack's 2026-03-19 How Slack Rebuilt Notifications post, where it was chosen over a database- level backfill specifically for rollback safety.
Shape¶
write path read path
| |
v v
┌───────────────┐ ┌──────────────┐
│ new schema │ │ legacy + │
│ columns added │ │ new stored │
│ via additive │────────────► │ values │
│ migration │ └──────┬───────┘
└───────────────┘ │
│
┌──────▼───────┐
│ translator │
│ maps legacy │
│ to new-schema│
└──────┬───────┘
│
▼
new-schema consumers
Worked example: Slack Notifications 2.0¶
Legacy schema:
New schema:
Migration steps:
- Additive write: add
desktop_push_enabledcolumn. Backfill existing users:desktop_push_enabled = falseifdesktop == 'nothing', elsetrue. Legacydesktopvalues untouched. - Translator at every read site: when reading a user's
preferences, if
desktop == 'nothing'(legacy value), return(desktop: 'mentions', desktop_push_enabled: false). Otherwise return the stored pair. - New-write-path discipline: new writes always set
both
desktop∈{everything, mentions}anddesktop_push_enabled∈{true, false}. They never write the legacy'nothing'value.
Result: users who never touch their preferences keep their
legacy 'nothing' value forever; users who do touch their
preferences get opportunistically migrated by the
new-write-path discipline. Both populations see consistent
behaviour.
When to use it¶
Use read-time schema translation when:
- Rollback safety is paramount — any migration that rewrites stored bytes is a one-way door.
- The legacy-value enumeration is small and the translation rule is deterministic (no user-specific context needed to translate).
- The new schema is a superset of the old (additive columns, not replacements).
- The system can tolerate permanent legacy values in storage — there's no operational reason to clean them up.
Do NOT use it when:
- The schema change is destructive — e.g. the legacy values represent invalid states in the new schema with no valid translation.
- The legacy-value distribution is unbounded — e.g. arbitrary user-provided strings that need structural reshaping.
- Storage cost matters and the legacy columns are large.
- The translator would need user-specific or time-specific context to translate (at which point it's no longer deterministic).
Comparison to adjacent patterns¶
- vs patterns/expand-migrate-contract: that pattern ends in a contract step where legacy values are removed from storage. Read-time translation skips the contract step; translation is permanent. Trade: cheaper + safer at cost of indefinite translator maintenance.
- vs patterns/feature-flagged-dual-implementation: feature flags are application-code-level (run the old or new code path). Read-time translation is schema- level (run the new code, translate old stored state). They compose: the translator can itself be feature- flagged during rollout.
- vs shadow migration / dual-write: those patterns maintain both the old and new schema as synchronously-updated sources of truth. Read-time translation keeps only one source of truth (the legacy schema, with new columns additively bolted on).
Preconditions¶
- Every read path must use the translator. Bypass
= split-brain bug. In large codebases this often
requires a typed accessor (
User#getPreferences()instead of raw column reads) that wraps the translator. - Write path discipline: new writes must produce
values compatible with both the legacy read path (for
in-flight rollback) and the new read path. In Slack's
case: new writes populate
desktop_push_enabledexplicitly, and the legacydesktopcolumn stores only{everything, mentions}— never'nothing'. - Fallback semantics are defined: if the translator
sees an unexpected value, what does it return? Slack's
rule: "
push_enabled: falsealways means 'no push,' even during rollbacks." The translator must have a well-defined failure mode.
Failure modes¶
- Translator bypass — a code path reads stored values and interprets them with legacy semantics, producing split-brain behavior for the subset of users who exercise that path.
- Write-path gap — new writes don't preserve enough legacy-compatible state to satisfy rollback. When the new code is rolled back, those users get empty/default preferences.
- Default-collapse incident — cache miss or malformed-field condition causes the system to serve a default value instead of the stored-and-translated value. At scale this can silently reset millions of users' preferences to defaults. Slack's disclosed incident: "A malformed field once reset preferences to Mentions until we cleaned data and flushed memcache."
- Indefinite technical debt — the translator is permanent infrastructure. Every future developer must learn the translation rule; every future schema change must compose with it.
Mitigations¶
- Write typed accessors: force every read site
through a typed API (e.g.
User#getNotificationPref()) that internally runs the translator. Make raw-column reads either impossible or visibly suspicious in code review. - Cover the cache layer explicitly: either cache the post-translation value (fastest but harder to invalidate on rule changes) or cache the raw stored value and translate on every read (safer but slower). Document the choice.
- Add a schema-version column: prepare for a future contract step if you change your mind about permanence.
- Validate translator fall-through: unit-test the translator on every possible legacy value + every possible new value. An exhaustive-match discipline catches regressions from future schema additions.
Seen in¶
- systems/slack-notifications-2-0 — canonical
instance. Legacy
desktop: 'off'translated at read time todesktop: 'mentions'+desktop_push_enabled: false.
Related¶
- concepts/read-time-preference-translation — the concept this pattern names.
- patterns/expand-migrate-contract — the canonical alternative, with the contract step this pattern skips.
- patterns/feature-flagged-dual-implementation — parallel application-layer pattern; composes with read-time translation.
- concepts/backward-compatibility — the structural property preserved.
- concepts/coupled-vs-decoupled-database-schema-app-deploy — the broader framing; this is a schema-level decouple where the translator is the boundary.
- concepts/preference-schema-decoupling — the usual motivation for wanting the schema change in the first place.