Nguyen Le PhongNguyen Le Phong

Hexagonal Architecture in Practice

A practical guide to applying Hexagonal Architecture to one small feature: drawing the boundary, naming ports in business language, keeping adapters at the edge, testing the core, and deciding when the extra structure is worth the cost.

The feature looked small enough to finish before lunch. A customer support agent needed a button to refund an order, notify the buyer, and leave a short audit trail for finance. The product rule was simple in the meeting: refund only paid orders, block refunds after thirty days, and never send the email before the refund is recorded. Then someone opened the codebase, and the small feature started touching an API controller, a payment SDK, a database transaction, an email template, and three tests that were slow because they needed real infrastructure.

This is the kind of moment where Hexagonal Architecture becomes practical. Not as a diagram to admire, and not as a reason to reorganize the whole codebase. It is useful because it gives the team a calm way to ask one question: what is the business decision here, and what is only the outside world helping that decision happen?

For the refund feature, the inside of the hexagon is not Postgres, Stripe, SendGrid, Next.js, or a queue. The inside is the rule that decides whether a refund is allowed and what must happen when it is accepted. The outside is everything that delivers the request or carries out side effects. The controller is outside because it translates HTTP into a command. The payment provider is outside because it moves money through a vendor. The database is outside because it persists facts. The email sender is outside because it talks to a delivery service. The audit logger is outside because it records the decision somewhere durable.

A useful first pass is to write the feature in plain language before writing interfaces. A support agent requests a refund for an order. The system loads the order, checks whether it can be refunded, records the refund, asks the payment provider to return the money, writes an audit entry, and sends a notification. That sentence already shows the boundary. The words order, refund, thirty days, and audit entry belong to the product. The words HTTP, SQL, Stripe, and SMTP do not.

From there, the ports should sound like the feature, not like the tools. A driven port might be OrderStore with findById and recordRefund. Another might be RefundPaymentGateway with refund. A third might be BuyerNotifier. If the interface name contains a vendor name, it is probably already leaking. StripeRefundGateway is a good adapter name, but it is a poor port name. The port describes what the core needs. The adapter describes how the outside world fulfills it.

The input side deserves the same care. The controller should not contain the refund rule. It should parse the request, authenticate the actor if that is its responsibility, build a small command such as RequestRefund, call the use case, and translate the result back to HTTP. This keeps the web framework from becoming the place where business policy quietly collects over time. If a CLI command or an internal admin tool needs the same behavior later, it can call the same use case through another driving adapter.

The core use case can stay boring, which is a compliment. It receives a command, asks OrderStore for the order, asks the order whether the refund is allowed, records the accepted refund, calls the payment gateway, writes the audit entry, and asks the notifier to send the message at the correct point. The important part is not the number of classes. The important part is that the code can be read without knowing the payment vendor, the database library, or the HTTP framework. A reviewer can focus on the refund policy itself.

This is also where testing gets quieter. Instead of running a test that needs a real database, a payment sandbox, and an email account, the core test can use small in-memory adapters. One test says a paid order inside the refund window returns an accepted result and records the expected refund. Another says an order older than thirty days is rejected and the payment gateway is never called. Another says the notification is sent only after the refund is recorded. These tests are not fake in the dismissive sense. They test the part of the system where the product promise lives.

The real adapters still need tests, but they are different tests. A database adapter test checks mapping, transactions, and query behavior. A Stripe adapter test checks request shape, error handling, and idempotency behavior against the contract you own. An email adapter test checks the template and delivery call. These tests sit at the edge, where slowness and integration detail are expected. They do not need to be the main way the team learns whether the refund rule works.

One common mistake is to draw too many ports too early. A small feature does not need an interface for every helper, formatter, and date calculation. Hexagonal Architecture is most useful at boundaries where the core crosses into I/O, vendors, delivery mechanisms, or persistence. If a function is pure, stable, and local to the use case, wrapping it in an interface usually adds noise. The goal is not to make the code look architectural. The goal is to make change less risky where change is likely.

Another mistake is letting outside types travel inward. If the core receives a raw HTTP request, returns a database row, or throws a vendor-specific exception, the boundary exists only in the folder structure. The core should receive its own command and return its own result. Adapters can translate from messy outside shapes into clean inside shapes, and back again. This translation can feel like extra work on day one, but it often saves confusion on day thirty, when a second adapter appears and the team realizes the first adapter had been quietly defining the product model.

The cost is real. There are more names to choose, more files to open, and more wiring to do. For a thin CRUD screen that only edits one table and has no meaningful policy, this structure may be too much. A direct route handler can be the kinder design. For a feature with business rules, vendor calls, side effects, and a long life ahead of it, the extra boundary often pays for itself through faster tests, smaller reviews, and safer vendor changes.

I usually look for three signals before applying the pattern seriously. First, the feature has rules that people discuss in product language, not only fields in a database. Second, at least one outside dependency is likely to change, fail, or need careful testing. Third, the team has already felt pain from slow tests or scattered changes. If those signals are present, a small hexagonal slice can be enough. You do not need to redesign the whole application. Pick the refund feature, draw the boundary around it, name the ports, write the core tests, and let the adapters stay at the edge.

The quiet value of Hexagonal Architecture is that it makes the important part easier to see. In the refund example, the important part is not that the code uses a fashionable pattern. It is that a teammate can read the use case and understand the promise being made to the customer, support, and finance. The database, payment SDK, and email provider still matter, but they no longer get to speak louder than the rule they are serving.

If you are trying this in an existing codebase, start with one feature that already hurts a little. Write down the business sentence. Circle the words that belong to the product. Put ports around the outside work. Test the core without the outside world. Then stop. Architecture becomes useful when it leaves the next change calmer than the last one, not when every folder has the perfect name.

What did you think?