This is the multi-page printable view of this section. Click here to print.

Return to the regular view of this page.

Evolutionary Coding Techniques

Choose the least intrusive technique for integrating incomplete work to trunk, from dark code to feature flags as a last resort.

Phase 1 - Foundations | Scope: Team

Trunk-based development requires integrating incomplete work daily without breaking trunk or exposing half-built features to users. This section covers the techniques that make that possible, ordered from least to most costly to maintain.

Deployment Is Not Release

Deployment is a technical action: pushing code to production. Release is a business decision: making a capability available to users. Evolutionary coding techniques are how you deploy continuously while controlling release independently.

Feature flags are the best-known way to make that separation, which is why teams reach for them first. But a runtime if (flag) branch is conditional complexity: every flag combination has to be tested, and the flag has to be deleted later or it becomes permanent debt. Most incomplete work does not need a flag at all. It needs to be structured so the unfinished parts are inert until they are ready.

Use the least intrusive technique that solves the problem. Reach for a flag only when nothing simpler applies.

In This Section

PageWhat You’ll Learn
Dark CodeDeploy new logic before anything calls it, so it carries zero release risk
Branch by AbstractionReplace an existing implementation behind a stable interface, one commit at a time
Parallel RunProve a new implementation matches production behavior before you trust it
Expand and ContractEvolve a shared database schema or API contract without a breaking change

The Hierarchy of Techniques

Work down this list. Each technique carries more long-term maintenance cost than the one above it.

TechniqueWhat It Costs to Maintain
Dark codeNothing. The code sits unreferenced until it’s wired in.
Branch by abstractionOne interface and one deletion once the swap is complete.
Parallel runA temporary comparison harness.
Expand and contractA multi-step migration with a defined end state.
Strangler figA routing layer, but no branching inside the code it replaces.
Feature flagsOngoing lifecycle management: an owner, a removal date, and combinatorial test cases until it’s deleted.

How to Choose

Ask these questions in order. Stop at the first “yes.”

  1. Can the change be introduced additively, with nothing pointing to it yet? Use dark code.
  2. Does an existing interface already isolate this behavior, or can you extract one first? Use branch by abstraction.
  3. Do you need to prove the new logic produces the same answer as the old logic before you trust it? Use parallel run.
  4. Are you changing a shared contract, like a database schema or an API, that other code or services depend on? Use expand and contract.
  5. Are you replacing a whole subsystem or service, not a single implementation? Use the strangler fig pattern at the routing layer.
  6. Is this strictly a business release timing decision, such as a coordinated launch, a kill switch, an entitlement, or an experiment, that none of the above can express? Use a feature flag, and only at the edge of the system.

Reaching question 6 is a legitimate reason to flag. Reaching for a flag at question 1 is not.

Rules If You Do Reach for a Flag

  • Flag at the edge, not in domain logic. Put the check in a controller, router, or top-level entry point, never buried inside business logic or a data-access layer.
  • Give every flag an expiration date at creation time. No date means the flag is permanent, and permanent release flags are debt.
  • Create the removal ticket in the same pull request that adds the flag. Not later.
  • Never nest flags. If capability B depends on capability A, extend A’s flag instead of stacking a second flag on top of it.

See Feature Flags for the full flag lifecycle, from creation through removal.

Key Pitfalls

1. “We used a flag because it was the tool we already knew”

Familiarity is not a reason to skip the hierarchy. A flag left in place after launch is technical debt that dark code or branch by abstraction would never have created in the first place.

2. “We picked branch by abstraction, but nothing was calling the old code through an interface yet”

Extract the interface first, as its own zero-behavior-change commit, before starting the swap. Introducing an abstraction and a new implementation in the same change makes the abstraction hard to review on its own merits.

3. “We treated a database migration like a code refactor”

A shared schema or API is a contract other people’s code depends on. Use expand and contract rather than branch by abstraction for anything consumed outside your own codebase.

Next Step

These techniques are what make trunk-based development safe for incomplete work. Once your team can integrate daily without flag sprawl, continue building the test architecture that backs it.

1 - Dark Code

Deploy new logic to production before anything calls it, so it carries zero release risk until you wire it in.

Phase 1 - Foundations | Scope: Team

Dark code is the default technique for integrating incomplete work. It costs nothing to maintain and requires no cleanup, because the code never runs until you decide to connect it.

What Is Dark Code?

Dark code is new logic that is fully built, tested, and deployed to production, but not yet reachable. No route, UI trigger, or message consumer points to it. It sits inert in the running binary until the final commit connects it.

