Skip to main content

4 - Hexagonal Architecture (Ports and Adapters)

Hexagonal Architecture (Ports and Adapters): Hexagonal Architecture, also known as Ports and Adapters, was formulated by Alistair Cockburn to decouple business logic from infrastructure. Learn how to structure your applications for flexibility and testability.

What do you think about this article?

At a Glance

Hexagonal Architecture (Ports and Adapters): Hexagonal Architecture, also known as Ports and Adapters, was formulated by Alistair Cockburn to decouple business logic from infrastructure. Learn how to structure your applications for flexibility and testability.

Hexagonal Architecture: Ports and Adapters

Hexagonal Architecture, also known as Ports and Adapters, was formulated by Alistair Cockburn in the early 2000s with a clear goal: to separate the application core from the surrounding infrastructure. This architectural pattern allows you to develop and test business logic in complete isolation from databases, frameworks, external APIs, and any other technical detail.

In this article, we will explore the core concepts of hexagonal architecture in depth, see how to implement it in TypeScript and Angular, and compare it with Clean Architecture and Onion Architecture to understand when to prefer it.

What You Will Learn

  • Core concepts: hexagon, ports, and adapters
  • Difference between primary (driving) and secondary (driven) ports
  • Practical implementation in TypeScript with Dependency Injection
  • Detailed comparison with Clean Architecture and Onion Architecture
  • Complete example: a notification system
  • Testing advantages and maintainability benefits

The Hexagon: The Heart of the Architecture

The hexagon represents the application domain, meaning all pure business logic free from external dependencies. The hexagonal shape is purely metaphorical: it conveys the idea that the application has many faces through which it communicates with the outside world, not just the two traditional ones (UI and database) from the layered model.

Inside the hexagon we find:

  • Domain entities: Objects representing the fundamental business concepts
  • Domain services: Logic that operates on entities and implements business rules
  • Ports: Interfaces that define how the domain communicates with the outside

Ports: The Domain's Interfaces

Ports are interfaces defined in the domain that establish a contract. They are divided into two fundamental categories:

  • Primary Ports (Driving Ports): Define the application's use cases. These are the interfaces through which the outside world drives the application. Example: an OrderService interface exposed to a REST API or a CLI.
  • Secondary Ports (Driven Ports): Define the application's dependencies on the outside. These are the interfaces that the domain uses but does not implement. Example: an OrderRepository interface for persistence, or NotificationSender for sending notifications.

Adapters: The Concrete Implementations

Adapters are concrete implementations of ports. They translate calls between the external world's format and the domain's format:

  • Primary Adapters (Driving Adapters): Receive input from the outside and invoke primary ports. Examples: REST controllers, GraphQL handlers, message consumers, CLI interfaces.
  • Secondary Adapters (Driven Adapters): Implement secondary ports. Examples: SQL repositories, HTTP clients for external APIs, email service adapters, filesystem adapters.

Implementation in TypeScript

Let's see how to implement hexagonal architecture in TypeScript, starting from port definitions up to adapter composition.

Defining Domain Entities

domain/entities/notification.ts

export type NotificationChannel = 'email' | 'sms' | 'push';
export type NotificationStatus = 'pending' | 'sent' | 'failed';

export interface Notification {
  readonly id: string;
  readonly recipientId: string;
  readonly channel: NotificationChannel;
  readonly subject: string;
  readonly body: string;
  readonly status: NotificationStatus;
  readonly createdAt: Date;
  readonly sentAt?: Date;
}

export interface NotificationRequest {
  recipientId: string;
  channel: NotificationChannel;
  subject: string;
  body: string;
}
    

Defining the Ports

domain/ports/primary/notification-use-cases.port.ts

// Primary Port: defines the use cases
export interface NotificationUseCases {
  send(request: NotificationRequest): Promise<Notification>;
  getById(id: string): Promise<Notification | null>;
  getByRecipient(recipientId: string): Promise<Notification[]>;
  retry(id: string): Promise<Notification>;
}
    
domain/ports/secondary/notification-repository.port.ts

// Secondary Port: the domain depends on this interface
export interface NotificationRepository {
  save(notification: Notification): Promise<Notification>;
  findById(id: string): Promise<Notification | null>;
  findByRecipientId(recipientId: string): Promise<Notification[]>;
  update(notification: Notification): Promise<Notification>;
}
    
domain/ports/secondary/notification-sender.port.ts

// Secondary Port: abstracts the actual sending
export interface NotificationSender {
  send(
    channel: NotificationChannel,
    recipient: string,
    subject: string,
    body: string
  ): Promise<boolean>;
}
    

