Ports and Adapters isolating the application core from external concerns. Trigger: When building testable services with swappable infrastructure.
Isolates application core from external systems. Core defines interfaces (ports), external systems provide implementations (adapters). Enables easy swapping of implementations and testing via mocks.
Don't use for:
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>;
}
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: '...' });
}
}
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);
// ❌ 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
}
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
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
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
// 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 }) }
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.
Search for places (restaurants, cafes, etc.) via Google Places API proxy on localhost.
Interact with GitHub using the `gh` CLI. Use `gh issue`, `gh pr`, `gh run`, and `gh api` for issues, PRs, CI runs, and advanced queries.
Create or update AgentSkills. Use when designing, structuring, or packaging skills with scripts, references, and assets.
Start voice calls via the OpenClaw voice-call plugin.
Notion API for creating and managing pages, databases, and blocks.
Gemini CLI for one-shot Q&A, summaries, and generation.
Category:developer