This is sometimes called “connect tests last” or a “dark launch,” because the implementation is complete; only the wiring is missing.

What Dark Code Is Not

  • It is not dead code left behind after a change. Dark code is temporary and has a defined moment it becomes live.
  • It is not a feature flag. There is no runtime check and no conditional branch. The only cleanup is the wiring commit itself, which is not cleanup at all.
  • It is not untested. The code has full unit and integration test coverage before it deploys; only production traffic hasn’t reached it yet.

What Dark Code Improves

ProblemHow Dark Code Helps
A feature takes multiple days or weeks to buildEach piece integrates and deploys daily; only the last commit exposes it
Feature flag sprawlNo flag is created, so there’s nothing to track or remove later
Fear of half-built features reaching usersCode with no caller cannot be reached by any user, regardless of deployment frequency
Large, risky final pull requestsThe final change is a small wiring commit, not the whole feature

Building Behind Dark Code

Step 1: Build the implementation

Write the domain logic, service, or component with its own unit and integration tests, exactly as you would if it were shipping today. Commit and deploy continuously as you go.

Step 2: Deploy without wiring

Each commit ships to production. The code compiles, is tested, and runs inside the deployed artifact, but nothing calls it. There is no behavior change for users, because there is no path to the new code.

Dark code: new service deployed with no caller
// discountEngine.js - deployed, tested, unreferenced
class DiscountEngine {
  calculate(cart) {
    // Fully implemented and unit tested
  }
}

module.exports = DiscountEngine;

// Nothing in the request handlers imports DiscountEngine yet.
// It ships with every deploy and does nothing until it's wired in.

Step 3: Wire it in as the final change

Once the implementation is complete and reviewed, the last pull request adds the entry point: the route, the UI trigger, or the consumer binding.

Dark code: final commit adds the entry point
const DiscountEngine = require('./discountEngine');
const engine = new DiscountEngine();

app.post('/cart/checkout', (req, res) => {
  const discount = engine.calculate(req.body.cart);
  res.json({ discount });
});

This commit is small and easy to review, because all the risk was already tested and deployed in the commits before it.

When Dark Code Is Not Enough

Dark code works when you control every caller and can wait to wire the last one in. It does not work when:

  • You need to compare the new logic against production behavior before trusting it. Use parallel run instead.
  • You are replacing an implementation that already has live callers. Use branch by abstraction instead.
  • The business needs the release timed independently of when the code is ready, such as a coordinated launch or a gradual percentage rollout. Use a feature flag instead.

Key Pitfalls

1. “We wired it in early to test in production”

If you need production traffic to validate the new code before trusting it, that is a parallel run, not dark code. Wiring in an unfinished path to see what happens exposes users to unfinished work.

2. “The dark code sat unwired for three months”

Dark code should be wired in within days, not months. If the entry point keeps slipping, the feature isn’t actually close to done, and calling it dark code is hiding that from the team.

Measuring Success

MetricTargetWhy It Matters
Time from first dark commit to wiringDaysConfirms dark code isn’t a substitute for finishing the feature
Size of the final wiring commitSmall: a route or binding, not logicConfirms the risk was already tested and deployed incrementally
Feature flags created per sprintDecreasing as dark code adoption increasesConfirms flags are reserved for cases dark code can’t cover

Next Step

When you’re replacing an implementation that already has live callers instead of adding a new one, use Branch by Abstraction.

2 - Branch by Abstraction

Replace an existing implementation behind a stable interface, one small commit at a time, without a long-lived branch.

Phase 1 - Foundations | Scope: Team

Branch by abstraction lets you replace an internal implementation, algorithm, or library on trunk, without a long-lived branch and without disrupting the code that already depends on it.

What Is Branch by Abstraction?

Branch by abstraction introduces an interface over an existing implementation, redirects callers to that interface, then builds and switches in a new implementation behind it, all as small commits on trunk. The “branching” happens in the abstraction layer, not in version control.

The technique gets its name because it replaces a source-control branch with a branch in the code itself: an interface with two implementations, one of which is live.

What Branch by Abstraction Is Not

  • It is not a long-lived feature branch with an interface added to justify it. If the work still takes weeks on a branch, the abstraction hasn’t replaced anything.
  • It is not the strangler fig pattern. Branch by abstraction swaps an implementation behind an in-process interface. Strangler fig replaces a whole subsystem or service by routing traffic to it at a system boundary. Use branch by abstraction inside a codebase you own; use strangler fig when the thing being replaced is bigger than one component.
  • It is not a permanent abstraction layer. Once the swap is complete, remove the old implementation, and remove the interface too if nothing else needs it.

