Evolving Architecture
Changing a system that is already running: technical debt, refactoring, the strangler fig pattern, branch by abstraction, fitness functions and architecture reviews.
Architecture is never finished
Most architecture work is not designing new systems but changing existing ones while they run. Requirements change, load grows, teams split, and the decisions that were right five years ago start to hurt. Martin Fowler wrote in 2003 that "one of an architect's most important tasks is to remove architecture by finding ways to eliminate irreversibility in software designs": the more of a system you can change cheaply, the less you have to get right up front.
Kestrel Energy's billing system KBS (Kestrel Billing System) is a typical case. It has run Kestrel's tariffs, invoices, payments and dunning since the 2010s, grew one feature at a time, and works. It is also hard to change: the portal reads its tables straight from the shared database db01, and a change to how advance payments are calculated touches code all over the application. Many people would call it a legacy system. In software, Michael Feathers' well-known working definition of legacy code is code without tests: code you cannot change with confidence. This page is about changing such a system safely, step by step, without stopping the business that depends on it.
Technical debt
Ward Cunningham introduced the metaphor in a 1992 experience report on a portfolio management system: "Shipping first time code is like going into debt. A little debt speeds development so long as it is paid back promptly with a rewrite. … The danger occurs when the debt is not repaid." Technical debt is the gap between the design a system has and the design it would need to be easy to change today. The principal is the effort to close the gap; the interest is the extra effort every change costs while it stays open.
Cunningham later stressed that the metaphor is not an excuse for sloppy work: "I'm never in favor of writing code poorly, but I am in favor of writing code to reflect your current understanding of a problem even if that understanding is partial." Debt arises naturally when you learn more about the problem than the code reflects. It also arises when the world changes: a design built for 10,000 customers is debt at 400,000, through no fault of the people who built it.
The technical debt quadrant
Fowler's technical debt quadrant (2009) sorts debt along two axes: was it taken on deliberately or inadvertently, and was that prudent or reckless? His example sentences make the four kinds easy to recognize:
| Reckless | Prudent | |
|---|---|---|
| Deliberate | "We don't have time for design" | "We must ship now and deal with consequences" |
| Inadvertent | "What's Layering?" | "Now we know how we should have done it" |
In Fowler's words, "the useful distinction isn't between debt or non-debt, but between prudent and reckless debt." When the portal was launched in 2019, letting it read billing tables directly on db01 was prudent and deliberate: the launch date was fixed, and building an API in KBS would have taken months. The missing tests on KBS's tariff rules are reckless and inadvertent: nobody decided to skip them; nobody knew how. Prudent debt comes with a plan to repay it; reckless debt needs a different conversation, about skills and time, not just about code.
Making debt visible: the debt register
Debt that nobody writes down never gets repaid, because it always loses to the next feature. Joost Bakker keeps a short register for KBS, with a rough estimate of the principal and the monthly interest of each item. The numbers are judgment, not measurement, but even rough numbers turn "we should clean this up some day" into a comparison:
# KBS technical debt register: principal = hours to fix,
# interest = hours lost every month while the debt stays
debts = [
("Portal reads billing tables on db01", 120, 16),
("No automated tests for tariff rules", 80, 20),
("CustomerManager class, 3,800 lines", 160, 12),
("db01 still on PostgreSQL 16", 40, 2),
]
print(f"{'Item':42} {'Fix (h)':>7} {'Cost/month (h)':>14} {'Pays back in':>13}")
for item, principal, interest in sorted(debts, key=lambda d: d[1] / d[2]):
months = principal / interest
print(f"{item:42} {principal:7} {interest:14} {months:10.1f} mo")
total = sum(interest for _, _, interest in debts)
developer_month = 140 # working hours in a month
print(f"Interest on all four: {total} h a month, "
f"{total / developer_month:.0%} of one developer")
Item Fix (h) Cost/month (h) Pays back in No automated tests for tariff rules 80 20 4.0 mo Portal reads billing tables on db01 120 16 7.5 mo CustomerManager class, 3,800 lines 160 12 13.3 mo db01 still on PostgreSQL 16 40 2 20.0 mo Interest on all four: 50 h a month, 36% of one developer
Interest in hours is not the whole story. The server db01 runs PostgreSQL 16, several major versions behind the current release. The upgrade costs little time today, but once that version leaves community support it becomes a security risk, which hours do not capture. The register goes into the Product Backlog next to the features, where the Product Owner can order it with everything else, instead of on a separate list that only developers read. Arc42 has a section for it too: "Risks and Technical Debts" (see Documenting Architecture).
Refactoring
Fowler defines refactoring (the noun) as "a change made to the internal structure of software to make it easier to understand and cheaper to modify without changing its observable behavior." The verb means applying a series of such small changes: rename a function, extract a method, move a class to another module, replace a conditional with a lookup table. Each step is small enough to check, and the system works after every one of them.
The phrase that matters is "without changing its observable behavior". You can only know that if something checks it, so refactoring depends on tests: the unit tests and regression tests of the code you change. Legacy code often has none. Then the first step is a characterization test: run the existing code on many inputs, record what it does (right or wrong) and compare the new code against that recording.
Here is KBS's monthly advance payment calculation from 2014, and two attempts to tidy it up. The first looks obviously equivalent:
from decimal import Decimal, ROUND_HALF_UP
def advance_legacy(annual_kwh, price_cents):
"""Monthly advance payment in cents, as KBS has computed it since 2014."""
x = annual_kwh * price_cents / 12
x = int(x + 0.5) # round half up to whole cents
return x
def advance_refactored_v1(annual_kwh, price_cents):
return round(annual_kwh * price_cents / 12)
def advance_refactored_v2(annual_kwh, price_cents):
monthly = Decimal(annual_kwh * price_cents) / 12
return int(monthly.quantize(Decimal(1), rounding=ROUND_HALF_UP))
# Characterization test: record what the old code does, compare the new code
cases = [(kwh, price) for kwh in range(1000, 5001, 50) for price in range(22, 36)]
expected = {case: advance_legacy(*case) for case in cases}
for candidate in (advance_refactored_v1, advance_refactored_v2):
diffs = [c for c in cases if candidate(*c) != expected[c]]
print(f"{candidate.__name__}: {len(cases)} cases, {len(diffs)} different")
for kwh, price in diffs[:2]:
print(f" {kwh} kWh at {price} ct: legacy {expected[(kwh, price)]}, "
f"new {candidate(kwh, price)}")
advance_refactored_v1: 1134 cases, 75 different 1050 kWh at 23 ct: legacy 2013, new 2012 1050 kWh at 27 ct: legacy 2363, new 2362 advance_refactored_v2: 1134 cases, 0 different
Python's round() rounds halves to the nearest even
number (2012.5 becomes 2012), while the old code always rounded
halves up. Version 1 changed the advance payment by one cent in 75
of the 1,134 cases: not a refactoring but a behavior change, and in
billing a visible one. Version 2 uses Decimal with
explicit half-up rounding (see
Floating-Point Numbers
for why money and binary floats do not mix) and matches the old
behavior in every case. Only now can the clearer version replace the
old one.
The strangler fig pattern
When the change is bigger than a refactoring, a full rewrite is tempting: build the new system next to the old one and switch over on a fixed date. Big-bang rewrites have a poor track record. The old system keeps changing while the new one is built, the new one has to match years of undocumented behavior before it is useful at all, and the switch-over is one large, risky change.
Fowler described the alternative in 2004, inspired by a 2001 trip to the rain forests of Queensland, where he saw strangler figs: plants that seed in the branches of a host tree and grow down to the soil until they can stand on their own. The strangler fig pattern replaces a system gradually: put a routing facade in front of it, move one piece of functionality at a time to new code, route the requests for that piece to the new code, and repeat until the old system is small enough to retire or to leave alone. Fowler first called it "Strangler Application" and later renamed it, because of the violent connotations of the old name. The Azure Architecture Center summarizes it as "incrementally migrate a legacy system by gradually replacing specific pieces of functionality with new applications and services."
At Kestrel the facade is a small routing layer in front of
billing-api, KBS's HTTP interface. The first step,
CHG-0752, moves the generation of annual statement PDFs to a new
statement-renderer service: a self-contained job with
clear inputs and outputs, which makes it a low-risk first step. The
second, CHG-0761, moves meter reading validation. The facade's
routing table is the whole migration plan in a few lines:
# The routing facade in front of billing-api: the longest matching prefix wins
phases = {
"today": {"/": "KBS"},
"after CHG-0752": {"/": "KBS",
"/statements/pdf": "statement-renderer"},
"after CHG-0761": {"/": "KBS",
"/statements/pdf": "statement-renderer",
"/readings/validate": "reading-validator"},
}
def route(routes, path):
matches = [prefix for prefix in routes if path.startswith(prefix)]
return routes[max(matches, key=len)]
requests = ["/invoices/2026-09", "/statements/pdf/2025",
"/readings/validate", "/payments/direct-debit"]
print(f"{'path':24}" + "".join(f"{name:20}" for name in phases).rstrip())
for path in requests:
row = f"{path:24}" + "".join(f"{route(r, path):20}" for r in phases.values())
print(row.rstrip())
path today after CHG-0752 after CHG-0761 /invoices/2026-09 KBS KBS KBS /statements/pdf/2025 KBS statement-renderer statement-renderer /readings/validate KBS KBS reading-validator /payments/direct-debit KBS KBS KBS
Each step can be rolled back by changing one route, which makes it a small, well-understood change in change enablement terms rather than a high-risk cutover. Kestrel does not plan to replace all of KBS: ADR-0003 turns the rest of it into a modular monolith. The strangler fig works just as well when you move out only the parts that need to move, and stop.
Branch by abstraction
The strangler fig works at the edge of a system, where requests can be routed. Sometimes the part you want to replace sits deep inside: KBS's homegrown tariff engine is called from invoicing, the portal export and the annual statements. Replacing it on a long-lived feature branch means months of merge conflicts and one huge merge at the end.
Branch by abstraction is, in Fowler's words, "a technique for making a large-scale change to a software system in gradual way that allows you to release the system regularly while the change is still in-progress." Paul Hammant introduced the name, crediting Stacy Curl with coming up with it, in his writing on trunk-based development. The steps:
- Create an abstraction (an interface) for the part you want to replace, with the old code as its first implementation.
- Move every caller onto the abstraction, one at a time. Nothing changes for users; each move is a small refactoring that can be released.
- Build the new implementation behind the same abstraction, in small commits on the main branch. A feature flag decides which implementation answers.
- Switch over, first for a few contracts, then for all.
- Delete the old implementation, and often the flag.
The abstraction is also the perfect place for a parallel run: call both implementations, answer with the trusted one and report every difference. Joost's switch below answers with the new engine only for contract C-1001 (the test group) and compares both engines for every contract:
from dataclasses import dataclass
from decimal import Decimal
from typing import Protocol
@dataclass(frozen=True)
class Contract:
id: str
kwh: int
tariff: str
class TariffCalculator(Protocol): # step 1: the abstraction
def annual_cost(self, contract: Contract) -> Decimal: ...
class LegacyTariffEngine:
PRICES = {"fixed": Decimal("0.27"), "variable": Decimal("0.25")}
def annual_cost(self, contract):
cost = contract.kwh * self.PRICES[contract.tariff]
return cost.quantize(Decimal("0.01"))
class NewTariffEngine: # step 3: the replacement
PRICES = {"fixed": Decimal("0.27"), "variable": Decimal("0.25")}
def annual_cost(self, contract):
cost = contract.kwh * self.PRICES[contract.tariff]
if contract.tariff == "variable" and contract.kwh > 4000:
cost -= Decimal("25.00") # volume discount: intended or a bug?
return cost.quantize(Decimal("0.01"))
class EngineSwitch:
"""Behaves like any TariffCalculator. Runs both engines, reports every
difference, and answers with the new engine only for flagged contracts."""
def __init__(self, legacy, replacement, new_for):
self.legacy, self.replacement, self.new_for = legacy, replacement, new_for
def annual_cost(self, contract):
old = self.legacy.annual_cost(contract)
new = self.replacement.annual_cost(contract)
if old != new:
print(f" MISMATCH {contract.id}: legacy {old}, new {new}")
return new if contract.id in self.new_for else old
def print_invoice(calculator: TariffCalculator, contract: Contract): # step 2: callers
print(f"{contract.id}: {calculator.annual_cost(contract)}")
switch = EngineSwitch(LegacyTariffEngine(), NewTariffEngine(), new_for={"C-1001"})
for contract in [Contract("C-1001", 2800, "fixed"), Contract("C-1002", 3500, "variable"),
Contract("C-1003", 4200, "variable"), Contract("C-1004", 1900, "fixed")]:
print_invoice(switch, contract)
C-1001: 756.00 C-1002: 875.00 MISMATCH C-1003: legacy 1050.00, new 1025.00 C-1003: 1050.00 C-1004: 513.00
The mismatch for C-1003 is exactly what a parallel run is for. The
new engine gives a volume discount the old one never gave. Femke
Jansen, head of billing, has to decide whether that is a bug in the
new engine or a rule the old engine forgot; until then, customers
keep getting the legacy answer. In Python the abstraction is a
Protocol, which nothing has to declare that it
implements. In Java the same steps are spelled out in the types: the
interface is explicit, and each engine says
implements TariffCalculator, so the compiler refuses a
replacement that does not fit.
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.List;
import java.util.Map;
import java.util.Set;
record Contract(String id, int kwh, String tariff) {}
interface TariffCalculator { // step 1: the abstraction
BigDecimal annualCost(Contract contract);
}
class LegacyTariffEngine implements TariffCalculator {
private static final Map<String, BigDecimal> PRICES =
Map.of("fixed", new BigDecimal("0.27"), "variable", new BigDecimal("0.25"));
public BigDecimal annualCost(Contract contract) {
return PRICES.get(contract.tariff())
.multiply(BigDecimal.valueOf(contract.kwh()))
.setScale(2, RoundingMode.HALF_UP);
}
}
class NewTariffEngine implements TariffCalculator { // step 3: the replacement
private static final Map<String, BigDecimal> PRICES =
Map.of("fixed", new BigDecimal("0.27"), "variable", new BigDecimal("0.25"));
public BigDecimal annualCost(Contract contract) {
BigDecimal cost = PRICES.get(contract.tariff())
.multiply(BigDecimal.valueOf(contract.kwh()));
if (contract.tariff().equals("variable") && contract.kwh() > 4000) {
cost = cost.subtract(new BigDecimal("25.00")); // intended or a bug?
}
return cost.setScale(2, RoundingMode.HALF_UP);
}
}
class EngineSwitch implements TariffCalculator {
private final TariffCalculator legacy;
private final TariffCalculator replacement;
private final Set<String> newFor;
EngineSwitch(TariffCalculator legacy, TariffCalculator replacement, Set<String> newFor) {
this.legacy = legacy;
this.replacement = replacement;
this.newFor = newFor;
}
public BigDecimal annualCost(Contract contract) {
BigDecimal old = legacy.annualCost(contract);
BigDecimal now = replacement.annualCost(contract);
if (old.compareTo(now) != 0) {
System.out.println(" MISMATCH " + contract.id() + ": legacy " + old + ", new " + now);
}
return newFor.contains(contract.id()) ? now : old;
}
}
public class Main {
static void printInvoice(TariffCalculator calculator, Contract contract) { // step 2
System.out.println(contract.id() + ": " + calculator.annualCost(contract));
}
public static void main(String[] args) {
TariffCalculator calculator = new EngineSwitch(
new LegacyTariffEngine(), new NewTariffEngine(), Set.of("C-1001"));
List<Contract> contracts = List.of(
new Contract("C-1001", 2800, "fixed"), new Contract("C-1002", 3500, "variable"),
new Contract("C-1003", 4200, "variable"), new Contract("C-1004", 1900, "fixed"));
for (Contract contract : contracts) {
printInvoice(calculator, contract);
}
}
}
C-1001: 756.00 C-1002: 875.00 MISMATCH C-1003: legacy 1050.00, new 1025.00 C-1003: 1050.00 C-1004: 513.00
Note compareTo rather than equals: Java's
BigDecimal.equals also compares the scale, so
1050.0 and 1050.00 would count as
different. The interface,
records
and dependency on the abstraction only are the same ideas that
Layers and Boundaries
uses to keep the core of a system independent of its details.
Fitness functions
Architectural rules written in a wiki erode one pull request at a time. "The Tariffs module must not depend on Payments" holds until a developer under deadline pressure imports one helper function, and then the modules are coupled again. Neal Ford, Rebecca Parsons and Patrick Kua borrowed a term from evolutionary computing for the fix in Building Evolutionary Architectures (2017; second edition 2022 with Pramod Sadalage). A fitness function is an automated, objective check of an architectural characteristic: the Thoughtworks Technology Radar describes it as something that "provides an objective integrity assessment of some architectural characteristics". Examples:
- Dependency rules: module A must not import module B; the domain code must not import database drivers; no cycles between modules.
- Performance budgets: the 99th-percentile response time in a load test stays under 300 ms (see tail latency).
- Operational rules: every service exposes a health endpoint; no container image runs as root; every ADR has a status.
When Kestrel decided to turn KBS into a modular monolith (ADR-0003),
Joost wrote down which module may use which, and turned that table
into a check. It parses the import statements with Python's
ast module instead of searching text, so comments and
strings cannot fool it:
import ast
# Which KBS modules each module may import (the rest of KBS is off limits)
ALLOWED = {
"tariffs": set(),
"invoicing": {"tariffs", "accounts"},
"payments": {"invoicing", "accounts"},
"accounts": set(),
}
# In CI these would be read from the files in kbs/; here they are inline
sources = {
"kbs/tariffs/rules.py": "from decimal import Decimal\n",
"kbs/invoicing/run.py": "from kbs.tariffs import rules\nimport kbs.accounts\n",
"kbs/payments/dunning.py": "from kbs.invoicing.run import open_invoices\n",
"kbs/tariffs/discounts.py": "from kbs.payments.dunning import is_in_dunning\n",
}
def kbs_imports(source):
for node in ast.walk(ast.parse(source)):
if isinstance(node, ast.Import):
names = [alias.name for alias in node.names]
elif isinstance(node, ast.ImportFrom) and node.module:
names = [node.module]
else:
continue
for name in names:
parts = name.split(".")
if parts[0] == "kbs" and len(parts) > 1:
yield node.lineno, parts[1]
violations = 0
for path, source in sorted(sources.items()):
module = path.split("/")[1]
for line, target in kbs_imports(source):
if target != module and target not in ALLOWED[module]:
print(f"FAIL {path}:{line}: {module} must not import {target}")
violations += 1
print(f"{len(sources)} files checked, {violations} violation(s)")
# In CI, end with: sys.exit(1 if violations else 0)
FAIL kbs/tariffs/discounts.py:1: tariffs must not import payments 4 files checked, 1 violation(s)
A fitness function earns its name by running automatically, on every change, as a quality gate in the pipeline. A GitHub Actions workflow for the check above:
name: architecture-fitness
on:
pull_request:
paths:
- "kbs/**"
jobs:
module-boundaries:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
with:
python-version: "3.14"
- run: python3 tools/check_module_boundaries.py
You rarely need to write such checks from scratch. For Java, ArchUnit expresses dependency rules as unit tests; for Python, import-linter checks import contracts declared in a configuration file. Check the current state of any such tool before adopting it. The principle matters more than the tool: a rule the pipeline checks survives deadline pressure; a rule in a document does not.
Architecture reviews
An architecture review is a deliberate look at an architecture, or a proposed change to it, against its quality goals: are the important decisions still right, and what are the risks? Reviews come in very different sizes, and the right size depends on how much is at stake:
- Heavyweight. The Architecture Tradeoff Analysis Method (ATAM) from the Software Engineering Institute brings stakeholders together for structured sessions, builds a utility tree of quality goals and produces risks, sensitivity points and trade-off points (see Quality Attributes and Trade-offs). Worth it for large, expensive or safety-critical decisions; too much for most teams' daily work.
- Per decision. Every ADR is reviewed in a pull request by the people affected, before it is accepted. This is the review most teams can afford for every significant decision.
- Periodic. A short session every quarter or so: walk through the context and container diagrams, the fitness function results, the debt register and recent incidents. Are the diagrams still true? Did an incident point at a structural weakness? Which ADRs need revisiting?
At Kestrel, the problem record PRB-0712 for INC-22047 is the main input for the first periodic review: Ravi, Joost and Hamid Rahimi (the DBA) will go through the shared use of db01, the subject of the proposed ADR-0007. Incidents and postmortems are among the best inputs for an architecture review, because they show where the structure actually failed rather than where people expected it to.
Common mistakes
- "Technical debt" as a polite word for bad code. Cunningham meant code that reflects an earlier, partial understanding. Reckless shortcuts are real, but call them what they are, and fix the cause, not only the code.
- The big rewrite. Stopping feature work for a year to rebuild a working system rarely ends well. Replace it piece by piece with the strangler fig, and deliver value at every step.
- "Refactoring" that changes behavior. If the output changes, it is not a refactoring. Separate the two, and prove the structure-only change with tests.
- Long-lived feature branches for big changes. Branch by abstraction and feature flags keep the change on the main branch, merged and released continuously.
- Rules that only live in documents. An architectural rule without a fitness function is a wish. Automate the ones that matter.
- Strangling without moving the data. A new service that still reads the legacy tables is the old coupling with an extra network hop.