
von Zelkulon14. März 20251 min Lesezeit
Clean Architecture nach Robert C. Martin konsequent umgesetzt in Java/Spring Boot: Schichten, Abhängigkeiten und warum die Domäne nichts von Spring wissen darf.
Die vier Schichten
┌──────────────────────────────────┐
│ Frameworks & Drivers │ (Spring, JPA, REST)
│ ┌─────────────────────────────┐ │
│ │ Interface Adapters │ │ (Controller, Repositories)
│ │ ┌───────────────────────┐ │ │
│ │ │ Application/UseCases│ │ │ (Business-Workflow)
│ │ │ ┌─────────────────┐ │ │ │
│ │ │ │ Domain │ │ │ │ (Entitäten, Regeln)
│ │ │ └─────────────────┘ │ │ │
│ │ └───────────────────────┘ │ │
│ └─────────────────────────────┘ │
└──────────────────────────────────┘
Abhängigkeitsregel: Pfeile zeigen IMMER nach innen.
Die Domäne kennt weder Spring noch JPA.Projektstruktur
order-service/
└── src/main/java/com/zelkulon/order/
├── domain/ ← Innerste Schicht (kein Spring!)
│ ├── model/
│ │ └── Order.java
│ └── valueobject/
│ └── Money.java
│
├── application/ ← Use Cases
│ ├── port/
│ │ ├── in/ ← Eingehende Ports
│ │ │ └── PlaceOrderUseCase.java
│ │ └── out/ ← Ausgehende Ports
│ │ └── OrderRepository.java
│ └── service/
│ └── PlaceOrderService.java
│
└── adapter/ ← Äußerste Schicht
├── in/web/
│ └── OrderController.java
└── out/persistence/
├── OrderJpaEntity.java ← Nur hier JPA!
└── OrderPersistenceAdapter.javaDie Domäne: Spring-frei
// KEINE Spring-Annotationen, KEIN JPA!
public class Order {
private final OrderId id;
private final CustomerId customerId;
private final List<OrderItem> items;
private OrderStatus status;
public Order(OrderId id, CustomerId customerId, List<OrderItem> items) {
if (items == null || items.isEmpty())
throw new OrderException("Mindestens ein Artikel nötig");
this.id = id;
this.items = List.copyOf(items);
this.status = OrderStatus.PENDING;
}
// Domain-Logik in der Domäne!
public void confirm() {
if (this.status != OrderStatus.PENDING)
throw new OrderException("Nur PENDING kann bestätigt werden");
this.status = OrderStatus.CONFIRMED;
}
}
// Value Object (Record)
public record Money(BigDecimal amount, Currency currency) {
public Money {
if (amount.compareTo(BigDecimal.ZERO) < 0)
throw new IllegalArgumentException("Betrag darf nicht negativ sein");
}
public Money add(Money other) {
return new Money(amount.add(other.amount), currency);
}
}Ports und Use Case
// Eingehender Port (Use Case Interface)
public interface PlaceOrderUseCase {
OrderId placeOrder(PlaceOrderCommand command);
}
// Use Case Implementation
@UseCase // eigene Annotation, semantisch klarer als @Service
@Transactional
@RequiredArgsConstructor
public class PlaceOrderService implements PlaceOrderUseCase {
private final OrderRepository orderRepository; // Interface!
private final PaymentGateway paymentGateway; // Interface!
@Override
public OrderId placeOrder(PlaceOrderCommand command) {
var order = new Order(
OrderId.newId(), command.customerId(), mapItems(command)
);
OrderId saved = orderRepository.save(order);
paymentGateway.initiatePayment(order);
return saved;
}
}Abhängigkeitsumkehr visualisiert
Ohne Clean Architecture: Mit Clean Architecture:
Controller Controller
│ │
▼ ▼ (implementiert)
OrderService ◄── JPA/DB PlaceOrderUseCase (Port)
│ ▲ (implementiert)
▼ │
JpaRepository PlaceOrderService
│
OrderRepository (Port)
▲ (implementiert)
│
OrderPersistenceAdapter
│
JpaRepositoryFazit
- Testbarkeit: Domäne und Use Cases ohne Spring testbar (reine JUnit-Tests)
- Wartbarkeit: Änderungen an der Datenbank berühren die Domäne nicht
- Austauschbarkeit: Framework-Migration ohne Domänenänderungen möglich
- Lesbarkeit: Jede Klasse hat eine klar definierte Verantwortung
- Langlebigkeit: Sauber strukturierte Projekte bleiben über Jahre wartbar