What Branch by Abstraction Improves

ProblemHow Branch by Abstraction Helps
Large refactors force a long-lived branchThe refactor happens in small commits on trunk, behind an interface
Fear of breaking existing callers during a rewriteCallers depend on the interface, not the implementation, so the swap is invisible to them
“Big bang” cutover riskThe switch is a single dependency-injection change, easy to revert
Dead code left behind after a migrationThe interface makes the old implementation easy to find and delete

Making the Swap

Step 1: Abstract

Introduce an interface over the existing code and redirect every caller to it. This is a zero-behavior-change commit: the interface wraps the current implementation and nothing else changes.

Step 1: introduce the interface over the existing implementation
class AuthService {
  authenticate(credentials) {
    // existing implementation, moved behind the interface unchanged
  }
}

// callers now depend on AuthService, not the concrete legacy class
const auth = new AuthService();

Step 2: Implement

Build the new implementation alongside the old one, as its own class. Commit and deploy the new class in small pieces; it isn’t wired to any caller yet, so it carries the same zero risk as dark code.

Step 2: build the new implementation alongside the old one
class LegacyAuthService {
  authenticate(credentials) {
    // existing messy implementation, untouched
  }
}

class ModernAuthService {
  authenticate(credentials) {
    // new implementation, built and tested incrementally
  }
}

Step 3: Switch

Change the dependency injection or factory binding to instantiate the new implementation instead of the old one. This is the entire cutover: one line, one commit, easy to revert.

Step 3: switch the binding to the new implementation
// container.js
container.register('AuthService', ModernAuthService); // was LegacyAuthService

If you need to de-risk the switch further, or need confidence the two implementations produce identical results first, run them side by side with a parallel run before flipping the binding.

Step 4: Prune

Delete the legacy implementation. Delete the interface too if only one implementation remains and nothing else depends on the abstraction.

Step 4: delete the legacy implementation
class AuthService {
  authenticate(credentials) {
    // just the modern implementation now
  }
}

Cleanup here is a straightforward deletion of a class. There is no scattered if/else logic to search for, because the old and new implementations were never in the same function.

Key Pitfalls

1. “We built the new implementation and the interface in the same commit”

This makes the abstraction hard to review on its own merits, and it removes the option to ship the interface as a safe, standalone step. Extract the interface first, verify it changes nothing, then start on the new implementation.

2. “We left the old implementation in place after the switch”

The switch commit is not the finish line. If the old class is still in the codebase a month later, delete it. An unused implementation behind a working interface is exactly the kind of dead weight branch by abstraction is supposed to avoid.

3. “We used branch by abstraction to replace a whole service”

If the replacement spans multiple components, teams, or a system boundary, that’s a strangler fig problem, not an in-process interface swap.

Measuring Success

MetricTargetWhy It Matters
Time from abstraction to switchDays to a few weeksConfirms the technique is replacing a branch, not becoming one
Legacy implementations still in the codebase after switchZero after the agreed cleanup windowConfirms pruning actually happens
Commits per swapMany small commits, no single large diffConfirms the refactor stayed on trunk in small pieces

Next Step

If you need to prove the new implementation matches production behavior before switching the binding, use Parallel Run.

3 - Parallel Run

Run a new implementation alongside the old one in production and compare results before you trust it.

Phase 1 - Foundations | Scope: Team

A parallel run executes the old and new code paths against the same production input, but only returns the old result to the caller. It proves correctness with real traffic before anyone depends on the new path.

What Is a Parallel Run?

A parallel run, sometimes called shadowing or a dark launch of logic, wraps a call so both the current implementation and a candidate replacement execute against identical production input. The caller always receives the current implementation’s result. The candidate’s result is captured and compared, never returned.

This technique is best known from GitHub’s open-source Scientist library, which formalized the pattern for verifying refactors of high-risk code paths.

What a Parallel Run Is Not

  • It is not a percentage rollout. Every request runs through both implementations; nothing is split between them. Percentage rollout is a feature flag concern that comes after a parallel run has already established parity.
  • It is not A/B testing or hypothesis-driven development. Users never see the candidate’s output during a parallel run, so it measures technical correctness, not user response.
  • It is not a permanent architecture. The comparison harness is temporary scaffolding, removed once the candidate becomes the primary path.

