Hexagonal Architecture without overengineering — ports, adapters and the line you shouldn't cross
Hexagonal Architecture is one of the most useful ideas in software design. It's also one of the most over-applied. Here's what the ports-and-adapters model actually buys you, where it becomes ceremony, and how to apply it without drowning in abstractions.
- Architecture
- Hexagonal Architecture
- Software Design
- Backend
Hexagonal Architecture — also called Ports and Adapters — is one of those ideas that sounds abstract until you've been burned by the problem it solves. Then it sounds obvious. The problem is that "obvious" often tips into "applied everywhere," and that's where the overengineering starts.
This is our take on what the pattern actually buys you, where it earns its keep, and the line between useful abstraction and ceremony that slows you down.
The idea in one paragraph
Your application has a core — the business logic, the domain model, the things that make your product what it is. Everything else is infrastructure: the HTTP server that receives requests, the database that stores data, the email service that sends notifications, the payment gateway that charges cards.
Hexagonal Architecture says: keep the core ignorant of the infrastructure. The core defines what it needs — "I need something that can save an order," "I need something that can send an email" — through interfaces called ports. The infrastructure provides implementations of those interfaces called adapters. The core never imports the infrastructure; the infrastructure imports the core.
That's the whole idea. The hexagon shape in the name is just a way of drawing it — the core in the middle, ports on the edges, adapters outside.
What it actually buys you
Testability without infrastructure. If your business logic doesn't import Postgres, you can test it without Postgres. Swap in an in-memory adapter for the repository port, run the test in milliseconds, get a fast feedback loop. This is the most immediate and most valuable benefit.
Replaceability. When you need to swap Stripe for Paddle, or SendGrid for Resend, or Postgres for DynamoDB — you write a new adapter. The core doesn't change. The tests don't change. The business logic doesn't change. Only the adapter changes.
Clarity about what's core and what's infrastructure. The act of defining ports forces you to think about what your application actually needs versus what it happens to be using today. That distinction is valuable even if you never swap implementations.
Multiple entry points. The same core can be called from an HTTP handler, a CLI command, a message queue consumer, or a test — because none of them are special. They're all just adapters that call the same ports.
Where it becomes ceremony
The pattern earns its keep when the benefits above are real. It becomes ceremony when they're not.
When you have one adapter and will always have one adapter. If you're building a web app that will always use Postgres and you're never going to swap it, the UserRepository interface with one implementation is three files where one would do. The abstraction costs maintenance without buying replaceability.
Our rule: write the port when you have two adapters (e.g., in-memory for tests, Postgres for prod) or when the adapter is genuinely hard to test against. Skip it when you have one adapter and it's fast enough to test against directly.
When the port leaks infrastructure concerns. A port that returns a QueryBuilder object, or accepts a Transaction parameter, or has methods named findWithJoin — that's not a port, that's an ORM interface. The core is now coupled to the infrastructure through the port. The abstraction exists but doesn't protect anything.
When every class gets a port. Some teams apply the pattern to everything — a port for the logger, a port for the clock, a port for the config reader. The result is a codebase where finding the actual logic requires navigating a maze of interfaces. Apply the pattern to the things that are genuinely external and genuinely variable. Don't apply it to things that are stable and cheap to use directly.
When the adapters are thicker than the core. If your adapters contain business logic — validation, calculation, decision-making — the architecture has inverted. The core should be the richest part of the codebase. If it's thin and the adapters are thick, the pattern is providing structure without providing the benefit.
The version that works in practice
Define ports for the things that are genuinely external and variable. Database access, email sending, payment processing, external APIs, file storage. These are the things that change between environments (test vs prod), between providers (Stripe vs Paddle), and between deployment contexts (cloud vs on-premise).
Don't define ports for the things that are stable. Logging, configuration, in-process utilities. Use them directly. If you need to swap them later, add the port then.
Keep ports small and domain-shaped. A OrderRepository port with findById, save, and findPendingOrders — not a generic Repository<T> with 15 methods. The port should express what the core needs, not what the infrastructure can do.
Use in-memory adapters for tests. The biggest win from the pattern is fast tests. An in-memory OrderRepository that stores orders in a Map is 20 lines of code and makes your use case tests run in milliseconds. Write it. Use it.
Keep the core rich. The business logic lives in the core — in domain objects, in use cases, in domain services. If the core is just a pass-through to the adapters, you've built the structure without the substance.
A concrete example
A notification system in a SaaS product:
notifications/
core/
notification-service.ts # sendWelcomeEmail(), sendPasswordReset(), etc.
email-port.ts # interface: send(to, subject, body)
template-port.ts # interface: render(templateName, data)
adapters/
sendgrid-email-adapter.ts # implements EmailPort using SendGrid SDK
resend-email-adapter.ts # implements EmailPort using Resend SDK (swap candidate)
handlebars-template-adapter.ts # implements TemplatePort
in-memory-email-adapter.ts # implements EmailPort, stores sent emails in array (for tests)
The core imports EmailPort and TemplatePort — interfaces it defines. It never imports SendGrid or Handlebars. Tests use InMemoryEmailAdapter and run in milliseconds. When the team decides to switch from SendGrid to Resend, they write ResendEmailAdapter and change one line of wiring. The core, the tests, and the business logic don't change.
That's the pattern working. Two adapters for the email port (SendGrid and in-memory) justified the abstraction. If there were only ever one adapter and it was fast to test against, the port would be overhead.
The line you shouldn't cross
The line between useful and overengineered is roughly: does this port have two real implementations, or is it genuinely hard to test without the abstraction?
If yes — write the port. If no — use the concrete implementation directly and add the port later if the situation changes.
The teams that get the most out of Hexagonal Architecture apply it to the right things and skip it for the rest. The teams that get burned by it apply it to everything and end up with a codebase that's structurally correct but practically unmaintainable.
TL;DR
Hexagonal Architecture's core idea — keep business logic ignorant of infrastructure — is worth applying on every project. The full ports-and-adapters ceremony is worth applying selectively, to the things that are genuinely external and genuinely variable.
Write ports for your database, your email service, your payment gateway. Don't write ports for your logger. Keep the core rich. Keep the adapters thin. Use in-memory adapters in tests.
The architecture should make your codebase easier to work in, not harder. If it's making it harder, you've crossed the line.
If you're designing a new system or untangling an existing one and want a second opinion on where to draw the lines, let's talk.
