SOLID, hexagonal, refactoring vocabulary, and decision machinery

What it is

Four bodies of vocabulary that a staff-level conversation assumes, held together by one idea: they are all about where you put boundaries and how you change them later.

SOLID is five principles about class and module design. Hexagonal, Clean and Onion architecture are three names for one idea about dependency direction. Refactoring vocabulary (strangler fig, branch by abstraction, seams) is how you move a boundary in a running system. Decision machinery (one-way doors, ADRs, C4, DACI) is how you decide where the boundary goes and record why.

What this is confused with: treating these as rules to comply with. SOLID applied mechanically produces an interface per class and a factory per interface, which is worse than the code it replaced. Each principle has a cost, and the mature version is knowing when the cost exceeds the benefit, which is what "with the caveats" means below.

SOLID, with the mature caveats

S: Single Responsibility. "A class should have one reason to change."

The mature reading: "one reason to change" means one STAKEHOLDER or one
axis of change, not "one method" or "does one thing."

The failure: decomposing until every class has one method, producing
40 files where 3 would do, and the coupling moves from within a class
to between files, where it is harder to see.

S is the most abused principle, because "does one thing" is subjective and always argues for more decomposition. Use "one reason to change" and ask who requests the change: if billing and reporting both change this class for different reasons, split it; if you are splitting because a class has 200 lines, do not.

O: Open/Closed. "Open for extension, closed for modification."

The mature reading: a caveat, not a goal. Predicting extension points
before you have two real cases produces the WRONG abstraction, which is
more expensive than the duplication it avoided.

Rule of three: duplicate twice, abstract on the third, when you can see
what varies.

"Duplication is far cheaper than the wrong abstraction" (Sandi Metz) is the counterweight, and it is the correction to O's most common misapplication: building a plugin architecture for a requirement that never arrives.

L: Liskov Substitution. "A subtype must be usable wherever its supertype is."

The one with NO caveats: violating it is a real bug.
The classic: Square extends Rectangle. setWidth(5); setHeight(4);
             a Rectangle has area 20 and a Square has area 16.
             Code written against Rectangle breaks.

L is the principle that is straightforwardly correct, and violations produce genuine defects rather than aesthetic complaints.

I: Interface Segregation. "No client should depend on methods it does not use."

The mature reading: this is about COMPILE-TIME and DEPLOY-TIME coupling.
A client depending on a fat interface must be rebuilt and redeployed when
an unrelated method on it changes.

Less relevant in dynamic languages and in services communicating over
HTTP, where the interface is the wire contract.

D: Dependency Inversion. "Depend on abstractions, not concretions."

The mature reading: this is the one that matters most, and it is what
hexagonal architecture is. The point is DEPENDENCY DIRECTION, not
interfaces everywhere.

The failure: an interface for every class with exactly one implementation,
which adds indirection and no substitutability. An interface earns its
place when there are two implementations OR a real test seam OR a
genuine boundary.

The honest summary: L is a rule, D is a design principle worth internalising, S is useful with the right reading, O is a caveat, and I matters mostly for compile-time coupling. Applying all five uniformly produces over-engineered code, and saying so is the mature position.

Hexagonal, Clean and Onion: one idea

             ┌─────────────────────────────┐
   HTTP ───▶ │  adapter (driving)          │
   CLI  ───▶ │                             │
             │   ┌─────────────────────┐   │
             │   │   APPLICATION       │   │
             │   │  ┌───────────────┐  │   │
             │   │  │   DOMAIN      │  │   │
             │   │  └───────────────┘  │   │
             │   └─────────────────────┘   │
             │  adapter (driven)           │ ───▶ Postgres
             │                             │ ───▶ Kafka
             └─────────────────────────────┘

The single rule: dependencies point INWARD. The domain knows nothing about the database, the HTTP framework, or the message broker.

// DOMAIN defines the port (an interface it owns).
package domain;
public interface OrderRepository {          // the domain OWNS this
    Optional<Order> findById(OrderId id);
    void save(Order order);
}

// INFRASTRUCTURE implements it (the adapter).
package infrastructure.persistence;
class PostgresOrderRepository implements domain.OrderRepository { ... }

The direction is the whole idea: infrastructure depends on domain, never the reverse. That is Dependency Inversion applied at the architectural scale, and it is why the three names describe one thing: Hexagonal calls them ports and adapters, Onion calls them layers, Clean calls them circles, and all three say dependencies point inward toward the domain.

What it buys:

- the domain is testable with no database, no HTTP, no broker
- swapping Postgres for DynamoDB touches ONE package
- the business rules are readable without framework noise