Domain Service: The Core Logic

domain/services/notification.service.ts

import { v4 as uuidv4 } from 'uuid';

export class NotificationDomainService implements NotificationUseCases {

  constructor(
    private readonly repository: NotificationRepository,
    private readonly sender: NotificationSender,
    private readonly recipientResolver: RecipientResolver
  ) {}

  async send(request: NotificationRequest): Promise<Notification> {
    // Pure domain logic: no infrastructure dependencies
    const recipient = await this.recipientResolver.resolve(request.recipientId);
    if (!recipient) {
      throw new DomainError('Recipient not found');
    }

    const notification: Notification = {
      id: uuidv4(),
      recipientId: request.recipientId,
      channel: request.channel,
      subject: request.subject,
      body: request.body,
      status: 'pending',
      createdAt: new Date()
    };

    // Save as pending
    const saved = await this.repository.save(notification);

    // Attempt to send
    try {
      const success = await this.sender.send(
        request.channel,
        recipient.contactInfo[request.channel],
        request.subject,
        request.body
      );

      const updated = {
        ...saved,
        status: success ? 'sent' as const : 'failed' as const,
        sentAt: success ? new Date() : undefined
      };

      return this.repository.update(updated);
    } catch (error) {
      const failed = { ...saved, status: 'failed' as const };
      return this.repository.update(failed);
    }
  }

  async getById(id: string): Promise<Notification | null> {
    return this.repository.findById(id);
  }

  async getByRecipient(recipientId: string): Promise<Notification[]> {
    return this.repository.findByRecipientId(recipientId);
  }

  async retry(id: string): Promise<Notification> {
    const notification = await this.repository.findById(id);
    if (!notification) {
      throw new DomainError('Notification not found');
    }
    if (notification.status !== 'failed') {
      throw new DomainError('Only failed notifications can be retried');
    }
    return this.send({
      recipientId: notification.recipientId,
      channel: notification.channel,
      subject: notification.subject,
      body: notification.body
    });
  }
}
    

Secondary Adapters: Concrete Implementations

adapters/secondary/firestore-notification.repository.ts

// Secondary Adapter: implements the repository port
export class FirestoreNotificationRepository implements NotificationRepository {

  constructor(private readonly firestore: Firestore) {}

  async save(notification: Notification): Promise<Notification> {
    const docRef = doc(this.firestore, 'notifications', notification.id);
    await setDoc(docRef, {
      ...notification,
      createdAt: Timestamp.fromDate(notification.createdAt)
    });
    return notification;
  }

  async findById(id: string): Promise<Notification | null> {
    const docRef = doc(this.firestore, 'notifications', id);
    const snapshot = await getDoc(docRef);
    return snapshot.exists() ? this.toDomain(snapshot.data()) : null;
  }

  async findByRecipientId(recipientId: string): Promise<Notification[]> {
    const q = query(
      collection(this.firestore, 'notifications'),
      where('recipientId', '==', recipientId),
      orderBy('createdAt', 'desc')
    );
    const snapshot = await getDocs(q);
    return snapshot.docs.map(d => this.toDomain(d.data()));
  }

  async update(notification: Notification): Promise<Notification> {
    const docRef = doc(this.firestore, 'notifications', notification.id);
    await updateDoc(docRef, { ...notification });
    return notification;
  }

  private toDomain(data: any): Notification {
    return {
      ...data,
      createdAt: data.createdAt.toDate(),
      sentAt: data.sentAt?.toDate()
    } as Notification;
  }
}
    
adapters/secondary/multi-channel-notification.sender.ts

// Secondary Adapter: handles multichannel sending
export class MultiChannelNotificationSender implements NotificationSender {

  private readonly channels: Map<NotificationChannel, ChannelProvider>;

  constructor(
    emailProvider: EmailProvider,
    smsProvider: SmsProvider,
    pushProvider: PushProvider
  ) {
    this.channels = new Map([
      ['email', emailProvider],
      ['sms', smsProvider],
      ['push', pushProvider]
    ]);
  }

  async send(
    channel: NotificationChannel,
    recipient: string,
    subject: string,
    body: string
  ): Promise<boolean> {
    const provider = this.channels.get(channel);
    if (!provider) {
      throw new Error('Unsupported channel: ' + channel);
    }
    return provider.deliver(recipient, subject, body);
  }
}
    

Primary Adapter: REST Controller

adapters/primary/notification.controller.ts

// Primary Adapter: translates HTTP into domain calls
@Controller('/api/notifications')
export class NotificationController {

  constructor(
    private readonly notificationUseCases: NotificationUseCases
  ) {}

