Hexagonale Architektur in der Praxis
Wie du hexagonale Architektur (Ports and Adapters) real umsetzt: klare Grenzen, Dependency Inversion und eine testbare Kerndomäne.

Hexagonale Architektur, auch Ports and Adapters genannt, löst genau ein Problem: Sie hält deine Geschäftslogik unabhängig von der Infrastruktur. Dein Domänencode sollte nicht wissen, ob er mit PostgreSQL oder DynamoDB spricht, ob Requests über HTTP oder aus einer Message Queue kommen, oder ob E-Mails über SendGrid oder einen Test-Spy verschickt werden. Sobald Infrastrukturabhängigkeiten in die Domänenlogik sickern, erzwingt jeder Wechsel der Datenbank oder eines externen Dienstes Änderungen an Code, den diese Details nichts angehen sollten.
Die Grundidee ist einfach. Deine Anwendung hat ein Innen (Geschäftslogik) und ein Außen (Infrastruktur). Das Innen definiert Ports — Schnittstellen, die beschreiben, was es braucht. Das Außen liefert Adapter — Implementierungen, die diese Schnittstellen erfüllen.
Die Struktur
Eine hexagonale Anwendung hat drei Schichten. Die Kerndomäne enthält Geschäftsregeln und Entitäten. Ports sind Schnittstellen, die festlegen, wie die Domäne mit der Außenwelt interagiert. Adapter implementieren diese Ports mit echter Infrastruktur.
src/
domain/ # Pure business logic, no imports from adapters
entities/
services/
ports/ # Interfaces the domain exposes and requires
adapters/
driven/ # Infrastructure adapters (DB, email, etc.)
postgres/
redis/
sendgrid/
driving/ # Entry point adapters (HTTP, CLI, queue consumers)
http/
cli/
queue/
config/ # Wiring — connects adapters to ports
Domäne und Ports definieren
Fang mit der Domäne an. Definiere die Entitäten und die Ports (Schnittstellen), die die Domäne braucht. Die Domäne importiert nie etwas aus den Adaptern — sie kennt nur ihre eigenen Typen und ihre Ports.
// domain/entities/order.ts
export interface Order {
id: string;
customerId: string;
items: OrderItem[];
status: OrderStatus;
total: number;
createdAt: Date;
}
export interface OrderItem {
productId: string;
quantity: number;
unitPrice: number;
}
export type OrderStatus =
| 'pending'
| 'confirmed'
| 'shipped'
| 'delivered'
| 'cancelled';
// domain/ports/order-repository.ts — driven port (outgoing)
export interface OrderRepository {
findById(id: string): Promise<Order | null>;
findByCustomer(customerId: string): Promise<Order[]>;
save(order: Order): Promise<void>;
updateStatus(id: string, status: OrderStatus): Promise<void>;
}
// domain/ports/payment-gateway.ts — driven port (outgoing)
export interface PaymentGateway {
charge(customerId: string, amount: number): Promise<PaymentResult>;
refund(paymentId: string, amount: number): Promise<RefundResult>;
}
// domain/ports/notification-service.ts — driven port (outgoing)
export interface NotificationService {
sendOrderConfirmation(order: Order): Promise<void>;
sendShippingUpdate(order: Order, trackingId: string): Promise<void>;
}Domänenservices implementieren
Domänenservices enthalten die Geschäftslogik. Sie hängen ausschließlich von Ports ab, nie von konkreten Implementierungen. Hier leben die Regeln.
// domain/services/order-service.ts
import type { Order, OrderItem } from '../entities/order';
import type { OrderRepository } from '../ports/order-repository';
import type { PaymentGateway } from '../ports/payment-gateway';
import type { NotificationService } from '../ports/notification-service';
export class OrderService {
constructor(
private readonly orders: OrderRepository,
private readonly payments: PaymentGateway,
private readonly notifications: NotificationService
) {}
async createOrder(
customerId: string,
items: OrderItem[]
): Promise<Order> {
// Business rule: orders must have at least one item
if (items.length === 0) {
throw new Error('Order must contain at least one item');
}
// Business rule: calculate total from items
const total = items.reduce(
(sum, item) => sum + item.quantity * item.unitPrice,
0
);
// Business rule: minimum order value
if (total < 1) {
throw new Error('Order total must be at least $1.00');
}
const order: Order = {
id: crypto.randomUUID(),
customerId,
items,
status: 'pending',
total,
createdAt: new Date(),
};
// Charge the customer
const payment = await this.payments.charge(customerId, total);
if (!payment.success) {
throw new Error(`Payment failed: ${payment.error}`);
}
// Persist the order
order.status = 'confirmed';
await this.orders.save(order);
// Notify the customer (non-critical — don't throw on failure)
await this.notifications
.sendOrderConfirmation(order)
.catch(() => {}); // Log but don't fail the order
return order;
}
async cancelOrder(orderId: string): Promise<void> {
const order = await this.orders.findById(orderId);
if (!order) throw new Error('Order not found');
// Business rule: only pending/confirmed orders can be cancelled
if (order.status !== 'pending' && order.status !== 'confirmed') {
throw new Error(
`Cannot cancel order in '${order.status}' status`
);
}
await this.payments.refund(orderId, order.total);
await this.orders.updateStatus(orderId, 'cancelled');
}
}Adapter bauen
Adapter implementieren die Port-Schnittstellen mit echter Infrastruktur. Jeder Adapter liegt in seinem eigenen Verzeichnis und lässt sich unabhängig austauschen.
// adapters/driven/postgres/postgres-order-repository.ts
import type { Order, OrderRepository, OrderStatus } from '../../../domain';
import type { Pool } from 'pg';
export class PostgresOrderRepository implements OrderRepository {
constructor(private readonly pool: Pool) {}
async findById(id: string): Promise<Order | null> {
const result = await this.pool.query(
`SELECT o.*, json_agg(oi.*) as items
FROM orders o
LEFT JOIN order_items oi ON oi.order_id = o.id
WHERE o.id = $1
GROUP BY o.id`,
[id]
);
if (result.rows.length === 0) return null;
return this.toDomain(result.rows[0]);
}
async findByCustomer(customerId: string): Promise<Order[]> {
const result = await this.pool.query(
`SELECT o.*, json_agg(oi.*) as items
FROM orders o
LEFT JOIN order_items oi ON oi.order_id = o.id
WHERE o.customer_id = $1
GROUP BY o.id
ORDER BY o.created_at DESC`,
[customerId]
);
return result.rows.map((row) => this.toDomain(row));
}
async save(order: Order): Promise<void> {
const client = await this.pool.connect();
try {
await client.query('BEGIN');
await client.query(
`INSERT INTO orders (id, customer_id, status, total, created_at)
VALUES ($1, $2, $3, $4, $5)`,
[order.id, order.customerId, order.status, order.total, order.createdAt]
);
for (const item of order.items) {
await client.query(
`INSERT INTO order_items (order_id, product_id, quantity, unit_price)
VALUES ($1, $2, $3, $4)`,
[order.id, item.productId, item.quantity, item.unitPrice]
);
}
await client.query('COMMIT');
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
}
async updateStatus(id: string, status: OrderStatus): Promise<void> {
await this.pool.query(
'UPDATE orders SET status = $1 WHERE id = $2',
[status, id]
);
}
private toDomain(row: Record<string, unknown>): Order {
return {
id: row.id as string,
customerId: row.customer_id as string,
items: (row.items as Array<Record<string, unknown>>).map((i) => ({
productId: i.product_id as string,
quantity: i.quantity as number,
unitPrice: i.unit_price as number,
})),
status: row.status as OrderStatus,
total: Number(row.total),
createdAt: new Date(row.created_at as string),
};
}
}Testen ohne Infrastruktur
Der eigentliche Gewinn der hexagonalen Architektur sind die Tests. Domänenservices lassen sich vollständig mit In-Memory-Fakes testen — ohne Datenbank, ohne externe APIs, ohne Docker-Container.
// ❌ Without hexagonal architecture — tests need real infrastructure
describe('OrderService', () => {
beforeAll(async () => {
await startPostgresContainer(); // Slow
await runMigrations(); // Brittle
await seedTestData(); // Complex setup
});
// Tests are slow, flaky, and hard to maintain
});
// ✅ With hexagonal architecture — tests use in-memory fakes
describe('OrderService', () => {
let orderService: OrderService;
let fakeOrders: InMemoryOrderRepository;
let fakePayments: FakePaymentGateway;
let fakeNotifications: FakeNotificationService;
beforeEach(() => {
fakeOrders = new InMemoryOrderRepository();
fakePayments = new FakePaymentGateway();
fakeNotifications = new FakeNotificationService();
orderService = new OrderService(
fakeOrders,
fakePayments,
fakeNotifications
);
});
it('creates an order and charges the customer', async () => {
const items = [{ productId: 'p1', quantity: 2, unitPrice: 25 }];
const order = await orderService.createOrder('customer-1', items);
expect(order.status).toBe('confirmed');
expect(order.total).toBe(50);
expect(fakePayments.charges).toHaveLength(1);
expect(fakePayments.charges[0].amount).toBe(50);
});
it('rejects order with no items', async () => {
await expect(
orderService.createOrder('customer-1', [])
).rejects.toThrow('Order must contain at least one item');
});
it('does not save order if payment fails', async () => {
fakePayments.shouldFail = true;
await expect(
orderService.createOrder('customer-1', [
{ productId: 'p1', quantity: 1, unitPrice: 10 },
])
).rejects.toThrow('Payment failed');
expect(fakeOrders.all()).toHaveLength(0);
});
});Alles zusammenstecken
Die Konfigurationsschicht verbindet Adapter mit Ports. Sie ist die einzige Stelle, die die konkreten Implementierungen kennt. Dependency Injection passiert am Einstiegspunkt der Anwendung.
// config/container.ts — wiring adapters to ports
import { Pool } from 'pg';
import { OrderService } from '../domain/services/order-service';
import { PostgresOrderRepository } from '../adapters/driven/postgres/postgres-order-repository';
import { StripePaymentGateway } from '../adapters/driven/stripe/stripe-payment-gateway';
import { SendGridNotificationService } from '../adapters/driven/sendgrid/sendgrid-notification-service';
export function createContainer(config: AppConfig) {
const pgPool = new Pool({ connectionString: config.databaseUrl });
const orderRepository = new PostgresOrderRepository(pgPool);
const paymentGateway = new StripePaymentGateway(config.stripeKey);
const notifications = new SendGridNotificationService(config.sendgridKey);
const orderService = new OrderService(
orderRepository,
paymentGateway,
notifications
);
return { orderService, pgPool };
}Die wichtigsten Punkte
- Domänencode importiert nichts aus den Adaptern — Geschäftslogik hängt allein von Port-Schnittstellen ab, nie von Infrastruktur
- Ports sind Schnittstellen, Adapter sind Implementierungen — tausche PostgreSQL gegen DynamoDB, indem du einen neuen Adapter schreibst, nicht indem du Domänencode änderst
- Testbarkeit ist der Hauptgewinn — Domänenservices lassen sich vollständig mit In-Memory-Fakes testen, ganz ohne Container
- Die Verdrahtung passiert am Einstiegspunkt — die Konfigurationsschicht ist die einzige Stelle, die die konkreten Adapterklassen kennt
- Abstrahiere nicht zu viel — wenn deine Anwendung eine Datenbank hat und immer haben wird, bringt hexagonale Architektur nur Komplexität ohne Nutzen; setz sie ein, wenn Infrastrukturflexibilität oder Testbarkeit zählen
- Fang mit Ports für externe Abhängigkeiten an — Datenbanken, Payment-Gateways, E-Mail-Dienste und Message Queues sind die Grenzen, die am meisten von diesem Muster profitieren