What it costs, honestly:

- a mapping layer between domain objects and persistence models
  (an ORM entity and a domain entity are different objects)
- more files, more indirection
- for a CRUD service with no domain logic, it is pure overhead

The judgement: apply it where there is real domain logic, and skip it where the service is a thin layer over a table. A service whose "domain" is validating three fields and calling save does not need ports and adapters, and imposing them there is where the pattern gets its reputation for ceremony.

Refactoring vocabulary

Strangler fig: incrementally replace a system by routing traffic feature-by-feature to the new one, with the old one shrinking until it can be deleted.

      ┌── facade / router ──┐
      │                     │
  new system            old system
  (grows)               (shrinks)
The properties that make it work:
  - a routing layer (a proxy, a facade) that can send a request to
    either implementation
  - INCREMENTAL: one endpoint or feature at a time, each independently
    reversible
  - the old system stays in production until the last route moves

The named alternative is the rewrite, which fails for the documented reason: the old system encodes years of edge cases nobody wrote down, and a rewrite rediscovers them in production. Strangler fig's value is that each step is small and reversible, so a wrong step costs one feature rather than the project.

Branch by abstraction: make a large change on trunk without a long-lived branch.

1. Introduce an abstraction over the thing you are changing.
2. Point existing callers at the abstraction (no behaviour change).
3. Add a NEW implementation behind the abstraction.
4. Migrate callers to the new implementation, INCREMENTALLY, behind a flag.
5. Delete the old implementation and (optionally) the abstraction.

This is how you do a large refactor with trunk-based development, and the alternative (a six-week branch) produces a merge that nobody can review and that conflicts with everything.

Seams (Michael Feathers): a place where you can change behaviour without editing the code there.

An object seam:  a dependency injected rather than constructed inside,
                 so a test can substitute it.
A link seam:     a different library at link time.
A preprocessor seam: conditional compilation.

"Legacy code is code without tests" and seams are how you get tests into it: you cannot test a class that constructs its own database connection, so you introduce a seam (inject the connection) as the first, behaviour-preserving step. Finding the seam is the skill, and it is what makes a legacy codebase tractable.

Decision machinery

One-way versus two-way doors (Bezos): the single most useful framing for decision speed.

TWO-WAY DOOR:  reversible. Decide fast, with incomplete information,
               and reverse if wrong.
               e.g. a library choice inside one service, a UI layout,
               a feature flag default.

ONE-WAY DOOR:  expensive or impossible to reverse. Decide slowly, gather
               information, write it down.
               e.g. a public API contract, a data model that will be
               populated with billions of rows, a database engine choice,
               a Kubernetes maxParallelism setting, an event schema.

Most decisions are two-way doors treated as one-way, which is where organisational slowness comes from. And the genuinely one-way doors are frequently treated as two-way, which is where the expensive mistakes come from. The skill is classifying correctly, and the examples in this book are full of one-way doors people did not recognise: a compacted topic's partition count, Flink's maxParallelism, a DynamoDB shard count, an Iceberg partition spec (which Iceberg made two-way, which is precisely its contribution).

ADRs (Architecture Decision Records): a short document per decision, in the repository.

# ADR-014: Use an event store for the ledger aggregate

## Status
Accepted (2026-08-04)

## Context
Balance discrepancies cannot be explained or corrected because the
current-state store is overwritten. ~6 unexplained discrepancies/month.

## Decision
Event-source the ledger aggregate only. Other aggregates remain
state-based.

## Consequences
+ retroactive correction becomes possible
+ full audit trail
- upcasters are permanent and accumulate
- onboarding is harder: "where is the balance" is non-obvious
- projection rebuild is ~40 min at current volume

The Consequences section including the negatives is what makes an ADR useful. An ADR listing only benefits is marketing, and the value six months later is reading what the team knew they were accepting. The second value is defeating the "why is it like this" question, which otherwise consumes a senior engineer's time repeatedly.

C4 model: four levels of architecture diagram at decreasing zoom.

1. Context:   your system, its users, and the systems it talks to
2. Container: the deployable units (services, databases, SPAs)
3. Component: the major parts inside one container
4. Code:      classes (rarely worth drawing; generate it if you need it)

C4's contribution is that a diagram must declare its zoom level, because the recurring failure is one diagram mixing a user, a Kubernetes pod, a database table and a class. Most teams need levels 1 and 2 and stop there, and level 4 is almost never worth maintaining by hand.

DACI / RACI: who is Driver, Approver, Contributor, Informed.

Its value is naming the Approver, because the recurring failure is a decision everyone discusses and nobody makes. A decision with no named approver defaults to whoever is most persistent, which is not a good selection function.

