Hexagonal Architecture Implementation for Backend
Hexagonal Architecture (Ports & Adapters, author — Alistair Cockburn) isolates application core from external details: frameworks, DB, HTTP, message queues. Core defines interfaces (Ports), external implementations (Adapters) plug into them. Application tested equally via HTTP, CLI, or direct tests.
Structure
src/
├── domain/ # Pure domain: entities, VO, business rules
│ ├── entities/
│ ├── value-objects/
│ └── exceptions/
├── application/ # Use Cases + Ports (interfaces)
│ ├── use-cases/
│ ├── ports/
│ │ ├── inbound/ # Driving ports — how application is called
│ │ └── outbound/ # Driven ports — what application calls
├── infrastructure/ # Adapters
│ ├── persistence/ # PostgreSQL, Redis adapters
│ ├── messaging/ # Kafka, RabbitMQ adapters
│ ├── http/ # REST, GraphQL controllers
│ └── external/ # Stripe, SendGrid adapters
└── main.ts # Composition Root — adapter wiring
Inbound and Outbound Ports
Inbound Port (Driving) — interface through which external world calls application:
// ports/inbound/OrderUseCases.ts
export interface CreateOrderUseCase {
execute(command: CreateOrderCommand): Promise<CreateOrderResult>;
}
export interface GetOrderUseCase {
execute(query: GetOrderQuery): Promise<OrderDto | null>;
}
Outbound Port (Driven) — interface application uses for external dependencies:
// ports/outbound/OrderRepository.ts
export interface OrderRepository {
findById(id: string): Promise<Order | null>;
save(order: Order): Promise<void>;
}
// ports/outbound/PaymentGateway.ts
export interface PaymentGateway {
charge(amount: Money, card: CardDetails): Promise<PaymentResult>;
refund(paymentId: string, amount: Money): Promise<RefundResult>;
}
// ports/outbound/NotificationService.ts
export interface NotificationService {
sendOrderConfirmation(order: Order, customer: Customer): Promise<void>;
}
Use Case (Application Core)
// application/use-cases/CreateOrderUseCase.ts
export class CreateOrderUseCaseImpl implements CreateOrderUseCase {
constructor(
private orderRepo: OrderRepository, // outbound port
private productRepo: ProductRepository, // outbound port
private paymentGateway: PaymentGateway, // outbound port
private notificationSvc: NotificationService // outbound port
) {}
async execute(cmd: CreateOrderCommand): Promise<CreateOrderResult> {
const order = Order.create(cmd.customerId);
for (const item of cmd.items) {
const product = await this.productRepo.findById(item.productId);
if (!product) throw new ProductNotFoundError(item.productId);
order.addItem(product, item.quantity);
}
order.submit();
const payment = await this.paymentGateway.charge(
order.total, cmd.paymentDetails
);
order.confirmPayment(payment.id);
await this.orderRepo.save(order);
// Fire and forget
this.notificationSvc.sendOrderConfirmation(order, { id: cmd.customerId });
return { orderId: order.id, status: order.status };
}
}
Use Case knows nothing about HTTP, PostgreSQL or Stripe — only ports.
Adapters
Inbound HTTP Adapter (Express/Fastify):
// infrastructure/http/OrderController.ts
export class OrderController {
constructor(private createOrder: CreateOrderUseCase) {}
async create(req: Request, res: Response) {
try {
const result = await this.createOrder.execute({
customerId: req.user.id,
items: req.body.items,
paymentDetails: req.body.payment
});
res.status(201).json(result);
} catch (e) {
if (e instanceof ProductNotFoundError) {
return res.status(422).json({ error: e.message });
}
throw e;
}
}
}
Outbound PostgreSQL Adapter:
// infrastructure/persistence/PostgresOrderRepository.ts
export class PostgresOrderRepository implements OrderRepository {
constructor(private db: Pool) {}
async findById(id: string): Promise<Order | null> {
const row = await this.db.query(
'SELECT * FROM orders WHERE id = $1', [id]
).then(r => r.rows[0]);
return row ? OrderMapper.toDomain(row) : null;
}
async save(order: Order): Promise<void> {
const data = OrderMapper.toPersistence(order);
await this.db.query(
`INSERT INTO orders (id, customer_id, status, total, created_at)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (id) DO UPDATE SET status=$3, total=$4`,
[data.id, data.customerId, data.status, data.total, data.createdAt]
);
}
}
Outbound Stripe Adapter:
// infrastructure/external/StripePaymentGateway.ts
export class StripePaymentGateway implements PaymentGateway {
private stripe: Stripe;
async charge(amount: Money, card: CardDetails): Promise<PaymentResult> {
const intent = await this.stripe.paymentIntents.create({
amount: Math.round(amount.value * 100),
currency: amount.currency.toLowerCase(),
payment_method: card.tokenId,
confirm: true
});
return { id: intent.id, status: intent.status };
}
}
Composition Root
Only place where adapters wire to ports:
// main.ts
const db = new Pool({ connectionString: process.env.DATABASE_URL });
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const orderRepo = new PostgresOrderRepository(db);
const productRepo = new PostgresProductRepository(db);
const paymentGateway = new StripePaymentGateway(stripe);
const notificationSvc = new SendGridNotificationService(process.env.SENDGRID_KEY);
const createOrderUseCase = new CreateOrderUseCaseImpl(
orderRepo, productRepo, paymentGateway, notificationSvc
);
const orderController = new OrderController(createOrderUseCase);
// Register routes
app.post('/orders', (req, res) => orderController.create(req, res));
Testing
Main advantage — testing without real dependencies:
describe('CreateOrderUseCase', () => {
it('creates order and charges payment', async () => {
const mockOrderRepo = { save: jest.fn(), findById: jest.fn() };
const mockPaymentGateway = {
charge: jest.fn().mockResolvedValue({ id: 'pay_123', status: 'succeeded' })
};
const mockNotifications = { sendOrderConfirmation: jest.fn() };
const useCase = new CreateOrderUseCaseImpl(
mockOrderRepo, mockProductRepo, mockPaymentGateway, mockNotifications
);
const result = await useCase.execute(validCommand);
expect(result.orderId).toBeDefined();
expect(mockPaymentGateway.charge).toHaveBeenCalledWith(
expect.objectContaining({ value: 150 }),
validCommand.paymentDetails
);
expect(mockOrderRepo.save).toHaveBeenCalled();
});
});
Unit tests run milliseconds. No running DB or Stripe needed.
Implementation Timeline
- Refactor existing Express/Fastify app to hexagonal architecture — 2–4 weeks
- New service from scratch per hexagonal: 1 use case — 1–2 days, full module of 10+ use cases — 2–3 weeks
- Test infrastructure setup (mocks, in-memory adapters) — 3–5 days







