Skip to content
Knowledge Hub

Hexagonal and clean architecture: switch payment providers without touching the place-order use case Intermediate

Estimated reading time: 9 min read

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?

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

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.

The question: which parts does a place-order request pass through, and which part changes when you switch payment providers?

A request passes through a driving adapter, the use case and a driven port; replacing the Payment adapter does not touch PlaceOrder
A request passes through a driving adapter, the use case and a driven port; replacing the Payment adapter does not touch PlaceOrderStep 1/7: The client sends order o-1 (2 × SKU-A, 1 × SKU-B) to the controller.Driving adapterApplication: use case+ portDriven adapterHTTP clientPOST /ordersOrdersControllerDTO → commandPlaceOrderuse caseOrderRepositoryportPaymentGatewayportPostgresOrderRepopgHttpPaymentGatewayfetchFakePaymentGatewayin-memoryPOST /orders
HTTP client → OrdersControllerPOST /orders
Step 1/7

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

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 root

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 export providers 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).

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

Terminal window
cd examples/src/hexagonal-architecture
npx ts-node run-both.ts && npx ts-node nest-demo.ts
npx ts-node check-deps.ts . && npx ts-node check-deps.ts bad-example
Actual 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/case
postgres + 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/adapter
VIOLATION bad-example/domain/order-entity.ts: import '@nestjs/common'
VIOLATION bad-example/domain/order-entity.ts: import 'pg'
2 violations

Three 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):

  1. Use case unit tests with fake adapters: every rule branch.
  2. Integration tests per adapter against real Postgres or a sandbox: SQL, transactions, error mapping.
  3. A few e2e tests over HTTP to check wiring.

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

  1. Write down which rules need separating: if no rule deserves its own tests, stop at layered.
  2. Name ports in the use case’s language (charge, stockOf), not the SDK’s.
  3. Use cases and domain do not import @nestjs/*, the ORM or an HTTP client; add a CI step that checks imports.
  4. Each port has an in-memory fake shared by unit tests.
  5. Each driven adapter has integration tests against real infrastructure or a sandbox.
  6. The payment adapter still needs timeouts, an idempotency key and error classification (see Adapter pattern).
  7. Wiring lives in one module; switching adapters means switching providers, never new inside a use case.

The most common mistake is letting ORM entities leak into the domain:

Domain uses ORM/framework types

Common

domain/order.ts
import { Entity, Column } from 'typeorm';
@Entity() export class Order { @Column() total!: number; }

Prefer

// domain/order.ts: plain types; adapters/out/postgres maps OrderRow <-> Order
export 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
  1. In the demo, why does application/ports.ts use an abstract class instead of an interface?
  2. You add a Kafka consumer that places orders: which files are new, which stay unchanged?
  3. The use case calls PaymentGateway outward, so is the dependency rule violated? Explain using the direction of import.