What a Parallel Run Improves

ProblemHow a Parallel Run Helps
High-risk rewrites of pricing, billing, or calculation logicMismatches surface on real production input before the new logic is trusted
“We think the refactor is equivalent, but we’re not sure”Telemetry gives statistical confidence instead of a guess
Regressions that only show up on rare production inputsEvery production request exercises both paths, including edge cases test suites miss
Risky migrations with no rollback storyThe old path stays authoritative until the data proves the new one is safe

Running the Comparison

Step 1: Wrap the call

Introduce a thin proxy around the existing call. The caller’s contract does not change.

Step 1: wrap the call so both implementations execute
async function calculatePrice(order) {
  const legacyResult = await legacyPricingEngine.calculate(order);

  // Fire the candidate asynchronously; never let it affect the response
  shadowRun(() => modernPricingEngine.calculate(order), legacyResult, order);

  return legacyResult;
}

Step 2: Run the candidate and compare

Run the new implementation against the same input, in the background, and log any mismatch along with enough context to debug it.

Step 2: compare results and log mismatches
async function shadowRun(candidateFn, legacyResult, order) {
  try {
    const candidateResult = await candidateFn();
    if (!isEquivalent(candidateResult, legacyResult)) {
      telemetry.recordMismatch('pricing-engine', {
        orderId: order.id,
        legacyResult,
        candidateResult,
      });
    }
  } catch (err) {
    telemetry.recordCandidateError('pricing-engine', { orderId: order.id, err });
  }
}

Step 3: Watch the telemetry

Track mismatch rate, candidate error rate, and performance delta over a statistically meaningful window. Investigate every mismatch; each one is either a genuine bug in the candidate or a case where the legacy behavior was wrong and needs a deliberate decision.

Step 4: Cut over and remove the harness

Once the mismatch rate holds at zero for the agreed period, switch the candidate to the primary path, typically with branch by abstraction, and delete the comparison harness. Leaving it in place after cutover is unnecessary runtime cost with no further benefit.

When a Parallel Run Is Not Enough

  • The old and new implementations must not both execute, for example when the operation has side effects like sending an email or charging a card. Idempotent, side-effect-free logic (pricing, scoring, routing decisions) is what parallel run is for. For operations with side effects, use branch by abstraction with a smaller, monitored rollout instead.
  • You are changing a shared schema or contract, not just an implementation. Use expand and contract instead.

Key Pitfalls

1. “We let the candidate’s exceptions bubble up to the caller”

The candidate’s failures must never affect the response. Catch and log every exception from the candidate path independently of the legacy path.

2. “We ran the comparison for a day and called it proven”

A parallel run needs enough volume and enough time to cover the input space that matters, including rare edge cases and periodic patterns like end-of-month billing. Set the comparison window based on when those cases actually occur, not a fixed number of days.

3. “We kept the shadow harness running after cutover”

The harness is temporary. Once the candidate is primary and stable, remove the shadow call entirely. Running both implementations forever doubles compute cost for no ongoing benefit.

Measuring Success

MetricTargetWhy It Matters
Mismatch rateTrending to zero before cutoverThe core signal that the candidate is safe to promote
Candidate error rateZero, independent of the legacy pathConfirms the candidate doesn’t crash on real production input
Time from shadow start to harness removalWeeks, not indefiniteConfirms the harness is treated as temporary scaffolding

Next Step

If the change touches a shared database schema or an API contract rather than a single implementation, use Expand and Contract.

4 - Expand and Contract

Evolve a shared database schema or API contract across non-breaking phases instead of mutating it in place.

Phase 1 - Foundations | Scope: Team

Expand and contract, also called parallel change, replaces a single breaking schema or contract change with a sequence of small, backward-compatible deployments. Nothing outside your team has to be redeployed in lockstep.

What Is Expand and Contract?

Expand and contract evolves a shared contract, a database schema, an event schema, or an API, without ever mutating it in place. Instead of replacing the old shape with the new one in a single change, you add the new shape alongside the old one, migrate consumers over incrementally, and only remove the old shape once nothing depends on it.

The name comes from the two ends of the sequence: you expand the contract to support both shapes at once, then contract it back down to just the new shape.

What Expand and Contract Is Not

  • It is not a single migration script run during a deployment window. Each phase is its own independent, reversible deployment.
  • It is not limited to databases. The same four phases apply to API fields, event schemas, and message formats: anything with more than one reader or writer.
  • It is not optional for shared contracts. Branch by abstraction is enough when only your own code depends on the thing you’re changing. Once another service, another team, or stored data depends on it, use expand and contract instead.

