Zum Inhalt springen

Hexagonale Architektur: Ports und Adapter in der Praxis

Hexagonale Architektur in TypeScript: Ports definieren Verträge, Adapter kapseln Infrastruktur, Dependency Inversion hält die Domäne testbar.

5 Min. Lesezeit
Diagramm einer hexagonalen Architektur mit dem Domänenkern, umgeben von Ports, mit Input-Adaptern links und Output-Adaptern rechts, die externe Systeme anbinden

Hexagonale Architektur—auch Ports and Adapters genannt—löst das Problem von Geschäftslogik, die mit Frameworks, Datenbanken und HTTP-Handlern verwoben ist. Wenn deine Domänenlogik Express importiert, direkt die Datenbank abfragt und HTTP-Antworten formatiert, kannst du die Geschäftsregeln nicht testen, ohne den kompletten Infrastruktur-Stack hochzufahren.

Das Hexagon kapselt den Domänenkern hinter Ports (Schnittstellen), an die Adapter (Implementierungen) die Außenwelt anbinden. Das Ergebnis: Geschäftslogik, die sich mit einfachen Unit-Tests prüfen lässt, austauschbare Infrastruktur, ohne Domänencode anzufassen, und eine Codebasis, in der die Architektur die Absicht sichtbar macht.

Das Kernkonzept: Ports und Adapter

Ports sind Schnittstellen, die von der Domäne definiert werden. Treibende Ports (Input) definieren, was die Anwendung kann. Getriebene Ports (Output) definieren, was die Anwendung von der Außenwelt braucht.

tstypescript
// ❌ Domain logic coupled to infrastructure
class OrderService {
  async createOrder(req: express.Request) {
    const items = req.body.items; // Coupled to Express
    const user = await db.query(   // Coupled to database
      "SELECT * FROM users WHERE id = $1",
      [req.user.id]
    );
    // Business logic mixed with infrastructure
    const order = { userId: user.id, items, total: 0 };
    for (const item of items) {
      const product = await db.query(
        "SELECT price FROM products WHERE id = $1",
        [item.productId]
      );
      order.total += product.price * item.quantity;
    }
    await db.query("INSERT INTO orders...", [order]);
    return res.json(order); // Coupled to HTTP response
  }
}
tstypescript
// ✅ Domain core with ports
 
// ---- DOMAIN TYPES ----
interface OrderItem {
  productId: string;
  quantity: number;
}
 
interface Order {
  id: string;
  userId: string;
  items: OrderItem[];
  total: number;
  status: "pending" | "confirmed" | "shipped";
  createdAt: Date;
}
 
// ---- DRIVING PORT (input) ----
// What the application can do — defined by domain needs
interface OrderUseCase {
  createOrder(
    userId: string,
    items: OrderItem[]
  ): Promise<Order>;
  getOrder(orderId: string): Promise<Order | null>;
  confirmOrder(orderId: string): Promise<Order>;
}
 
// ---- DRIVEN PORTS (output) ----
// What the domain needs from infrastructure
interface OrderRepository {
  save(order: Order): Promise<void>;
  findById(id: string): Promise<Order | null>;
  findByUserId(userId: string): Promise<Order[]>;
}
 
interface ProductCatalog {
  getPrice(productId: string): Promise<number>;
  checkAvailability(
    productId: string,
    quantity: number
  ): Promise<boolean>;
}
 
interface PaymentGateway {
  charge(
    userId: string,
    amount: number
  ): Promise<{ transactionId: string }>;
}
 
interface EventPublisher {
  publish(event: DomainEvent): Promise<void>;
}
 
type DomainEvent =
  | { type: "order.created"; data: Order }
  | { type: "order.confirmed"; data: Order };

Der Application Service (der Kern des Hexagons)

Der Application Service implementiert den treibenden Port und nutzt dabei ausschließlich die Schnittstellen der getriebenen Ports. Er enthält reine Geschäftslogik ohne jede Infrastrukturabhängigkeit.

tstypescript
class OrderApplicationService implements OrderUseCase {
  constructor(
    private orderRepo: OrderRepository,
    private catalog: ProductCatalog,
    private payment: PaymentGateway,
    private events: EventPublisher
  ) {}
 
