Pattern-Katalog
Jedes hier verwendete Entwurfsmuster mit Fundstelle im Code, kurzer Erklärung und Begründung "warum hier". Wird mit jeder Phase ergänzt - diese Version deckt den finalen Stand nach Phase 8 ab (alle 9 Spring-Boot-Module + das Angular-Frontend + Container/Kubernetes/CI- Infrastruktur + E2E/Lasttest-Vertiefung).
| Pattern | Kategorie | Fundstelle | Warum hier verwendet |
|---|---|---|---|
| Aggregate Root | DDD (taktisch) | AggregateRoot.java |
Erzwingt, dass fachliche Invarianten nicht umgangen werden können, indem nur das Root von außen erreichbar ist. |
| Value Object | DDD (taktisch) | Isbn.java, Money.java, EmailAddress.java |
"Make illegal states unrepresentable" - ein Isbn-Objekt kann nie eine ungültige ISBN enthalten, die Prüfung passiert genau einmal im Konstruktor statt an jeder Verwendungsstelle. |
| Domain Event | DDD (taktisch) | DomainEvent.java |
Trennt "was ist im Aggregate fachlich passiert" (intern) von "was wird nach außen kommuniziert" (Integrationsevent, siehe common-events) - bewusst zwei verschiedene Modelle. |
| Custom Exception Hierarchy | Fehlerbehandlung | DomainException.java |
Technische von fachlichen Fehlern trennen; ermöglicht zentrales Mapping auf HTTP-Statuscodes (siehe RFC-7807-Handler unten). |
| Problem Details / RFC 7807 | API-Design | GlobalExceptionHandler.java |
Standardisiertes, maschinenlesbares Fehlerformat statt projektspezifischem Ad-hoc-JSON; von Spring Boot 3 nativ unterstützt (ProblemDetail). |
| Chain of Responsibility (technisch: Servlet Filter) | Querschnittsbelang | CorrelationIdFilter.java |
Jeder Request durchläuft eine Kette von Filtern; hier zur Anreicherung des Log-Kontexts, unabhängig von der eigentlichen fachlichen Verarbeitung. |
| Auto-Configuration-Modul ("Mini-Starter") | Modularität | CommonWebAutoConfiguration.java, CommonSecurityAutoConfiguration.java |
Macht ein internes Modul zu einem "Plug-and-Play"-Baustein, unabhängig vom Hauptklassen-Package des einbindenden Service - dasselbe Muster wie offizielle Spring-Boot-Starter. |
Sensible Default, Override via @ConditionalOnMissingBean |
Erweiterbarkeit | CommonSecurityAutoConfiguration.java |
Bibliothekscode erzwingt keine starre Vorgabe, sondern liefert einen überschreibbaren Standard - jeder Service kann eigene Sicherheitsregeln definieren, wo nötig. |
| Adapter (Claim-Mapping) | Integration | KeycloakRealmRoleConverter.java |
Übersetzt Keycloaks proprietäres Token-Format (realm_access.roles) auf Spring Securitys Standard-GrantedAuthority-Schnittstelle. |
| Constants-Registry statt String-Literalen | Wartbarkeit | LibraryTopics.java, LibraryQueues.java, LibraryRoles.java |
Verhindert Tippfehler-Divergenz zwischen Producer/Consumer bzw. zwischen Token-Rolle und @PreAuthorize-Check - der Compiler findet den Fehler statt eines Laufzeit-403. |
| Consumer-Driven Schema Versioning | Integration | IntegrationEvent.schemaVersion() |
Leichtgewichtige Alternative zu einer zentralen Schema-Registry (Trade-off dokumentiert in ADR-0003 bzw. common-events/pom.xml). |
| Singleton Container Pattern (Testcontainers) | Testing | LibraryTestContainers.java |
withReuse(true) vermeidet, dass jeder Testlauf Container komplett neu hochfährt - spürbarer Geschwindigkeitsgewinn in einem Multi-Module-Reactor mit vielen Integrationstests. |
Spezifischster-Handler-gewinnt (mehrere @RestControllerAdvice) |
Fehlerbehandlung | SecurityExceptionHandler.java |
Realer Bug, gefunden beim Testen (Phase 2): AccessDeniedException wurde vom generischen Exception-Handler in common-web als HTTP 500 statt 403 beantwortet. Spring waehlt automatisch den spezifischsten @ExceptionHandler ueber ALLE Advice-Beans hinweg - die Lehre daraus steht in der Teststrategie. |
| Fitness Function (ArchUnit) | Architektur-Governance | architecture-tests/ |
Architekturregeln (z. B. "common-domain bleibt framework-frei", "keine Feld-Injection") werden automatisiert bei jedem Build geprüft statt nur im Code-Review erinnert. |
| Constructor Injection | Dependency Injection | erzwungen durch ConstructorInjectionTest.java |
Sichtbare, testbare, unveränderliche (final) Abhängigkeiten statt versteckter Feld-Injection. |
| Layered Architecture (Fitness Function) | Architektur-Governance | LayeredArchitectureTest.java |
Erzwingt die Schichtenfolge api → application → domain ← infrastructure je Service - Verstöße lassen den Build fehlschlagen statt erst im Review aufzufallen. |
| Strategy (via Enum-Konstanten) | GoF-Verhalten | MembershipTier.java implements LendingPolicy.java |
Tarifabhängige Ausleihregeln ohne if/else-Kaskade; Enum-Strategie statt Klassenhierarchie, da die Tarifmenge fachlich geschlossen ist (siehe Klassen-Javadoc für die Abwägung gegenüber separaten Strategy-Klassen). |
| Strongly Typed ID | DDD (taktisch) | MemberId.java, BookId.java, CopyId.java |
Verhindert, dass zwei UUID-Parameter in einer Methodensignatur versehentlich vertauscht werden - der Compiler unterscheidet die Typen. |
| Aggregate-lokale Entity + öffentlicher Snapshot-Typ | DDD (taktisch) | Copy.java, CopySnapshot.java |
Copy hat einen paketprivaten Konstruktor - die Infrastruktur-Schicht darf ein Aggregat nur über Book.reconstitute(..., List<CopySnapshot>) wiederherstellen, nie einzelne Kindobjekte direkt erzeugen. Erzwingt die Aggregate-Grenze auf Compiler-Ebene statt nur per Konvention. |
| Specification Pattern (Spring Data JPA) | Persistenz | BookSpecifications.java |
Kombinierbare, unabhängig erweiterbare Suchkriterien statt einer Repository-Methode mit wachsender Parameterliste (findByTitleAndAuthorAndCategoryAnd...). |
| Cache-Aside / Read-Through | Performance | BookQueryService.java, CacheConfig.java |
@Cacheable/@CacheEvict gegen Redis für den häufigen "Buch per Id"-Zugriff; die (parameterreichere, seltener wiederholte) Suche wird bewusst NICHT gecacht. |
| Domain Event → Integrationsevent-Übersetzung nach Commit | Event-Driven Architecture | CatalogEventPublisher.java |
@TransactionalEventListener(phase = AFTER_COMMIT) verhindert, dass ein Event veröffentlicht wird, dessen DB-Änderung durch einen Rollback nie stattfand. Leichtgewichtiger Kompromiss gegenüber echtem Transactional Outbox (lending-service) - Trade-off dokumentiert in CopyMarkedAvailableEvent.java. |
| Saga (Orchestrierung) mit Kompensation | Verteilte Transaktionen | BorrowBookService.java |
Koordiniert catalog-service (Exemplar reservieren) + member-service (Berechtigung) + lokale DB - schlägt der letzte Schritt fehl, wird die bereits erfolgte Exemplar-Reservierung kompensiert. Siehe ADR-0008. |
| Transactional Outbox | Verteilte Transaktionen | OutboxEntry.java, OutboxEventListener.java, OutboxPublisher.java |
Loan-Änderung und Event-Eintrag committen atomar in derselben Transaktion (synchroner @EventListener, NICHT AFTER_COMMIT); ein separater Poller veröffentlicht später an Kafka. Stärkere Garantie als catalog-service's Ansatz - Vergleich in ADR-0008. |
| Circuit Breaker + Retry (Resilience4j) | Resilience | CatalogServiceAdapter.java |
@Retry überbrückt kurze Aussetzer, @CircuitBreaker verhindert bei anhaltendem Ausfall weiteres Warten ("Fail Fast") - mit fachlicher Fallback-Exception statt technischem Fehler. |
| Anti-Corruption Layer | Integration | CatalogBookView.java, CatalogServiceAdapter.java |
lending-service übersetzt catalog-services externes JSON-Format in eigene, schlanke Domain-Typen - das externe Format "leakt" nur in der Adapter-Klasse, nirgendwo sonst. |
| Token Relay | Security | TokenRelayFeignInterceptor.java |
Reicht das JWT des aufrufenden Nutzers unverändert an nachgelagerte Services weiter, statt sich erneut zu authentifizieren. |
| Self-Invocation-Falle vermeiden (separates Transactional-Bean) | Spring/AOP | LoanWriteService.java |
@Transactional auf einer Methode, die von derselben Klasse intern aufgerufen wird, wird von Spring stillschweigend IGNORIERT (Proxy-basierte AOP faengt nur externe Aufrufe ab) - deshalb ein eigenes Bean statt einer privaten Methode. |
| Event-Choreographie | Event-Driven Architecture | FulfillReservationService.java |
reservation-service reagiert eigenständig auf catalog-services CopyAvailableEvent, ohne dass ein Orchestrator es dazu auffordert - Kontrast zu lending-service's Saga (Orchestrierung). Beide Stile im selben Projekt zu sehen, macht den Unterschied greifbar. |
| Idempotent Consumer (Upsert-Variante) | Messaging | Fine.java#recalculate, HandleLoanOverdueService.java |
Bei erneuter Zustellung desselben LoanOverdueEvent (z. B. weil der tägliche Scan die Überfälligkeit erneut feststellt) wird die bestehende Gebühr aktualisiert statt eine zweite angelegt. |
| Idempotent Consumer (Dedup-Variante) | Messaging | ProcessNotificationCommandService.java |
commandId als Idempotenzschlüssel: eine bereits verarbeitete Nachricht (RabbitMQ "at-least-once") wird beim zweiten Empfang übersprungen, statt ein zweites Mal "gesendet" zu werden. |
| Retry + Dead-Letter-Queue | Messaging/Resilience | RabbitConfig.java |
Bis zu 3 lokale Wiederholversuche mit Backoff, bevor eine Nachricht (ohne Requeue) an die konfigurierte Dead-Letter-Queue weitergeleitet wird - verhindert einen endlosen "Poison-Message"-Zustellungs-Loop. |
| Consumer-Driven Contract Testing | Testing/Integration | CatalogServiceContractTest.java (Konsument), CatalogServiceProviderContractIT.java (Provider) |
Prüft, ob Producer und Consumer eines REST-Aufrufs bzw. Kafka-Events kompatibel bleiben, ohne beide gemeinsam laufen lassen zu müssen (Definition siehe Glossar) - deckt alle 8 echten Service-zu-Service-Paare ab, siehe teststrategie.md. |
| Treibender vs. getriebener Adapter | Architektur-Governance | LayeredArchitectureTest.java |
Ein Message-Listener (Infrastructure-Paket) ist architektonisch ein TREIBENDER Adapter (wie ein Controller) und darf application aufrufen; ein Repository-Adapter ist GETRIEBEN (wird von application über ein Domain-Interface aufgerufen). Erkenntnis aus einem echten ArchUnit-Regelverstoß bei reservation-/fine-/notification-service. |
| API Gateway | Integration | SecurityConfig.java, application.yml |
Single Entry Point: Routing per Pfad-Präfix zu den Fachservices, zentrale JWT-Prüfung, CORS für das Angular-Frontend - Clients kennen nur eine Adresse, nicht sechs. |
| Client-Side Service Discovery | Integration | discovery-server, eureka.client-Block in jeder Service-application.yml, CatalogFeignClient.java |
Jeder Fachservice registriert sich bei Eureka (sichtbar im Dashboard unter :8761) UND die tatsächliche Adressauflösung nutzt das inzwischen auch: @FeignClient(..., url = "${...:}") mit leerem Default lässt Feign bei fehlender expliziter URL auf Eureka-/spring-cloud-starter-loadbalancer-basierte Namensauflösung zurückfallen - aktiv in der vollcontainerisierten Compose-Umgebung, siehe ADR-0007 ("Nachtrag"). |
| Zentrale Konfiguration (Config Server) | Infrastruktur | config-repo/application.yml |
Zwei Einstellungen, die in Phase 1-4 wortwörtlich in jedem Service dupliziert waren (logging.pattern.level, management.endpoint.health.show-details), liegen jetzt an einer Stelle. optional:configserver:... lässt jeden Service auch ohne laufenden Config Server starten (mit lokalen Defaults). |
| Signals-basierter State (Standard-Stil) | Frontend-Zustandsverwaltung | catalog.service.ts, fines.service.ts, members-admin.service.ts |
@Injectable({providedIn: 'root'})-Service haelt signal<T>()-Zustand direkt; Komponenten lesen ihn ohne async-Pipe oder manuelles Un-/Subscriben. Bewusster Standard-Stil dieser Anwendung - schlanker als NgRx fuer Zustand, der nicht global/Feature-uebergreifend geteilt werden muss. |
| NgRx (Redux-Pattern) als bewusster Kontrast | Frontend-Zustandsverwaltung | catalog-admin/store/ (catalog-admin.actions.ts, .reducer.ts, .effects.ts, .selectors.ts) |
Einziges Feature mit NgRx statt Signals - absichtlich, um Store/Reducer/Effects/Selectors direkt neben dem Signals-Stil zu zeigen. createActionGroup (moderne NgRx-API) statt einzelner createAction-Aufrufe; Side Effects (HTTP) leben ausschließlich in CatalogAdminEffects, der Reducer bleibt eine reine Funktion. |
| Functional Route Guard | Frontend/Routing | role.guard.ts |
Angular-15+-Stil: einfache Funktion statt Guard-Klasse mit CanActivate-Interface - weniger Boilerplate, direkt mit inject() nutzbar. |
| Functional HTTP Interceptor / Token Relay (Client-Seite) | Frontend/Security | auth.interceptor.ts |
Haengt das Access-Token automatisch an jeden Request an API_BASE_URL - Pendant zum serverseitigen Token-Relay-Pattern in lending-service (dort JWT von Service zu Service, hier von Browser zu Gateway). |
| RFC-7807-Fehlerparsing (Frontend-Spiegel) | Frontend/Fehlerbehandlung | z. B. fines.service.ts#extractErrorMessage |
Liest err.error.detail aus der ProblemDetail-Antwort des Backends (siehe GlobalExceptionHandler) - dieselbe Fehlerkontrakt-Annahme auf beiden Seiten der Anwendung, keine eigene Frontend-Fehlerkonvention. |
| Authorization Code Flow + PKCE | Security | auth.config.ts, auth.service.ts |
angular-oauth2-oidc mit initCodeFlow() - der für Single-Page-Apps empfohlene OAuth2-Flow (kein Client-Secret im Browser, PKCE schützt den Code-Austausch), Pendant zum Resource-Server-Setup im Backend (ADR-0005). |
| Multi-Stage-Container-Build | Deployment | library-platform/Dockerfile |
Ein generisches Dockerfile (von podman build gelesen) fuer alle neun Services (parametrisiert per --build-arg MODULE): Stage 1 baut mit vollem Maven+JDK-Image, Stage 2 kopiert NUR das fertige Jar in ein schlankes JRE-Image - vermeidet Compiler/Build-Tools im Produktions-Image. |
| Classpath-getriebene Auto-Configuration (reines Abhaengigkeits-Modul) | Modularität | common-observability |
Anders als common-web/common-security bringt dieses Modul KEINE eigene @AutoConfiguration-Klasse mit - Spring Boot erkennt Micrometer/Zipkin-Bibliotheken automatisch allein an ihrer Anwesenheit auf dem Klassenpfad. Bewusster Kontrast im selben Projekt: zwei verschiedene Wege, wie ein "common"-Modul Verhalten hinzufügt. |
| Zwei Adressen fuer denselben Dienst (Issuer vs. JWKS-Endpunkt) | Security | api-gateway/application.yml |
issuer-uri muss zum iss-Claim im Token passen (das sieht der Browser, der sich ueber localhost:8180 einloggt), jwk-set-uri wird dagegen vom Backend-Container selbst aufgerufen (keycloak:8080, containerinternes Netzwerk) - zwei unterschiedliche Hostnamen fuer denselben Keycloak, je nachdem WER die Anfrage stellt. Ausfuehrlich erklaert in docker-compose-guide.md. |
| Dual-Profile-Deployment (Compose vs. Kubernetes) | Architektur-Governance | on-profile: k8s-Bloecke in acht application.yml-Dateien, siehe auch ADR-0007 |
Derselbe Anwendungscode laeuft mit unveraendertem Jar in zwei grundverschiedenen Deployment-Umgebungen - der einzige Code-seitige Unterschied ist ein aktiviertes Spring-Profil (SPRING_PROFILES_ACTIVE=k8s), alles andere (Hostnamen) kommt ueber Umgebungsvariablen. |
| Generisches Helm-Chart + service-spezifische values-Dateien | Deployment | infra/helm/library-service, infra/helm/values/ |
EIN Chart fuer acht strukturell identische (zustandslose Deployment+Service+optional HPA/Ingress), aber unterschiedlich konfigurierte Services - vermeidet, dieselben Kubernetes-Ressourcen-Templates achtmal zu duplizieren. |
| Stabile Selector-Labels getrennt von vollen Labels | Deployment | _helpers.tpl |
library-service.selectorLabels ist eine bewusst kleinere, ZEITLICH STABILE Teilmenge von library-service.labels - ein Deployment-Selector darf sich nach dem ersten Rollout nie mehr aendern, waehrend z. B. die Chart-Version in den vollen Labels bei jedem Release wechseln kann. |
| Matrix-Build (CI) | CI/CD | .github/workflows/ci.yml |
Ein Job-Template (container-build, podman build), einmal parallel fuer alle neun Services ausgefuehrt (strategy.matrix) statt neun fast identischer Kopier-Jobs. |
| Environment-Gated Promotion | CI/CD | Jenkinsfile |
Automatischer Durchlauf bis zur QS-Stufe, danach ein manuelles input-Freigabe-Gate vor Produktion - verhindert, dass jeder erfolgreiche Build automatisch live geht, ohne jede Stufe einzeln von Hand anzustossen. |
| Values-Layering statt Datei-Duplizierung (Helm) | Deployment | infra/helm/values/env/ |
Zwei kleine, umgebungsWEITE Overlay-Dateien (test-values.yaml, qs-values.yaml) werden als zweite -f hinter der service-spezifischen Datei uebergeben, statt alle 8 Service-Dateien pro Umgebung zu duplizieren (8x3=24 Dateien). |
| Map statt Liste fuer overlay-faehige Helm-Werte | Deployment | library-service/values.yaml, deployment.yaml, env/kind-values.yaml |
env ist bewusst eine Map ({SPRING_X: "Y"}) statt einer Liste ([{name: X, value: Y}]) - Helm merged zwischen mehreren -f-Dateien nur MAPS schluesselweise, Listen werden bei der letzten -f-Datei komplett ERSETZT. Erst dadurch kann ein gemeinsames kind-Overlay (siehe kind-cluster.md) einzelne Umgebungsvariablen ueberschreiben, ohne den kompletten env-Block jedes der 8 Services zu duplizieren. |
| Label-basiertes Service-Discovery (Reverse-Proxy) | Deployment/Infrastruktur | infra/docker-compose.yml (Traefik-Labels je Service), shared/enterprise-infrastructure/docker-compose.yml |
Traefik erkennt Dienste automatisch anhand von Container-Labels (traefik.http.routers..., via Podmans Docker-kompatible API) statt einer manuell gepflegten, separaten Proxy-Konfigurationsdatei - ein neuer Dienst mit Hostnamen-Wunsch bekommt einfach vier zusaetzliche Zeilen labels:, keine zweite Datei zu pflegen. Funktioniert projektuebergreifend ueber ein gemeinsames EXTERNES Container-Netzwerk (proxy), siehe reverse-proxy.md. |
| Automatisierte PR-Checkliste (ArchUnit) | Architektur-Governance | NoJpaEntityInApiTest.java |
Ein bisher rein manuell im Review geprüfter Punkt ("REST-DTOs sind keine JPA-Entities") wird zum automatisierten Build-Fehler statt eines im Review leicht übersehenen Details. |
| Slice-Zyklenfreiheit (ArchUnit) | Architektur-Governance | NoCyclicDependenciesTest.java |
Ergänzt die Schichten-Regel (erlaubte RICHTUNG zwischen Schichten) um Zyklenfreiheit ZWISCHEN den Unterpaketen jedes Service - zwei Pakete derselben Schicht dürften sich laut Schichten-Regel gegenseitig referenzieren, ein Zyklus bleibt trotzdem ein Wartbarkeits-Alarmsignal. |
Stolperstein: MapStruct erkennt fluent-benannte Accessoren NICHT automatisch
Der erste vollständige E2E-Testlauf gegen den echten Stack (siehe
library-frontend/e2e/README.md#gefundene-bugs) deckte einen Bug auf, der fast jede API-Antwort
im gesamten Backend betraf: BookDtoMapper, FineDtoMapper, LoanDtoMapper, MemberDtoMapper
und ReservationDtoMapper gingen (ein MemberDtoMapper-Kommentar sagte das sogar explizit, aber
FALSCH) davon aus, MapStruct erkenne fluent-benannte Accessor-Methoden (title(), status() -
bewusst ohne get-Präfix, siehe die reichen Domainmodelle) automatisch, "wie bei einem Record".
Das stimmt nicht: MapStructs automatische Feld-zu-Feld-Zuordnung erkennt nur JavaBean-Getter
(getTitle()) ODER echte record-Typen (per Reflection auf deren canonical constructor) - eine
normale Klasse mit fluent-benannten Methoden fällt durch beide Raster. Ohne explizite
@Mapping(target = "...", expression = "java(...)") wird ein "passend benanntes" Zielfeld dann
still auf seinen Default-Wert gesetzt (null bei Objekten, 0 bei int) - nur eine
Build-Warnung, kein Fehler. Betraf zuletzt u. a. BookResponse.title/publisher,
MemberResponse.firstName/lastName/tier/status/registeredAt,
LoanResponse.borrowedAt/dueDate/status und ReservationResponse.queuePosition/status.
Lehre: bei MapStruct + reichem Domainmodell (fluent Accessoren) gilt die Regel "JEDES
Zielfeld bekommt eine explizite @Mapping", nicht nur die Felder, die offensichtlich eine
Transformation brauchen (Value-Object-Entpacken o. ä.) - genau die scheinbar "einfachen" Felder
sind hier das Risiko, weil sie am ehesten fälschlich als "wird schon automatisch gemappt"
durchgehen. Ein unmappedTargetPolicy = ReportingPolicy.ERROR auf Reactor-Ebene hätte das schon
beim Build gefangen statt erst im Browser - eine dokumentierte Vertiefungsaufgabe für dieses
Projekt.
Noch nicht implementiert (bewusste Vertiefungsaufgaben)
Alle acht geplanten Phasen sind umgesetzt. Ein paar bewusst offen gelassene Vertiefungsaufgaben bleiben als Lernübung bestehen (jeweils an Ort und Stelle im Code/ADR dokumentiert, keine vergessenen Punkte):
- Keine Verknüpfung zwischen Keycloak-Login-Nutzer und
member-service-Mitglieds-ID im Frontend (siehelibrary-frontend/e2e/README.md) - Ausleihen bleibt bibliothekar:innen-assistiert. ProcessFinePaymentCommand/die zugehörige RabbitMQ-Queue ist definiert, aber ohne Consumer.- Deployment gegen einen produktionsnahen, dauerhaften Kubernetes-Cluster (CRC/OKD oder ein
echter Cloud-Cluster): ein konkreter CRC-Versuch scheiterte an einer Kombination aus abgelaufenen
Bundle-Zertifikaten und einer zu alten CRC-Version - vollständiges Versuchsprotokoll und nächste
Schritte in kubernetes-guide.md.
Für die Jenkins-Test-Stufe ist das inzwischen nicht mehr offen: ein lokaler
kind-Cluster deployt dort seit Phase 11 wirklich, siehe kind-cluster.md.