What Expand and Contract Improves

ProblemHow Expand and Contract Helps
Coordinated multi-service deployments for a schema changeEach phase deploys independently; producers and consumers never need to deploy at the same instant
Downtime during database migrationsThe schema is never in a state where old and new code can’t both function
No rollback path for a breaking changeEvery phase is reversible on its own; you can pause or back out at any step
Consumers of an API broken by a field changeOld and new fields coexist until every consumer has migrated

The Four Phases

Phase 1: Expand

Add the new shape alongside the old one. Nothing reads from it yet, and nothing that currently works stops working.

Phase 1: add new columns alongside the old one
ALTER TABLE users ADD COLUMN first_name VARCHAR(255);
ALTER TABLE users ADD COLUMN last_name VARCHAR(255);

Deploy this on its own. The application still reads and writes the name column exclusively. There is no behavior change.

For an API, the equivalent is adding a new field or a new endpoint version without removing the old one.

Phase 2: Dual-write and backfill

Update the write path to populate both the old and new shapes at once, then backfill existing data in the background.

Phase 2: write to both old and new columns
async function createUser(name) {
  const [firstName, lastName] = name.split(' ');
  await db.query(
    'INSERT INTO users (name, first_name, last_name) VALUES (?, ?, ?)',
    [name, firstName, lastName]
  );
}
Phase 2: backfill existing rows in the background
async function backfillNames() {
  const users = await db.query('SELECT id, name FROM users WHERE first_name IS NULL');
  for (const user of users) {
    const [firstName, lastName] = user.name.split(' ');
    await db.query(
      'UPDATE users SET first_name = ?, last_name = ? WHERE id = ?',
      [firstName, lastName, user.id]
    );
  }
}

Deploy the dual-write change, then run the backfill as its own job. Both are independently reversible: if the backfill has a problem, the application still works off the old column.

For an API, this phase is a tolerant reader: consumers ignore fields they don’t recognize, and producers populate both the old and new field until every consumer has moved.

Phase 3: Dual-read and cutover

Switch reads to consume the new shape, one caller at a time.

Phase 3: switch reads to the new columns
async function getUser(id) {
  const user = await db.query('SELECT * FROM users WHERE id = ?', [id]);
  return {
    firstName: user.first_name,
    lastName: user.last_name,
  };
}

Because Phase 2 guarantees the new columns are always populated, this switch has nothing to migrate; it’s a straightforward read-path change deployed independently of the write-path change that came before it.

Phase 4: Contract

Once every reader and writer uses the new shape exclusively, remove the old one.

Phase 4: drop the old column
ALTER TABLE users DROP COLUMN name;

For an API, this phase is deprecating and eventually removing the old field or version, once telemetry confirms no consumer still calls it.

Result: four independent deployments instead of one big-bang change. Each one is reversible on its own, and none of them requires another team or service to redeploy in the same window.

When Expand and Contract Is Not Enough

If the two shapes cannot coexist even briefly, for example a uniqueness constraint that the old and new schema can’t both satisfy, you need a more deliberate migration plan with an explicit maintenance window. That is the exception, not the default: most schema and contract changes can be expressed as expand and contract if you’re willing to take more, smaller steps.

Key Pitfalls

1. “We skipped the backfill and just changed the read path”

Reads that assume the new column is populated will fail or return nulls for any row that predates the change. Backfill before cutting over reads, not after.

2. “We dropped the old column right after the dual-write phase”

The contract phase must wait until every writer and every reader has confirmed to use the new shape only. Removing the old column while any code, including code outside your immediate deploy, still references it turns a safe migration into an outage.

3. “We coordinated the four phases into one deployment”

Collapsing the phases back into a single deployment defeats the purpose. Each phase exists so it can be deployed, verified, and rolled back independently.

Measuring Success

MetricTargetWhy It Matters
Deployments per contract changeFour independent, small deploymentsConfirms the migration stayed incremental
Downtime during migrationZeroConfirms no phase required a maintenance window
Time from expand to contractWeeks, bounded by an agreed deadlineConfirms the old shape doesn’t linger indefinitely
Consumers still on the old shape at contract timeZeroConfirms the contract phase is actually safe to run

Next Step

Return to Evolutionary Coding Techniques to see when a broader strangler fig migration, or a feature flag for business release timing, is the more appropriate tool.