Layered, hexagonal and clean architecture
Monday, 10:00: switching payment providers would save StayHub €30,000 a month, but the current provider's SDK is imported in 23 files, three of them inside the booking rules, and the payment tests take 14 minutes.
Keep your business rules at the center and plug the database, payment provider and web framework in around them.
- Problem
Business rules (pricing, refunds, overbooking) are tangled with SQL, HTTP and the payment SDK, so every infrastructure change breaks the rules and tests need a real database.
- Why the simple system fails
Classic layering points the dependency downward to the database: the business layer imports the ORM and the Stripe SDK, so the most valuable code depends on the most volatile code.
- Engineering concept
Invert the dependency: the core owns interfaces (ports) for what it needs, and infrastructure plugs in as adapters. Source-code dependencies only point inward.
- Technology
Layered (controller → service → repository), hexagonal (ports & adapters), clean architecture (entities, use cases, adapters, frameworks), dependency injection at a composition root.
- Trade-offs
More files, interfaces and indirection. Worth it when rules are rich and long-lived; overkill for CRUD apps, scripts and prototypes.
The problem
2 min- Karim · CTOA second payment provider would save about €30,000 a month. How fast can we switch?
- You · Engineer at StayHubThe current SDK is imported in 23 files, three inside the booking rules. And payment tests take 14 minutes.
- Lina · Senior engineer, your mentorYou lead this one. Commit to a guess first.
The big idea
4 minMake the arrows point inward: business rules declare what they need in their own words, and the database and vendors plug in from outside.
A hotel whose front desk is built around one laundry company's forms must remodel to switch. A hotel that writes its own spec ("pick up at 8:00, back in 24 h") can hire any company that meets it. The spec is the ; each company is an .
How it works
5 minIn a app, business code calls down into data access and vendors, so the most valuable code depends on the most changeable code. architecture flips that; draws the same idea as rings. Separate policy (the business rules: deposits, refunds) from details (database, payment SDK, HTTP).
- 1Driving adapter
The HTTP controller turns JSON into a plain
BookRoomcommand. No business rules here. - 2Use case
BookRoomloads the room, asks for the deposit (30% for non-refundable rates), and calls ports. - 3Port owned by the core
A port is an interface the core owns, in business words:
PaymentPort.charge(), with outcomes paid, declined, provider unavailable. - 4Driven adapter
An adapter implements a port with a real tool (Stripe, Adyen) or a fake for tests, translating vendor words and errors. Only it imports the SDK.
- 5Composition root
main.tsis the one place that picks adapters, for example Adyen for 10% of EU bookings.
The law behind it is the : source code only points inward. Adapters import the core; the core imports no adapter.
Watch a decision being made
4 minYou switch providers through a PaymentPort (Lina reviews)
Move from Stripe to Adyen gradually (10% of EU bookings first) without touching booking rules, and make payment tests fast and reliable.
- 1List what the core needs, write the port in its words
The 23 call sites boil down to charge and refund; outcomes: paid, declined, provider unavailable.
tsexport type PaymentResult = | { ok: true; paymentId: string } | { ok: false; reason: "declined" | "provider_unavailable" }; export interface PaymentPort { charge(i: { amountCents: number; token: string; idempotencyKey: string }): Promise<PaymentResult>; refund(paymentId: string, amountCents: number): Promise<void>; }Why: A port designed from the core's needs fits any provider; one designed from Stripe's API only fits Stripe.
- 2Move Stripe behind an adapter first
adapters/stripe.tsmapscard_declinedtodeclined, anything unexpected toprovider_unavailable. Replace the 23 SDK calls.Why: No behaviour change, so it ships safely before Adyen exists.
- 3Test the rules with a fake
FakePaymentsapproves or declines on demand. Deposit and refund tests move onto it: 14 minutes → about 6 seconds, no sandbox keys.Why: Fast, reliable tests are half the value of a port.
- 4Add Adyen behind the same tests, switch gradually
The same contract tests run against the fake, Stripe and Adyen.
main.tsroutes 10% of EU bookings to Adyen. A lint rule forbids SDK imports in the core.Why: Two adapters passing one suite is the evidence the port is right; a gradual switch limits damage if it isn't.
In real life
3 minNetflix Studio: hexagonal on purpose
- Engineers publicly described hexagonal architecture for studio apps
- Data sources behind ports, so one could be swapped for another
Pays off when data sources and vendors are expected to change.
37signals: Rails conventions, few layers
- Its founders have publicly argued against indirection added mainly for tests
- Models talk to the database directly through the framework
Pays off when rules are moderate and infrastructure rarely changes.
What goes wrong
3 minVendor concepts leak into the core; the second provider doesn't fit.
Write the port from the use case's needs.
Ports around logging or date formatting triple files without isolating real change.
Ports at slow, external or changing boundaries: payments, email, database, the clock.
If the controller decides refunds, you have the ceremony without the benefit.
Decisions in the core; adapters only translate.
Trade-offs
3 min| Style | Fits when | You pay |
|---|---|---|
| Simple layered | CRUD screens, prototypes, internal tools | Rules depend on DB and SDKs; slow tests |
| Hexagonal | Rich rules plus external systems, a credible vendor switch | More files, mapping, lint rules |
| Full clean architecture | Long-lived, rule-heavy systems, large teams | Highest ceremony; easy to over-apply |
- Omar · On-call SREI want ports on everything: admin screens, reports, photo upload. No more surprises.
- Lina · Senior engineer, your mentorYesterday was a badly designed port, not a missing one. The admin screens have no rules.
- Omar · On-call SRETwo styles in one codebase confuse new hires.
Your job as tech lead
4 minClaude Code can write the code for this. You still own the decisions, and you have to know what good looks like to accept or reject its work.
- What the port says, in business words:
charge,refund; resultspaid,declined,provider_unavailable. Claude copies the vendor's API shape unless you decide. - How each adapter translates vendor errors, and that the core never retries a decline.
- Where ports are worth it (payments with two providers) and where not (the hotel-address admin screen); and how the 10% rollout is watched.
We're moving card payments from Stripe to Adyen gradually. The Stripe SDK is in 23 files, 3 inside booking rules. First list those call sites grouped by what the core needs and propose a PaymentPort with only charge and refund, our own result type (paid, declined, provider_unavailable) and an idempotency key; no vendor types. Wait for approval. Then move Stripe behind an adapter, add a FakePayments adapter, one contract suite for all adapters, and a lint rule. Route no traffic to Adyen.
- The port file has no
Stripe.orAdyentypes and no vendor words like PaymentIntent. - Each adapter maps a decline to
declined, never retried. - Payment unit tests run in seconds without sandbox keys, and the same contract tests run against fake, Stripe and Adyen.
- The port takes or returns the vendor's types: a Stripe adapter in disguise.
- The core retries every failure the same way, declines included.
- It deletes the slow integration tests instead of keeping a contract test.
Check yourself
2 minPaymentPort interface?Next episode: hotels upload 4,000 photos on Sunday evenings, the web servers choke resizing them, and Karim asks whether this is a job for serverless.
Mark it complete to update your skill map.
Go deeper (optional)You don't need this to understand the lesson. Open it when a project needs the details: Technical deep dive, Visual diagram, Analogy, Why it matters, Check your understanding, Practice ladder, Explain it yourself.
Technical deep dive
You lead; Lina reviews. The folder layout already tells you the dependency direction:
src/modules/payments/ domain/ deposit.ts, refund-policy.ts (imports nothing outside domain/) application/ charge-booking.ts, ports.ts (imports domain/ only) adapters/ stripe.ts, adyen.ts, fake.ts src/main.ts composition root
Switching providers through a PaymentPort
Move StayHub from Stripe to Adyen gradually (10% of EU bookings first) without touching booking rules, and make payment tests fast and reliable.
- 1List what the core needs
Read the 23 call sites and group them: charge a deposit or full amount, refund, and nothing else. The outcomes the core handles: paid, declined, provider unavailable.
Why: A port designed from the core's needs fits any provider. One designed from Stripe's API only fits Stripe.
- 2Write the port in business words
Declare it in
application/ports.ts.tsexport type PaymentResult = | { ok: true; paymentId: string } | { ok: false; reason: "declined" | "provider_unavailable" }; export interface PaymentPort { charge(i: { amountCents: number; token: string; idempotencyKey: string }): Promise<PaymentResult>; refund(paymentId: string, amountCents: number): Promise<void>; }Why: The core owns the contract, so adapters depend on the core and not the reverse.
- 3Move Stripe behind an adapter
Create
adapters/stripe.tsimplementing the port. It translatescard_declinedintodeclined, everything unexpected intoprovider_unavailable. Replace the 23 SDK calls with calls to the use cases.Why: The current provider goes behind the port first, with no behaviour change, so you can ship it safely before Adyen exists.
- 4Test the rules with a fake
Write
FakePayments(approve or decline on demand, records charges). Move deposit and refund tests onto it: 14 minutes → about 6 seconds, no sandbox keys, no flaky runs.Why: Fast, reliable tests are half the value of a port. They also prove the core no longer needs a real provider.
- 5Add Adyen and switch gradually
Write
adapters/adyen.ts, run the same contract tests against both adapters, then pick the adapter inmain.ts: Adyen for 10% of EU bookings, watch declines and errors, then raise it.Why: Two adapters passing the same tests is the evidence the port is right. A gradual switch limits the damage if it isn't.
- 6Lock the dependency rule
Lint rule:
domain/andapplication/may not import any payment SDK or database client. Record the decision in a short ADR.Why: Without a check, an SDK import sneaks back into the rules within months.
| Layered | Hexagonal (ports & adapters) | Clean architecture | |
|---|---|---|---|
| Picture | Horizontal stack | Core with ports on each side | Concentric rings |
| Dependency direction | Down, toward the database | Into the core | Inward only (dependency rule) |
| Who owns the DB interface? | The data layer | The core (port) | The use-case ring |
| Business test needs a DB? | Usually yes | No, use a fake adapter | No, use a fake adapter |
| Extra ceremony | Low | Medium (ports + adapters) | Highest (often data objects per ring) |
| Good default for | CRUD apps, small teams | Rich domains with several integrations | Long-lived, rule-heavy systems with strict boundaries |
Visual diagram
Source-code dependencies in a hexagonal booking service. Notice the arrows into the core: adapters depend on the ports, never the reverse.
Analogy
- In a hotelLobby built around one laundry's trucksIn your codeLayered: rules tied to the database
- In a hotel"Pick up at 8, return in 24 hours"In your codeA port
- In a hotelAny laundry company meeting that specIn your codeAn adapter
- In a hotelHouse rules at the centerIn your codeThe core in clean architecture
- In a hotelA trainee playing the laundry companyIn your codeA fake adapter for tests
Where the analogy breaks: changing laundry companies takes a phone call. In code, the port only stays clean if you refuse to let vendor-specific types leak into it.
Why it matters
Picture a booking service that talks to Stripe and the database directly, everywhere.
Finance finds a cheaper payment provider. But Stripe is woven into 23 files, so switching takes months.
Testing the refund rule needs a real database and fake network calls. The tests take 14 minutes, so people run them less, and bugs ship.
The overbooking check gets copied into three places, and the copies drift apart.
Keeping rules at the center fixes all three. Swap an adapter, test rules in milliseconds with fakes, and give each rule one home.
Check your understanding
Practice ladder
Trace a decline through the layers
Code Warm-upStayHub's code after the fix:
// adapters/adyen.ts
if (r.resultCode === "Authorised") return { ok: true, paymentId: r.pspReference };
if (r.resultCode === "Refused") return { ok: false, reason: "declined" };
return { ok: false, reason: "provider_unavailable" };
// application/charge-booking.ts
const res = await payments.charge(input);
if (!res.ok && res.reason === "provider_unavailable") return retryLater(input); // max 2 retries, same idempotencyKey
if (!res.ok) return { error: "card_declined_message" };
// adapters/http/booking.controller.ts
if (result.error === "card_declined_message") res.status(402).json({ message: "Your card was declined." });Trace two cases: (a) Adyen returns "Refused"; (b) Adyen returns "Error" twice, then "Authorised". For each, say how many charge attempts reach Adyen, how many times the card is charged, and what the guest sees.
Finish the fake payment adapter
Code PracticeLina's fake for unit tests is missing two parts. Fill in the blanks so tests can (1) simulate a decline and an outage, and (2) check that the same idempotency key never charges twice.
export class FakePayments implements PaymentPort {
charges = new Map<string, number>(); // idempotencyKey -> amount
constructor(private behaviour: "approve" | "decline" | "unavailable" = "approve") {}
async charge({ amountCents, idempotencyKey }) {
// ___ (1) return the right result for "decline" and "unavailable"
// ___ (2) if this key was already charged, don't charge again
this.charges.set(idempotencyKey, amountCents);
return { ok: true, paymentId: `fake_${idempotencyKey}` } as const;
}
async refund() {}
}A port for channel managers
Design PracticeStayHub pushes bookings to hotels' channel managers. Today the code calls SiteLink directly in 9 files. Hotel Partners must now support a second vendor, RoomBridge, which uses different words ("reservation" vs "booking"), returns errors as HTTP 409 for "room already sold" and 503 for outages, and has no idempotency keys (it de-duplicates by your booking reference).
Design the port and the two adapters: the port's methods and result type, what each adapter translates, how you test the core, and how you pick the vendor per hotel.
Ports for the hotel-photo admin?
Decision StretchThe hotel-photo admin lets partners upload, reorder and delete photos. It has no business rules beyond "max 60 photos per hotel". It stores files in object storage and rows in PostgreSQL. An engineer proposes giving it full ports and adapters (StoragePort, PhotoRepository, use cases, mappers) "to match Payments".
Write a decision: "We chose X over Y because…, accepting the risk of…", and say what would make you add ports later.
Explain it yourself
Explain to a teammate why the booking core should define a PaymentPort instead of importing the payment SDK, and when you would not bother.