Skip to content

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:

'desktop': everything | mentions | nothing

New schema:

'desktop':              everything | mentions
'desktop_push_enabled': true | false

Migration steps:

  1. Additive write: add desktop_push_enabled column. Backfill existing users: desktop_push_enabled = false if desktop == 'nothing', else true. Legacy desktop values untouched.
  2. 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.
  3. New-write-path discipline: new writes always set both desktop{everything, mentions} and desktop_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_enabled explicitly, and the legacy desktop column 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: false always 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 to desktop: 'mentions' + desktop_push_enabled: false.
Last updated · 470 distilled / 1,213 read