Skip to content
Bitloops - Git captures what changed. Bitloops captures why.
HomeAbout usDocsBlog
ResourcesSoftware ArchitectureHexagonal Architecture: Ports and Adapters

Hexagonal Architecture: Ports and Adapters

Design your application first, then plug in external systems via ports. Swap a database adapter without rewriting core logic. Test without infrastructure. The hexagon's just a convention; the real power is inverting dependencies so external systems adapt to you.

9 min readUpdated March 4, 2026Software Architecture

Definition

Hexagonal Architecture, introduced by Alistair Cockburn, organizes systems around a clear boundary between the application core and the outside world. The core exposes ports (interfaces) for communicating with external systems. Adapters (implementations) connect those ports to specific technologies—databases, message queues, APIs, UIs.

The hexagon isn't the point. The shape is just a convention for drawing it. The actual architecture is the inversion of dependency: external systems depend on the core, never the reverse.

Why "ports"? Because a port is where you plug things in. Your application has a port for persistence (database, file, memory—doesn't matter). An adapter implements that port for a specific technology. Swap adapters without touching the core.

Why This Matters

Most systems are built inside-out: you pick a database, build a schema, then build the application on top of it. The application depends on the database. The database shapes the code.

Hexagonal Architecture inverts this: design the application first (what does it need to do?), then figure out how to persist state. The core knows it needs to store users. It doesn't know about PostgreSQL. An adapter bridges the gap.

This solves three problems:

Testability without infrastructure. You don't need a database to test core logic. Mock the port, plug in a test adapter, run your tests. Fast.

Replacing external systems is cheap. Started with PostgreSQL? Switching to MongoDB is a repository adapter rewrite, not an application rewrite.

Technology diversity. Different parts of the system can use different technologies. One adapter talks to PostgreSQL, another to MongoDB, another to a file system. The core is agnostic.

Ports: The Application Boundary

A port is an interface that describes what the application needs from the outside world.

Ports come in two types:

Primary Ports

Primary ports are where actors drive the application. The user sends an HTTP request. A scheduled job triggers. A CLI command runs. These are the ways the application gets work to do.

// Primary port - incoming, drives the application
interface UserCommandPort {
  createUser(request: CreateUserRequest): Promise<CreateUserResponse>;
  updateUser(id: string, request: UpdateUserRequest): Promise<void>;
  deleteUser(id: string): Promise<void>;
}

interface UserQueryPort {
  getUser(id: string): Promise<User>;
  listUsers(filters: UserFilters): Promise<User[]>;
}
typescript

When an HTTP request arrives, the controller implements these ports and calls the core. The core doesn't know about HTTP. It knows about these abstract operations.

Secondary Ports

Secondary ports are where the application drives external systems. "I need to persist this user." "I need to send an email." "I need to publish an event." These are the things the application asks the outside world to do.

// Secondary ports - outgoing, application depends on
interface UserRepository {
  save(user: User): Promise<void>;
  findById(id: string): Promise<User | null>;
  delete(id: string): Promise<void>;
}

interface EmailService {
  sendWelcome(email: string): Promise<void>;
  sendReset(email: string, token: string): Promise<void>;
}

interface EventBus {
  publish(event: DomainEvent): Promise<void>;
}
typescript

The application core depends on these interfaces. The outside world provides implementations.

Adapters: The Technology Detail

An adapter implements a port for a specific technology. Two adapters for the same port means two implementations of the same contract.

Secondary Adapters (Driven)

These implement secondary ports. They handle persistence, messaging, external APIs.

// Adapter 1: PostgreSQL
class PostgresUserRepository implements UserRepository {
  constructor(private db: Database) {}

  async save(user: User): Promise<void> {
    await this.db.query(
      'INSERT INTO users (id, name, email) VALUES ($1, $2, $3)',
      [user.id, user.name, user.email]
    );
  }

  async findById(id: string): Promise<User | null> {
    const row = await this.db.query('SELECT * FROM users WHERE id = $1', [id]);
    if (!row) return null;
    return new User(row.id, row.name, row.email);
  }

  async delete(id: string): Promise<void> {
    await this.db.query('DELETE FROM users WHERE id = $1', [id]);
  }
}

// Adapter 2: In-memory (for testing)
class MemoryUserRepository implements UserRepository {
  private users = new Map<string, User>();

  async save(user: User): Promise<void> {
    this.users.set(user.id, user);
  }

  async findById(id: string): Promise<User | null> {
    return this.users.get(id) || null;
  }

  async delete(id: string): Promise<void> {
    this.users.delete(id);
  }
}

// Adapter 3: MongoDB
class MongoUserRepository implements UserRepository {
  constructor(private collection: MongoCollection) {}

  async save(user: User): Promise<void> {
    await this.collection.updateOne(
      { _id: user.id },
      { $set: { name: user.name, email: user.email } },
      { upsert: true }
    );
  }

  async findById(id: string): Promise<User | null> {
    const doc = await this.collection.findOne({ _id: id });
    if (!doc) return null;
    return new User(doc._id, doc.name, doc.email);
  }

  async delete(id: string): Promise<void> {
    await this.collection.deleteOne({ _id: id });
  }
}
typescript

All three adapt the same port to different technologies. In production, you use PostgreSQL. In tests, you use in-memory. The application doesn't know the difference.

Primary Adapters (Drivers)

These adapt inbound requests to primary port calls. An HTTP controller, a CLI handler, a gRPC service.

// HTTP adapter
class CreateUserController {
  constructor(private userCommands: UserCommandPort) {}

  async handle(req: Request, res: Response): Promise<void> {
    try {
      const response = await this.userCommands.createUser({
        name: req.body.name,
        email: req.body.email
      });
      res.status(201).json(response);
    } catch (error) {
      res.status(400).json({ error: error.message });
    }
  }
}

// CLI adapter
class CreateUserCommand {
  constructor(private userCommands: UserCommandPort) {}

  async execute(args: CLIArgs): Promise<void> {
    try {
      const response = await this.userCommands.createUser({
        name: args.name,
        email: args.email
      });
      console.log(`User created: ${response.userId}`);
    } catch (error) {
      console.error(`Error: ${error.message}`);
    }
  }
}
typescript

Same core logic, different entry points. HTTP, CLI, gRPC, cron job—all adapt the same application.

The Architecture in Practice

A typical structure:

/core
  /domain
    User.ts (entity)
  /ports
    UserRepository.ts (interface)
    UserCommands.ts (interface)
    EmailService.ts (interface)
  /usecases
    CreateUserUseCase.ts (uses ports)
    GetUserUseCase.ts (uses ports)

/adapters
  /primary
    /http
      CreateUserController.ts
      GetUserController.ts
    /cli
      CreateUserCommand.ts
  /secondary
    /repositories
      PostgresUserRepository.ts
      MemoryUserRepository.ts
    /services
      SendgridEmailService.ts
      MockEmailService.ts

/configuration
  Bootstrap.ts (wire everything together)
Text

The core is isolated. The adapters live on the outside, plugging into ports. The bootstrap layer wires them together.

Hexagonal vs. Clean Architecture

These patterns are cousins. They both isolate domain logic from infrastructure. But they emphasize different things:

Clean Architecture emphasizes layering: entities are most stable, outer layers depend on inner ones. It provides a general framework.

Hexagonal Architecture emphasizes ports: external systems are abstracted behind interfaces. It's more about what adapts to what.

In practice, many systems use both: Clean Architecture's layering to organize the core, Hexagonal Architecture's ports to isolate external dependencies.

Testing with Hexagonal Architecture

Here's the payoff. Your use case depends on repositories and services. In tests, you inject mock implementations

// Test: no database, no email service
describe('CreateUserUseCase', () => {
  let useCase: CreateUserUseCase;
  let mockRepository: UserRepository;
  let mockEmailService: EmailService;

  beforeEach(() => {
    mockRepository = new MemoryUserRepository();
    mockEmailService = new MockEmailService();
    useCase = new CreateUserUseCase(mockRepository, mockEmailService);
  });

  it('creates a user and sends welcome email', async () => {
    const response = await useCase.execute({
      name: 'Alice',
      email: 'alice@example.com'
    });

    expect(response.userId).toBeDefined();
    const user = await mockRepository.findById(response.userId);
    expect(user?.email).toBe('alice@example.com');
    expect(mockEmailService.sent).toContain('alice@example.com');
  });
});
javascript

No database setup. No external service calls. Tests run in milliseconds. This is what dependency inversion buys you.

Handling Cross-Service Communication

Hexagonal Architecture shines when systems need to talk to each other. Each system is a hexagon with its own ports.

Service A publishes a UserCreated event via its EventBus port. Service B listens via an adapter. Service B's logic depends on the event interface, not on Service A directly.

// Service A
interface EventBus {
  publish(event: DomainEvent): Promise<void>;
}

class UserCreatedEvent implements DomainEvent {
  constructor(public userId: string, public email: string) {}
}

class CreateUserUseCase {
  constructor(
    private repository: UserRepository,
    private eventBus: EventBus
  ) {}

  async execute(request: CreateUserRequest): Promise<void> {
    const user = new User(generateId(), request.email);
    await this.repository.save(user);
    await this.eventBus.publish(
      new UserCreatedEvent(user.id, user.email)
    );
  }
}

// Service B: Adapter listens to Service A's events
class EventBusKafkaAdapter implements EventBus {
  subscribe(topic: string, handler: (event: DomainEvent) => Promise<void>) {
    kafka.subscribe(topic, async (message) => {
      const event = JSON.parse(message.value.toString());
      await handler(event);
    });
  }
}

// Service B: Use case reacts to external event
class WelcomeUserUseCase {
  constructor(private emailService: EmailService) {}

  async execute(event: UserCreatedEvent): Promise<void> {
    await this.emailService.sendWelcome(event.email);
  }
}
typescript

Services are decoupled. Service B doesn't import from Service A. It reacts to events. Either can change without breaking the other.

Common Mistakes

Mixing adapters with core logic. If your use case imports from an adapter, you've missed the point. Core logic should depend on ports (abstractions), not adapters (implementations).

Too many ports. If every data structure has its own repository, you've created overhead. Group related data under one repository port.

Confusing primary and secondary. Primary adapters drive the application (HTTP requests, timers). Secondary adapters are driven by it (database, email). Mixed up? You've broken the architecture.

Not mocking adapters in tests. The whole point of ports is testability. If you're spinning up a database in tests, you're using real adapters when you should be using mocks.

When Hexagonal Works Best

Multiple entry points. Your system needs HTTP, CLI, gRPC, cron jobs. Hexagonal makes this natural.

Multiple implementations of the same concern. You might use PostgreSQL in production but want MySQL as an option. You might use SMTP for email but also want Sendgrid. Different adapters, same port.

Systems that evolve technology. You don't know which database you'll end up with. Hexagonal delays that decision until you know more.

Microservices. Each service is a hexagon. Services communicate via adapters. The pattern scales naturally.

AI Agents and Hexagonal Architecture

Hexagonal Architecture makes AI-assisted development safer. Clear ports mean clear contracts. An agent trying to write a use case that directly imports a database adapter will violate the port contract, which Bitloops can catch.

Moreover, when you provide an AI agent with detailed port definitions, it knows exactly what abstractions to depend on. "Here's the UserRepository port, here's what it offers." The agent generates code that respects those boundaries.

This is context engineering: the better your architecture is defined (explicit ports, clear contracts), the safer AI generation becomes. See How AI-Generated Code Impacts Architecture for strategies to enforce these patterns at scale, and What Is AI-Native Development for how architectural thinking changes in AI-driven teams.

FAQ

Isn't Hexagonal Architecture overkill for a simple CRUD app?

For a simple app with a single database and UI, maybe. But even simple apps benefit from mocking in tests. And "simple" grows. Build the structure early; you'll thank yourself when requirements change.

How many adapters should I have for one port?

Usually two: a production adapter and a test adapter. Sometimes three or four if you truly have multiple implementations (e.g., file storage, S3, and memory for different use cases). If you have ten adapters for one port, you've probably over-abstracted.

What about shared libraries between services?

Share domain models, not adapters. Service A and Service B might both have a User entity. They can share the definition. But they shouldn't share repositories—each service owns its own data layer.

How do I handle transactions across multiple adapters?

Usually you keep transactions within one adapter (one database). If you need distributed transactions, you're in microservices territory and should use sagas or event sourcing.

Can I use ORMs with Hexagonal Architecture?

Yes. The ORM stays in the secondary adapter. Your repository adapter uses the ORM to persist entities. The core never imports ORM code.

What about dependency injection frameworks?

They're useful for wiring adapters together. But don't let the framework leak into your core. Spring Boot is an adapter, not part of your application.

Primary Sources

  • Cockburn's original article introducing the hexagonal architecture pattern and its principles. Hexagonal Architecture
  • Martin's foundational principle on how source code dependencies point inward. Dependency Rule
  • Shore's guide to test-driven development discipline and three core rules. TDD Rules
  • Fowler's comprehensive exploration of mock objects and testing patterns. Mock Objects
  • Robert C. Martin's canonical text on architecture, dependency rules, and design principles. Clean Architecture
  • Bass, Clements, and Kazman's comprehensive guide to software architecture decisions. Architecture in Practice

Get Started with Bitloops.

Apply what you learn in these hubs to real AI-assisted delivery workflows with shared context, traceable reasoning, and architecture-aware engineering practices.

curl -sSL https://bitloops.com/install.sh | bash