  @Post('/')
  async sendNotification(
    @Body() dto: SendNotificationDto
  ): Promise<NotificationResponseDto> {
    const notification = await this.notificationUseCases.send({
      recipientId: dto.recipientId,
      channel: dto.channel,
      subject: dto.subject,
      body: dto.body
    });
    return NotificationMapper.toDto(notification);
  }

  @Get('/:id')
  async getNotification(
    @Param('id') id: string
  ): Promise<NotificationResponseDto> {
    const notification = await this.notificationUseCases.getById(id);
    if (!notification) {
      throw new NotFoundException('Notification not found');
    }
    return NotificationMapper.toDto(notification);
  }

  @Post('/:id/retry')
  async retryNotification(
    @Param('id') id: string
  ): Promise<NotificationResponseDto> {
    const notification = await this.notificationUseCases.retry(id);
    return NotificationMapper.toDto(notification);
  }
}
    

Composition with Dependency Injection in Angular

Angular DI Configuration

// Injection tokens for secondary ports
export const NOTIFICATION_REPOSITORY =
  new InjectionToken<NotificationRepository>('NotificationRepository');

export const NOTIFICATION_SENDER =
  new InjectionToken<NotificationSender>('NotificationSender');

export const RECIPIENT_RESOLVER =
  new InjectionToken<RecipientResolver>('RecipientResolver');

// Provider configuration in app.config.ts
export const appConfig: ApplicationConfig = {
  providers: [
    // Secondary adapters: easily replaceable
    {
      provide: NOTIFICATION_REPOSITORY,
      useClass: FirestoreNotificationRepository
    },
    {
      provide: NOTIFICATION_SENDER,
      useClass: MultiChannelNotificationSender
    },
    {
      provide: RECIPIENT_RESOLVER,
      useClass: FirestoreRecipientResolver
    },

    // Domain service composed with ports
    {
      provide: NotificationDomainService,
      useFactory: (repo, sender, resolver) =>
        new NotificationDomainService(repo, sender, resolver),
      deps: [
        NOTIFICATION_REPOSITORY,
        NOTIFICATION_SENDER,
        RECIPIENT_RESOLVER
      ]
    }
  ]
};
    

Comparison: Hexagonal vs Clean vs Onion

Hexagonal architecture shares fundamental principles with Robert C. Martin's Clean Architecture and Jeffrey Palermo's Onion Architecture. All three aim to isolate the domain, but with different nuances.

Comparison Table

Aspect Hexagonal Clean Architecture Onion Architecture
Author Alistair Cockburn Robert C. Martin Jeffrey Palermo
Metaphor Hexagon with ports Concentric circles Onion layers
Focus Inside/outside interactions Dependency rule Domain layering
Interfaces Ports (driving/driven) Use Cases + Gateways Domain Services
Implementations Adapters Interface Adapters + Frameworks Infrastructure Layer
Testability Excellent: swap adapters Excellent: mock gateways Excellent: mock outer layers
Complexity Medium High (more layers) Medium-high
Ideal for Systems with many integrations Enterprise applications Complex domains with DDD

The main difference lies in the perspective: hexagonal architecture reasons in terms of inside and outside (the domain and the periphery), Clean Architecture in terms of concentric layers with a dependency rule, and Onion Architecture emphasizes the domain layering with model, domain services, and application services.

Testing Advantages

The most significant advantage of hexagonal architecture is ease of testing. Since the domain depends only on interfaces (ports), we can replace adapters with test implementations without any modification to the business code.

Testing the domain service with mock adapters

describe('NotificationDomainService', () => {
  let service: NotificationDomainService;
  let mockRepository: jest.Mocked<NotificationRepository>;
  let mockSender: jest.Mocked<NotificationSender>;
  let mockResolver: jest.Mocked<RecipientResolver>;

  beforeEach(() => {
    // Mock adapters: implement the same ports
    mockRepository = {
      save: jest.fn().mockImplementation(n => Promise.resolve(n)),
      findById: jest.fn(),
      findByRecipientId: jest.fn(),
      update: jest.fn().mockImplementation(n => Promise.resolve(n))
    };

    mockSender = {
      send: jest.fn().mockResolvedValue(true)
    };

    mockResolver = {
      resolve: jest.fn().mockResolvedValue({
        id: 'user-1',
        contactInfo: { email: 'user@example.com' }
      })
    };

    service = new NotificationDomainService(
      mockRepository, mockSender, mockResolver
    );
  });

  it('should send a notification successfully', async () => {
    const request: NotificationRequest = {
      recipientId: 'user-1',
      channel: 'email',
      subject: 'Test',
      body: 'Test message'
    };

    const result = await service.send(request);

    expect(result.status).toBe('sent');
    expect(mockSender.send).toHaveBeenCalledWith(
      'email', 'user@example.com', 'Test', 'Test message'
    );
    expect(mockRepository.save).toHaveBeenCalled();
    expect(mockRepository.update).toHaveBeenCalled();
  });

  it('should handle send failure', async () => {
    mockSender.send.mockResolvedValue(false);

    const result = await service.send({
      recipientId: 'user-1',
      channel: 'email',
      subject: 'Test',
      body: 'Body'
    });

    expect(result.status).toBe('failed');
  });

  it('should not retry non-failed notifications', async () => {
    mockRepository.findById.mockResolvedValue({
      id: 'notif-1',
      recipientId: 'user-1',
      channel: 'email',
      subject: 'Test',
      body: 'Body',
      status: 'sent',
      createdAt: new Date()
    });

    await expect(service.retry('notif-1'))
      .rejects.toThrow('Only failed notifications can be retried');
  });
});
    

