DEV Community

Cover image for A Quick Note On Hexagonal Architecture
Stanislav Somov
Stanislav Somov

Posted on

A Quick Note On Hexagonal Architecture

Intro

Let’s look at one of my favorite architecture patterns and understand it with a
simple example.

Everyday analogy

Imagine you need to connect your game console to a monitor. The console provides
an HDMI output; the monitor has a DVI input. You go to a store and buy an
HDMI-to-DVI cable, so the console doesn’t depend on a specific display device.
Another day you want to connect the console to a TV — just grab an HDMI-to-HDMI
cable and you’re good to go. You don’t need to swap the console or the monitor
because the console depends only on the HDMI port. That’s the main idea of
hexagonal architecture: keep the core independent, and make the “devices”
(adapters) modular and swappable.

From analogy to code

You can think of hexagonal architecture as a conversation between two entities,
mediated by a protocol (port). Your EmailService may need a database for
tracking emails, and it might ask: “I need retrieve and save functions. The
retrieve function should have the signature (emailId) -> Json, and the save
function should have the signature (emailId, fromId, toId, body) -> Status.”
Through that interface, it can speak with any external service (adapter) that
implements the protocol.

The database service will look at the protocol and
think: “Okay, I need to implement a save function that takes emailId, fromId,
toId, and body as parameters, and I need to return the status of the operation.”
Which database you use and how it works internally does not matter — these details
are hidden behind the port(protocol implementation). In TypeScript it may
look like this:

// Domain (core, framework-agnostic)
type UUID = string;

interface Email {
  readonly emailId: UUID;
  readonly fromId: UUID;
  readonly toId: UUID;
  readonly body: string;
}

// Outbound Port (driven port from the domain to the outside)
interface EmailRepository {
  save(fromId: UUID, toId: UUID, body: string): Promise<Email>;
  findById(emailId: UUID): Promise<Email | null>;
}

// In-memory Adapter (driving the port)
class InMemoryEmailRepo implements EmailRepository {
  private storage = new Map<UUID, Email>();

  async save(fromId: UUID, toId: UUID, body: string): Promise<Email> {
    const emailId = globalThis.crypto?.randomUUID?.() as UUID;
    const email: Email = { emailId, fromId, toId, body };
    this.storage.set(emailId, email);
    return email;
  }

  async findById(emailId: UUID): Promise<Email | null> {
    return this.storage.get(emailId) ?? null;
  }

  // test helper (not part of the port)
  getAll() {
    return Array.from(this.storage.values());
  }
}

// SQL Adapter (driving the port)
// NOTE: Replace the client with a real one (pg, mysql2, etc.)
type SQLClient = {
  query: (q: string, vals?: unknown[]) => Promise<{ rows: any[] }>;
};

class SQLEmailRepo implements EmailRepository {
  constructor(private client: SQLClient) { }

  async save(fromId: UUID, toId: UUID, body: string): Promise<Email> {
    const result = await this.client.query(
      `INSERT INTO email (email_id, from_id, to_id, body) VALUES ($1, $2, $3, $4)`,
      [ // generate id on app side for consistency with in-mem
        globalThis.crypto?.randomUUID?.(),
        fromId,
        toId,
        body,
      ],
    );
    const row = result.rows[0];
    return {
      emailId: row.email_id as UUID,
      fromId: row.from_id as UUID,
      toId: row.to_id as UUID,
      body: row.body as string,
    };
  }

  async findById(emailId: UUID): Promise<Email | null> {
    const result = await this.client.query(
      `SELECT email_id, from_id, to_id, body FROM email WHERE email_id = $1`,
      [emailId],
    );
    const row = result.rows[0];
    if (!row) return null;
    return {
      emailId: row.email_id as UUID,
      fromId: row.from_id as UUID,
      toId: row.to_id as UUID,
      body: row.body as string,
    };
  }
}

// Application Service (inbound port implementation)
class EmailService {
  constructor(private repo: EmailRepository) { }

  create(fromId: UUID, toId: UUID, body: string) {
    return this.repo.save(fromId, toId, body);
  }

  get(emailId: UUID) {
    return this.repo.findById(emailId);
  }
}

// --- Composition at the edges ---
// Example with in-memory adapter
const inMemService = new EmailService(new InMemoryEmailRepo());
await(async () => {
  const created = await inMemService.create("1", "2", "hi second!");
  const fetched = await inMemService.get(created.emailId);
  console.log({ created, fetched });
})();

// Example with SQL adapter (pseudo client)
const fakeSqlClient: SQLClient = {
  async query(q, vals) {
    // Stub: emulate DB returning a single row
    const [email_id, from_id, to_id, body] = vals as string[];
    return { rows: [{ email_id, from_id, to_id, body }] };
  },
};
const sqlService = new EmailService(new SQLEmailRepo(fakeSqlClient));
await(async () => {
  const created = await sqlService.create("10", "20", "over SQL!");
  const fetched = await sqlService.get(created.emailId);
  console.log({ created, fetched });
})();
Enter fullscreen mode Exit fullscreen mode

As you can see, swapping one implementation for another is simple and
straightforward. This approach can also be used to inject other services. It can
even be used to compose use cases — for example, a user service can request an
external email service via this protocol:

interface EmailPort {
  sendEmail: (from: User, to: User, body: string) => Promise<Email | null>,
  getAllEmails: (user: User) =>  Promise<Email[]>
}
Enter fullscreen mode Exit fullscreen mode

...and our email service will provide this functionality:

interface User {
  readonly id: UUID;
  readonly name: string;
}
class EmailService implements EmailPort {
  // ... previous methods
    async sendEmail(from: User, to: User, body: string): Promise<Email> {
    return this.create(from.id, to.id, body);
  }
  async getAllEmails(user: User): Promise<Email[]> {
    // Just a stub
    return [{ emailId: "1", fromId: user.id, toId: "", body: "..." }]
  }
}
class Person implements User {
  private emailService: EmailService;
  id: UUID;
  name: string;

  constructor(id: UUID, name: string, emailService: EmailService) {
    this.id = id;
    this.name = name;
    this.emailService = emailService;
  }

  async sendEmail(to: User, body: string) {
    await this.emailService.create(this.id, to.id, body);

    return `Email has been succefully sent from ${this.id} to ${to.id}`
  }
}

const p1 = new Person("1", "John", inMemService);

await p1.sendEmail({ id: "2", name: "Mary" }, "Hi! How is going?");
Enter fullscreen mode Exit fullscreen mode

Let's sum it up.

First, think from the use-case perspective: what functions do you need from the
external service? Then, describe the protocol to be implemented by the external
service. Finally, compose the port and the adapter.

I hope this article helps you comprehend hexagonal architecture and build more
modular, robust, and maintainable software.

See you next time!

Top comments (0)