Hexagonal and clean architecture: switch payment providers without touching the place-order use case Intermediate
Estimated reading time: 9 min read
Read first
The problem
Section titled “The problem”The ordering service has three rules: a 5% discount when the subtotal reaches 2,000,000 VND, a stock check, and a credit limit of 5,000,000 VND per customer. All three live in OrdersController, interleaved with TypeORM orderRepo.findOne() calls and axios.post() to the payment gateway.
The team wants to switch payment providers and write unit tests for the rules. Both are blocked: a test has to boot a Nest app, a database and an axios mock; switching providers means opening the controller.
How do you separate the business core from the framework, and when is the separation worth its cost?
What to choose (summary first)
Section titled “What to choose (summary first)”| Situation | Choose | Condition |
|---|---|---|
| Simple CRUD, few rules | Layered (controller → service → repository) is enough | Rules are mostly input validation |
| Rule-heavy domain, many entry points (HTTP, queue, cron) or many external systems | Hexagonal | Some rules need fast tests, or adapters may change |
| Small team, prototype | Keep it simple, separate gradually | Start with a pure core; add a port when a second adapter appears or tests get slow |
Core concepts
Section titled “Core concepts”Cockburn’s original idea (2005)
Section titled “Cockburn’s original idea (2005)”Cockburn’s goal: the application is driven equally by users, programs, automated tests or batch scripts, and it can be tested in isolation from the real database.
- The application is in the middle, everything else is outside. The number six has no meaning.
- A port is a “purposeful conversation” between the application and the outside world, expressed as an API. In code, a port is an interface defined by the application side according to its needs.
- An adapter converts between a port and a specific technology (HTTP, SQL, an SDK).
Cockburn classifies adapters by who starts the conversation:
| Driving (primary) | Driven (secondary) | |
|---|---|---|
| Who calls whom | The outside calls into the application | The application calls out |
| Examples | HTTP controller, queue consumer, cron, tests | Postgres repository, payment client, email sender |
| Port | A use case the application offers | An interface the application needs (OrderRepository) |
The original article already mentions a fake in-memory database adapter: exactly the fake adapter used for tests below.
Robert C. Martin’s Clean Architecture (2012)
Section titled “Robert C. Martin’s Clean Architecture (2012)”Martin draws four circles: Entities, Use Cases, Interface Adapters, Frameworks and Drivers. The governing rule is the dependency rule: source code dependencies point only inward. An inner circle knows the name of nothing in an outer circle.
Loading diagram…
When a use case needs to write to a database in an outer circle, it uses Dependency Inversion: the inner circle declares the interface, the outer circle implements it.
The call flow goes outward while import still points inward. Data crossing the boundary is a simple structure, not an ORM entity.
Hexagonal, onion, clean: similarities and differences
Section titled “Hexagonal, onion, clean: similarities and differences”| Hexagonal (Cockburn, 2005) | Onion (Palermo, 2008) | Clean (Martin, 2012) | |
|---|---|---|---|
| Shape | Inside / outside, many ports | Concentric rings | Four concentric circles |
| Emphasis | Ports, driving/driven adapters, symmetry between UI and tests | Domain model at the center, repository interfaces inside the core | Dependency rule, use cases as their own circle |
| Layering inside the core | Not prescribed | Domain model, then service layers around it | Entities separate from Use Cases |
What they share: dependencies point toward the core, interfaces are declared inside, infrastructure is outside. Martin himself describes Clean Architecture as a merger of hexagonal, onion and a few other architectures. The differences are mostly vocabulary and how much inner layering is prescribed.
How it works
Section titled “How it works”The question: which parts does a place-order request pass through, and which part changes when you switch payment providers?
POST /ordersThe client sends order o-1 (2 × SKU-A, 1 × SKU-B) to the controller.
| Step | Component at work | Knows HTTP/SQL? | Change |
|---|---|---|---|
| 1–2 | OrdersController |
Yes (DTO, HTTP status) | DTO → PlaceOrderCommand |
| 3 | PostgresOrderRepository |
Yes (SQL) | Reads stock and credit limit |
| 4 | domain/order.ts |
No | Computes 2,280,000, checks stock and credit limit |
| 5 | HttpPaymentGateway |
Yes | amount_minor, card_declined → ChargeResult |
| 6 | PostgresOrderRepository |
Yes | Decrements stock, adds to balance owed, saves the order in one transaction |
| 7 | Module wiring | Yes (NestJS) | Only the PaymentGateway provider changes |
Step 4 is the pure core: hexagonal extends that idea into a boundary for the whole application.
Sample folder structure
Section titled “Sample folder structure”src/orders/ domain/ # pure rules: imports nothing outside domain application/ # use cases + ports (abstract classes) adapters/ in/http/ # driving: controller, DTO in/queue/ # driving: consumer out/postgres/ # driven: repository out/payment/ # driven: provider client orders.module.ts # composition rootOperational details
Section titled “Operational details”In NestJS 11, an interface is erased at compile time, so it cannot be a DI token; the Custom providers docs suggest an abstract class as both contract and token. The use case stays a plain class created with useFactory, so application/ does not import @nestjs/common:
export abstract class PaymentGateway { abstract charge(input: { orderRef: string; customerId: string; amountVnd: number }): Promise<ChargeResult>;}
providers: [ { provide: OrderRepository, useClass: PostgresOrderRepository }, { provide: PaymentGateway, useClass: HttpPaymentGateway }, { provide: PlaceOrder, useFactory: (o, p) => new PlaceOrder(o, p), inject: [OrderRepository, PaymentGateway] },]- Making the use case
@Injectable()also works, at the cost of the inner circle importing the framework. - How modules
exportproviders and the missing-export error: see Modules, providers, controllers and DI container. - The dependency rule does not enforce itself: add a CI step that checks imports (dependency-cruiser, ESLint
no-restricted-imports, or a script like the demo’s).
Real-world example
Section titled “Real-world example”The demo examples/src/hexagonal-architecture/ (Node 24.17, PostgreSQL 17, its own schema, dropped when done) runs the same use case and 5 tests with two adapter sets: in-memory, and PostgresOrderRepository + HttpPaymentGateway calling a simulated provider (node:http, localhost).
cd examples/src/hexagonal-architecturenpx ts-node run-both.ts && npx ts-node nest-demo.tsnpx ts-node check-deps.ts . && npx ts-node check-deps.ts bad-exampleActual output, trimmed (ms values vary per run)
[in-memory] 5 case ok happy path: 5% discount when subtotal >= 2,000,000 ... ok empty cart[postgres + http] 5 case: same 5 ok lines
in-memory : 2000 rounds x 5 case = 10.1 ms, 1.0 µs/casepostgres + http: 20 rounds x 5 case = 381.6 ms, 3816.3 µs/case
[PaymentGateway = FakePaymentGateway] 201 {"ok":true,"orderId":"mem-1","totalVnd":2280000,"paymentId":"fake-o-1"} 409 {"code":"OUT_OF_STOCK","sku":"SKU-B","requested":2,"available":0}[PaymentGateway = HttpPaymentGateway] 201 {"ok":true,"orderId":"mem-1","totalVnd":2280000,"paymentId":"ch_1"}
OK .: domain/ and application/ do not import framework/adapterVIOLATION bad-example/domain/order-entity.ts: import '@nestjs/common'VIOLATION bad-example/domain/order-entity.ts: import 'pg'2 violationsThree runs on a dev machine: in-memory around 1 µs/case, Postgres + HTTP 3.5–4.6 ms/case (including TRUNCATE and seeding per case). A real provider over the network is slower still. The numbers only show the order of magnitude.
Test roles based on this result (see Unit, integration, e2e):
- Use case unit tests with fake adapters: every rule branch.
- Integration tests per adapter against real Postgres or a sandbox: SQL, transactions, error mapping.
- A few e2e tests over HTTP to check wiring.
Trade-offs
Section titled “Trade-offs”Pros
-
Rules are tested with fake adapters, no database or network needed.
-
Switching providers or adding entry points (queue, cron) does not change the use case.
-
Boundaries can be checked automatically with import rules.
< slot="cons"> >
-
More files: a port, an adapter and wiring for each integration.
-
DTO ↔ command ↔ row mapping at every boundary, easy to miss a field.
-
A port with only one implementation is abstraction you may not need yet (backlog: “Premature abstraction”).
-
Transactions spanning several repositories are hard to keep inside a tidy port.
Cons
Production checklist
Section titled “Production checklist”- Write down which rules need separating: if no rule deserves its own tests, stop at layered.
- Name ports in the use case’s language (
charge,stockOf), not the SDK’s. - Use cases and domain do not import
@nestjs/*, the ORM or an HTTP client; add a CI step that checks imports. - Each port has an in-memory fake shared by unit tests.
- Each driven adapter has integration tests against real infrastructure or a sandbox.
- The payment adapter still needs timeouts, an idempotency key and error classification (see Adapter pattern).
- Wiring lives in one module; switching adapters means switching providers, never
newinside a use case.
Common mistakes
Section titled “Common mistakes”The most common mistake is letting ORM entities leak into the domain:
Domain uses ORM/framework types
Common
import { Entity, Column } from 'typeorm';@Entity() export class Order { @Column() total!: number; }Prefer
// domain/order.ts: plain types; adapters/out/postgres maps OrderRow <-> Orderexport interface Order { customerId: string; lines: OrderLine[]; totalVnd: number }Why
When the domain imports typeorm, unit tests drag in ORM metadata and switching ORMs means editing rules.
The second mistake is in the shape of the port:
| Misconception | Consequence | What to do |
|---|---|---|
| Hexagonal = lots of folders | Folders have the right names, imports still point the wrong way | Check the dependency rule with a tool |
| Every repository needs a port | Interfaces with one implementation, more files with no added value | Add a port when there is a second adapter or tests are slow |
Self-check
Section titled “Self-check”- In the demo, why does
application/ports.tsuse an abstract class instead of aninterface? - You add a Kafka consumer that places orders: which files are new, which stay unchanged?
- The use case calls
PaymentGatewayoutward, so is the dependency rule violated? Explain using the direction ofimport.
Summary
Section titled “Summary”References
Section titled “References”- Alistair Cockburn — Hexagonal architecture: the original goal, ports, primary/secondary adapters, the in-memory database adapter.
- Robert C. Martin — The Clean Architecture: four circles, the dependency rule, data crossing boundaries.
- Jeffrey Palermo — The Onion Architecture: domain at the center, repository interfaces in the core.
- NestJS — Custom providers: class tokens, abstract classes,
useFactorywithinject.