Skip to content
Episode 33 · Twenty-three files know our payment provider

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.

30 min intermediateLayered architectureHexagonal architecture (ports & adapters)Clean architecture (concentric layers)The dependency rule (point inward)Coupling & cohesion
  1. 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.

  2. 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.

  3. 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.

  4. Technology

    Layered (controller → service → repository), hexagonal (ports & adapters), clean architecture (entities, use cases, adapters, frameworks), dependency injection at a composition root.

  5. Trade-offs

    More files, interfaces and indirection. Worth it when rules are rich and long-lived; overkill for CRUD apps, scripts and prototypes.

01

The problem

2 min
Monday, 10:00. Finance has a proposal.
  1. Karim · CTO
    A second payment provider would save about €30,000 a month. How fast can we switch?
  2. You · Engineer at StayHub
    The current SDK is imported in 23 files, three inside the booking rules. And payment tests take 14 minutes.
  3. Lina · Senior engineer, your mentor
    You lead this one. Commit to a guess first.
02

The big idea

4 min
Which way do the arrows point?
The big idea

Make 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 .

03

How it works

5 min

In 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).

One booking, hexagonal style
  1. 1
    Driving adapter

    The HTTP controller turns JSON into a plain BookRoom command. No business rules here.

  2. 2
    Use case

    BookRoom loads the room, asks for the deposit (30% for non-refundable rates), and calls ports.

  3. 3
    Port owned by the core

    A port is an interface the core owns, in business words: PaymentPort.charge(), with outcomes paid, declined, provider unavailable.

  4. 4
    Driven 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.

  5. 5
    Composition root

    main.ts is 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.

04

Watch a decision being made

4 min
Worked example

You 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.

  1. 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.

    ts
    export 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.

  2. 2Move Stripe behind an adapter first

    adapters/stripe.ts maps card_declined to declined, anything unexpected to provider_unavailable. Replace the 23 SDK calls.

    Why: No behaviour change, so it ships safely before Adyen exists.

  3. 3Test the rules with a fake

    FakePayments approves 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.

  4. 4Add Adyen behind the same tests, switch gradually

    The same contract tests run against the fake, Stripe and Adyen. main.ts routes 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.

The whole thing in one line: Port from the core's needs, current vendor behind an adapter first, fakes for fast tests, one contract suite for every adapter, a one-line switch in the composition root, and a lint rule to keep it.
05

In real life

3 min
Ports everywhere, or framework conventions

Netflix 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.

06

What goes wrong

3 min
Ports shaped like the vendor SDK

Vendor concepts leak into the core; the second provider doesn't fit.

Instead

Write the port from the use case's needs.

An interface for everything

Ports around logging or date formatting triple files without isolating real change.

Instead

Ports at slow, external or changing boundaries: payments, email, database, the clock.

Anaemic core, fat adapters

If the controller decides refunds, you have the ceremony without the benefit.

Instead

Decisions in the core; adapters only translate.

07

Trade-offs

3 min
StyleFits whenYou pay
Simple layeredCRUD screens, prototypes, internal toolsRules depend on DB and SDKs; slow tests
HexagonalRich rules plus external systems, a credible vendor switchMore files, mapping, lint rules
Full clean architectureLong-lived, rule-heavy systems, large teamsHighest ceremony; easy to over-apply
Friday, 14:00. The day after the blocked cards.
  1. Omar · On-call SRE
    I want ports on everything: admin screens, reports, photo upload. No more surprises.
  2. Lina · Senior engineer, your mentor
    Yesterday was a badly designed port, not a missing one. The admin screens have no rules.
  3. Omar · On-call SRE
    Two styles in one codebase confuse new hires.
08

Your job as tech lead

4 min

Claude 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.

Decisions you own
  • What the port says, in business words: charge, refund; results paid, 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.
Tell Claude Code
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.
Check before you accept
  • The port file has no Stripe. or Adyen types 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.
Red flags
  • 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.
09

Check yourself

2 min
Q1/3Reasoning
In a hexagonal booking service, which folder should contain the PaymentPort interface?
How confident are you?

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.

Finished?

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.
10

Technical deep dive

Building the PaymentPort

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
Worked example

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.

  1. 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.

  2. 2Write the port in business words

    Declare it in application/ports.ts.

    ts
    export 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.

  3. 3Move Stripe behind an adapter

    Create adapters/stripe.ts implementing the port. It translates card_declined into declined, everything unexpected into provider_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.

  4. 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.

  5. 5Add Adyen and switch gradually

    Write adapters/adyen.ts, run the same contract tests against both adapters, then pick the adapter in main.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.

  6. 6Lock the dependency rule

    Lint rule: domain/ and application/ 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.

The whole thing in one line: You designed a two-method PaymentPort from the core's needs, moved Stripe behind an adapter, cut payment tests from 14 minutes to 6 seconds with a fake, added Adyen behind the same tests, switched 10% of EU traffic in one line, and locked the rule with lint.
LayeredHexagonal (ports & adapters)Clean architecture
PictureHorizontal stackCore with ports on each sideConcentric rings
Dependency directionDown, toward the databaseInto the coreInward only (dependency rule)
Who owns the DB interface?The data layerThe core (port)The use-case ring
Business test needs a DB?Usually yesNo, use a fake adapterNo, use a fake adapter
Extra ceremonyLowMedium (ports + adapters)Highest (often data objects per ring)
Good default forCRUD apps, small teamsRich domains with several integrationsLong-lived, rule-heavy systems with strict boundaries
Same goal, different amounts of structure. Pick the least structure that removes your actual pain.
11

Visual diagram

Click a component

Source-code dependencies in a hexagonal booking service. Notice the arrows into the core: adapters depend on the ports, never the reverse.

12

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.

13

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.

14

Check your understanding

Q1/3Decision
Which project gains the least from a full ports-and-adapters structure?
How confident are you?
15

Practice ladder

Step 1 · Trace

Trace a decline through the layers

Code Warm-up

StayHub'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.

Step 2 · Complete

Finish the fake payment adapter

Code Practice

Lina'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() {}
}
Step 3 · Solve

A port for channel managers

Design Practice

StayHub 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.

Step 4 · Decide

Ports for the hotel-photo admin?

Decision Stretch

The 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.

16

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.

0 words