A worked example: a legacy extraction using all four

A monolithic order system, 11 years old, 400,000 lines, no tests on the pricing logic. Requirement: extract pricing into a service so a second product line could use it.

The decision, recorded first.

# ADR-031: Extract pricing via strangler fig, not rewrite

## Context
Pricing is 40k lines in the monolith with no tests. It encodes 11 years
of rules, many undocumented. A second product line needs it.

## Options
1. Rewrite pricing as a new service from the specification.
2. Strangler fig: extract incrementally behind a facade.

## Decision
Option 2.

## Rationale
Option 1's risk is the undocumented rules. We estimate 200+ pricing
edge cases; the specification documents ~60. A rewrite rediscovers
the rest in production.

## Consequences
+ each step is independently reversible
+ the old path stays until the last rule moves
- slower: ~2 quarters vs an estimated 1 for a rewrite
- both implementations exist simultaneously (a maintenance cost)

Naming the 200-versus-60 edge cases is what made this a decision rather than a preference, and the ADR is where that reasoning survives.

Step 1: find the seam. Pricing was called from 34 places, each constructing a PricingCalculator directly.

// Before: no seam. Untestable, unreplaceable.
class OrderService {
    BigDecimal total(Order o) {
        return new PricingCalculator(db, config).calculate(o);   // constructed inline
    }
}
// Step 1: introduce a seam. NO behaviour change.
interface PricingPort { Money price(Order o); }

class OrderService {
    private final PricingPort pricing;                            // injected
    OrderService(PricingPort pricing) { this.pricing = pricing; }
    Money total(Order o) { return pricing.price(o); }
}

That change is branch-by-abstraction step 1 and 2: an abstraction introduced, callers pointed at it, no behaviour changed. It shipped in a day and was independently valuable, because pricing became testable for the first time.

Step 2: characterisation tests through the seam.

// Capture what the system ACTUALLY does, not what it should do.
// 8,400 real orders replayed through the old calculator, outputs recorded.
@ParameterizedTest @MethodSource("recordedOrders")
void legacyPricingUnchanged(Order o, Money expected) {
    assertEquals(expected, legacyPricing.price(o));
}
edge cases DISCOVERED by characterisation tests:  214
edge cases in the specification:                   61

Two hundred and fourteen against a documented sixty-one validated the ADR's rationale exactly: a rewrite would have shipped 153 unknown behaviour changes.

Step 3: the new implementation behind the abstraction.

class PricingServiceClient implements PricingPort { ... }   // the new service
// Step 4: migrate incrementally, behind a flag, comparing outputs.
class ComparingPricingPort implements PricingPort {
    public Money price(Order o) {
        Money old = legacy.price(o);
        if (flags.enabled("pricing.shadow", o)) {
            Money neu = remote.price(o);
            if (!old.equals(neu)) metrics.mismatch(o, old, neu);   // OBSERVE
        }
        return flags.enabled("pricing.use-new", o) ? remote.price(o) : old;
    }
}

Shadow first, then switch: run both, compare, and only route to the new one when the mismatch rate is zero. That is the strangler fig's routing layer plus shadow traffic (see shadow traffic).

week 1 shadow:    mismatch rate 4.2%   (139 distinct rules differing)
week 6 shadow:    mismatch rate 0.02%  (2 rules, both legacy BUGS,
                                        deliberately not replicated)
week 8:           routing switched, 1% -> 100% over two weeks
week 11:          old implementation deleted

The two remaining mismatches were legacy bugs, and the decision not to replicate them was itself recorded as an ADR, because "the new service intentionally differs here" is exactly the thing a future engineer will file as a defect.

Step 5: the hexagonal boundary in the new service.

pricing-service/
  domain/          Money, PricingRule, Order (no framework imports)
  application/     PriceOrder use case
  adapters/
    inbound/http/  the REST controller
    outbound/db/   PostgresRuleRepository implements domain.RuleRepository

The domain package has no imports from the framework or the database, which was enforced with an ArchUnit test:

@ArchTest
static final ArchRule domain_is_clean = noClasses().that()
    .resideInAPackage("..domain..")
    .should().dependOnClassesThat()
    .resideInAnyPackage("..adapters..", "org.springframework..", "javax.persistence..");

The ArchUnit test is what makes the architecture real rather than aspirational, because without enforcement the dependency direction erodes on the first deadline.

Step 6: SOLID applied with judgement, not uniformly.

Interfaces introduced:  4  (RuleRepository, PricingPort, RateProvider,
                            TaxCalculator)
  each with 2+ implementations OR a genuine test seam.

