Can CeylanVienna-based, globally curious.
Learn/Architecture

Make workflow state additive before making it authoritative

When migrating a system to a new state model, the instinct is to replace the old state immediately. The safer path is additive rollout: the new state coexists with the old, falls back to it when absent, and only becomes authoritative once it is proven in production.

2026-04-25·3 min read·intermediate

The replacement instinct

When you design a better data model for your system, the natural instinct is to replace the old one. Remove the legacy fields, migrate existing records, ship the new shape. Clean, complete, done.

This works fine when the migration is safe, fast, and reversible. It becomes dangerous when:

  • The migration touches every record in a live production system
  • The new model is not yet fully proven in all code paths
  • The rollout cannot be completed in a single deploy
  • You need old code and new code to run simultaneously during the transition

The additive pattern

Instead of replacing, extend. Add the new state alongside the old. Make the new state optional and derived — built from existing data when absent, but preferred when present.

// Before: flat legacy structure
const article = {
  socialPosts: {
    linkedin: "Here is the LinkedIn post",
    twitter: "Here is the tweet",
  }
};

// After: new structured state added alongside, not replacing
const article = {
  socialPosts: { linkedin: "...", twitter: "..." }, // still there
  distributionState: {                               // new, optional
    linkedin: { textStatus: "ready", text: { body: "..." }, publishStatus: "ready" },
    twitter:  { textStatus: "ready", text: { body: "..." }, publishStatus: "ready" },
  }
};

Code that reads the new state falls back to the old state when the new one is absent:

function getPostText(article, platform) {
  // Prefer new structured state
  const structured = article.distributionState?.[platform]?.text?.body;
  if (structured) return structured;

  // Fall back to legacy flat field
  return article.socialPosts?.[platform] ?? null;
}

Three phases of additive rollout

Phase 1 — Coexistence. New state is written for new records and new operations. Old records keep working via fallback. Both states may exist simultaneously. New state is not required for anything to function.

Phase 2 — Preference. All reads prefer the new state when present. Generation and save flows start building the new state consistently. Legacy fallback is still active. New state is now the primary path for all records that have it.

Phase 3 — Authoritative. The new state is fully proven. Legacy fallback can be removed. Old fields can be deprecated. Records without new state can be lazily backfilled or explicitly migrated.

The key: do not move to Phase 3 until Phase 2 is stable in production, with real data.

Why "authoritative" is a milestone, not a starting point

The mistake is treating the new state as authoritative from the moment it is deployed. This forces every read path to have the new state present or fail — which means you need a complete migration before you can ship anything.

Treating the new state as additive first means:

  • You can ship Phase 1 and 2 incrementally
  • Real production data validates the new model before you depend on it
  • If the new model has a gap, the legacy fallback catches it instead of breaking
  • You can delay the deprecation decision until confidence is high

The invariant to preserve during rollout

The critical rule during additive rollout: do not delete or rewrite legacy state while the fallback is still active.

If legacy state disappears before the new state is proven everywhere, you lose both the primary path (not yet authoritative) and the fallback (deleted). This is how additive rollouts turn into silent data loss.

// ✓ Build new state alongside legacy, preserve legacy
await kv.set(articleKey(slug), {
  ...existing,
  socialPosts: existing.socialPosts,       // preserved
  distributionState: newDistributionState, // added
});

// ✗ Do not remove legacy while still in Phase 2
// delete article.socialPosts; // too early — fallback still needed

When to skip the additive pattern

Not every migration needs three phases. The additive pattern is worth the overhead when:

  • The new model is significantly different from the old one
  • You have existing records in production that cannot all be migrated atomically
  • Multiple code paths depend on the state (admin UI, API routes, generation flows, publish flows)

For simple field additions or renames with no fallback logic, a direct migration is usually cleaner.

More like this, straight to your inbox.

I write about Architecture and a handful of other things I actually care about. No schedule, no filler — just when I have something worth saying.

More on Architecture

Preventing a single channel from becoming the accidental default in multi-channel systems

In multi-platform publishing and notification systems, whichever channel was implemented first tends to become the silent default. Other channels get skipped without error. The fix is making required outputs explicit from the start — not implicit from what exists.

When the bug isn't a bug: diagnosing runtime barriers before debugging

Some failures aren't bugs in your code — they're structural mismatches between your runtime and the capability you're trying to use. Recognising this distinction saves days of misdirected debugging.

Soft deletes aren't just for audit trails — they're your sales pipeline

When you hard-delete a record, you lose the sales lead. Inactive records in a marketplace platform are your best prospects — enforce soft delete at the database role level, not in application code.

If this raised a question, I'd be happy to talk about it.

Find me →
← Back to Learn