Hexagonal Architecture: Ports and Adapters Explained

SOFTWARE ARCHITECTURE By TryzTech Team
Hexagonal ArchitectureSoftware ArchitectureBackendTestingClean Architecture

Table of Contents

Introduction

Many applications become hard to change because business logic is mixed with frameworks, databases, HTTP handlers, message queues, and third-party SDKs.

Hexagonal architecture, also called ports and adapters, tries to protect the core application from those external details.

The goal is not to create folders with fancy names. The goal is to keep the business rules easy to test, replace, and understand.

The Core Idea

Hexagonal architecture places the application core in the center.

The outside world talks to the core through ports. Adapters implement those ports for specific technologies.

HTTP Controller -> Input Port -> Application Service -> Output Port -> Database Adapter

The core should not care whether a request came from REST, GraphQL, CLI, or a message queue. It should care about the use case.

Ports and Adapters

A port is an interface that describes what the application needs or offers.

An adapter connects that port to a real tool.

ConceptExample
Input portRegisterUserUseCase
Input adapterHTTP controller, CLI command
Output portUserRepository, EmailSender
Output adapterPostgreSQL repository, SMTP sender

This separation makes technology replaceable without rewriting the business rules.

Dependency Direction

The most important rule is dependency direction.

The core can define interfaces. External adapters implement them. The core should not import framework or infrastructure details.

Bad direction:

Application service -> Express request -> Database client

Better direction:

Controller -> Application service -> Repository port
Postgres adapter -> Repository port

The application service depends on abstractions. The infrastructure depends on the application contract.

Example: User Registration

Imagine a user registration use case.

The core needs to:

  • validate input
  • check if the email already exists
  • create the user
  • send a welcome email

Ports:

PortResponsibility
UserRepositoryfind and save users
PasswordHasherhash passwords
EmailSendersend welcome email

Adapters:

AdapterImplements
PostgresUserRepositoryUserRepository
BcryptPasswordHasherPasswordHasher
SmtpEmailSenderEmailSender

The registration logic stays stable even if the email provider or database changes.

Hexagonal Architecture Diagram

The following diagram shows the simple shape. The application core sits in the center. The outside world enters through input ports or leaves through output ports.

flowchart LR
  subgraph Outside["Outside World"]
    HTTP["HTTP Controller"]
    CLI["CLI Command"]
    Queue["Message Consumer"]
    DB["PostgreSQL Adapter"]
    SMTP["SMTP Adapter"]
    Hash["Bcrypt Adapter"]
  end

  subgraph Core["Application Core"]
    UseCase["RegisterUser Use Case"]
    UserRepoPort["UserRepository Port"]
    EmailPort["EmailSender Port"]
    HashPort["PasswordHasher Port"]
  end

  HTTP --> UseCase
  CLI --> UseCase
  Queue --> UseCase

  UseCase --> UserRepoPort
  UseCase --> EmailPort
  UseCase --> HashPort

  DB -. implements .-> UserRepoPort
  SMTP -. implements .-> EmailPort
  Hash -. implements .-> HashPort

The important part is not the visual hexagon shape. The important part is dependency direction: adapters may know about the core, but the core does not need to know about adapters.

Example Folder Structure

Folder structures vary, but this split is easy to reason about:

src/
  application/
    ports/
      UserRepository.ts
      EmailSender.ts
      PasswordHasher.ts
    use-cases/
      RegisterUser.ts
  domain/
    User.ts
  infrastructure/
    database/
      PostgresUserRepository.ts
    email/
      SmtpEmailSender.ts
    security/
      BcryptPasswordHasher.ts
  interfaces/
    http/
      RegisterUserController.ts
  main.ts

application and domain are the parts you want to keep clean. infrastructure and interfaces are adapters that can depend on frameworks, database clients, email SDKs, or hashing libraries.

Code Example: Register User

Start with ports. A port describes what the use case needs, not the technical way to do it.

export type User = {
  id: string;
  email: string;
  passwordHash: string;
};

export interface UserRepository {
  findByEmail(email: string): Promise<User | null>;
  save(user: User): Promise<void>;
}

export interface PasswordHasher {
  hash(rawPassword: string): Promise<string>;
}

export interface EmailSender {
  sendWelcomeEmail(email: string): Promise<void>;
}

Then the application service or use case:

type RegisterUserInput = {
  email: string;
  password: string;
};

export class RegisterUser {
  constructor(
    private readonly users: UserRepository,
    private readonly passwordHasher: PasswordHasher,
    private readonly emailSender: EmailSender,
  ) {}

  async execute(input: RegisterUserInput) {
    if (!input.email.includes("@")) {
      throw new Error("Invalid email");
    }

    if (input.password.length < 8) {
      throw new Error("Password must be at least 8 characters");
    }

    const existingUser = await this.users.findByEmail(input.email);

    if (existingUser) {
      throw new Error("Email is already registered");
    }

    const passwordHash = await this.passwordHasher.hash(input.password);
    const user: User = {
      id: crypto.randomUUID(),
      email: input.email,
      passwordHash,
    };

    await this.users.save(user);
    await this.emailSender.sendWelcomeEmail(user.email);

    return {
      id: user.id,
      email: user.email,
    };
  }
}

Notice that RegisterUser does not know whether data is stored in PostgreSQL, MySQL, DynamoDB, or memory. It also does not know whether email is sent through SMTP, SendGrid, or a message queue.

A database adapter can look like this:

export class PostgresUserRepository implements UserRepository {
  constructor(private readonly db: DatabaseClient) {}

