Strangler fig, not the big bang rewrite

The big bang rewrite has a seductive pitch: the existing system is a mess, the new one will be clean, we’ll migrate users when it’s ready. What actually happens: the rewrite starts with “just the core functionality” and spends years discovering that the old system’s quirks were load-bearing. Edge cases nobody documented, accumulated over years. Integrations with external systems nobody wrote down. Data migration complexity nobody scoped. Behavioral requirements nobody stated because everyone assumed they were obvious.

The existing system’s behavior was always the spec. Nobody ever wrote it down.

The strangler fig pattern, named by Martin Fowler, is the incremental alternative: build the new implementation alongside the old, route new usage to it, delete the old one once it’s fully adopted. Each migration is a vertical slice: pick the most important usage, migrate it end to end, ship, repeat. There’s no big cutover. At every point, there’s a rollback path.

Old: a monolithic transfer-processing module, tightly coupled to the old ledger schema

Big bang: rewrite transfer processing from scratch, cut over when "done"
  → months of parallel work, one high-risk cutover, no fallback if it breaks

Strangler fig: put a router in front of transfer processing
  Week 1: new engine handles retail transfers only, old handles the rest
          → smallest, most isolated slice; low blast radius if it's wrong
  Week 3: new engine takes business transfers too
  Week 6: old transfer module gets zero traffic, delete it
          → at every step, flipping the router back is the rollback

The router isn’t a metaphor — it’s an actual piece of code that has to exist somewhere, and it’s usually smaller than people expect:

// handlers/transfer.go — sits in front of both implementations
func handleTransfer(w http.ResponseWriter, r *http.Request) {
    customer := customerFromContext(r.Context())
    useNewEngine := customer.Segment == "retail" && featureFlags.IsEnabled(r.Context(), "transfer-engine-v2", customer)
    if useNewEngine {
        transferEngineV2Handler(w, r) // new implementation — retail transfers only, for now
        return
    }
    transferEngineV1Handler(w, r) // old implementation — still owns everything else
}

That’s the whole mechanism. transferEngineV1Handler and transferEngineV2Handler don’t know about each other, don’t share state, and can be deployed independently. Rolling back week 1’s slice is featureFlags.Disable(ctx, "transfer-engine-v2") — no deploy, no migration, no coordination with anyone. Widening the slice in week 3 is loosening the condition (customer.Segment == "retail" becomes true), not rewriting anything.

This works even when the new implementation turns out to need real changes mid-migration — that’s what the small slices are for. If week 3’s business-transfer slice reveals the new schema doesn’t handle multi-signer approval correctly, you find that out with one slice’s worth of traffic exposed, not with retail and business transfers both live on a design that’s wrong. Fix the slice, keep going. The router in front of the whole thing means “the new implementation needs to change” is a normal Tuesday, not a crisis — you’re never more than one slice’s worth of blast radius away from a clean rollback.

Spot it: proposals to “start fresh” or “greenfield this,” long-lived branches named new-architecture or v2, “we’ll migrate users when the new system is ready,” no incremental rollout plan anywhere in the proposal.

Raise it: “I think the direction is right. Could we get there incrementally instead of a full cutover? Route new traffic to the new implementation while the old one keeps running, and we validate as we go with a rollback path the whole time.”

Domain boundaries, not the distributed monolith

The distributed monolith is the worst of both worlds: the operational complexity of microservices with the coupling of a monolith. Services that share a database, call each other synchronously on every request, and require coordinated deployments aren’t independent. They’re a monolith with network hops bolted on. The hops add latency and new failure modes without buying you any of the autonomy that justified the split.

The diagnostic is simple: if two services have to deploy together and share a database, they’re not independent. Ask where the real ownership boundary is. Which data does each side actually own? If the answer isn’t obvious, the right move is usually to keep them together until a natural seam shows up on its own.

Take an “accounts” service and a “statements” service that share one database. A cron job in statements reaches straight into accounts’ tables to find who’s due, so a schema change to accounts can silently break statement generation, and a slow statement query can lock rows accounts needs mid-transaction. Two services, one outage surface.

// Before: statements reaches directly into accounts' tables
// statements/jobs/generate_monthly.go
overdueAccounts, err := accountsDB.QueryContext(ctx, `
  SELECT * FROM accounts WHERE status = 'active' AND statement_due_at < now()
`)

A schema change to accounts can silently break this query. A slow version of it can lock rows accounts needs mid-transaction. Neither team can deploy without thinking about the other. Split along the real boundary instead — accounts owns its own state and publishes what happened; statements owns its own tables and reacts:

// After: accounts publishes, statements owns its own copy of what it needs
// accounts/service.go
eventBus.Publish(ctx, "StatementDue", StatementDueEvent{AccountID: id, PeriodEnd: end})