  async createOrder(
    userId: string,
    items: OrderItem[]
  ): Promise<Order> {
    // Business rule: validate availability
    for (const item of items) {
      const available =
        await this.catalog.checkAvailability(
          item.productId,
          item.quantity
        );
      if (!available) {
        throw new DomainError(
          `Product ${item.productId} not available ` +
            `in quantity ${item.quantity}`
        );
      }
    }
 
    // Business rule: calculate total
    let total = 0;
    for (const item of items) {
      const price = await this.catalog.getPrice(
        item.productId
      );
      total += price * item.quantity;
    }
 
    // Business rule: minimum order value
    if (total < 10) {
      throw new DomainError(
        "Minimum order value is $10"
      );
    }
 
    const order: Order = {
      id: generateId(),
      userId,
      items,
      total,
      status: "pending",
      createdAt: new Date(),
    };
 
    await this.orderRepo.save(order);
    await this.events.publish({
      type: "order.created",
      data: order,
    });
 
    return order;
  }
 
  async confirmOrder(orderId: string): Promise<Order> {
    const order = await this.orderRepo.findById(orderId);
    if (!order) {
      throw new DomainError(`Order ${orderId} not found`);
    }
 
    if (order.status !== "pending") {
      throw new DomainError(
        `Order ${orderId} cannot be confirmed ` +
          `from status ${order.status}`
      );
    }
 
    // Business rule: charge payment
    await this.payment.charge(order.userId, order.total);
 
    const confirmed: Order = {
      ...order,
      status: "confirmed",
    };
 
    await this.orderRepo.save(confirmed);
    await this.events.publish({
      type: "order.confirmed",
      data: confirmed,
    });
 
    return confirmed;
  }
 
  async getOrder(orderId: string): Promise<Order | null> {
    return this.orderRepo.findById(orderId);
  }
}
 
class DomainError extends Error {
  constructor(message: string) {
    super(message);
    this.name = "DomainError";
  }
}

Beachte, was fehlt: kein SQL, kein HTTP, kein Express, keine Message-Broker-SDKs. Der Application Service ist reines TypeScript ohne import-Anweisungen außer für Domänentypen und Port-Schnittstellen.

Adapter: die Anbindung an die reale Welt

Adapter implementieren die Port-Schnittstellen und übersetzen zwischen der Domäne und externen Systemen.

tstypescript
// ---- OUTPUT ADAPTER: PostgreSQL ----
class PostgresOrderRepository implements OrderRepository {
  constructor(private pool: Pool) {}
 
  async save(order: Order): Promise<void> {
    await this.pool.query(
      `INSERT INTO orders (id, user_id, items, total, status, created_at)
       VALUES ($1, $2, $3, $4, $5, $6)
       ON CONFLICT (id) DO UPDATE SET
         status = EXCLUDED.status,
         total = EXCLUDED.total`,
      [
        order.id,
        order.userId,
        JSON.stringify(order.items),
        order.total,
        order.status,
        order.createdAt,
      ]
    );
  }
 
  async findById(id: string): Promise<Order | null> {
    const result = await this.pool.query(
      "SELECT * FROM orders WHERE id = $1",
      [id]
    );
    if (result.rows.length === 0) return null;
    return this.toDomain(result.rows[0]);
  }
 
  async findByUserId(userId: string): Promise<Order[]> {
    const result = await this.pool.query(
      "SELECT * FROM orders WHERE user_id = $1",
      [userId]
    );
    return result.rows.map(this.toDomain);
  }
 
  private toDomain(row: any): Order {
    return {
      id: row.id,
      userId: row.user_id,
      items: row.items,
      total: parseFloat(row.total),
      status: row.status,
      createdAt: new Date(row.created_at),
    };
  }
}
 
// ---- INPUT ADAPTER: Express HTTP ----
class OrderHttpAdapter {
  constructor(private orderUseCase: OrderUseCase) {}
 
  createRoutes(): express.Router {
    const router = express.Router();
 
    router.post("/orders", async (req, res) => {
      try {
        const order = await this.orderUseCase.createOrder(
          req.user!.id,
          req.body.items
        );
        res.status(201).json(order);
      } catch (error) {
        if (error instanceof DomainError) {
          res.status(400).json({
            error: error.message,
          });
        } else {
          res.status(500).json({
            error: "Internal server error",
          });
        }
      }
    });
 
    router.post(
      "/orders/:id/confirm",
      async (req, res) => {
        try {
          const order =
            await this.orderUseCase.confirmOrder(
              req.params.id
            );
          res.json(order);
        } catch (error) {
          if (error instanceof DomainError) {
            res.status(400).json({
              error: error.message,
            });
          } else {
            res.status(500).json({
              error: "Internal server error",
            });
          }
        }
      }
    );
 
    return router;
  }
}

