You’ve felt this before: a feature that should take an afternoon takes three days, because the “simple change” to how you send emails turns out to be wired into twelve files. The payment code knows about the database, the database code knows about the web request, and somewhere in the middle — barely visible — are the actual business rules that make your product your product.
Ports & Adapters (you’ll also hear it called Hexagonal Architecture, coined by Alistair Cockburn in 2005) is a way of arranging code so that this stops happening. It’s not a framework, you don’t install it, and it works in any language. It’s really one idea wearing a fancy name. Let’s unwrap it slowly, with pictures and real examples, and by the end you’ll know exactly when to use it — and when not to.
The problem: code that grows up badly
Most apps start the same friendly way — a controller calls a service, the service talks to a database and a couple of vendors, and everyone’s happy. The trouble is what happens over time. Here’s a checkout that’s only a few months old and already painful:
// checkout.ts — everything tangled together
class OrderService {
async checkout(cart: Cart) {
const db = new PgClient(process.env.DB_URL) // a specific database
const stripe = new Stripe(process.env.STRIPE_KEY) // a specific vendor
const sg = new SendGrid(process.env.SENDGRID_KEY) // another vendor
// the actual business rules are buried between vendor calls…
const total = cart.items.reduce((s, i) => s + i.price, 0)
await stripe.charges.create({ amount: total, source: cart.token })
await db.query('INSERT INTO orders …')
await sg.send({ to: cart.email, template: 'order-ok' })
}
}
Nothing here is “wrong”, exactly. But the business logic (what an order is, when it’s valid, what “confirmed” means) is tangled up with three vendors and a database driver. You can’t test the rules without a live database. You can’t move off SendGrid without surgery. And a new teammate reads this and learns about Stripe before they learn about your orders.
Ports & Adapters answers one question: how do I keep the part that makes money (the business rules) separate from the parts I’ll inevitably swap out (databases, vendors, frameworks, UIs)?
The core idea, in one picture
Put your business rules — the domain — in the centre. Forbid it from importing any framework, database driver, or vendor SDK. Then let the outside world talk to it only through ports (interfaces), with adapters doing the messy real-world work on the other side.
That’s the whole shape. People draw a hexagon instead of a circle for a charming reason: the six sides are a reminder that an app has many ways in and out (a web UI, an API, tests, a cron job, a database, a queue…), not just “top” and “bottom”. The number six means nothing — don’t count your ports.
The vocabulary, in plain words
Three words do all the work. Here they are without jargon:
- Domain (the core): your business rules and concepts —
Order,Money, “a cart with zero items can’t check out”. Pure logic. It should read like a description of your business, not your tech stack. - Port: an interface the domain owns, describing something it needs or offers — “I need to be able to
save(order)” or “I need tocharge()a payment”. A port is a promise, written in the domain’s language, with no vendor names in sight. - Adapter: the concrete code that fulfils a port using a real technology — a
PostgresOrderRepo, aStripeGateway. Adapters are where Stripe, Postgres, and React are allowed to exist.
And ports come in two flavours, which is the one subtlety worth learning:
- Driving (primary) adapters sit on the left and call into your app: a web controller, a CLI command, a test. They use an input port (your use-case interface).
- Driven (secondary) adapters sit on the right and are called by your app: a database, an email service, a payment API. They implement an output port the domain defined.
If something starts a conversation with your app, it’s driving. If your app starts the conversation, the other thing is driven. A human clicking “Buy” drives; the payment vendor is driven.
A concrete example: before and after
Let’s untangle that checkout. First, write down what the domain needs — the ports — using only words from your business:
// ports.ts — the domain's vocabulary. No vendor names allowed.
interface PaymentGateway { charge(amount: Money, token: string): Promise<Receipt> }
interface OrderRepository { save(order: Order): Promise<void> }
interface Notifier { orderConfirmed(order: Order): Promise<void> }
Now the use case depends on those promises, and nothing else. Read it out loud: it sounds like a description of checking out, not a list of vendors.
// checkout-service.ts — depends only on ports, never on vendors
class CheckoutService {
constructor(
private payments: PaymentGateway,
private orders: OrderRepository,
private notify: Notifier,
) {}
async checkout(cart: Cart): Promise<Order> {
const order = Order.fromCart(cart) // pure business rule
await this.payments.charge(order.total, cart.token)
await this.orders.save(order)
await this.notify.orderConfirmed(order)
return order
}
}
Finally, the real vendors live in adapters that implement the ports. Swapping one is a localized change — and in tests, you swap them for fakes and run thousands of cases in the time one network call used to take:
// adapters/*.ts — vendors live here, behind the port they implement
class StripeGateway implements PaymentGateway { /* Stripe SDK */ }
class PostgresOrderRepo implements OrderRepository { /* SQL here */ }
class SendGridNotifier implements Notifier { /* SendGrid */ }
// In a test, swap the real vendors for in-memory fakes — no network, milliseconds:
const service = new CheckoutService(new FakePayments(), new InMemoryOrders(), new NullNotifier())
Notice what moved: Stripe, Postgres, and SendGrid are now details at the edge. The thing that makes you money sits in the middle, readable and testable, and it has no idea any of those vendors exist.
The one rule that holds it together
If you remember nothing else, remember this: source-code dependencies always point inward. The outer ring (adapters, frameworks, I/O) is allowed to know about the inner rings. The domain in the centre is allowed to know about nothing outside itself.
This is why you can replace your entire web framework, or migrate from MySQL to DynamoDB, without the domain noticing. The arrows of dependency never point out of the core, so changes at the edge can’t ripple into it. (If you’ve heard of the Dependency Inversion Principle — the “D” in SOLID — this is it in practice: the domain defines the interface, the adapter conforms to it.)
Driving vs driven, made practical
Why does the primary/secondary split matter day to day? Because it tells you who owns the interface.
- For a driven port (database, email), the domain owns the interface and the adapter bends to fit. That’s what lets you swap vendors freely.
- For a driving port (your use cases), the application owns the interface and the outside world (HTTP, CLI, a test harness) calls it. That’s what lets the same logic power a REST API today and a gRPC endpoint or a scheduled job tomorrow with zero rule changes.
A useful litmus test: a brand-new delivery mechanism (say, a Slack bot) should be only a new driving adapter. A brand-new vendor (say, switching from Stripe to Adyen) should be only a new driven adapter. If either one forces you to open the domain, something has leaked.
What it looks like at different company sizes
This is where a lot of advice goes wrong — it treats architecture as one-size-fits-all. The right amount of Ports & Adapters depends heavily on where your company is. Here’s the honest version:
| Company stage | The pain it removes | How much to apply |
|---|---|---|
| Solo / early startup | You picked SQLite and a payment provider in a hurry and now fear you’re locked in. | One or two ports max — usually the database and payments. Leave everything else direct. Speed beats purity here. |
| Small / growing (≈ Series A) | Tests hit the real database and a vendor sandbox, so they’re slow, flaky, and devs stop running them. | Put ports around all I/O. Fakes make unit tests run in milliseconds and new hires understand the domain in a day. |
| Mid-size / scale-up | Several teams step on each other; a cost-cutting “move off SendGrid” turns into a month-long rewrite. | Ports become contracts between teams. Swapping a vendor is one new adapter, not a migration. |
| Big corp / enterprise | Legacy systems, regional vendors, audits, and procurement rules that demand “no single-vendor lock-in”. | Many adapters per port (old + new), strangler-fig migrations, and vendor independence as a written requirement. |
Three quick real-world stories
The small e-commerce shop (10 people). They wrapped email behind a one-method Notifier port “just in case”. Two years later their email costs spiked; moving from SendGrid to Amazon SES was a single new adapter and a one-line wiring change, shipped in an afternoon. The order logic — their crown jewels — wasn’t touched.
The scale-up fintech (≈120 engineers). Payments had to run through a local provider in one country and an international one in another, with the choice made at runtime per transaction. Because PaymentGateway was a port, “which provider” became a routing decision behind the interface. Compliance auditors loved it too: the boundary was an obvious, testable seam.
The enterprise bank (1,000+ engineers). A 20-year-old mainframe couldn’t be replaced overnight. They defined an AccountLedger port and wrote two adapters — one talking to the old mainframe, one to the new core-banking system — then migrated traffic gradually (a “strangler fig”). Teams built new features against the port while the migration happened underneath them, invisible.
Ports & Adapters vs the classic layered approach
Most teams come from a traditional layered architecture (UI on top, services in the middle, database at the bottom). Here’s how the two compare:
| Question | Classic layered (UI → service → DB) | Ports & Adapters |
|---|---|---|
| Who depends on whom? | Top-down: every layer depends on the one below, ending at the database. | Everything depends inward, ending at the domain. The database is just another plug-in. |
| Can you test rules without a DB? | Usually no — the service reaches the DB directly. | Yes — inject a fake repository; no DB, no network. |
| Swap a vendor? | Touches the service layer and ripples outward. | Write one new adapter; the domain never changes. |
| Where do business rules live? | Often smeared across services and the DB. | Concentrated in the domain, in plain language. |
| Cost | Low ceremony, but couples you to your stack over time. | A few extra interfaces up front; pays off as change accelerates. |
The headline difference is the direction of the arrows. In layered code, everything ultimately depends on the database. In Ports & Adapters, the database depends on you. That inversion is the entire point.
When to reach for it (and when not)
Good architecture is about matching effort to stakes. Reach for Ports & Adapters when:
- The app has real business rules worth protecting (not just CRUD over a table).
- You expect to swap or add integrations — vendors, databases, channels — over its life.
- Testing speed and confidence matter, and you want fast, deterministic unit tests.
- Multiple teams or a long lifespan mean clear boundaries pay for themselves.
Be skeptical — or apply it very lightly — when:
- It’s a thin CRUD app or a throwaway prototype; the indirection is pure overhead.
- The “domain” is basically your database schema; there’s nothing to protect.
- You’re a solo dev shipping fast and the cost of a future rewrite is lower than the cost of ceremony now.
Wrapping a value-less CRUD endpoint in three interfaces doesn’t make it “clean” — it makes it harder to read. Architecture is a cost you pay to buy changeability. If nothing’s going to change, you’re paying for nothing.
Common mistakes (and easy fixes)
- Leaky ports. If your
PaymentGatewayreturns aStripe.Chargeobject, the vendor has leaked through the port. Fix: ports speak only in your own types (Receipt,Money). - Anemic domain. If all the real logic lives in adapters and the domain is just data bags, you’ve drawn the boundary in the wrong place. Fix: push rules into the core.
- One giant port. A single
IRepositorywith 40 methods isn’t a boundary, it’s a junk drawer. Fix: small, purpose-named ports (OrderRepository,InventoryRepository). - Confusing DTOs with domain models. The shape you send over HTTP isn’t your domain entity. Fix: let adapters translate between the wire format and the domain.
- Interfaces for everything. Not every class needs a port — only the things that cross a boundary (I/O, vendors, delivery mechanisms). Fix: add a port when you feel the pain, not preemptively.
How to start on Monday
You don’t rewrite anything. You introduce one seam where it hurts most, and stop there:
- 1. Pick your most painful dependency. Usually the database or a flaky vendor.
- 2. Write the interface you wish you had. In your business’s words:
save(order), notexecSql(...). - 3. Move the vendor code behind an adapter that implements it. Nothing else changes yet.
- 4. Inject the adapter instead of constructing it inline, and write one fake for tests.
- 5. Feel the relief, then repeat — but only where churn or testing pain justifies another port.
Within a week you’ll have fast tests around your nastiest integration and a domain that’s starting to read like your business. That’s the whole win, available incrementally.
Key takeaways
- One idea: business rules in the centre, technology at the edge, talking only through interfaces.
- Port = an interface the domain owns. Adapter = the real implementation (Stripe, Postgres, React).
- Dependencies point inward. The domain knows nothing about the outside world.
- Driving adapters call your app; driven adapters are called by it.
- Match the dose to the stakes: a couple of ports for a startup, vendor-independent boundaries for an enterprise, and almost none for a throwaway CRUD app.
- Adopt it incrementally — one painful seam at a time. You never need a big rewrite.
Next in this series we’ll build on this foundation and look at how Ports & Adapters relates to Clean Architecture and Onion Architecture — three names that point at the same northern star — and where they genuinely differ in practice.