  async findByEmail(email: string): Promise<User | null> {
    const row = await this.db.queryOne(
      "select id, email, password_hash from users where email = $1",
      [email],
    );

    if (!row) {
      return null;
    }

    return {
      id: row.id,
      email: row.email,
      passwordHash: row.password_hash,
    };
  }

  async save(user: User): Promise<void> {
    await this.db.execute(
      "insert into users (id, email, password_hash) values ($1, $2, $3)",
      [user.id, user.email, user.passwordHash],
    );
  }
}

The HTTP adapter becomes the entry point. It translates an HTTP request into use case input.

export class RegisterUserController {
  constructor(private readonly registerUser: RegisterUser) {}

  async handle(req: Request): Promise<Response> {
    const body = await req.json();

    try {
      const result = await this.registerUser.execute({
        email: body.email,
        password: body.password,
      });

      return Response.json(result, { status: 201 });
    } catch (error) {
      return Response.json(
        { message: error instanceof Error ? error.message : "Registration failed" },
        { status: 400 },
      );
    }
  }
}

The controller may know HTTP details. The database adapter may know SQL details. The use case still talks through the interfaces it needs.

Composition Root

A practical question often comes up: if the core should not create adapters by itself, who wires everything together?

The answer is the composition root, usually in the application entry point.

const db = new DatabaseClient(process.env.DATABASE_URL);

const userRepository = new PostgresUserRepository(db);
const passwordHasher = new BcryptPasswordHasher();
const emailSender = new SmtpEmailSender(process.env.SMTP_URL);

const registerUser = new RegisterUser(
  userRepository,
  passwordHasher,
  emailSender,
);

export const registerUserController = new RegisterUserController(registerUser);

This is where real objects are assembled. The core stays clean, while the application can still use real technologies.

Why It Helps Testing

Hexagonal architecture makes business logic easier to test because the core can be tested without real infrastructure.

For example:

Test targetDependency style
registration rulesfake repository and fake email sender
HTTP behaviorcontroller test
database SQLrepository integration test

This keeps tests focused. You do not need a full web server and real database just to test a business rule.

Example unit test for the use case:

class FakeUserRepository implements UserRepository {
  users = new Map<string, User>();

  async findByEmail(email: string) {
    return this.users.get(email) ?? null;
  }

  async save(user: User) {
    this.users.set(user.email, user);
  }
}

class FakePasswordHasher implements PasswordHasher {
  async hash(rawPassword: string) {
    return `hashed:${rawPassword}`;
  }
}

class FakeEmailSender implements EmailSender {
  sentTo: string[] = [];

  async sendWelcomeEmail(email: string) {
    this.sentTo.push(email);
  }
}

const users = new FakeUserRepository();
const emailSender = new FakeEmailSender();
const useCase = new RegisterUser(
  users,
  new FakePasswordHasher(),
  emailSender,
);

const result = await useCase.execute({
  email: "[email protected]",
  password: "secret123",
});

expect(result.email).toBe("[email protected]");
expect(emailSender.sentTo).toContain("[email protected]");

This test does not need a web server, database, SMTP server, or real bcrypt. It tests the registration rule.

When This Pattern Is Worth It

Hexagonal architecture is most useful when the application has important business rules and external dependencies that may change.

Use it when:

  • use cases have business rules that need serious tests
  • the application has multiple delivery mechanisms, such as HTTP and queues
  • external dependencies change often or carry meaningful risk
  • the team wants to separate domain logic from framework code
  • workflows are becoming more complex than simple CRUD

For a small CRUD app, the pattern can feel too heavy. It is fine to start with a simpler structure and add ports when a boundary becomes genuinely useful.

Common Mistakes

Creating too many interfaces

Not every class needs a port. Add boundaries where replacement, testing, or ownership actually matters.

Letting framework types leak into the core

If your use case accepts an HTTP request object, the core is now tied to the web framework.

Confusing architecture with folder structure

Folders help, but dependency direction matters more than folder names.

Overengineering small CRUD apps

Simple applications may not need full hexagonal structure. Use the pattern where complexity justifies the boundaries.

Checklist

  • Put business use cases near the center.
  • Keep framework and infrastructure details outside the core.
  • Define ports for important external dependencies.
  • Implement ports with adapters.
  • Keep dependency direction pointing inward.
  • Test core logic with fake adapters.
  • Use integration tests for real adapters.
  • Avoid creating interfaces just for decoration.

FAQ

Is hexagonal architecture the same as clean architecture?

They are closely related and often paired with concepts like Layered Architecture vs Clean Architecture and Domain-Driven Design (DDD). Both emphasize dependency direction and protecting business rules from external details. For managing transactions across distributed services outside the core boundary, you can combine it with the Saga Pattern. The terminology and layering differ.

Does every repository need an interface?

Not always. Add a port when it gives you useful isolation, testing flexibility, or technology independence.

Can frontend apps use hexagonal architecture?

Yes. The same idea can separate UI frameworks from business logic, API clients, storage, and domain rules.

Conclusion

Hexagonal architecture helps keep the application core independent from delivery mechanisms and infrastructure.

Use ports to express what the core needs. Use adapters to connect real technologies. Keep the direction clear, and the system becomes easier to test and change.

Have you implemented Hexagonal Architecture or Ports & Adapters in your projects? Or do you feel the abstraction adds too much overhead for your current app scale? Share your thoughts and experience in the comments below! 💬

Keep reading within the same topic.

Don't Miss Out

Get the latest tech articles, tips, and insights delivered to your inbox.