Zum Inhalt springen

Clean-Architecture-Prinzipien in echten Anwendungen

Wende Clean-Architecture-Muster an, die Geschäftslogik von Infrastruktur trennen: testbare, wartbare und anpassungsfähige Anwendungen.

4 Min. Lesezeit
Diagramm mit konzentrischen Kreisen, das die Schichten der Clean Architecture zeigt – von den Entities im Zentrum bis zu den Frameworks im äußeren Ring

Bei Clean Architecture geht es nicht darum, einer bestimmten Ordnerstruktur zu folgen. Es geht um eine einzige Regel: Abhängigkeiten zeigen nach innen. Die Geschäftslogik kennt keine Datenbanken, HTTP-Frameworks oder externe APIs. Diese Umkehrung der Kontrolle macht den Kern deiner Anwendung testbar ohne Infrastruktur, austauschbar ohne Rewrites und verständlich, ohne jedes Integrationsdetail lesen zu müssen.

Die Dependency Rule in der Praxis

Die Dependency Rule besagt, dass innere Schichten nicht auf äußere Schichten verweisen dürfen. Geschäftsentitäten importieren keine Datenbank-Clients. Use Cases importieren weder Express noch Next.js.

tstypescript
// ❌ Business logic coupled to infrastructure
import { prisma } from "../lib/prisma";
import { sendgrid } from "../lib/email";
 
async function createOrder(userId: string, items: CartItem[]): Promise<Order> {
  // Business logic directly depends on Prisma and SendGrid
  const user = await prisma.user.findUnique({ where: { id: userId } });
  if (!user) throw new Error("User not found");
 
  const total = items.reduce((sum, i) => sum + i.price * i.quantity, 0);
 
  const order = await prisma.order.create({
    data: { userId, total, items: { create: items } },
  });
 
  await sendgrid.send({
    to: user.email,
    subject: "Order Confirmation",
    text: `Your order #${order.id} total: $${total}`,
  });
 
  return order;
}
// Can't test without a database and email service running
// Can't switch from Prisma to another ORM without rewriting business logic
tstypescript
// ✅ Business logic depends only on interfaces (ports)
// domain/entities/order.ts
interface Order {
  id: string;
  userId: string;
  items: OrderItem[];
  total: number;
  status: OrderStatus;
  createdAt: Date;
}
 
type OrderStatus = "pending" | "confirmed" | "shipped" | "delivered";
 
interface OrderItem {
  productId: string;
  name: string;
  price: number;
  quantity: number;
}
 
// domain/ports/order-repository.ts
interface OrderRepository {
  create(order: Omit<Order, "id" | "createdAt">): Promise<Order>;
  findById(id: string): Promise<Order | null>;
  findByUserId(userId: string): Promise<Order[]>;
}
 
// domain/ports/user-repository.ts
interface UserRepository {
  findById(id: string): Promise<User | null>;
}
 
// domain/ports/notification-service.ts
interface NotificationService {
  sendOrderConfirmation(email: string, order: Order): Promise<void>;
}

Use Cases: die Anwendungsschicht

Use Cases orchestrieren die Geschäftslogik. Sie hängen von Domain-Interfaces (Ports) ab und sind vollständig von Infrastrukturdetails entkoppelt.

tstypescript
// application/use-cases/create-order.ts
class CreateOrderUseCase {
  constructor(
    private readonly orderRepo: OrderRepository,
    private readonly userRepo: UserRepository,
    private readonly notifier: NotificationService
  ) {}
 
  async execute(input: CreateOrderInput): Promise<Order> {
    // Validate user exists
    const user = await this.userRepo.findById(input.userId);
    if (!user) {
      throw new DomainError("USER_NOT_FOUND", "User does not exist");
    }
 
    // Apply business rules
    const total = this.calculateTotal(input.items);
 
    if (total <= 0) {
      throw new DomainError("INVALID_ORDER", "Order total must be positive");
    }
 
    if (input.items.length > 50) {
      throw new DomainError("ORDER_TOO_LARGE", "Maximum 50 items per order");
    }
 
    // Create order through repository interface
    const order = await this.orderRepo.create({
      userId: input.userId,
      items: input.items,
      total,
      status: "pending",
    });
 
    // Notify user through notification interface
    await this.notifier.sendOrderConfirmation(user.email, order);
 
    return order;
  }
 
  private calculateTotal(items: OrderItem[]): number {
    return items.reduce(
      (sum, item) => sum + item.price * item.quantity,
      0
    );
  }
}
 
interface CreateOrderInput {
  userId: string;
  items: OrderItem[];
}
 
class DomainError extends Error {
  constructor(
    public readonly code: string,
    message: string
  ) {
    super(message);
    this.name = "DomainError";
  }
}

Infrastruktur-Adapter

Adapter implementieren die vom Domain definierten Interfaces. Sie enthalten den gesamten infrastrukturspezifischen Code.

tstypescript
// infrastructure/repositories/prisma-order-repository.ts
class PrismaOrderRepository implements OrderRepository {
  constructor(private readonly prisma: PrismaClient) {}
 
  async create(
    data: Omit<Order, "id" | "createdAt">
  ): Promise<Order> {
    const record = await this.prisma.order.create({
      data: {
        userId: data.userId,
        total: data.total,
        status: data.status,
        items: {
          create: data.items.map(item => ({
            productId: item.productId,
            name: item.name,
            price: item.price,
            quantity: item.quantity,
          })),
        },
      },
      include: { items: true },
    });
 
    return this.toDomain(record);
  }
 
  async findById(id: string): Promise<Order | null> {
    const record = await this.prisma.order.findUnique({
      where: { id },
      include: { items: true },
    });
    return record ? this.toDomain(record) : null;
  }
 
