A business rule often begins as a few lines of code and gradually becomes entangled with everything around it. A pricing decision reads directly from a database. An order workflow calls a payment SDK from the middle of its logic. Tests need a web server, network access, and several configuration files just to exercise one decision.
The problem is not that databases, frameworks, or SDKs are bad. The problem is that application decisions have become dependent on details that change for different reasons.
Ports and adapters, also called hexagonal architecture, provides a simple mental model for separating those concerns. The application defines the interactions it needs. Technology-specific code implements those interactions at the edges. This article develops that model from a small example, then shows where the separation helps, what it does not guarantee, and when a simpler design is enough.
Start with the direction of dependency
The central idea is easier to understand as a dependency rule than as a diagram:
external technology -> adapter -> port <- application logicA port describes an interaction at the application boundary. An adapter translates between that port and a particular external mechanism.
The important part is who owns the boundary. The application should not need to know that customer records happen to come from PostgreSQL, a REST service, an in-memory map, or a test fixture. It should express the capability it needs in application terms.
For example, an order workflow may need to find a customer. Its port can express that need:
CustomerLookup:
find_customer(customer_id) -> Customer or NotFoundA database adapter can implement CustomerLookup with SQL. A test adapter can implement it with an in-memory collection. The order workflow depends on the capability CustomerLookup, not on either implementation.
This is dependency inversion in a concrete architectural form: the application-facing abstraction is shaped by application needs, while infrastructure code depends on that abstraction.
Separate the decision from the mechanism
Consider a simplified service that decides whether an order qualifies for a loyalty discount. A tightly coupled version might look like this:
function discounted_total(customer_id, total):
row = database.query(
"select loyalty_level from customers where id = ?",
customer_id
)
if row.loyalty_level == "gold":
return total * 0.90
return totalThe discount rule is simple, but exercising it requires something that behaves like database. The function also knows how customer data is stored and which storage field contains the relevant concept.
Now define the interaction from the rule’s point of view:
CustomerLookup:
find_customer(customer_id) -> CustomerThe application logic becomes:
function discounted_total(customer_lookup, customer_id, total):
customer = customer_lookup.find_customer(customer_id)
if customer.loyalty_level == GOLD:
return total * 0.90
return totalThis is still a simplified teaching example. Production money calculations need an appropriate numeric representation and explicit rounding rules. Those details do not change the architectural point: the rule now asks for a customer through an application-owned interaction instead of issuing a storage command itself.
A database adapter performs the translation:
DatabaseCustomerLookup implements CustomerLookup:
find_customer(customer_id):
row = database.query(...)
return Customer(
id = row.id,
loyalty_level = parse_loyalty_level(row.loyalty_level)
)The adapter knows about rows and queries. The application knows about customers and loyalty levels. Each side speaks the vocabulary appropriate to its responsibility.
Ports exist on both sides of the application
The word “port” is sometimes explained only as an interface around a database or external service. That misses half of the model.
Applications also have incoming interactions. A user interface, HTTP handler, command-line program, scheduled job, or message consumer needs a way to ask the application to perform work.
Suppose the application supports placing an order:
PlaceOrder:
place_order(command) -> OrderResultAn HTTP adapter can translate an HTTP request into PlaceOrder input. A command-line adapter could invoke the same application operation from parsed command-line arguments.
The shape is then:
HTTP request
|
HTTP adapter
|
PlaceOrder port
|
application logic
|
CustomerLookup port
|
database adapter
|
databaseThe HTTP adapter should handle HTTP concerns such as request parsing and response status mapping. The database adapter should handle persistence concerns. The application operation coordinates the business use case without requiring either mechanism to be present in its source code.
Terminology varies across descriptions of this architecture. You may see incoming and outgoing ports called primary and secondary ports, with corresponding adapters. The labels matter less than the dependency rule: technology at the edge should not dictate the internal application model.
Design ports around use cases, not vendor APIs
A common mistake is to create an interface but preserve the external API almost exactly:
DatabasePort:
execute_sql(query, parameters)The application no longer imports a database library, but it still thinks in SQL. Replacing the implementation does little to reduce conceptual coupling because application code must understand database queries and result shapes.
Prefer a port that describes the capability the application needs:
OrderRepository:
save(order)
find_by_id(order_id) -> Order or NotFoundThe difference is not cosmetic. If the storage mechanism changes, execute_sql forces the change into application code. save(order) gives an adapter room to translate the stable application concept into the new mechanism.
The same principle applies to external services. Instead of exposing a payment provider’s request object throughout the application, a port might say:
PaymentGateway:
charge(payment_request) -> PaymentResultHere payment_request and PaymentResult should represent concepts the application actually needs. The adapter translates them to and from the provider-specific API.
Do not make ports artificially generic. A boundary such as ExternalService.call(operation, payload) hides names without reducing coupling. A useful port makes the application’s required capability explicit.
Let adapters absorb translation
External systems rarely represent concepts exactly as your application does. A provider may call a successful payment CAPTURED; your application may use Paid. A database may store timestamps as strings; your application may use a time value. An HTTP request may contain optional text fields that must become validated domain values.
Adapters are natural places for this translation:
provider response: { status: "CAPTURED", reference: "p-42" }
|
v
payment adapter
|
v
application result: PaymentResult(status = PAID, reference = "p-42")This keeps provider terminology from spreading through business rules. It also gives one location where mismatches can be handled deliberately.
Translation has limits. An adapter cannot invent guarantees that the external system does not provide. If a payment API can return an indeterminate result after a timeout, the application-facing model must be able to represent that uncertainty or define a recovery process. Hiding a meaningful failure mode behind a simpler interface makes the boundary misleading rather than useful.
Testing becomes a consequence of the design
Ports and adapters is often presented as a testing technique. Testability is valuable, but it is a consequence of the dependency structure rather than the primary rule.
Because application logic depends on a small port, a test can provide a small implementation:
FakeCustomerLookup:
customers = {
"c-1": Customer(id = "c-1", loyalty_level = GOLD)
}
find_customer(customer_id):
return customers[customer_id]A test for the discount decision can now run without a database:
lookup = FakeCustomerLookup()
result = discounted_total(lookup, "c-1", 100)
expect result == 90That test demonstrates the application’s decision under a controlled input. It does not prove that the database adapter correctly maps rows to customers. Test the adapter separately against the behavior that matters at its boundary.
This distinction prevents a common testing error: replacing every real dependency with a fake and then assuming the complete system works. A fake can verify application behavior against the port’s contract. It cannot verify a production adapter it never executes.
Keep composition at the edge
Something must choose the concrete adapters and connect them to the application. Keep that assembly near an outer entry point, often called a composition root.
Conceptually:
database = connect_database(configuration)
customers = DatabaseCustomerLookup(database)
payments = ProviderPaymentGateway(provider_client)
place_order = PlaceOrderService(customers, payments)
http_server = HttpServer(place_order)The exact mechanism depends on the language and framework. Manual construction may be sufficient. A dependency-injection container can also perform assembly, but the architecture does not require one.
Keeping construction outside the application logic has an important consequence: the application does not select its own infrastructure. If PlaceOrderService creates DatabaseCustomerLookup internally, the source-level dependency on the concrete technology has simply moved rather than disappeared.
Decide where a port earns its cost
Every port introduces another concept, name, and translation boundary. That cost is justified when the boundary separates concerns that are likely to vary independently or when direct coupling makes important logic difficult to reason about or test.
A port is often useful when:
- application rules would otherwise import a vendor SDK or infrastructure API;
- multiple entry mechanisms need to invoke the same use case;
- external data needs meaningful translation into application concepts;
- tests need to exercise decisions without slow or unreliable external resources; or
- an external dependency changes on a different schedule from the application rules.
A port may add little value around a stable, trivial helper that is already expressed in application terms. Wrapping every library call in a one-method interface can create ceremony without isolating a meaningful design decision.
The question is not “Can this dependency be abstracted?” Almost anything can. A better question is “Which change or concern would this boundary keep local?”
Avoid turning the architecture into layers of forwarding
Ports and adapters can fail even when the directory structure looks correct.
One failure mode is a chain of classes that only forward calls:
controller -> service -> manager -> repository -> databaseIf each layer exposes the same operations and data structures, the system has more files but not more separation. A useful boundary changes responsibility or vocabulary: an HTTP adapter translates transport concerns, an application operation coordinates a use case, and a persistence adapter translates storage concerns.
Another failure mode is allowing infrastructure types to cross the port. If OrderRepository returns a database driver’s row object, callers still depend on that driver’s representation. The interface has not protected the application from the external detail.
A third failure mode is creating one enormous port for all persistence or all external services. Large ports make consumers depend conceptually on operations they do not need. Prefer boundaries shaped around coherent application capabilities rather than a universal gateway.
Finally, do not assume that ports remove operational concerns. Network timeouts, retries, transaction boundaries, concurrency, and partial failures still exist. Decide deliberately which of those concerns belong in an adapter and which must be visible to the application because they affect business behavior.
Know when a simpler structure is enough
A small application with straightforward data entry and little business logic may be easier to maintain with direct framework conventions. If changing the database is unlikely, tests against a lightweight real database are fast, and persistence behavior is itself central to the feature, an additional repository abstraction may not improve the design.
Ports and adapters becomes more useful as application decisions become valuable independently of delivery and infrastructure details. The pattern is particularly helpful when a use case must survive changes in UI, storage, messaging, or third-party integrations.
Adopt it at meaningful boundaries rather than as a requirement that every class must have an interface.
Conclusion
Ports and adapters is fundamentally about dependency direction. The application describes the interactions it needs as ports. Adapters translate those interactions to and from concrete technologies. The result is an application model that can remain focused on use cases while databases, transports, and vendor APIs stay at the edges.
When considering a new boundary, identify the application decision first. Then ask what external mechanism it currently knows too much about. Define the smallest application-shaped interaction that removes that knowledge, and let an adapter handle the translation.
If no important decision is being protected and no meaningful change is being localized, keep the simpler design. The value of the pattern comes from separating reasons to change, not from maximizing the number of interfaces.