Interfaces NOT introduced:  the ~30 domain classes with one implementation
                            and no substitutability requirement.

Four interfaces, not thirty-four, and the reasoning was Dependency Inversion at the boundaries rather than an interface per class. The team's previous service had 60 interfaces for 62 classes, which was the anti-pattern this deliberately avoided.

Final:

                              rewrite (est.)  strangler fig (actual)
duration                      1 quarter       2 quarters
edge cases discovered
  before production           61 (the spec)   214 (characterisation)
behaviour changes shipped
  unintentionally             ~153 (est.)     0
reversibility at each step    no              yes
production incidents          unknown         0
interfaces in the new service n/a             4 (not 34)

Two quarters instead of one, and zero unintentional behaviour changes. The ADR's rationale (the gap between documented and actual rules) was the prediction, and the characterisation tests confirmed it at 214 against 61.

The decision machinery earned its place twice: the ADR recorded why the slower path was chosen, so the "why is this taking two quarters" question had a written answer, and the second ADR recorded the deliberate divergence from two legacy bugs, so nobody files them as defects.

Production evidence

Robert Martin's SOLID and the subsequent decades of critique are both worth knowing. Dan North's "SOLID is not solid" and Sandi Metz's "duplication is far cheaper than the wrong abstraction" are the mainstream counterweights, and the mature position is that L is a rule and the others are heuristics with costs.

Alistair Cockburn's Hexagonal Architecture (2005), Jeffrey Palermo's Onion (2008) and Robert Martin's Clean (2012) are three formulations of inward-pointing dependencies, and their convergence is the evidence that the idea is real independent of the naming.

Martin Fowler's Strangler Fig (named for the tree) is the reference for incremental replacement, and his and Paul Hammant's writing on branch by abstraction is the trunk-based alternative to long-lived refactor branches.

Michael Feathers' Working Effectively with Legacy Code defines seams and characterisation tests, and the definition "legacy code is code without tests" is the framing that makes the technique follow.

Bezos's 2015 shareholder letter introduced one-way and two-way doors as a decision-speed framing, and it has become the standard vocabulary for why most decisions should be made quickly.

Michael Nygard's ADR format (2011) is the canonical template, and the adoption of ADRs as files in the repository (rather than a wiki) is what makes them survive.

Simon Brown's C4 model is the widely-used answer to inconsistent architecture diagrams, and its insistence that a diagram declares its zoom level is the contribution.

The debate

Is SOLID still good advice? Partially, and the honest version is per-principle: Liskov is a rule whose violation is a bug; Dependency Inversion is worth internalising and is what hexagonal architecture is; Single Responsibility is useful under the "one reason to change" reading and harmful under "does one thing"; Open/Closed is a caveat rather than a goal and predicting extension points produces the wrong abstraction; Interface Segregation matters mostly for compile-time coupling. Applying all five uniformly produces over-engineered code, and the mature answer says which ones and why.

Should every service be hexagonal? No. It buys a testable domain and swappable infrastructure at the cost of a mapping layer and more files. For a service with real domain logic, worth it; for a thin layer over a table, it is ceremony. The judgement of which you have is the skill, and imposing ports and adapters on a CRUD service is where the pattern earns its reputation.

Strangler fig or rewrite? Strangler fig, in almost every case, and the reason is specific: the old system encodes undocumented edge cases, and a rewrite rediscovers them in production. In the worked example characterisation tests found 214 edge cases against 61 in the specification. The rewrite is defensible only when the old system's behaviour is genuinely not worth preserving, which is rarer than teams believe when they are frustrated with a codebase.

How much decision machinery is too much? ADRs for one-way doors and for decisions whose rationale will be questioned; nothing for two-way doors. The failure is a process that requires an ADR for a library choice inside one service, which is a reversible decision that should be made in five minutes. The one-way/two-way classification is what tells you which machinery to apply, and getting that classification wrong in either direction is the actual cost: slow on reversible decisions, or fast on irreversible ones.

What makes an ADR useful six months later? The Consequences section including the negatives, and the Context including the numbers. An ADR that lists only benefits is marketing; one that says "upcasters are permanent and onboarding gets harder" is a record of what the team knowingly accepted, which is what a future reader needs.

Follow-up Q&A

"Is SOLID still relevant?"

Per-principle. Liskov is a rule and violating it is a genuine defect. Dependency Inversion is the one worth internalising, and hexagonal architecture is it at the architectural scale. Single Responsibility is useful under "one reason to change, asked by one stakeholder" and harmful under "does one thing," which always argues for more decomposition. Open/Closed is a caveat rather than a goal, because predicting extension points before two real cases produces the wrong abstraction, which is more expensive than the duplication. Applying all five uniformly is how you get an interface per class with one implementation.

