Most teams that read about ports and adapters understand the diagram and then stall on the first line of code. The practical version of the pattern is smaller than the diagram suggests: pick one dependency your domain code currently reaches into directly, define an interface for exactly what the domain needs from it, and write a real implementation and a fake implementation behind that interface. Everything else, the composition root, the incremental rollout, the discipline about what counts as a seam, follows from getting that first one right. The value shows up the first time a database migration, a vendor swap, or a slow integration test suite no longer requires touching business logic at all.
How it works, concretely
A port is an interface the domain owns, describing what it needs without naming any technology:
// Port: owned by the domain, describes what it needs
interface SessionStore {
get(sessionId: string): Promise<Session | null>;
save(session: Session): Promise<void>;
}
// Adapter 1: real implementation backed by a cookie-based session store
class CookieSessionStore implements SessionStore {
async get(sessionId: string) { /* read from the cookie-backed store */ }
async save(session: Session) { /* write it back */ }
}
// Adapter 2: test double, no infrastructure at all
class InMemorySessionStore implements SessionStore {
private data = new Map<string, Session>();
async get(sessionId: string) { return this.data.get(sessionId) ?? null; }
async save(session: Session) { this.data.set(session.id, session); }
}
A use case such as renewSession depends only on SessionStore, never on cookies or a database directly. If the storage mechanism itself needs explaining to a newer engineer, see what a cookie actually is; the port hides exactly that detail from the domain. A generic Repository<T> port is just as common — if your language supports it, how TypeScript generics work is worth reading before you write one interface that covers many entity types instead of duplicating it per entity. Whichever shape the port takes, the test for whether the boundary is right is simple: can you write a full unit test for the use case without starting a database, a browser, or a network call? If not, something from an adapter has leaked into the core.
Introducing it into an existing codebase
- Find the seam that hurts most in tests today. Usually the database or an external API call.
- Define the port from the call site's perspective, not the implementation's. Write the interface you wish already existed.
- Extract the current code behind that interface as your first real adapter. Nothing behaviorally changes yet.
- Add an in-memory or fake adapter and switch your slowest tests to use it.
- Repeat on the next painful seam. Do not attempt to wrap an entire codebase in one pass; if you want to try the pattern without risk, prototype it on a fork or a throwaway branch first.
- Measure the payoff, not just the effort. Track how much faster the tests behind that seam run, and use that number as evidence for whether the next seam is worth wrapping too.
Common mistakes
- Wrapping everything in a port on day one. Most of a typical codebase is not a seam worth abstracting; over-application adds indirection with no test or flexibility benefit.
- Letting a framework type leak into the port signature. If the interface imports an ORM entity or an HTTP framework type, the abstraction is not actually decoupling anything.
- Skipping the fake adapter. The in-memory implementation is what makes the domain fast and easy to test; without it, you built the interface but not the payoff.
- Wiring adapters inside business logic. Instantiating a concrete adapter inside a use case, instead of receiving it as a dependency, reintroduces the coupling the port was supposed to remove.
FAQ
Do I need a dependency injection framework to do this?
No. A composition root can be a plain function that constructs adapters and passes them in; a DI container is a convenience, not a requirement.
How many ports should a typical service have?
As many as it has real external seams, usually a handful: persistence, external APIs, the clock, maybe a queue or file store.
Is this the same as the repository pattern?
A repository is a common example of a port specifically for persistence; ports and adapters is the more general idea applied to every external dependency.
Can I retrofit this onto a legacy codebase?
Yes, incrementally, one seam at a time, which is usually far more realistic than a rewrite.
What is the first seam most teams should pick?
Whatever currently makes the test suite slowest or flakiest, usually the database or an external HTTP call, since that is where the payoff from a fake adapter shows up fastest and most visibly.
Where to go next
See clean architecture vs hexagonal for how this pattern relates to its closest sibling, domain-driven design explained for how to decide what belongs inside the boundary this protects, and how TypeScript generics work for writing reusable port interfaces.