Why Tests Are Simpler

Notice how we don't need Firestore, an SMTP server, or any external service to test the business logic. Each port is replaced with a mock that implements the same interface. This makes tests:

  • Fast: No network connections or I/O
  • Deterministic: No dependency on external state
  • Isolated: Each test verifies only domain logic
  • Maintainable: Changing the database doesn't require modifying tests

When to Use Hexagonal Architecture

Ideal Scenarios

  • Multiple integrations: The application communicates with many external systems (DB, APIs, message brokers, email services)
  • Replaceability required: Service providers may change over time (migrating from AWS to Azure)
  • Critical testability: Business logic is complex and requires thorough testing
  • Distributed teams: Different teams work on different adapters in parallel
  • Project longevity: The project will last for years and the framework might change

When to Avoid It

  • Simple CRUD: Applications with little business logic don't justify the added complexity
  • Prototypes and MVPs: Delivery speed matters more than architecture
  • Small, inexperienced teams: The learning curve could slow down development
  • Minimal microservices: Very small services with a single integration

Recommended Folder Structure

Project structure with hexagonal architecture

src/
  domain/
    entities/
      notification.ts
      recipient.ts
    ports/
      primary/
        notification-use-cases.port.ts
      secondary/
        notification-repository.port.ts
        notification-sender.port.ts
        recipient-resolver.port.ts
    services/
      notification-domain.service.ts
    errors/
      domain-error.ts

  adapters/
    primary/
      rest/
        notification.controller.ts
        dto/
          send-notification.dto.ts
      graphql/
        notification.resolver.ts
    secondary/
      persistence/
        firestore-notification.repository.ts
        in-memory-notification.repository.ts
      messaging/
        multi-channel-notification.sender.ts
        email/
          sendgrid-email.provider.ts
        sms/
          twilio-sms.provider.ts

  config/
    dependency-injection.ts
    

Conclusion

Hexagonal architecture is a powerful pattern that puts the domain at the center and protects it from infrastructure dependencies. Through the ports and adapters mechanism, we achieve a highly testable, flexible, and maintainable application over time.

The key takeaways are:

  • Primary ports define what the application can do (use cases)
  • Secondary ports define what the application needs (dependencies)
  • Adapters are interchangeable and can be swapped without touching the domain
  • Dependency Injection is the mechanism that wires everything together
  • Tests become naturally simple and fast

Key Takeaways

  • Domain at the center: Business logic never depends on infrastructure
  • Ports as contracts: Interfaces define the boundaries
  • Replaceable adapters: Change database, framework, or provider without touching the domain
  • Isolated tests: Mock secondary ports for fast and deterministic tests
  • DI as glue: Angular's injection token makes composition explicit

Frequently asked questions

What is the main goal of Hexagonal Architecture?

The main goal of Hexagonal Architecture is to separate the application core from the surrounding infrastructure.

What are ports in the context of Hexagonal Architecture?

Ports are interfaces defined in the domain that establish a contract and are divided into two fundamental categories.

How does Hexagonal Architecture make testing easier?

Since the domain depends only on interfaces (ports), we can replace adapters with test implementations without any modification to the business code, making tests more efficient.

What is the key advantage of using Hexagonal Architecture in application development?

The key advantage of using Hexagonal Architecture is that it allows for a highly testable, flexible, and maintainable application over time.

Sources and further reading

Commenti

Caricamento commenti...

Sign in to comment

Discuss this article in the community

Join the Telegram group to discuss with other developers, ask questions and share your experiences.

Did you like this article?

Explore other content on the blog or discover my projects