Skills

Install

$ npx ai-agents-skills add --skill hexagonal-architecture
Domain v1.0

Hexagonal Architecture (Ports and Adapters)

Isolates application core from external systems. Core defines interfaces (ports), external systems provide implementations (adapters). Enables easy swapping of implementations and testing via mocks.

When to Use

  • Application needs to be testable without real DB/email/external APIs
  • Need to swap infrastructure (Postgres → MySQL, SendGrid → AWS SES)
  • Multiple adapters for same port (REST + CLI + GraphQL)
  • Business logic must be isolated from framework details

Don’t use for:

  • Simple CRUD with no testability requirements
  • Scripts with single external dependency
  • Prototypes

Critical Patterns

✅ REQUIRED: Define Ports in Application Core

Ports are interfaces owned by the application, not by infrastructure.

// application/ports/IUserRepository.ts (PRIMARY — driven port)
export interface IUserRepository {
  findById(id: string): Promise<User | null>;
  save(user: User): Promise<void>;
}

// application/ports/IEmailService.ts (SECONDARY — driven port)
export interface IEmailService {
  sendWelcome(email: string): Promise<void>;
}

✅ REQUIRED: Adapters Implement Ports

Infrastructure adapters implement interfaces defined by the core.

// infrastructure/PostgresUserRepository.ts
export class PostgresUserRepository implements IUserRepository {
  constructor(private db: PrismaClient) {}
  async findById(id: string): Promise<User | null> {
    return this.db.user.findUnique({ where: { id } });
  }
  async save(user: User): Promise<void> {
    await this.db.user.upsert({ where: { id: user.id }, create: user, update: user });
  }
}

// infrastructure/SendGridEmailService.ts
export class SendGridEmailService implements IEmailService {
  async sendWelcome(email: string): Promise<void> {
    await sendgrid.send({ to: email, subject: 'Welcome', text: '...' });
  }
}

✅ REQUIRED: Inject via Constructor (Composition Root)

Wire adapters to ports at the application entry point.

// Composition root (e.g., main.ts or DI container)
const userRepo   = new PostgresUserRepository(prisma);
const emailSvc   = new SendGridEmailService();
const useCase    = new RegisterUserUseCase(userRepo, emailSvc);
const controller = new UserController(useCase);

// Testing — swap with mocks
const mockRepo  = { findById: jest.fn(), save: jest.fn() };
const mockEmail = { sendWelcome: jest.fn() };
const testCase  = new RegisterUserUseCase(mockRepo, mockEmail);

❌ NEVER: Import Infrastructure from Core

// ❌ WRONG: Application use case imports concrete implementation
import { PrismaClient } from '@prisma/client';  // Infrastructure in application!

class RegisterUserUseCase {
  private db = new PrismaClient();  // Tightly coupled
}

// ✅ CORRECT: Depend on interface
class RegisterUserUseCase {
  constructor(private userRepo: IUserRepository) {}  // Inject via port
}

✅ REQUIRED: Know Primary vs Secondary Ports

Primary (Driving) Ports:  Exposed by core, called by adapters
  → HTTP Controller calls use case (driving adapter)
  → CLI calls use case (another driving adapter)

Secondary (Driven) Ports: Defined by core, implemented by infrastructure
  → IUserRepository ← PostgresRepository
  → IEmailService   ← SendGridEmailService

✅ REQUIRED: Frontend Adapter Pattern (React)

Same principle in the browser: define the port in the feature, implement with fetch/axios, swap with mock in tests.

// Port — owned by the feature, not by the API layer
interface IUserApi {
  getUser(id: string): Promise<User>;
  updateUser(id: string, data: Partial<User>): Promise<User>;
}

// Secondary adapter — implements the port with real network calls
class RestUserApi implements IUserApi {
  async getUser(id: string)                         { return fetch(`/api/users/${id}`).then(r => r.json()); }
  async updateUser(id: string, data: Partial<User>) { return fetch(`/api/users/${id}`, { method: 'PATCH', body: JSON.stringify(data) }).then(r => r.json()); }
}

// Mock adapter — same port, no network (for tests and Storybook)
class MockUserApi implements IUserApi {
  async getUser(id: string)                         { return { id, name: 'Test User', email: 'test@example.com' }; }
  async updateUser(id: string, data: Partial<User>) { return { id, ...data } as User; }
}

// Driving adapter — hook consumes the port; concrete impl injected at composition root
function useUser(id: string, api: IUserApi = new RestUserApi()) {
  const [user, setUser] = useState<User | null>(null);
  useEffect(() => { api.getUser(id).then(setUser); }, [id]);
  return user;
}

// Tests: inject MockUserApi — no HTTP calls, no server needed

Decision Tree

Need to test without real infrastructure?
  → Define port (interface) in application layer
  → Inject mock in tests

Need to swap DB/email/API without changing business logic?
  → Implement new adapter for existing port

Core importing concrete infra class?
  → Extract interface, move concrete to infrastructure/

Multiple ways to trigger same use case (HTTP + CLI)?
  → Create separate driving adapters, both call same use case

Example

// Port
interface IPaymentGateway { charge(amount: number, token: string): Promise<PaymentResult>; }

// Use Case (core — no infra imports)
// Result<T>: typed wrapper for success/failure — Result.ok(value) | Result.fail("error")
class PlaceOrderUseCase {
  constructor(private payment: IPaymentGateway) {}
  async execute(order: Order, token: string): Promise<Result<Order>> {
    const result = await this.payment.charge(order.total, token);
    if (!result.success) return Result.fail('Payment failed');
    return Result.ok(order);
  }
}

// Adapters (infrastructure)
class StripeAdapter implements IPaymentGateway { ... }
class PayPalAdapter  implements IPaymentGateway { ... }
class MockAdapter    implements IPaymentGateway { charge: jest.fn().mockResolvedValue({ success: true }) }

Edge Cases

Port granularity: Too many small ports = port explosion. Group related operations (IUserRepository with findById + save + delete, not separate interfaces).

Shared domain types: DTOs and domain entities cross layers but only move inward. Infrastructure adapters map to/from domain types.

Partial adoption: Can apply hexagonal to specific layers without full Clean Architecture. Most common: isolate DB + external APIs via ports.


Resources