  async findByUserId(userId: string): Promise<Order[]> {
    const records = await this.prisma.order.findMany({
      where: { userId },
      include: { items: true },
      orderBy: { createdAt: "desc" },
    });
    return records.map(r => this.toDomain(r));
  }
 
  private toDomain(record: PrismaOrder & { items: PrismaOrderItem[] }): Order {
    return {
      id: record.id,
      userId: record.userId,
      total: record.total.toNumber(),
      status: record.status as OrderStatus,
      createdAt: record.createdAt,
      items: record.items.map(i => ({
        productId: i.productId,
        name: i.name,
        price: i.price.toNumber(),
        quantity: i.quantity,
      })),
    };
  }
}
tstypescript
// infrastructure/notifications/email-notification-service.ts
class EmailNotificationService implements NotificationService {
  constructor(
    private readonly emailClient: EmailClient,
    private readonly templateEngine: TemplateEngine
  ) {}
 
  async sendOrderConfirmation(
    email: string,
    order: Order
  ): Promise<void> {
    const html = this.templateEngine.render("order-confirmation", {
      orderId: order.id,
      total: order.total.toFixed(2),
      items: order.items,
    });
 
    await this.emailClient.send({
      to: email,
      subject: `Order #${order.id} Confirmed`,
      html,
    });
  }
}

Testen ohne Infrastruktur

Der Gewinn der Clean Architecture ist die Testbarkeit. Tests der Geschäftslogik laufen in Millisekunden – ohne Datenbanken, externe Services oder Netzwerkaufrufe.

tstypescript
describe("CreateOrderUseCase", () => {
  const mockOrderRepo: OrderRepository = {
    create: async (data) => ({
      id: "order-1",
      createdAt: new Date(),
      ...data,
    }),
    findById: async () => null,
    findByUserId: async () => [],
  };
 
  const mockUserRepo: UserRepository = {
    findById: async (id) =>
      id === "user-1"
        ? { id: "user-1", email: "test@example.com", name: "Test" }
        : null,
  };
 
  const mockNotifier: NotificationService = {
    sendOrderConfirmation: async () => {},
  };
 
  const useCase = new CreateOrderUseCase(
    mockOrderRepo,
    mockUserRepo,
    mockNotifier
  );
 
  test("creates order with correct total", async () => {
    const order = await useCase.execute({
      userId: "user-1",
      items: [
        { productId: "p1", name: "Widget", price: 10, quantity: 3 },
        { productId: "p2", name: "Gadget", price: 25, quantity: 1 },
      ],
    });
 
    expect(order.total).toBe(55);
    expect(order.status).toBe("pending");
  });
 
  test("rejects order for non-existent user", async () => {
    await expect(
      useCase.execute({
        userId: "non-existent",
        items: [{ productId: "p1", name: "X", price: 10, quantity: 1 }],
      })
    ).rejects.toThrow("User does not exist");
  });
 
  test("rejects order with too many items", async () => {
    const items = Array.from({ length: 51 }, (_, i) => ({
      productId: `p${i}`,
      name: `Item ${i}`,
      price: 1,
      quantity: 1,
    }));
 
    await expect(
      useCase.execute({ userId: "user-1", items })
    ).rejects.toThrow("Maximum 50 items per order");
  });
});

Composition Root: alles verdrahten

Der Composition Root ist der Ort, an dem du konkrete Implementierungen erstellst und in die Use Cases injizierst. Es ist die einzige Stelle, die alle Infrastrukturdetails kennt.

tstypescript
// infrastructure/composition-root.ts
function createOrderModule(config: AppConfig) {
  const prisma = new PrismaClient();
  const emailClient = new SendGridClient(config.sendgridApiKey);
  const templateEngine = new HandlebarsTemplateEngine();
 
  const orderRepo = new PrismaOrderRepository(prisma);
  const userRepo = new PrismaUserRepository(prisma);
  const notifier = new EmailNotificationService(emailClient, templateEngine);
 
  const createOrder = new CreateOrderUseCase(orderRepo, userRepo, notifier);
 
  return { createOrder };
}
 
// api/routes/orders.ts — thin adapter layer
function orderRoutes(app: Express, modules: ReturnType<typeof createOrderModule>) {
  app.post("/orders", async (req, res) => {
    try {
      const order = await modules.createOrder.execute({
        userId: req.user.id,
        items: req.body.items,
      });
      res.status(201).json(order);
    } catch (error) {
      if (error instanceof DomainError) {
        res.status(400).json({ code: error.code, message: error.message });
      } else {
        res.status(500).json({ message: "Internal server error" });
      }
    }
  });
}

Die wichtigsten Erkenntnisse

Bei Clean Architecture geht es um eine konsequent angewendete Regel: Innere Schichten hängen niemals von äußeren ab. Definiere deine Geschäftsentitäten und Use Cases in der Domain-Schicht, drücke externe Abhängigkeiten als Interfaces (Ports) aus und implementiere diese Interfaces in Infrastruktur-Adaptern. Der Composition Root verdrahtet alles beim Anwendungsstart. Der unmittelbare Gewinn ist Testbarkeit – Use Cases lassen sich mit einfachen Mock-Objekten in Millisekunden testen. Der langfristige Gewinn ist Anpassungsfähigkeit – der Wechsel einer Datenbank, eines E-Mail-Providers oder Web-Frameworks bedeutet, einen neuen Adapter zu schreiben, nicht die Geschäftslogik neu zu schreiben. Halte die Architektur pragmatisch: Nicht jede Anwendung braucht vier Schichten. Die minimal praktikable Clean Architecture ist Domain-Logik, die von Interfaces abhängt, nicht von Implementierungen.

Wilfredo Rujel

Wilfredo Rujel

Full-Stack-Softwareentwickler

Diesen Beitrag teilenX