// statements/handlers/on_statement_due.go
eventBus.Subscribe(ctx, "StatementDue", func(ctx context.Context, event StatementDueEvent) error {
    return statementsDB.Jobs.Create(ctx, StatementJob{AccountID: event.AccountID, Status: "pending"})
})

Now a statements outage doesn’t block a transfer, and a statements migration doesn’t need an accounts deploy — statementsDB is statements’ own schema, changeable on statements’ own schedule.

Independent services should be independently operable: separate databases, asynchronous communication for anything non-critical, no coordinated deployments. The seam should track a real domain boundary, not an org chart.

The boundary you draw today isn’t permanent, and it shouldn’t be treated as if it were. Maybe eighteen months later accounts and statements turn out to share so much logic that the split was actually the mistake, not the fix — that’s a legitimate outcome, not a failure of the original decision. It’s a far cheaper mistake to recover from than a distributed monolith, though: merging two independently-deployable services back together is a known, bounded piece of work. Untangling a monolith with a decade of implicit coupling baked in is the multi-year rewrite this same principle exists to prevent in the first place.

Spot it: services that query each other’s databases directly, a request that synchronously calls five other services before it can return, “we have to deploy A, B, and C together,” a service that fails whenever another “independent” service gets slow.

Raise it: “If these two have to deploy together and share a database, they’re not independent yet. We’ve added network hops without the autonomy that was supposed to come with them. Where’s the real ownership boundary? Might make more sense to keep these together until we find a natural seam.”

Abstraction management

The right abstraction multiplies productivity. The wrong one creates friction and resists every change you try to make. The cost of a wrong abstraction scales with the number of callers depending on it: change a plain function with one caller and you edit one line. Change the signature of a Repository[T] interface with twelve implementations and you’re touching twelve types, plus every test that mocks the interface, plus anywhere that pattern-matches on its shape. That’s exactly why the right time to introduce an abstraction is when you have two concrete use cases in hand, not when you’re imagining a third.

Three similar implementations beat a premature abstraction. When the third use case actually shows up, the right shape is usually obvious. Abstract after the first use case and you’re guessing at a shape that doesn’t exist yet, and you’ll usually guess wrong.

Speculative generality is an abstraction built for a requirement that doesn’t exist. The cost is real: more code, more indirection, more to hold in your head. The benefit is hypothetical. An interface with exactly one implementation, a generic type parameter that’s always instantiated with the same type, a plugin architecture for a feature nobody has ever needed to plug in.

// Over-abstracted — one Repository[T], always will be one
type Repository[T any] interface {
    Fetch(ctx context.Context, id string) (T, error)
    Save(ctx context.Context, item T) error
}
type AccountRepository struct{ /* implements Repository[Account] */ }

// Just the thing — simpler, testable with a fake HTTP client
func fetchAccount(ctx context.Context, id AccountID) (Account, error) {
    return api.Get(ctx, fmt.Sprintf("/accounts/%s", id))
}

Resume-driven development is picking a technology because it’s novel and impressive rather than because it solves the problem in front of you. Reaching for Kafka to handle a few hundred wire-confirmation events a day, when a Postgres table and a cron job would do the job with a fraction of the operational surface, is the pattern in miniature: real capability, aimed at a problem you don’t have.

Kafka, for 300 wire confirmations/day:
  a cluster, ZooKeeper or KRaft, topic partitioning, consumer group
  rebalancing, a schema registry if you want type safety, someone
  on the team who knows how to operate all of it at 2am

Postgres + cron, for 300 wire confirmations/day:
  INSERT INTO wire_events (type, payload) VALUES ($1, $2);
  SELECT * FROM wire_events WHERE processed_at IS NULL ORDER BY created_at LIMIT 100;
  — a table you already have, a job you already know how to debug

New technology is a liability: unknown failure modes, a smaller community, a steeper learning curve, and it sometimes pays off with real capability. The question is always whether the capability is worth the liability, in this specific context, not in general.

Raise it, speculative generality: “I want to make sure this abstraction earns its complexity. What’s the concrete second use case we’re designing for? If we don’t have one yet, could we ship the direct implementation and extract the abstraction when we actually need it?”

Raise it, resume-driven development: “What does this solve that our current approach doesn’t? I want to make sure we’re trading one kind of complexity for another on purpose, not by accident. What would it look like to solve this with what we already have?”

Both of these are about sequencing, not prohibition. The Repository[T] interface might be exactly right once a second and third resource genuinely need fetch-and-save with the same shape — the objection was never “abstraction is bad,” it was “you’re guessing at a shape you don’t have evidence for yet.” Same with Kafka: the day event volume and consumer count actually justify it, adopting it is a well-reasoned decision instead of a speculative one, because now there’s a real second use case (or a real few-hundred-events-a-day system that’s become a few-hundred-thousand) standing behind it instead of an imagined one.