Testen ohne Infrastruktur

Der größte Gewinn: Die Domänenlogik lässt sich mit einfachen Mocks testen — ohne Datenbank, ohne HTTP-Server, ohne Docker-Container.

tstypescript
describe("OrderApplicationService", () => {
  let service: OrderApplicationService;
  let mockRepo: jest.Mocked<OrderRepository>;
  let mockCatalog: jest.Mocked<ProductCatalog>;
  let mockPayment: jest.Mocked<PaymentGateway>;
  let mockEvents: jest.Mocked<EventPublisher>;
 
  beforeEach(() => {
    mockRepo = {
      save: jest.fn(),
      findById: jest.fn(),
      findByUserId: jest.fn(),
    };
    mockCatalog = {
      getPrice: jest.fn().mockResolvedValue(25),
      checkAvailability: jest.fn().mockResolvedValue(true),
    };
    mockPayment = {
      charge: jest
        .fn()
        .mockResolvedValue({ transactionId: "tx-123" }),
    };
    mockEvents = { publish: jest.fn() };
 
    service = new OrderApplicationService(
      mockRepo,
      mockCatalog,
      mockPayment,
      mockEvents
    );
  });
 
  it("creates order with calculated total", async () => {
    mockCatalog.getPrice.mockResolvedValue(15);
 
    const order = await service.createOrder("user-1", [
      { productId: "prod-1", quantity: 2 },
    ]);
 
    expect(order.total).toBe(30);
    expect(order.status).toBe("pending");
    expect(mockRepo.save).toHaveBeenCalledWith(
      expect.objectContaining({ total: 30 })
    );
  });
 
  it("rejects orders below minimum value", async () => {
    mockCatalog.getPrice.mockResolvedValue(3);
 
    await expect(
      service.createOrder("user-1", [
        { productId: "prod-1", quantity: 1 },
      ])
    ).rejects.toThrow("Minimum order value is $10");
  });
 
  it("rejects unavailable products", async () => {
    mockCatalog.checkAvailability.mockResolvedValue(false);
 
    await expect(
      service.createOrder("user-1", [
        { productId: "prod-1", quantity: 100 },
      ])
    ).rejects.toThrow("not available");
  });
 
  it("publishes event on order creation", async () => {
    await service.createOrder("user-1", [
      { productId: "prod-1", quantity: 1 },
    ]);
 
    expect(mockEvents.publish).toHaveBeenCalledWith(
      expect.objectContaining({ type: "order.created" })
    );
  });
});

Diese Tests laufen in Millisekunden. Kein Datenbank-Setup, keine Test-Container, keine flakigen Integrationsprobleme. Die Geschäftsregeln werden isoliert verifiziert.

Die wichtigsten Punkte

Ports sind von der Domäne definierte Schnittstellen, die eine Grenze zwischen Geschäftslogik und Infrastruktur ziehen—treibende Ports definieren, was die Anwendung tut, getriebene Ports definieren, was sie braucht. Der Application Service implementiert die treibenden Ports allein über die Schnittstellen der getriebenen Ports und enthält reine Geschäftslogik ohne Framework-Importe—kein SQL, kein HTTP, keine Message-Broker-SDKs. Adapter implementieren die Port-Schnittstellen, um die Domäne an echte Infrastruktur anzubinden: Input-Adapter übersetzen externe Requests in Domänenaufrufe, Output-Adapter übersetzen Domänenbedarf in Infrastrukturoperationen. Dependency Inversion bedeutet, dass die Domäne die Schnittstellen definiert und die Infrastruktur sie implementiert, nicht umgekehrt—die Domäne hängt nie von Adaptern ab. Das Testen des Domänenkerns braucht nur einfache Mocks der Port-Schnittstellen, läuft in Millisekunden ohne Datenbanken, HTTP-Server oder Container, und die Geschäftsregeln werden völlig losgelöst von der Infrastruktur verifiziert.

Wilfredo Rujel

Wilfredo Rujel

Full-Stack-Softwareentwickler

Diesen Beitrag teilenX