"What is hexagonal architecture, in one sentence?"

Dependencies point inward: the domain owns its interfaces (ports) and infrastructure implements them (adapters), so the domain knows nothing about the database, the HTTP framework or the broker. Hexagonal, Clean and Onion are three names for that one rule. It buys a domain testable without infrastructure and swappable adapters, and it costs a mapping layer and more files, so it is worth it where there is real domain logic and it is ceremony over a thin CRUD service.

"Strangler fig or rewrite?"

Strangler fig almost always, because the old system encodes undocumented edge cases that a rewrite rediscovers in production. In one extraction, characterisation tests replaying 8,400 real orders found 214 distinct edge cases against 61 in the specification, so a rewrite would have shipped about 153 unintentional behaviour changes. The strangler fig took two quarters instead of an estimated one and shipped zero.

"What is branch by abstraction and why does it matter?"

A way to make a large change on trunk without a long-lived branch: introduce an abstraction over the thing you are changing, point existing callers at it with no behaviour change, add the new implementation behind it, migrate callers incrementally behind a flag, then delete the old one. It matters because the alternative is a six-week branch producing a merge nobody can review and that conflicts with everything, and because each step is independently shippable and reversible.

"What is a one-way door and why does the distinction matter?"

A decision that is expensive or impossible to reverse, versus a two-way door you can undo. Two-way doors should be decided fast with incomplete information; one-way doors deserve information-gathering and a written record. It matters because most organisational slowness is two-way doors treated as one-way, and most expensive mistakes are one-way doors treated as two-way: a compacted topic's partition count, Flink's maxParallelism, a public API contract, an event schema. The skill is classifying correctly.

"What makes an ADR worth writing?"

The Context with the numbers that drove the decision and the Consequences including the negatives. An ADR listing only benefits is marketing; the value six months later is reading what the team knew it was accepting, like "upcasters are permanent" or "onboarding gets harder." The second value is that it answers "why is it like this" without consuming a senior engineer repeatedly. Write them for one-way doors and for decisions whose rationale will be questioned, and not for reversible library choices.

Common misconceptions

"SOLID is a checklist to comply with." Each principle has a cost. Applied uniformly they produce an interface per class and a factory per interface, which is worse than the code they replaced. Liskov is the only one whose violation is straightforwardly a bug.

"Single Responsibility means a class does one thing." It means one reason to change, which means one stakeholder or one axis of change. "Does one thing" is subjective and always argues for more decomposition.

"Hexagonal, Clean and Onion are different architectures." They are three formulations of inward-pointing dependencies. The naming differs; the rule does not.

"A rewrite is faster." The old system encodes edge cases nobody documented, and the rewrite finds them in production. One measurement: 214 actual edge cases against 61 documented.

"ADRs are documentation overhead." For a one-way door, the ADR is the record of what was knowingly accepted, and it defeats the recurring "why is it like this" question. For a two-way door, writing one is the overhead, which is why the classification matters.

Interview delivery note

Say this verbatim: "The one-way versus two-way door distinction is the most useful decision framing I know, because most organisational slowness is reversible decisions treated as irreversible, and most expensive mistakes are the reverse. A compacted topic's partition count and an event schema are one-way doors people treat as two-way." A framing plus concrete examples from real systems.

The senior-versus-staff separator is stating which SOLID principles have costs. A senior engineer applies SOLID. A staff engineer says Liskov is a rule whose violation is a defect, Dependency Inversion is worth internalising and is what hexagonal architecture is, Single Responsibility is useful only under the "one reason to change" reading, and Open/Closed predicting extension points before two real cases produces the wrong abstraction, which is more expensive than the duplication it avoided. Having a position on each, rather than reciting five, is the signal.

The second signal is characterisation tests as the argument against a rewrite. "We replayed 8,400 real orders and found 214 edge cases against 61 in the specification" turns "rewrites are risky" from an opinion into a measurement, and it is the number that wins the argument with a stakeholder who wants the faster path.

Further reading

  • Alistair Cockburn's Hexagonal Architecture, read alongside Martin's Clean Architecture, for the same rule stated twice.
  • Michael Feathers, Working Effectively with Legacy Code, for seams and characterisation tests.
  • Martin Fowler on the Strangler Fig application and Paul Hammant on branch by abstraction.
  • Michael Nygard's ADR template and Simon Brown's C4 model, for the decision and diagram machinery.