
Hexagonal Architecture: Isolating Core Logic from External Systems
Why is leaking implementation details into business logic is problematic?
Say that you have an order service that contains a placeOrder method, which does the following:
- Calculate the total amount of the order
- Charge the customer the total amount
- Send an order confirmation email
Specifically, this is what the business logic looks like:
- Calculating the total
- Saving the order
- Charging the customer
- Updating the order with the charge ID
- Notifying the customer
This is what it looks like:
1class OrderProcessor {2 private db = new PostgresDB();3 private stripe = new StripeClient();4 private sendgrid = new SendGridClient();56 async placeOrder(customerId: string, items: string[], email: string): Promise<string> {7 const total = items.length * 29.99;89 const result = await this.db.query(10 'INSERT INTO orders VALUES ($1, $2, $3, $4)',11 [customerId, items, total, ]12 );13 const order = result.rows[0]1415 const charge = await this.stripe.createCharge(total, 'usd', order.id);1617 const updatedOrder = await this.db.query(18 `UPDATE orders SET chargeId = $119 WHERE id = $220 RETURNING id, customer_id, items, total, status, charge_id`,21 [charge.chargeId, order.id]22 );2324 await this.sendgrid.send(25 email, '[email protected]', 'Order Confirmation',26 `<h1>Order ${order.id} confirmed!</h1><p>Total: $${total}</p>`27 );2829 return updatedOrder;30 }31}
Now let's see if it works! We can do this by testing the logic. However, the code above exhibits a common testability problem: business logic is tangled with infrastructure. You can see that our OrderProcessor service talks directly to PostgresDB, StripeClient, and SendGridClient. The result of this coupling is that we now require this infrastructure to be running when we want to test the service! We can see what the code does, but we're unable to test the business logic in isolation.
This is the problem that hexagonal architecture aims to address. It provides a structure for our codebase that decouples our core business logic from the infrastructure it runs on.
Where do we begin? We start by extracting the infrastructure, the low-level details, and placing them behind interfaces.
What does the interface look like?
The naive fix
A naive fix would have the interfaces mimic the infrastructure calls. Such an attempt would look like this:
1interface Database {2 query(sql: string, params: unknown[]): Promise<{ rows: unknown[] }>;3}45interface PaymentProvider {6 createCharge(amount: number, currency: string, description: string): Promise<{ chargeId: string }>;7}89interface EmailClient {10 send(to: string, from: string, subject: string, html: string): Promise<void>;11}
Then, instead of OrderProcessor depending on the infrastructure directly, we make it dependent on these abstracted interfaces:
12class OrderProcessor {3 constructor(4 private db: Database,5 private payment: PaymentProvider,6 private email: EmailClient7 ) {}89 async placeOrder(customerId: string, items: string[], email: string): Promise<string> {10 // ... same SQL, same Stripe args, same SendGrid format11 }12}
Notice that the dependencies are now declared in the constructor. This means that we pass these in when we initialize the OrderProcessor service.
This is progress! What this unlocks for us is that we can now inject test doubles. These can be mock implementations that don't require the infrastructure do be running, so we can now test the logic in isolation.
However, the abstraction above introduces another problem: it leaks details about the infrastructure which, in turn, leaks it into our OrderProcessor service:
- It's forced to write SQL due to the
Database.querymethod PaymentProvider.createCharge's signature is just Stripe's APIEmailClient.send's signature is Sendgrid's API
What happens if you replace Postgres with DynamoDB, which doesn't use SQL? Or Stripe with some other payment provider with a different API? Or Sendgrid with Resend, which also has a different API? The abstractions (i.e. interfaces) break! This is because they're coupled to the infrastructure details. Change the infrastructure and the abstractions, as well as the OrderProcessor service, need to be updated, even if the business logic hasn't changed at all.
You may have noticed that this issue of coupling abstractions to infrastructure details is a violation of DIP, which we covered in another post. In fact, hexagonal architecture is built on top of DIP! The architecture just provides a concrete way to structure your codebase, applying DIP to do so.
In order to overcome this problem, we need to decouple the abstraction from the infrastructure.
The real fix
Instead of the abstraction being shaped by what the infrastructure needs, we have it shaped by what the high-level service needs (i.e. OrderProcessor). How do we do this? For each operation the service needs that depends on infrastructure to implement, we ask what that operation accomplishes for the business:
- PostgresDB -> an order repository
- Lines 9-12: Execute this SQL -> Create an order
- Lines 17-22: Execute this SQL -> Set the charge ID
- StripeClient -> a payment gateway
- Line 15: Create a charge with this currency and description -> Charge a customer
- SendGridClient -> a notifier
- Lines 24-27: Send an email with this HTML -> Send order confirmation to a customer
With these in mind, this is how the interface would be structured:
1// ===== ENTITIES =====2type OrderStatus = "pending" | "completed" | "cancelled";34type Order = {5 id: string;6 customerId: string;7 items: string[];8 total: number;9 status: OrderStatus;10 chargeId?: string;11};1213type ChargeStatus = "succeeded" | "failed";1415type Charge = {16 id: string;17 status: ChargeStatus;18};1920// ===== ENTITIES =====2122232425interface OrderRepository {26 createOrder: (input: {27 customerId: string;28 items: string[];29 status: OrderStatus;30 total: number;31 }) => Promise<Order>;32 setChargeId: (input: { chargeId: string; orderId: string }) => Promise<Order>;33}3435interface PaymentGateway {36 chargeCustomer: (input: {37 orderId: string;38 amount: number;39 }) => Promise<Charge>;40}4142interface Notifier {43 sendOrderConfirmation: (input: {44 destination: string;45 orderId: string;46 }) => Promise<void>;47}48
How can we tell if the interface is shaped correctly? Ask yourself the following question: If the infrastructure was ripped out entirely, does this interface make sense? You can see that it does in this case:
OrderRepositorydoesn't care if we swap out PostgreSQL with DynamoDB or some other database technologyPaymentGatewaydoesn't care if we swap out Stripe with some other technology with a different APINotifierdoesn't care if we swap out Sendgrid with some other library for sending emails. In fact, it doesn't even care about sending emails! The interface just requires a destination and the order ID, which allows us to implement some other means of notification down the line if we want to move away from/add to email (e.g. SMS)
What does the service look like?
Given our newly defined ports, our service now looks like this:
1export class PlaceOrderUseCase {2 constructor(3 private orderRepository: OrderRepository,4 private paymentGateway: PaymentGateway,5 private notifier: Notifier,6 ) {}78 async execute(input: {9 customerId: string;10 items: string[];11 email: string;12 }): Promise<Order> {13 const total = input.items.length * 19.99;1415 const order = await this.orderRepository.createOrder({16 customerId: input.customerId,17 items: input.items,18 total,19 status: "pending",20 });2122 const payment = await this.paymentGateway.chargeCustomer({23 amount: total,24 orderId: order.id,25 });2627 const updatedOrder = await this.orderRepository.setChargeId({28 orderId: order.id,29 chargeId: payment.id,30 });3132 await this.notifier.sendOrderConfirmation({33 destination: input.email,34 orderId: order.id,35 });3637 return updatedOrder;38 }39}40
You can see now that the service now has zero infrastructure imports. It now reads like a business process:
- Save the order
- Charge the customer
- Record the charge
- Notify the customer
This is what we call the core. It only contains the business logic and the ports, which are special interfaces that sit in between two systems.
You may have noticed that we refactored our OrderProcessor service to PlaceOrderUseCase with an execute method. In this architecture, our core logic is organized into use cases, each of which implements a certain business process. With each use case only having a single responsibility, we limit the number of dependencies it needs
What does our infrastructure look like?
Now, our infrastructure (i.e. PostgreSQL, Stripe, Sendgrid) still have to live somewhere. Each of them gets an adapter, which is just a class that implements a port whose purpose is to translate between domain language and infrastructure language.
1export class PgOrderRepository implements OrderRepository {2 constructor(private pgClient: PostgresDB) {}3 async createOrder(input: {4 customerId: string;5 items: string[];6 status: OrderStatus;7 total: number;8 }): Promise<Order> {9 const result = await this.pgClient.query(10 `INSERT INTO orders (customer_id, items, total, status)11 VALUES ($1, $2, $3, $4)12 RETURNING id, customer_id, items, total, status, charge_id`,13 [input.customerId, JSON.stringify(input.items), input.total, input.status],14 );1516 const row = result.rows[0];17 return {18 id: row.id,19 customerId: row.customer_id,20 items: JSON.parse(row.items),21 total: parseFloat(row.total),22 status: row.status,23 chargeId: row.charge_id ?? undefined,24 };25 }2627 async setChargeId(input: {28 chargeId: string;29 orderId: string;30 }): Promise<Order> {31 const result = await this.pgClient.query(32 `UPDATE orders SET charge_id = $1, status = 'completed'33 WHERE id = $234 RETURNING id, customer_id, items, total, status, charge_id`,35 [input.chargeId, input.orderId],36 );3738 const row = result.rows[0];39 return {40 id: row.id,41 customerId: row.customer_id,42 items: JSON.parse(row.items),43 total: parseFloat(row.total),44 status: row.status,45 chargeId: row.charge_id,46 };47 }48}49
1export class StripePaymentGateway implements PaymentGateway {2 constructor(private stripeClient: StripeClient) {}34 async chargeCustomer(input: {5 orderId: string;6 amount: number;7 }): Promise<Charge> {8 const paymentIntent = await this.stripeClient.paymentIntents.create({9 amount: Math.round(input.amount * 100),10 currency: "usd",11 metadata: { orderId: input.orderId },12 });1314 return {15 id: paymentIntent.id,16 status: paymentIntent.status === "succeeded" ? "succeeded" : "failed",17 };18 }19}20
1export class SendgridEmailNotifier implements Notifier {2 constructor(private sendgridClient: SendGridClient) {}3 async sendOrderConfirmation(input: {4 destination: string;5 orderId: string;6 }): Promise<void> {7 await this.sendgridClient.send({8 to: input.destination,9 from: "[email protected]",10 subject: `Order Confirmation - ${input.orderId}`,11 text: `Your order ${input.orderId} has been placed successfully.`,12 });13 }14}15
At this stage, the code can be getting a bit hard to follow. For the full implementation, you can check out my implementation on Github
You can see that the infrastructure details now live in the adapter:
- SQL lives in
PgOrderRepository - Currency and description formatting live in
StripePaymentGateway - HTML and sender address live in
EmailNotifier
The use case doesn't need to know any of this! Need to swap PostgreSQL for DynamoDB? Write a new adapter. The use case isn't affected. Need to notify customers by SMS instead of email? Write an SmsNotifier adapter that implements Notifier. The use case doesn't change.
How do we wire everything together?
Notice that we now have our adapters, which implements our ports, and our use case, which is dependent on them. Where do we wire them together? In hexagonal architecture, we do this in a single location called the composition root.
1const dbClient = new PostgresDB();2const stripeClient = new StripeClient();3const sgClient = new SendGridClient();45const drizzlePostgresOrderRepository = new PgOrderRepository(dbClient);6const stripePaymentGateway = new StripePaymentGateway(stripeClient);7const sendgridEmailNotifier = new SendgridEmailNotifier(sg);89const placeOrderUseCase = new PlaceOrderUseCase(10 drizzlePostgresOrderRepository,11 stripePaymentGateway,12 sendgridEmailNotifier,13);1415// In a backend API, this would be wired up to a route16placeOrderUseCase17 .execute({18 customerId: "user-1",19 email: "[email protected]",20 items: ["apple", "banana"],21 })22 .then((res) => {23 console.log("New order created!");24 console.log(res);25 });26
This is the only file that imports both core types and infrastructure types. Everything else stays on one side of the boundary. Without this rule, you would end up with new PgOrderRepository() calls wherever someone needs to call the use case, which reintroduces the coupling we're trying to avoid.
- Separates business logic from implementation details
- Allows us to test business logic without worrying about implementation details
- Limits blast radius if the implementation details changed
- Hexagonal architecture definition
How does this address our testability problem?
As you may recall, our original problem was that our business logic was tangled with our infrastructure. This was the result of our use case being coupled to the infrastructure, preventing us from testing its logic without having the infrastructure running. Now, it's been decoupled via ports. All we need to do is just have these ports implemented with test doubles and use those. Here's what it would look like with a library like vitest:
1import { describe, it, expect, vi } from "vitest";23function createFakes() {4 const orderRepository: OrderRepository = {5 createOrder: vi.fn(async (input) => ({6 id: "order-1",7 customerId: input.customerId,8 items: input.items,9 total: input.total,10 status: input.status,11 })),12 setChargeId: vi.fn(async (input) => ({13 id: input.orderId,14 customerId: "user-1",15 items: ["apple", "banana"],16 total: 39.98,17 status: "completed" as const,18 chargeId: input.chargeId,19 })),20 };2122 const paymentGateway: PaymentGateway = {23 chargeCustomer: vi.fn(async () => ({24 id: "charge-1",25 status: "succeeded" as const,26 })),27 };2829 const notifier: Notifier = {30 sendOrderConfirmation: vi.fn(async () => {}),31 };3233 return { orderRepository, paymentGateway, notifier };34}3536describe("PlaceOrderUseCase", () => {37 it("creates an order, charges the customer, and sends a confirmation", async () => {38 const { orderRepository, paymentGateway, notifier } = createFakes();39 const useCase = new PlaceOrderUseCase(40 orderRepository,41 paymentGateway,42 notifier,43 );4445 const result = await useCase.execute({46 customerId: "user-1",47 items: ["apple", "banana"],48 email: "[email protected]",49 });5051 expect(result).toEqual<Order>({52 id: "order-1",53 customerId: "user-1",54 items: ["apple", "banana"],55 total: 39.98,56 status: "completed",57 chargeId: "charge-1",58 });5960 expect(orderRepository.createOrder).toHaveBeenCalledOnce();61 expect(orderRepository.createOrder).toHaveBeenCalledWith({62 customerId: "user-1",63 items: ["apple", "banana"],64 total: 39.98,65 status: "pending",66 });6768 expect(paymentGateway.chargeCustomer).toHaveBeenCalledOnce();69 expect(paymentGateway.chargeCustomer).toHaveBeenCalledWith({70 amount: 39.98,71 orderId: "order-1",72 });7374 expect(orderRepository.setChargeId).toHaveBeenCalledOnce();75 expect(orderRepository.setChargeId).toHaveBeenCalledWith({76 orderId: "order-1",77 chargeId: "charge-1",78 });7980 expect(notifier.sendOrderConfirmation).toHaveBeenCalledOnce();81 expect(notifier.sendOrderConfirmation).toHaveBeenCalledWith({82 destination: "[email protected]",83 orderId: "order-1",84 });85 });8687 // Add other test cases here (e.g. edge cases, non-happy paths)88});89
No database. No Stripe. No SendGrid. The tests run without the need for infrastructure and can finish quick. These test doubles just need to satisfy the port's shape and it will work. The use case can't tell the difference!
Additionally, with the ports being shaped by the domain's needs, instead of the infrastructure's details, these test doubles don't break when our infrastructure changes. A mock implementation for createOrder works whether the real adapter uses PostgreSQL or DynamoDB. A mock implementation for sendOrderConfirmation works whether the real adapter sends email or SMS. In our naive fix, the mocks would break on a provider change because they were shaped around the provider's API (i.e. infrastructure details).
What's the high-level structure of the hexagonal architecture?
To recap, hexagonal architecture gives your codebase a structure that dictates the flow of dependencies. Each file belongs in a specific layer, a dependency rule that enforces the boundary between layers, and a composition root that centralizes the wiring.
The diagram shows three layers:
- Domain - Where our domain knowledge lives (e.g. entities, value objects)
- Application - Where our business logic lives (e.g. use cases)
- Adapters - Where our infrastructure lives (e.g. PostgreSQL, Stripe, SendGrid)
The dependency rule is this: Dependencies must point inwards. In the diagram, dependencies are represented by the arrows which, you can see, all point inward. The adapters point to the ports because they conform to them. The ports point to the use case because it's the use case needs that dictate their shape. The use case points to the domain entities because it's what carries out business processes, which fall under domain knowledge.
The only outsider here is the composition root. It lives outside of this architecture because it's responsible for wiring up the adapters to the use cases. Without it, the two have no way of talking to each other besides having the use cases import the adapters that they depend on. However, this would cause an outward-facing dependency (application -> adapter), which violates the dependency rule enforced by hexagonal architecture.
When to not employ hexagonal architecture?
I've spent all this time discussing what hexagonal architecture is, a specific implementation of it, and what it unlocks for us. However, not every situation requires hexagonal architecture. The pattern pays off when there's business logic that's worth isolating. Some situations where implementing it isn't worth it:
- No logic to protect - A thin CRUD wrapper that just forwards to the database doesn't benefit from ports as there's no business logic to test independently.
- A single external dependency - If the service/use case only talks to a database and never anything else, ports just introduce indirection without adding value.
- Prototype with a short lifespan - The boilerplate isn't worth it if the code won't survive long enough to need a test suite.
You apply it at the seams between business logic and infrastructure, not between every pair of classes.
At the MVP stage, what you're building is most likely going to change often. Hexagonal architecture makes your code resilient to infrastructure changes, but if your business logic changes (which it most likely will), it introduces more hassle than it's worth. I would suggest keeping your code simple enough to adapt fast to changing business requirements and, once they stabilize, refactor it to use hexagonal architecture.
What did we learn?
Hexagonal architecture provides a way to structure our codebase to increase testability by decoupling business logic from infrastructure. It does this by making our use cases depend on ports, which are implemented by adapters, and are wired together via the composition root. This allows us to test our business logic without having our infrastructure running by having the tests create mock implementations of these ports, which don't require infrastructure to be running, and feeding them into the use case.
However, despite what it unlocks, the architecture introduces indirection and more complexity to the code. It's not a silver bullet. As a result, it's only worth implementing when you have stable business logic worth isolating. In situations where these aren't true, it's recommended to not employ it.
If you're interested in viewing my implementation, check it out on Github.
Hope you learned something! Happy coding🚀
Like
Related Posts

Dependency inversion and dependency injection are terms that developers often mix up the two. In this article, we tackle this confusion.