Bee flying around a sheet of honeycomb

Hexagonal Architecture: Isolating Core Logic from External Systems

Software TestingSoftware Design Patterns
Published

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:

  1. Calculate the total amount of the order
  2. Charge the customer the total amount
  3. Send an order confirmation email

Specifically, this is what the business logic looks like:

  1. Calculating the total
  2. Saving the order
  3. Charging the customer
  4. Updating the order with the charge ID
  5. Notifying the customer

This is what it looks like:

TypeScript
1class OrderProcessor {
2 private db = new PostgresDB();
3 private stripe = new StripeClient();
4 private sendgrid = new SendGridClient();
5
6 async placeOrder(customerId: string, items: string[], email: string): Promise<string> {
7 const total = items.length * 29.99;
8
9 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]
14
15 const charge = await this.stripe.createCharge(total, 'usd', order.id);
16
17 const updatedOrder = await this.db.query(
18 `UPDATE orders SET chargeId = $1
19 WHERE id = $2
20 RETURNING id, customer_id, items, total, status, charge_id`,
21 [charge.chargeId, order.id]
22 );
23
24 await this.sendgrid.send(
25 email, '[email protected]', 'Order Confirmation',
26 `<h1>Order ${order.id} confirmed!</h1><p>Total: $${total}</p>`
27 );
28
29 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:

TypeScript
1interface Database {
2 query(sql: string, params: unknown[]): Promise<{ rows: unknown[] }>;
3}
4
5interface PaymentProvider {
6 createCharge(amount: number, currency: string, description: string): Promise<{ chargeId: string }>;
7}
8
9interface 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:

TypeScript
1
2class OrderProcessor {
3 constructor(
4 private db: Database,
5 private payment: PaymentProvider,
6 private email: EmailClient
7 ) {}
8
9 async placeOrder(customerId: string, items: string[], email: string): Promise<string> {
10 // ... same SQL, same Stripe args, same SendGrid format
11 }
12}
Note

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.query method
  • PaymentProvider.createCharge's signature is just Stripe's API
  • EmailClient.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.

This sounds like the Dependency Inversion Principle (DIP)

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:

  1. PostgresDB -> an order repository
    1. Lines 9-12: Execute this SQL -> Create an order
    2. Lines 17-22: Execute this SQL -> Set the charge ID
  2. StripeClient -> a payment gateway
    1. Line 15: Create a charge with this currency and description -> Charge a customer
  3. SendGridClient -> a notifier
    1. 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:

TypeScript
1// ===== ENTITIES =====
2type OrderStatus = "pending" | "completed" | "cancelled";
3
4type Order = {
5 id: string;
6 customerId: string;
7 items: string[];
8 total: number;
9 status: OrderStatus;
10 chargeId?: string;
11};
12
13type ChargeStatus = "succeeded" | "failed";
14
15type Charge = {
16 id: string;
17 status: ChargeStatus;
18};
19
20// ===== ENTITIES =====
21
22
23
24
25interface 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}
34
35interface PaymentGateway {
36 chargeCustomer: (input: {
37 orderId: string;
38 amount: number;
39 }) => Promise<Charge>;
40}
41
42interface 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:

  • OrderRepository doesn't care if we swap out PostgreSQL with DynamoDB or some other database technology
  • PaymentGateway doesn't care if we swap out Stripe with some other technology with a different API
  • Notifier doesn'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:

TypeScript
1export class PlaceOrderUseCase {
2 constructor(
3 private orderRepository: OrderRepository,
4 private paymentGateway: PaymentGateway,
5 private notifier: Notifier,
6 ) {}
7
8 async execute(input: {
9 customerId: string;
10 items: string[];
11 email: string;
12 }): Promise<Order> {
13 const total = input.items.length * 19.99;
14
15 const order = await this.orderRepository.createOrder({
16 customerId: input.customerId,
17 items: input.items,
18 total,
19 status: "pending",
20 });
21
22 const payment = await this.paymentGateway.chargeCustomer({
23 amount: total,
24 orderId: order.id,
25 });
26
27 const updatedOrder = await this.orderRepository.setChargeId({
28 orderId: order.id,
29 chargeId: payment.id,
30 });
31
32 await this.notifier.sendOrderConfirmation({
33 destination: input.email,
34 orderId: order.id,
35 });
36
37 return updatedOrder;
38 }
39}
40

You can see now that the service now has zero infrastructure imports. It now reads like a business process:

  1. Save the order
  2. Charge the customer
  3. Record the charge
  4. 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.

Why rename the service?

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.

pg.ts
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 );
15
16 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 }
26
27 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 = $2
34 RETURNING id, customer_id, items, total, status, charge_id`,
35 [input.chargeId, input.orderId],
36 );
37
38 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


stripe.ts
1export class StripePaymentGateway implements PaymentGateway {
2 constructor(private stripeClient: StripeClient) {}
3
4 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 });
13
14 return {
15 id: paymentIntent.id,
16 status: paymentIntent.status === "succeeded" ? "succeeded" : "failed",
17 };
18 }
19}
20


sendgrid-email.ts
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,
10 subject: `Order Confirmation - ${input.orderId}`,
11 text: `Your order ${input.orderId} has been placed successfully.`,
12 });
13 }
14}
15
Note

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.

main.ts
1const dbClient = new PostgresDB();
2const stripeClient = new StripeClient();
3const sgClient = new SendGridClient();
4
5const drizzlePostgresOrderRepository = new PgOrderRepository(dbClient);
6const stripePaymentGateway = new StripePaymentGateway(stripeClient);
7const sendgridEmailNotifier = new SendgridEmailNotifier(sg);
8
9const placeOrderUseCase = new PlaceOrderUseCase(
10 drizzlePostgresOrderRepository,
11 stripePaymentGateway,
12 sendgridEmailNotifier,
13);
14
15// In a backend API, this would be wired up to a route
16placeOrderUseCase
17 .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:

TypeScript
1import { describe, it, expect, vi } from "vitest";
2
3function 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 };
21
22 const paymentGateway: PaymentGateway = {
23 chargeCustomer: vi.fn(async () => ({
24 id: "charge-1",
25 status: "succeeded" as const,
26 })),
27 };
28
29 const notifier: Notifier = {
30 sendOrderConfirmation: vi.fn(async () => {}),
31 };
32
33 return { orderRepository, paymentGateway, notifier };
34}
35
36describe("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 );
44
45 const result = await useCase.execute({
46 customerId: "user-1",
47 items: ["apple", "banana"],
48 email: "[email protected]",
49 });
50
51 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 });
59
60 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 });
67
68 expect(paymentGateway.chargeCustomer).toHaveBeenCalledOnce();
69 expect(paymentGateway.chargeCustomer).toHaveBeenCalledWith({
70 amount: 39.98,
71 orderId: "order-1",
72 });
73
74 expect(orderRepository.setChargeId).toHaveBeenCalledOnce();
75 expect(orderRepository.setChargeId).toHaveBeenCalledWith({
76 orderId: "order-1",
77 chargeId: "charge-1",
78 });
79
80 expect(notifier.sendOrderConfirmation).toHaveBeenCalledOnce();
81 expect(notifier.sendOrderConfirmation).toHaveBeenCalledWith({
82 destination: "[email protected]",
83 orderId: "order-1",
84 });
85 });
86
87 // 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.

Hexagonal Architecture Diagram

The diagram shows three layers:

  1. Domain - Where our domain knowledge lives (e.g. entities, value objects)
  2. Application - Where our business logic lives (e.g. use cases)
  3. 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.

If you're building an MVP, shy away from hexagonal architecture

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🚀

Last updated

Like

Related Posts