PROCUREX · Enterprise-Lernprojekt

Technologie-Exkurse

Vertiefende Vergleiche von Technologien, die im PROCUREX-Umfeld zur Sprache kommen, aber bewusst nicht (oder anders) eingesetzt werden. Jede Karte lässt sich per Klick auf den Titel aufklappen.

← Zurück zum Dokumentationsportal

Exkurs · Werkzeug-Installation

Was bedeutet „lokal installiert“?

Betriebssystem, Projekt, Ausführung und Browser-Cache bei npm/Playwright sauber unterschieden.

Bei Node- und Playwright-Projekten werden mehrere Installationsebenen leicht verwechselt. Der folgende Überblick zeigt, was auf dem Betriebssystem, im Projekt und im Benutzerprofil liegt.

1 · BetriebssystemNode.js, Java, Docker und k6 werden unter Windows oder Linux installiert und stehen dem Rechner zur Verfügung.
2 · Projektnpm ci installiert Playwright und andere Pakete in node_modules dieses Projekts.
3 · Ausführungnpx playwright test verwendet zuerst die lokale Projektversion und hält die Version reproduzierbar.
4 · Browsernpx playwright install chromium lädt den Testbrowser in den Playwright-Cache des Benutzerprofils.

Beispiel im PROCUREX-Projekt

Windows/Linux:  Node.js ─────────────── Voraussetzung für npm
                               │
PROCUREX:      npm ci ─────────┼─► procurex-frontend/node_modules
                               │       └─ @playwright/test
                               │
                               └─► npx playwright test

Benutzerprofil: npx playwright install chromium ─► Playwright-Browser-Cache
Git:            package.json + package-lock.json + e2e/ ─► versioniert
                node_modules + Browser-Cache       ─► nicht versioniert

Die wichtigsten Befehle

BefehlWirkungEbene
npm ciInstalliert exakt die im Lockfile festgelegten Projektabhängigkeiten.Projekt
npx playwright testStartet die lokal installierte Playwright-Testversion.Projekt ausführen
npx playwright install chromiumInstalliert den Chromium-Browser für Playwright.Benutzerprofil
npm install -g ...Installiert ein npm-Werkzeug global; für Playwright im Projekt normalerweise nicht nötig.Benutzer-/Systemumgebung
Merksatz: Node.js ist das Betriebssystem-Werkzeug, npm verwaltet Projektpakete, npx startet diese Pakete, und Playwright installiert den benötigten Browser separat. Deshalb sieht man Playwright nicht als klassisches Windows-Programm in der Systemsteuerung.
Empfehlung:
  • In CI und für reproduzierbare Builds immer npm ci statt npm install verwenden – es respektiert das Lockfile exakt und schlägt fehl, wenn package.json/package-lock.json auseinanderlaufen.
  • Bei „Executable doesn't exist“-Fehlern zuerst npx playwright install chromium erneut ausführen, bevor man an der npm-Installation selbst zweifelt – meist fehlt nur der Browser-Cache, nicht das Paket.
  • Globale npm-Installationen (npm install -g) für Projektwerkzeuge vermeiden, sobald ein Projekt eine eigene package.json hat – sie unterlaufen die Versionsfestlegung im Lockfile und führen zu „funktioniert nur auf meinem Rechner“.
Wichtig zu merken:
  • node_modules und der Playwright-Browser-Cache landen nie im Git-Repo (.gitignore) – ein frischer Checkout hat beides nicht und braucht erst npm ci plus npx playwright install.
  • npx bevorzugt immer die lokal in node_modules installierte Version vor einer global installierten – zwei Entwickler mit unterschiedlichen globalen Versionen bekommen trotzdem dasselbe Testverhalten.
  • Playwright taucht nirgends in „Programme und Features“ auf – es ist kein Windows-Programm, sondern ein Node-Paket mit eigenem Browser-Download.

Vollständige Playwright-/Selenium-Dokumentation öffnen →

Exkurs · Werkzeug-Landschaft

GitHub, GitLab, Bitbucket & Co. im Vergleich

GitHub, GitLab, Bitbucket & Co. im Direktvergleich – und warum PROCUREX auf GitHub liegt.

PROCUREX liegt als Git-Repository auf GitHub, während Jira und Confluence bei Atlassian laufen. Das wirft die naheliegende Frage auf, worin sich die großen Git-Hosting-Plattformen eigentlich unterscheiden – und warum man sich in der Praxis für die eine oder andere entscheidet.

GitHubMicrosoft. Größte Community, Standard für Open Source, Actions als CI/CD, Copilot-Integration.
GitLabGitLab Inc. „Single Application“ für den ganzen DevOps-Zyklus, stark im Self-Hosting.
BitbucketAtlassian. Enge Integration mit Jira und Confluence, verbreitet in Atlassian-Umgebungen.
WeitereAzure DevOps, Gitea/Forgejo, Codeberg, SourceHut – Nischen- und Self-Hosting-Alternativen.

Direktvergleich

PlattformEigentümerSchwerpunktSelf-HostingKosten (ca., pro Nutzer/Monat)Beliebtheit
GitHubMicrosoftOpen Source, Community, Ökosystem, CopilotNur Enterprise ServerFree / Team ~4 $ / Enterprise ~21 $Sehr hoch – über 100 Mio. Entwickler, De-facto-Standard
GitLabGitLab Inc. (börsennotiert)Durchgängige DevOps-/DevSecOps-Pipeline in einer AnwendungJa, Community Edition kostenlosFree / Premium ~29 $ / Ultimate ~99 $Hoch – stark bei Unternehmen mit eigenem Betrieb
BitbucketAtlassianEnge Jira-/Confluence-/Trello-AnbindungNein (Data Center wurde eingestellt)Free bis 5 Nutzer / Standard ~3 $ / Premium ~6 $Mittel – vor allem in Atlassian-Firmen
Azure DevOpsMicrosoftBoards, Pipelines, Repos für .NET-/Microsoft-UmfelderBedingt (Azure DevOps Server)Free bis 5 Nutzer, danach ~6 $Mittel – primär Microsoft-Konzerne
Gitea / ForgejoOpen-Source-CommunitySchlankes, selbst gehostetes Git-HostingJa, einziger ZweckKostenlos, nur ServerkostenWachsend – beliebt bei Selfhostern
Codeberggemeinnütziger Verein (e. V.)Kostenloses, werbefreies Hosting auf Forgejo-BasisNein (nutzt selbst Forgejo)Kostenlos, spendenfinanziertNische – FOSS- und Datenschutz-Community
SourceHutDrew DeVault / SourceHut LLCMinimalistisch, E-Mail-/CLI-zentrierter WorkflowJa„Pay what you want“, ab ~2 $Nische – FOSS-Puristen

Preise sind Richtwerte (Einstiegs- bzw. mittlere Stufe, Stand ca. 2025) und ändern sich laufend – vor einer Entscheidung immer die aktuelle Preisseite des Anbieters prüfen.

GitHub

Mit weit über 100 Millionen Entwicklerkonten und mehreren hundert Millionen Repositories ist GitHub die mit Abstand größte Plattform und faktisch der Standard für Open-Source-Projekte. Stärken sind das riesige Ökosystem (Actions, Marketplace, Copilot, Discussions, Sponsors) und die Sichtbarkeit für Personal Branding und Recruiting. Der kostenlose Plan deckt unbegrenzt öffentliche wie private Repositories ab; bezahlte Stufen bringen mehr CI/CD-Minuten, erweiterte Sicherheits- und Governance-Funktionen.

GitLab

GitLab versteht sich als „Single Application“ für den kompletten DevOps-Lebenszyklus – Planung, Code, CI/CD, Security-Scanning und Deployment in einem Werkzeug statt vieler Einzeltools. Die Community Edition lässt sich vollständig kostenlos selbst betreiben, was GitLab besonders bei Unternehmen mit Compliance- oder On-Premise-Anforderungen beliebt macht. Die kostenpflichtigen Stufen (Premium, Ultimate) sind spürbar teurer als vergleichbare GitHub-Pläne, bieten dafür aber tiefere integrierte Security- und Compliance-Funktionen ohne Zusatzprodukte.

Bitbucket

Bitbucket punktet vor allem dort, wo bereits Jira und Confluence im Einsatz sind – genau wie im PROCUREX-Projekt: Commits, Branches und Pull Requests lassen sich direkt mit Jira-Vorgängen verknüpfen. Der kostenlose Plan reicht für Teams bis 5 Personen; für größere Teams sind die bezahlten Stufen aber vergleichsweise günstig. Außerhalb des Atlassian-Ökosystems ist die Marktdurchdringung deutlich geringer als bei GitHub oder GitLab.

Weitere Plattformen

Azure DevOps (Microsoft) bündelt Boards, Repos, Pipelines, Test Plans und Artifacts und wird häufig dort eingesetzt, wo bereits stark auf Azure und .NET gesetzt wird. Gitea und dessen Fork Forgejo sind schlanke, quelloffene Git-Server für alle, die volle Kontrolle über die Infrastruktur wollen. Codeberg ist eine gemeinnützige, spendenfinanzierte SaaS-Instanz auf Forgejo-Basis – kostenlos, werbefrei und beliebt in der FOSS-Community als Alternative zu großen kommerziellen Anbietern. SourceHut verfolgt bewusst einen minimalistischen, textbasierten Ansatz (E-Mail-Patches statt Weboberfläche) und richtet sich an eine kleine, aber treue Nutzergruppe. Historisch relevant, aber heute kaum noch genutzt sind SourceForge und Launchpad.

Einordnung für PROCUREX: Der Code liegt auf GitHub (größte Reichweite, bestes CI/CD-Ökosystem über Actions), während Projektmanagement und Doku bewusst getrennt bei Atlassian (Jira/Confluence) laufen. Bitbucket hätte den Vorteil einer nativen Jira-Anbindung, wurde hier aber nicht gewählt – GitHub bietet die größere Community, mehr Lernressourcen und ist für ein Portfolio-/Lernprojekt sichtbarer.
Empfehlung:
  • GitHub wählen, wenn Sichtbarkeit, Open-Source-Reichweite oder ein möglichst großes Ökosystem (Actions, Marketplace, Copilot) im Vordergrund stehen.
  • GitLab wählen, wenn eine einzelne, selbst gehostete Anwendung den kompletten DevSecOps-Zyklus (Planung bis Security-Scanning) abdecken soll und volle Kontrolle über die Infrastruktur wichtiger ist als maximale Reichweite.
  • Bitbucket nur wählen, wenn bereits tief in Jira/Confluence investiert wurde und die native Verknüpfung von Commits mit Vorgängen den Ausschlag gibt – nicht als Neueinstieg ohne bestehendes Atlassian-Ökosystem.
  • Gitea/Forgejo wählen, wenn volle Selbstkontrolle bei minimalem Ressourcenbedarf zählt, z. B. für private oder firmeninterne Repositories ohne Cloud-Abhängigkeit.
Wichtig zu merken:
  • GitHub, GitLab und Bitbucket sind alle nur Hosting für Git – Git selbst ist dezentral, plattformunabhängig, und jedes lokale Repository enthält die volle Historie unabhängig vom Anbieter.
  • Bitbucket Server/Data Center (Self-Hosting) wurde 2024 eingestellt – Bitbucket gibt es seither nur noch als Cloud-Angebot.
  • Ein Plattformwechsel ist beim reinen Code jederzeit per git remote set-url möglich – echter Vendor-Lock-in entsteht meist erst über plattformspezifische CI/CD-Konfiguration (z. B. GitHub Actions), nicht über den Code selbst.
Exkurs · Java-Laufzeitlandschaft

WebSphere, WebLogic, JBoss & Co. im Vergleich

Versionen, Java-Kompatibilität, Support-Ende und der javax→jakarta-Bruch bei WebSphere, WebLogic, JBoss & Co.

PROCUREX wird nicht auf einem klassischen Java-EE-Anwendungsserver betrieben, sondern als Spring-Boot-Anwendung mit eingebettetem Tomcat gestartet (spring-boot-starter-web in allen Fachmodulen). Das ist ein bewusster architektonischer Unterschied zur „alten Welt“ der Application Server – dieser Exkurs zeigt, was WebSphere & Co. eigentlich sind und warum viele neue Projekte heute darauf verzichten.

IBM WebSphereVoller Jakarta-EE-Server für Großkonzerne; daneben die schlankere, moderne Variante WebSphere Liberty.
Oracle WebLogicVoller Java-EE-Server, Kernstück von Oracle Fusion Middleware, verbreitet in Banken und Telekommunikation.
Red Hat JBoss EAP / WildFlyKommerziell unterstützter bzw. freier Jakarta-EE-Server auf gleicher Codebasis.
Tomcat & Co.Schlanke Servlet-Container, oft in Spring-Boot-Anwendungen eingebettet statt separat betrieben.

Direktvergleich

ServerHerstellerAktuelle VersionJakarta/Java-EE-LevelMindest-Java (JDK)LizenzmodellKosten (ca.)Verbreitung
IBM WebSphere (traditional)IBM9.0.5.x (Fix-Pack-Reihe, kein neuer Major seit 2016)Java EE 8 / Jakarta EE 8 (Full Profile)Java SE 8; ab Fix-Pack 9.0.5.13 auch Java SE 11Kommerziell, pro CoreOft fünfstellig pro Core/JahrHoch in Banken/Versicherungen (Legacy), rückläufig bei Neuprojekten
IBM WebSphere LibertyIBMContinuous Delivery, jahrgangsbasiert (z. B. 24.0.0.x/25.0.0.x)Jakarta EE 10 (aktuellste Liberty-Feature-Sets)Java SE 8 bis 21, je nach gewähltem Feature-SetKommerziell (ILAN) oder kostenlos als Open LibertyDeutlich günstiger als traditional; Open Liberty kostenlosWachsend als IBM-Migrationspfad weg von WebSphere classic
Oracle WebLogic ServerOracle12.2.1.4 (Standard-LTS) bzw. 14.1.2 (neueste Linie)Java EE 8 (12.2.1.4) / Jakarta EE 10 (14.1.2)Java SE 8/11 (12.2.1.4); Java SE 17/21 (14.1.2)Kommerziell, pro ProzessorOft mehrere 10.000 $/Prozessor + ca. 22 %/Jahr SupportHoch in Konzern-IT mit Oracle-Stack, stark rückläufig
Red Hat JBoss EAPRed Hat (IBM)EAP 8.0 (Vorgänger EAP 7.4 weiterhin im Support)Jakarta EE 10 (EAP 8) / Java EE 8 (EAP 7.4)Java SE 11/17 (EAP 8); Java SE 8/11 (EAP 7.4)Subscription pro CoreCa. 3.500–7.000 $/Core/JahrMittel-hoch, v. a. Red-Hat-Kunden
WildFlyRed Hat (Community)ca. 34.x (fortlaufende Releases, Upstream von EAP)Jakarta EE 10Java SE 11 oder 17+Kostenlos, Open SourceKostenlosMittel – beliebt zum Lernen und für kleinere Deployments
Payara Server / GlassFishPayara Services / Eclipse FoundationPayara 6 / GlassFish 7Jakarta EE 10Java SE 11, 17 oder 21Community kostenlos, Enterprise-Support optionalKostenlos; Support optional ab ca. 3.000–5.000 $/JahrNische, oft ehemalige GlassFish-Nutzer
Apache TomcatApache Software Foundation10.1.x (aktiv); 11.0.x (neu, Jakarta EE 11); 9.0.x (Legacy, EOL 2026)Jakarta EE 10 (10.1.x) / Jakarta EE 11 (11.0.x) / Java EE 8 (9.0.x)Java SE 11+ (10.1.x); Java SE 17+ (11.0.x); Java SE 8+ (9.0.x)Kostenlos, Open SourceKostenlosSehr hoch – Standard-Unterbau u. a. für Spring Boot
Eclipse JettyEclipse Foundation12.0.x (aktiv); 11.0.x (Jakarta EE 9, ältere Basis)Jakarta EE 8/9/10/11 (modular, je nach Jetty-12-Environment)Java SE 17+ (Jetty 12); Java SE 11+ (Jetty 11)Kostenlos, Open SourceKostenlosHoch als eingebetteter Server in Microservices
UndertowRed Hat2.3.x (Standard-Embedded-Server in Spring Boot 3)Servlet 6.0 / Jakarta-EE-kompatibelJava SE 17+ (2.3.x); Java SE 8+ (ältere 2.2.x-Linie)Kostenlos, Open SourceKostenlosHoch als eingebetteter Server in Spring Boot & Microservices

Versionsangaben und Preise sind grobe Richtwerte (Stand ca. 2025/26); Fix-Packs, Support-Zeiträume und reale Lizenzangebote ändern sich laufend und hängen stark von Rabatten, Core-Zahl und Vertragslaufzeit ab. PROCUREX selbst erzwingt per Maven-Enforcer-Regel eine Java 17-Baseline und nutzt Spring Boot 3.3.4 mit eingebettetem Tomcat 10.1.x (Jakarta EE 10) – siehe pom.xml, Regel enforce-java-17-baseline.

Support-Status & Java-Empfehlung

Neben der reinen Versionsnummer entscheidet vor allem der Support-Zeitraum und die empfohlene (nicht nur minimal unterstützte) Java-Version, ob ein Server für ein Neuprojekt noch infrage kommt. Diese Tabelle fasst das pro Server zusammen.

ServerAktuelle VersionEmpfohlene Java-Version(Standard-)Support-EndeWichtiger Hinweis
IBM WebSphere (traditional) 9.0.5.x9.0.5.xJava 11Standard-Support ausgelaufen; erweiterter Support bis ca. 2030 gegen AufpreisKein neuer Major seit 2016 – IBM treibt Migration auf WebSphere Liberty aktiv voran
IBM WebSphere LibertyRolling (z. B. 24.0.0.x/25.0.0.x)Java 17 oder 21Kein fixes EOL, aber nur die jeweils aktuelle CD-Version wird unterstütztContinuous-Delivery-Modell wie Spring Boot; Open Liberty als kostenloser Zwilling
Oracle WebLogic Server 12.2.1.412.2.1.4Java 11Premier Support bereits ausgelaufen; erweiterter Support bis ca. Ende 2026De-facto-Auslaufmodell; Migration auf 14.1.2 oder Weg von WebLogic empfohlen
Oracle WebLogic Server 14.1.214.1.2Java 17 oder 21Premier Support bis ca. 2032Erste WebLogic-Linie mit Jakarta-EE-10-Unterstützung (Namespace-Wechsel javax→jakarta)
Red Hat JBoss EAP 8.08.0Java 17Volle Unterstützung bis ca. 2028, Maintenance bis ca. 2031Erste EAP-Generation mit Jakarta EE 10; deutlicher Sprung ggü. EAP 7.4
Red Hat JBoss EAP 7.47.4Java 11Bereits in Maintenance-Phase, ausgelaufen ca. 2025–2027Letzte EAP-Generation auf javax.*-Namespace; Ablösung durch EAP 8 empfohlen
WildFlyca. 34.xJava 17 oder neuerKein formales EOL; nur jeweils neueste Version wird von der Community gepflegtUpstream-Projekt von JBoss EAP – neue Features erscheinen hier zuerst
Payara Server / GlassFishPayara 6 / GlassFish 7Java 17 oder 21Community: kein festes EOL; Payara Enterprise ca. 5 Jahre pro MajorGlassFish ist die Jakarta-EE-Referenzimplementierung, Payara der produktionsnahe Fork
Apache Tomcat 10.1.x10.1.xJava 17Kein festes EOL, aktiv gepflegt (Stand 2025/26)Erste Tomcat-Linie auf Jakarta-Namespace (jakarta.servlet.*)
Apache Tomcat 9.0.x9.0.xJava 11EOL mehrfach verschoben, aktuell ca. 2027 angesetztLetzte Linie auf javax.servlet.*; für Alt-Anwendungen ohne Jakarta-Migration
Eclipse Jetty 1212.0.xJava 17Aktiv gepflegt, kein festes EOL bekanntModulares „Environments"-Konzept erlaubt Jakarta EE 8/9/10/11 nebeneinander
Undertow2.3.xJava 17Folgt dem Support-Zyklus von WildFly/JBoss EAPStandard-Embedded-Server, wenn spring-boot-starter-undertow statt Tomcat gewählt wird

Support-Enddaten sind Näherungswerte (Stand ca. 2025/26) und variieren je nach Support-Vertrag (Premier/Extended/Sustaining); für verbindliche Daten zählt immer die jeweilige Herstellermatrix. PROCUREX nutzt aktuell Java 17 – kompatibel mit allen hier gelisteten aktuellen Serverversionen, nicht aber mit den älteren javax.*-Linien (WebSphere traditional, WebLogic 12.2.1.4, EAP 7.4, Tomcat 9.0.x).

Wichtige Versionssprünge

Bei allen Servern gab es einen Bruch, der weit mehr war als eine normale Minor-Version: den Wechsel von Java EE (Namespace javax.*) zu Jakarta EE (Namespace jakarta.*), nachdem Oracle die Java-EE-Marke 2019 nicht an die Eclipse Foundation weitergeben wollte. Jede Anwendung, die von einer alten auf eine neue Serverversion wechselt, muss deshalb javax.servlet.*, javax.persistence.* usw. per Suchen-und-Ersetzen auf jakarta.* umstellen – ein mechanischer, aber projektweiter Eingriff.

ServerVersionssprungWas sich geändert hatWarum er wichtig ist
Alle Jakarta-EE-Server (übergreifend)Java EE 8 → Jakarta EE 9 (2020)Reiner Namespace-Wechsel javax.*jakarta.*, keine neuen FeaturesGrößter Breaking Change der Plattformgeschichte; jede Anwendung braucht Code-Anpassung oder ein Migrationstool (z. B. Eclipse Transformer)
Apache Tomcat9.0.x → 10.0.x (2021) → 10.1.x (2022)10.0 vollzieht den jakarta.*-Wechsel, 10.1 hebt auf Jakarta EE 10 / Servlet 6.0 anWer von Tomcat 9 kommt, muss Anwendungscode UND Third-Party-Libraries (z. B. JSP-Taglibs) aktualisieren
Red Hat JBoss EAPEAP 7.4 → EAP 8.0 (2023)Jakarta EE 10, Java-17-Baseline, MicroProfile 6, neue Management-CLI-FeaturesErster EAP-Major seit 2017; markiert das Ende der javax-Ära bei Red Hat
WildFlyWildFly 26 → WildFly 27 (2022)Vollständiger Umstieg auf Jakarta EE 10 / jakarta.*WildFly ist Upstream von JBoss EAP – der Sprung hier kündigt EAP 8 voraus
Oracle WebLogic Server12.2.1.4 → 14.1.2 (2024)Erstmals Jakarta EE 10 statt Java EE 8, zusätzlich Java-17/21-Support und schlankere Container-ImagesZehn Jahre ohne echten Major-Sprung (12c-Linie seit 2014) – 14.1.2 ist Oracles verspäteter Anschluss an Jakarta EE
IBM WebSphereWebSphere traditional 9.0 → WebSphere LibertyKein Versions-, sondern ein Architektursprung: vom monolithischen Vollserver zum modularen, cloud-nativen Leichtserver mit Jakarta-EE-10-UnterstützungIBM investiert neue Features fast ausschließlich in Liberty; traditional gilt intern als Legacy-Pfad
GlassFishGlassFish 5 → GlassFish 6/7Projekt wechselt 2019 von Oracle zur Eclipse Foundation, GlassFish 6 bringt Jakarta EE 9, GlassFish 7 Jakarta EE 10Als Jakarta-EE-Referenzimplementierung markiert dieser Sprung offiziell das Ende von „Java EE" als Markenname
Eclipse JettyJetty 11 → Jetty 12 (2023)Neues „Environments"-Konzept: Jakarta EE 8, 9, 10 und 11 laufen parallel im selben Server, Java-17-BaselineLöst das javax/jakarta-Dilemma dauerhaft, indem beide Welten gleichzeitig unterstützt werden
UndertowUndertow 2.2.x → 2.3.xAnhebung auf Servlet 6.0 / Jakarta EE 10, Java-17-Baseline passend zu Spring Boot 3Wer Spring Boot 2 → 3 migriert und Undertow statt Tomcat nutzt, ist von diesem Sprung direkt betroffen

PROCUREX ist von der javax→jakarta-Umstellung nicht betroffen, weil es von Anfang an auf Spring Boot 3 / Tomcat 10.1.x (bereits jakarta.*) aufgesetzt wurde – ein Vorteil, den nur Neuprojekte ohne Altlast-Migration haben.

Klassische Java-EE-Anwendungsserver

WebSphere, WebLogic und JBoss EAP entstammen einer Zeit, in der mehrere Anwendungen (WAR-Dateien) gemeinsam auf einem zentral verwalteten, langlaufenden Server-Prozess deployt wurden. Dafür bringen sie eingebaute Funktionen wie verteilte Transaktionen (JTA), Nachrichtenwarteschlangen (JMS), Clustering, Failover und zentrales Administrationstooling mit – Funktionen, die früher aufwendig selbst gebaut werden mussten. Der Preis dafür sind hohe Lizenzkosten, träge Release-Zyklen und ein Betriebsmodell, das schlecht zu Containern passt, weil ein Server-Prozess viele Anwendungen gleichzeitig trägt.

Leichte Servlet-Container und eingebettete Server

Tomcat, Jetty und Undertow sind selbst keine vollständigen Jakarta-EE-Server, sondern schlanke HTTP-/Servlet-Engines. Frameworks wie Spring Boot betten einen dieser Container direkt in die Anwendung ein: Aus einem mvn package entsteht ein ausführbares Fat-JAR, das per java -jar app.jar startet – ohne separate Serverinstallation, ohne WAR-Deployment auf einen extern verwalteten Anwendungsserver. Genau dieses Modell nutzt PROCUREX in allen Fachmodulen.

Warum PROCUREX keinen klassischen Application Server braucht

Im Microservice-Zuschnitt von PROCUREX bekommt jeder Bounded Context (Katalog, Bestellung, Genehmigung, Rechnung, …) einen eigenen, kleinen Prozess statt eines gemeinsam genutzten Servers. Ein eingebetteter Tomcat pro Container passt zu diesem „ein Prozess pro Container“-Muster aus Kubernetes und Helm deutlich besser als ein zentraler WebSphere- oder WebLogic-Cluster, auf dem mehrere Anwendungen gemeinsam liefen. Zusätzlich entfällt die kommerzielle Lizenzierung vollständig, was für ein Lernprojekt ohne Enterprise-Support-Vertrag ohnehin die einzig praktikable Wahl ist.

Einordnung für PROCUREX: Klassische Application Server wie WebSphere oder WebLogic sind in bestehenden Großkonzern-Landschaften weiterhin verbreitet, weil dort Migrationen teuer und riskant sind – für Neuentwicklungen setzen aber auch viele Unternehmen zunehmend auf eingebettete, containerisierte Server wie in Spring Boot. PROCUREX folgt bewusst diesem moderneren, cloud-nativen Muster.
Empfehlung:
  • Für neue Microservices/APIs einen eingebetteten Servlet-Container (Tomcat, Jetty, Undertow) über Spring Boot oder ein vergleichbares Framework wählen – kein separates Server-Deployment, kein Lizenzaufwand.
  • WebSphere oder WebLogic nur dann neu einführen, wenn bereits Lizenzen, Betriebs-Know-how und Governance-Prozesse dafür existieren – für ein Greenfield-Projekt ohne diese Altlast ist der Lizenz- und Betriebsaufwand kaum zu rechtfertigen.
  • JBoss EAP erwägen, wenn ein voller Jakarta-EE-Server mit kommerziellem Support gewünscht ist, die IBM-/Oracle-Preisklasse aber vermieden werden soll.
  • WildFly oder Open Liberty zum Lernen der Jakarta-EE-Vollprofil-Welt nutzen – kostenlos, aktuell, ohne Kaufentscheidung.
  • Bei jeder Migration zwischen Serverversionen zuerst prüfen, ob ein javax→jakarta-Namespace-Wechsel ansteht (siehe Versionssprünge oben) – das ist der eigentliche Aufwandstreiber, nicht die Versionsnummer selbst.
Wichtig zu merken:
  • javax→jakarta ist ein reiner Namespace-Wechsel ohne neue Features, aber der größte Breaking Change der Plattformgeschichte – er betrifft praktisch jeden hier gelisteten Server.
  • Tomcat ist kein vollständiger Jakarta-EE-Server, sondern nur ein Servlet-Container – kein eingebautes JTA, kein JMS. Genau deshalb passt es zu Spring Boot, das diese Funktionen selbst mitbringt.
  • PROCUREX erzwingt Java 17 als Baseline (Maven-Enforcer-Regel) – WebSphere traditional, WebLogic 12.2.1.4, JBoss EAP 7.4 und Tomcat 9.0.x unterstützen das nicht und wären damit für PROCUREX gar nicht einsetzbar.
Exkurs · Suchtechnologie

Elasticsearch & Volltextsuche im Vergleich

Elasticsearch, OpenSearch, Solr und PostgreSQL-Volltextsuche im Vergleich – inklusive des SSPL-Lizenzstreits.

Die Katalogsuche von PROCUREX läuft heute über eine einfache LIKE-Abfrage direkt in PostgreSQL (CatalogItemRepository.search()). Dieser Exkurs zeigt, was Elasticsearch & Co. zusätzlich bieten, warum diese Tools JVM-lastig und lizenzrechtlich bewegt sind – und ab wann sich ihr Einsatz für PROCUREX lohnen würde.

ElasticsearchVerteilte Such- und Analyse-Engine auf Basis von Apache Lucene, Kernprodukt des „Elastic Stack" (ELK).
OpenSearch2021 von AWS abgespaltener, komplett quelloffener Fork von Elasticsearch 7.10 als Reaktion auf Elastics Lizenzwechsel.
Apache SolrÄlterer, ebenfalls Lucene-basierter Suchserver mit eigenem Ökosystem (SolrCloud, Zookeeper-Koordination).
PostgreSQL-VolltextsucheIn PostgreSQL eingebaute tsvector/tsquery-Suche plus pg_trgm-Erweiterung für Fuzzy-Matching – kein separater Server nötig.

Direktvergleich

TechnologieProjekt/HerstellerAktuelle VersionLaufzeitEmpfohlene Java-VersionLizenzmodell
ElasticsearchElastic NV9.x (Hauptlinie seit 2025); 8.x weiterhin gepflegtJVM (Lucene-basiert), bringt eigenes Bundled-JDK mitJava 21 (im Server-Distributionspaket enthalten)Elastic License 2.0 / SSPL, seit 2024/8.16+ zusätzlich AGPL v3 als Open-Source-Option
OpenSearchAWS / OpenSearch Software Foundation2.x (Hauptlinie); 3.x als neueste Linie (seit 2025)JVM (Lucene-basiert), bringt eigenes Bundled-JDK mitJava 21 (im Server-Distributionspaket enthalten)Apache 2.0 (durchgehend quelloffen)
Apache SolrApache Software Foundation9.xJVM (Lucene-basiert)Java 11 minimal, Java 17/21 empfohlenApache 2.0
PostgreSQL-VolltextsuchePostgreSQL Global Development GroupFester Bestandteil seit PostgreSQL 8.3; PROCUREX nutzt PostgreSQL 16/17Kein separater Server – Feature der ohnehin genutzten DatenbankNicht zutreffend (in C implementiert)PostgreSQL-Lizenz (sehr permissiv)

Versionsangaben sind Richtwerte (Stand ca. 2025/26) und ändern sich mit jedem Major-Release. Elasticsearch und OpenSearch bündeln jeweils eine eigene JDK-Runtime im Serverpaket, sodass die Java-Version des restlichen Projekts (bei PROCUREX: Java 17) unabhängig davon ist.

Der Lizenz-Bruch bei Elasticsearch

Elasticsearch stand bis Version 7.10 (2021) unter der freizügigen Apache-2.0-Lizenz. Aus Sorge, dass Cloud-Anbieter wie AWS gehostete Elasticsearch-Dienste anbieten, ohne zum Projekt beizutragen, wechselte Elastic NV zur Server Side Public License (SSPL) bzw. zur proprietären Elastic License. AWS reagierte darauf mit einem eigenständigen Fork unter Apache 2.0: OpenSearch. Erst 2024 (ab Version 8.16 bzw. der 9.x-Linie) machte Elastic einen Teil-Rückzieher und bietet seither zusätzlich eine AGPL-v3-Variante als Open-Source-Option an. Wer heute zwischen Elasticsearch und OpenSearch wählt, wählt damit auch zwischen zwei Lizenzphilosophien und zwei seit 2021 getrennten Entwicklungslinien.

Warum PROCUREX (aktuell) kein Elasticsearch braucht

Ein Suchcluster wie Elasticsearch oder OpenSearch lohnt sich vor allem bei sehr großen, häufig wachsenden Datenmengen, Relevanz-Ranking, Facettensuche (Filter nach Kategorie, Preis, Lieferant gleichzeitig) oder Volltextsuche über mehrere Sprachen und Tippfehler hinweg. Der PROCUREX-Katalog ist dafür aktuell zu klein: Eine LIKE-Abfrage über Name und SKU in PostgreSQL (procurex-catalog/.../CatalogItemRepository.java) reicht für die Datenmengen eines Lernprojekts völlig aus und spart einen zusätzlichen, separat zu betreibenden Cluster samt eigenem Speicher- und Update-Management. Der nächste sinnvolle Schritt wäre nicht gleich Elasticsearch, sondern zunächst pg_trgm für unscharfe Suche direkt in PostgreSQL – erst bei echtem Skalierungsbedarf würde ein dedizierter Suchserver Sinn ergeben.

Einordnung für PROCUREX: Elasticsearch/OpenSearch sind Standardwerkzeuge für Suche & Log-Analyse in großen Systemen, bringen aber Betriebsaufwand (Cluster, JVM-Tuning, Index-Pflege) mit, der für den PROCUREX-Katalogumfang nicht gerechtfertigt ist. PROCUREX bleibt bewusst bei der einfachsten Lösung, die die ohnehin vorhandene PostgreSQL-Datenbank mitbringt.
Empfehlung:
  • Mit einer LIKE-/ILIKE-Abfrage in der ohnehin vorhandenen relationalen Datenbank starten, solange Datenmenge und Anforderungen klein sind – kein zusätzlicher Betriebsaufwand.
  • pg_trgm (PostgreSQL) als nächsten Schritt einplanen, sobald Tippfehler-Toleranz oder Ähnlichkeitssuche gefordert ist, aber noch kein Relevanz-Ranking oder Facettenfilter über mehrere Felder gleichzeitig nötig ist.
  • Erst zu Elasticsearch/OpenSearch wechseln, wenn mehrere dieser Kriterien gleichzeitig zutreffen: große/schnell wachsende Datenmenge, Relevanz-Ranking, Facettensuche, mehrsprachige Volltextsuche.
  • Zwischen Elasticsearch und OpenSearch anhand der Lizenz entscheiden: Apache 2.0 zwingend (z. B. wegen Weiterverteilung) → OpenSearch; Elastic-spezifische Zusatzfeatures wichtiger als Lizenzfreiheit → Elasticsearch.
Wichtig zu merken:
  • Elasticsearch ist seit 2021 nicht mehr durchgängig Open Source (SSPL/Elastic License) – OpenSearch ist der von AWS abgespaltene, weiterhin komplett Apache-2.0-lizenzierte Fork.
  • PROCUREX setzt aktuell gar kein Elasticsearch/OpenSearch ein – die Katalogsuche läuft über eine einfache LIKE-Query direkt in PostgreSQL.
  • pg_trgm ist der pragmatische Zwischenschritt für Tippfehler-Toleranz, bevor ein ganzer Suchcluster überhaupt in Betracht gezogen werden sollte.
Exkurs · Datenbanken

PostgreSQL, MySQL, Oracle & Co. im Vergleich

PostgreSQL, MySQL, Oracle & Co. sowie der NoSQL-Kontrast mit MongoDB/Redis und deren Lizenzdramen.

PROCUREX speichert alle Fachdaten in einer gemeinsam genutzten PostgreSQL-Instanz (shared-postgres, Datenbank procurex_db); Schemamigrationen laufen pro Modul über Flyway (flyway-database-postgresql, siehe z. B. procurex-catalog/pom.xml), und Integrationstests starten testweise postgres:16-alpine per Testcontainers. Dieser Exkurs zeigt, wie PostgreSQL im Vergleich zu anderen relationalen Datenbanken und zu NoSQL-Alternativen einzuordnen ist.

PostgreSQLQuelloffenes, objektrelationales Datenbanksystem mit sehr permissiver Lizenz und breiter Erweiterbarkeit.
MySQL / MariaDBWeitverbreitete relationale Datenbank (Oracle) und ihr quelloffener Fork MariaDB, entstanden nach der Sun-Übernahme durch Oracle.
Oracle Database / SQL ServerKommerzielle Enterprise-Datenbanken mit umfangreichem, aber kostenintensivem Funktionsumfang.
NoSQL (MongoDB, Redis)Dokumenten- bzw. Key-Value-Datenbanken für Anwendungsfälle, in denen starre relationale Schemata nicht passen.

Direktvergleich: relationale Datenbanken

DatenbankHersteller/ProjektAktuelle VersionJava-Anbindung (JDBC)LizenzmodellKosten (ca.)
PostgreSQLPostgreSQL Global Development Group17.x (aktuelle Hauptlinie); 16.x weiterhin unterstützt (PROCUREX nutzt 16 in Tests)org.postgresql:postgresql-TreiberPostgreSQL-Lizenz (sehr permissiv, MIT-/BSD-ähnlich)Kostenlos; Kosten entstehen höchstens durch verwaltete Cloud-Dienste (RDS, Cloud SQL)
MySQLOracle8.4 LTS bzw. 9.x als „Innovation Release“mysql-connector-jGPL v2 (Community) oder kommerziell (Enterprise Edition)Community kostenlos; Enterprise-Support ab ca. 2.000–5.000 $/Jahr
MariaDBMariaDB Foundation11.xmariadb-java-client (auch mysql-connector-j kompatibel)GPL v2, durchgehend quelloffenKostenlos; Enterprise-Support bei MariaDB plc optional
Oracle DatabaseOracle23ai (aktuelle Linie seit 2024)ojdbc-TreiberKommerziell, pro Prozessor/NUP; Express Edition kostenlos, aber stark limitiertOft fünf- bis sechsstellig pro Jahr in Enterprise-Umgebungen
Microsoft SQL ServerMicrosoftSQL Server 2022mssql-jdbcKommerziell, pro Core oder Server+CAL; Express Edition kostenlos, limitiertStandard ca. 3.700 $/Core, Enterprise ca. 15.000 $/Core

Versionsangaben und Preise sind Richtwerte (Stand ca. 2025/26) und ändern sich mit jedem Release bzw. Lizenzmodell des Anbieters.

NoSQL-Kontrast: wenn ein starres Schema nicht passt

Relationale Datenbanken sind nicht immer die beste Wahl. MongoDB speichert Daten als flexible JSON-ähnliche Dokumente ohne festes Schema und eignet sich für stark variierende oder tief verschachtelte Datenstrukturen. Redis ist eine In-Memory-Key-Value-Datenbank, die vor allem als Cache, Session-Store oder für Warteschlangen eingesetzt wird – nicht als primärer Datenspeicher für transaktionale Geschäftsdaten. Beide ergänzen relationale Datenbanken typischerweise, statt sie zu ersetzen.

TechnologieTypAktuelle VersionJava-AnbindungLizenzmodell
MongoDBDokumentenorientierte NoSQL-Datenbank8.xmongodb-driver-sync / Spring Data MongoDBSSPL (seit 2018, nicht OSI-anerkannt als Open Source)
RedisIn-Memory Key-Value-Store / Cache7.x / 8.xLettuce oder Jedis als Java-ClientSeit 2024 überwiegend RSALv2/SSPL statt BSD; BSD-Fork Valkey (Linux Foundation) als quelloffene Alternative

Lizenz-Dramen sind kein Einzelfall

Der Elasticsearch-Lizenzwechsel aus dem Suchtechnologie-Exkurs steht nicht allein: Auch MongoDB wechselte 2018 von AGPL zur selbst entworfenen SSPL, um Cloud-Anbieter am kostenlosen Weiterverkauf als Managed Service zu hindern – die SSPL gilt bis heute nicht als Open-Source-Lizenz im Sinne der Open Source Initiative. Redis vollzog 2024 einen ganz ähnlichen Schritt weg von der freizügigen BSD-Lizenz, woraufhin die Linux Foundation den bis dahin letzten BSD-Stand als Valkey weiterführte – genau das Muster, das bei Elasticsearch zu OpenSearch führte. Cloud-Hyperscaler als kostenlose Trittbrettfahrer sind der wiederkehrende Auslöser für diese Lizenzwechsel in der gesamten Datenbank- und Such-Branche.

Warum PROCUREX auf PostgreSQL setzt

Bestellungen, Rechnungen und Genehmigungen sind klassisch relationale, stark konsistenzkritische Geschäftsdaten mit festen Beziehungen (Bestellung → Positionen → Lieferant, Rechnung → Bestellung) – ein Anwendungsfall, für den ACID-Transaktionen und feste Schemata (per Flyway versioniert) besser passen als ein schemaloser Dokumentenspeicher. PostgreSQL bietet dafür ausgereifte Transaktionsgarantien, ist vollständig kostenlos und quelloffen unter einer sehr permissiven Lizenz (kein SSPL-/Elastic-License-Risiko), und Spring Data JPA/Hibernate unterstützen es als Referenzdatenbank erstklassig. Ein Cache wie Redis oder ein Dokumentenspeicher wie MongoDB wären ergänzende, aber keine ersetzenden Bausteine – und sind für den aktuellen PROCUREX-Umfang nicht notwendig.

Einordnung für PROCUREX: PostgreSQL passt zum transaktionalen, stark strukturierten Charakter von Beschaffungsdaten und vermeidet gleichzeitig die Lizenzunsicherheiten, die MongoDB, Redis und Elasticsearch in den letzten Jahren durchlaufen haben. Für ein Lernprojekt ohne Enterprise-Support-Vertrag ist die kostenlose, permissiv lizenzierte Lösung ohnehin die einzig praktikable Wahl.
Empfehlung:
  • PostgreSQL als Standardwahl für neue relationale Projekte ansetzen, sofern kein zwingender Grund dagegenspricht – permissive Lizenz, ausgereiftes Transaktionsmodell, breite Tool-Unterstützung.
  • MySQL/MariaDB nur wählen, wenn das Zielhosting oder bestehende Betriebs-Tooling bereits darauf ausgelegt ist – technisch meist austauschbar mit PostgreSQL, der Ausschlag kommt selten aus der Datenbank selbst.
  • Oracle Database oder SQL Server nur einsetzen, wenn sie im Unternehmen ohnehin lizenziert und betrieben werden – als Neuanschaffung für ein Projekt ohne bestehenden Vertrag sind die Lizenzkosten kaum zu rechtfertigen.
  • MongoDB nur wählen, wenn die Daten wirklich schemalos/stark verschachtelt sind – für Daten mit festen Beziehungen (wie Bestellungen/Rechnungen) ist eine relationale Datenbank die robustere Wahl.
  • Redis (oder den lizenzfreien Fork Valkey) als ergänzenden Cache einplanen, sobald Lesezugriffe auf stabile, selten wechselnde Daten zum Flaschenhals werden – nicht als Ersatz für den transaktionalen Primärspeicher.
Wichtig zu merken:
  • MongoDB (2018) und Redis (2024) haben denselben SSPL-Lizenzwechsel durchlaufen wie Elasticsearch (2021) – kein Einzelfall, sondern ein wiederkehrendes Muster gegen kostenlose Cloud-Weiterverkäufer.
  • Valkey (Linux Foundation) ist zu Redis das, was OpenSearch zu Elasticsearch ist: der letzte BSD-lizenzierte Stand als eigenständiger Fork.
  • PROCUREX läuft auf einer einzigen gemeinsamen PostgreSQL-Instanz (shared-postgres, Datenbank procurex_db) – keine eigene Datenbank pro Fachmodul.
Exkurs · Architektur & Entwurfsmuster

Java-/EE-Entwurfsmuster: von J2EE-Core-Patterns zu PROCUREX' hexagonaler Architektur

J2EE-Core-Patterns und alle 23 GoF-Muster gegen echten PROCUREX-Code geprüft, inklusive Architekturdiagramm.

Suns Katalog „Core J2EE Patterns“ (Alur/Crupi/Malks, 2001) hat für EJB-basierte Anwendungen Muster wie Data Access Object, Session Facade, Business Delegate oder Service Locator geprägt. PROCUREX verwendet keine EJBs mehr, folgt aber inhaltlich denselben Entwurfsproblemen – nur mit modernen Werkzeugen gelöst. Die Paketstruktur jedes Fachmoduls (domain, application, port, adapter/in, adapter/out, siehe z. B. procurex-catalog/src/main/java/com/procurex/catalog/) setzt bewusst eine hexagonale Architektur (Ports & Adapters) um. Darunter liegt eine noch ältere Schicht: die 23 „Gang of Four“ (GoF)-Entwurfsmuster, aus denen sich viele J2EE-Patterns erst zusammensetzen – dieser Exkurs prüft am Ende alle 23 einzeln gegen den PROCUREX-Code.

Hexagonale ArchitekturDie Domäne steht im Zentrum, umgeben von Ports (Schnittstellen) und Adaptern (technische Anbindung nach außen).
Dependency InjectionSpring injiziert Implementierungen über den Konstruktor statt sie per Service Locator/JNDI manuell nachzuschlagen.
Repository statt DAOSpring Data JPA generiert die Datenzugriffsschicht aus Interface-Signaturen – kein handgeschriebenes DAO-Boilerplate mehr.
DTO an ModulgrenzenJava Records mit statischer Factory-Methode transportieren Daten über REST- und Modulgrenzen, ohne Domain-Objekte preiszugeben.

Klassische J2EE-Muster und ihre moderne Entsprechung

J2EE-Core-Pattern (2001)Ursprünglicher ZweckModerne EntsprechungBeispiel in PROCUREX
Data Access Object (DAO)Kapselt SQL/Persistenz hinter einer SchnittstelleSpring-Data-Repository-InterfaceCatalogItemRepository (adapter/out)
Transfer Object / Value ObjectDatenbündel ohne Verhalten für den Transport über SchichtgrenzenJava Record als DTO, oft mit statischer from(...)-FactoryCatalogItemResponse, CreateCatalogItemRequest, CatalogItemSummary
Business DelegateVersteckt den komplexen EJB-Lookup vor dem ClientDirekt injiziertes Port-InterfaceCatalogLookupPort, konsumiert in CartSubmissionService (Modul procurex-requisition)
Session FacadeGrobkörnige Fassade über mehrere feingranulare EJBsApplication-Service als Fassade über Domäne und RepositoryCatalogService (application)
Service LocatorZentrale Registry zum manuellen Auflösen von Abhängigkeiten (JNDI)Dependency Injection über den Spring-ContainerKonstruktor-Injection in allen @Service-/@RestController-Klassen
Front ControllerEin zentraler Servlet-Einstiegspunkt für alle RequestsDispatcherServlet (Spring MVC) + ein @RestController pro RessourceCatalogController (adapter/in)
Intercepting FilterVor-/nachgelagerte Querschnittslogik (Auth, Logging) im Request-PfadServlet-/Security-Filterchain, @ControllerAdviceKeycloak-OIDC-Filterchain in procurex-app
Composite Entity / Value List HandlerEJB-2.x-Workarounds gegen teure Entity-Bean-ZugriffeEntfällt strukturellNicht nötig – JPA-Entities und paginierte Repository-Queries lösen das Problem direkt

Die rechte Spalte verweist auf real existierenden PROCUREX-Code (Stand dieses Exkurses); Klassennamen können sich mit künftigen Refactorings ändern.

Hexagonale Architektur in PROCUREX

Am Modul procurex-catalog lässt sich der Aufbau direkt ablesen: ein REST-Adapter nimmt Anfragen entgegen, ein Application-Service kapselt die Fachlogik und implementiert dabei einen Port, den andere Module direkt als Spring-Bean injizieren – ganz ohne REST-Aufruf zwischen den Modulen.

procurex-catalog procurex-requisition CatalogController adapter/in · REST CatalogService application · implements CatalogLookupPort nutzt Domain-Objekt CatalogItem CatalogItemRepository adapter/out · Spring Data JPA CartSubmissionService application · hält CatalogLookupPort-Referenz per Konstruktor injiziert PostgreSQL JDBC Dependency Injection — kein REST-Call, kein Service Locator

Innerhalb eines Moduls fließen Aufrufe von außen nach innen (Adapter → Application → Domäne) und wieder hinaus (Application → Adapter → Datenbank). Zwischen Modulen läuft die Kommunikation nicht über HTTP, sondern über ein gemeinsames Port-Interface, das der Spring-Container zur Laufzeit auflöst.

Warum Dependency Injection den Service Locator abgelöst hat

Im klassischen Service-Locator-Muster fragt der Client zur Laufzeit aktiv bei einer zentralen Registry (typischerweise über JNDI) nach einer Implementierung – der Aufruf ist im Code unsichtbar und lässt sich nur schwer durch ein Test-Double ersetzen. Dependency Injection dreht das um: Der Spring-Container reicht die passende Implementierung beim Erzeugen eines Objekts direkt in den Konstruktor. Das macht Abhängigkeiten explizit im Code sichtbar, erlaubt in Tests das einfache Einsetzen von Mocks (z. B. in CartSubmissionServiceTest) und verhindert verstreute, schwer auffindbare JNDI-Lookups quer durch die Anwendung.

Die 23 GoF-Muster: welche PROCUREX tatsächlich nutzt

Die J2EE-Core-Patterns von oben sind Enterprise-spezifische Kombinationen einer viel älteren, allgemeineren Quelle: dem 1994 erschienenen Katalog „Design Patterns: Elements of Reusable Object-Oriented Software“ von Gamma, Helm, Johnson und Vlissides – kurz „Gang of Four“ (GoF). Business Delegate etwa ist im Kern eine Kombination aus Proxy und Facade; Session Facade ist schlicht Facade auf EJB-Ebene. Die folgenden drei Tabellen ordnen alle 23 GoF-Muster ihren drei Kategorien zu und prüfen ehrlich, wo PROCUREX sie tatsächlich einsetzt, wo ein Framework sie unsichtbar für den Entwickler übernimmt – und wo bewusst darauf verzichtet wird, weil kein passendes Problem existiert.

Erzeugungsmuster (Creational)

MusterZweckIm PROCUREX-Code?Beleg/Begründung
SingletonGenau eine Instanz einer Klasse global zugänglich machenNicht handgeschriebenÜberflüssig – Spring verwaltet jeden @Service/@Component standardmäßig im Singleton-Scope des IoC-Containers
Factory MethodObjekterzeugung hinter einer Methode statt einem Konstruktor versteckenJaCatalogItemResponse.from(item), Money.of(amount, currency) als statische Factory-Methoden
Abstract FactoryGanze Familien zueinander passender Objekte erzeugenNicht genutztKein Fall mit mehreren austauschbaren Produktfamilien im Domänenmodell
BuilderKomplexe Objekte schrittweise aus vielen (oft optionalen) Teilen zusammensetzenNicht im PROCUREX-CodeJava Records mit wenigen Pflichtfeldern (z. B. CreateCatalogItemRequest) machen einen Builder unnötig; relevant erst bei sehr vielen optionalen Feldern
PrototypeNeue Objekte durch Klonen eines Prototyps statt Neuerzeugung gewinnenNicht genutztFür Domänenobjekte mit Datenbank-Identität (UUID, Flyway-Schema) unüblich und unpassend

Strukturmuster (Structural)

MusterZweckIm PROCUREX-Code?Beleg/Begründung
AdapterEine vorhandene Schnittstelle an eine erwartete anpassenJa, zentralDie Pakete adapter/in und adapter/out sind wörtlich dieser Gedanke: REST bzw. JPA werden an die Port-/Domänenschnittstelle angepasst (siehe Diagramm oben)
FacadeVereinfachten Zugang zu einem komplexeren Subsystem bietenJaCatalogService bündelt Repository-Zugriff und Domänenlogik hinter wenigen öffentlichen Methoden
BridgeAbstraktion und Implementierung unabhängig voneinander variieren lassenKonzeptionell jaEin Port (Abstraktion, z. B. CatalogLookupPort) ist von seiner Adapter-Implementierung entkoppelt – derselbe Trennungsgedanke wie bei Bridge, nur unter dem Namen „Ports & Adapters“
DecoratorVerhalten zur Laufzeit um ein Objekt herum ergänzen, ohne die Klasse zu ändernIndirekt, über das FrameworkSpring-AOP-Proxies, die @Transactional auf CatalogService umsetzen, sind strukturell Decorator/Proxy – deklarativ per Annotation erzeugt, nicht handgeschrieben
ProxyStellvertreter mit Zugriffskontrolle, Lazy Loading oder Remote-AufrufIndirektHibernate-Lazy-Loading-Proxies für JPA-Assoziationen sowie die Spring-Security-Interceptorkette vor jedem Controller
CompositeTeil-Ganzes-Baumstrukturen einheitlich behandelnNicht genutztKeine rekursiven Hierarchien im Datenmodell (Kataloge/Bestellungen sind flach modelliert)
FlyweightSpeicher sparen, indem viele kleine Objekte Zustand teilenNicht genutztKeine Objektmengen in einer Größenordnung, die geteilten Zustand rechtfertigen würde

Verhaltensmuster (Behavioral)

MusterZweckIm PROCUREX-Code?Beleg/Begründung
Template MethodAlgorithmus-Grundgerüst in einer Basisklasse festlegen, Details in Unterklassen füllenJa, explizitAbstractOutboxRelay.relay() definiert Scheduling, Kafka-Versand und Fehlerbehandlung fest; nur findUnpublished()/save() sind abstrakt und werden von ApprovalOutboxRelay, InvoicingOutboxRelay, OrderingOutboxRelay und ReceivingOutboxRelay je Modul implementiert
Chain of ResponsibilityEine Anfrage durch eine Kette potenzieller Bearbeiter reichen, bis einer zuständig istJa, über das FrameworkDie Spring-Security-Filterchain (OIDC/JWT-Prüfung vor Keycloak-Anbindung) ist ein Lehrbuchbeispiel dieses Musters
CommandEine Anfrage als eigenständiges Objekt kapselnTeilweiseDTOs wie CreateCatalogItemRequest kapseln eine Anfrage als Datenobjekt, aber ohne die volle Command-Schnittstelle (kein execute()/undo())
ObserverAbhängige Objekte automatisch über Zustandsänderungen benachrichtigenKonzeptionell, verteiltKein ApplicationEventPublisher im Code, aber das Kafka-Outbox-Publishing ist der verteilte Cousin von Observer: Module abonnieren Topics, statt Listener direkt zu registrieren
StrategyAustauschbare Algorithmen zur Laufzeit hinter einer gemeinsamen SchnittstelleNicht genutztJeder Port hat genau eine Adapter-Implementierung – Ports & Adapters zieht Architekturgrenzen, wählt aber zur Laufzeit nichts zwischen mehreren Algorithmen aus
StateZustandsabhängiges Verhalten in eigene Zustandsklassen auslagernNicht im GoF-SinnOrderStatus ist ein reines Enum ohne zustandsspezifische Methoden – für den aktuellen Umfang ausreichend, aber kein echtes State-Pattern
VisitorNeue Operationen auf einer Objektstruktur ergänzen, ohne die Klassen selbst zu ändernNicht genutztKeine heterogene Objektstruktur, die unterschiedliche „Besucher“ bräuchte
Iterator, Mediator, Memento, InterpreterKollektionsdurchlauf, Objekt-Kommunikation entkoppeln, Zustand sichern/wiederherstellen, Grammatiken interpretierenNicht genutztJava-Collections decken Iteration bereits ab; keine komplexe Objektvermittlung jenseits von Spring-DI, kein Undo-Stack, keine eigene DSL/Grammatik im Projekt

„Ja“ bedeutet handgeschriebener PROCUREX-Code; „indirekt/konzeptionell“ bedeutet, dass Spring, Hibernate oder Kafka das Muster für PROCUREX unsichtbar umsetzen. Das ist beabsichtigt: moderne Frameworks nehmen Entwicklern viele GoF-Muster als wiederkehrendes Handwerkszeug ab, statt sie jedes Mal neu zu implementieren.

Einordnung für PROCUREX: Kein einziges klassisches J2EE-Pattern wird 1:1 nachgebaut – aber die Entwurfsprobleme, die sie lösen sollten (Persistenz kapseln, Fachlogik bündeln, Abhängigkeiten auflösen, Request-Einstieg zentralisieren), sind dieselben geblieben. Dieselbe Beobachtung gilt eine Ebene tiefer für die GoF-Muster: Adapter, Facade und Template Method sind aktiver, sichtbarer PROCUREX-Code; Decorator, Proxy, Chain of Responsibility und der verteilte Observer-Gedanke stecken unsichtbar in Spring, Hibernate und Kafka; Singleton, Builder, Strategy und State wären für den aktuellen Umfang schlicht Overengineering. PROCUREX löst die eigentlichen Probleme mit Spring Data, Application Services, Konstruktor-Injection und einer bewusst hexagonalen Modulstruktur – schlanker als die EJB-Ära, aber im Kern denselben Prinzipien folgend.
Empfehlung:
  • Dependency Injection über den Framework-Container (Spring, CDI) statt handgeschriebenem Singleton oder Service Locator nutzen – bessere Testbarkeit, explizite statt versteckte Abhängigkeiten.
  • Ports & Adapters (hexagonale Architektur) ansetzen, sobald ein Modul fachliche Logik von technischer Anbindung (REST, JPA, Messaging) trennen soll – besonders wertvoll an Modulgrenzen, die künftig ausgetauscht oder eigenständig deploybar werden könnten.
  • Template Method einsetzen, wenn mehrere Klassen denselben Ablauf mit wenigen variablen Schritten wiederholen (wie die vier Outbox-Relays) – spart Duplikation, ohne ein volles Strategy-Interface einzuführen.
  • Nicht jedes GoF-Muster erzwingen: Builder, Strategy, Visitor & Co. nur einführen, wenn das konkrete Problem (viele optionale Felder, mehrere austauschbare Algorithmen, heterogene Objektstrukturen) tatsächlich auftritt – sonst ist es Overengineering, das Einsteiger eher verwirrt als hilft.
Wichtig zu merken:
  • Spring-Beans sind standardmäßig Singletons (Singleton-Scope des IoC-Containers) – ein Singleton-Pattern von Hand zu schreiben ist in Spring-Anwendungen praktisch nie nötig.
  • Kein einziges klassisches J2EE-Pattern wird in PROCUREX 1:1 nachgebaut – die Entwurfsprobleme, die sie lösen sollten, bleiben aber dieselben.
  • Von 23 GoF-Mustern nutzt PROCUREX nur eine Handvoll aktiv (v. a. Adapter, Facade, Template Method) – vollständige Musterabdeckung ist kein Qualitätsmerkmal, Passung zum tatsächlichen Problem schon.
Exkurs · Fehlerbehandlung

Exception-Best-Practices: Java, Jakarta EE und Spring im Vergleich

Checked vs. unchecked, RFC 7807 Problem Details und wie eine Exception in PROCUREX zur HTTP-Antwort wird.

PROCUREX übersetzt Fehler zentral in GlobalExceptionHandler (procurex-common/.../web/GlobalExceptionHandler.java): vier Domain-Exceptions (NotFoundException, ConflictException, DomainRuleViolationException, ForbiddenException) werden per @RestControllerAdvice auf passende HTTP-Status-Codes und ein standardisiertes ProblemDetail abgebildet, angereichert um eine Correlation-ID aus CorrelationIdFilter. Dieser Exkurs ordnet das in die allgemeine Java-Enterprise-Historie ein – von J2EE-Checked-Exceptions bis zum heutigen RFC-7807-Standard.

Checked vs. UncheckedJ2EE zwang mit throws-Klauseln zu Boilerplate; moderne Spring-/Jakarta-Anwendungen setzen fast durchgängig auf RuntimeException.
Zentrale ÜbersetzungEin Ort entscheidet, wie aus einer Exception eine HTTP-Antwort wird – statt verstreuter try/catch-Blöcke in jedem Controller.
RFC 7807 Problem DetailsEin standardisiertes JSON-Format für Fehlerantworten, das Spring seit Version 6 nativ als ProblemDetail unterstützt.
Nicht schlucken, nicht bloßstellenExceptions loggen und weiterreichen statt zu verschlucken – aber keine internen Details (Stacktraces, SQL) nach außen leaken.

Checked vs. Unchecked Exceptions: die große J2EE-Lehre

Frühe Java-EE-APIs (java.rmi.RemoteException, javax.naming.NamingException, java.sql.SQLException) waren fast alle checked – der Compiler zwang jeden Aufrufer, sie entweder zu fangen oder in der eigenen throws-Klausel weiterzureichen. In der Praxis führte das zu leeren catch-Blöcken, aufgeblähten Methodensignaturen und Exceptions, die durch fünf Schichten hindurch deklariert werden mussten, ohne dass eine davon sinnvoll reagieren konnte. Rod Johnsons Spring-Philosophie (ab „J2EE without EJB“, 2004) drehte das bewusst um: Sprachs eigene DataAccessException-Hierarchie ist komplett unchecked und wrappt SQLException intern. PROCUREX folgt exakt diesem Muster – alle vier Domain-Exceptions erben direkt von RuntimeException, nirgends im Code steht eine throws-Klausel für sie.

AspektChecked ExceptionUnchecked Exception (RuntimeException)
Compiler-ZwangMuss gefangen oder deklariert werdenOptional – Aufrufer kann ignorieren
Typischer EinsatzWiederherstellbare Zustände, die der Aufrufer aktiv behandeln kann/mussProgrammier- oder Zustandsfehler, die meist zentral statt lokal behandelt werden
Historisches BeispielSQLException, NamingException, RemoteException (klassisches Java EE/RMI)DataAccessException-Hierarchie (Spring), alle vier PROCUREX-Domain-Exceptions
PraxisproblemBoilerplate-catch-Blöcke, Signaturen „verschmutzen“ über Schichtgrenzen hinwegErfordert Disziplin: ohne zentrale Behandlung drohen unbehandelte 500er
Heutige EmpfehlungNur noch für echte, lokal behandelbare Ausnahmefälle (z. B. IOException bei Datei-Uploads)Standard für Domain- und Frameworkfehler in Spring/Jakarta-Anwendungen

Zentrale Fehlerübersetzung: Spring vs. Jakarta EE

Sowohl Spring als auch Jakarta EE (über JAX-RS) lösen dasselbe Problem – Exceptions an einer zentralen Stelle in HTTP-Antworten übersetzen –, nur mit unterschiedlicher Mechanik.

AnsatzTechnologieMechanikPROCUREX-Bezug
Spring MVC / WebFluxSpring Framework 6 / Boot 3@RestControllerAdvice-Klasse mit einer @ExceptionHandler-Methode pro Exception-Typ, Rückgabe als ProblemDetailGlobalExceptionHandler – eine Klasse für alle sieben Fachmodule, da procurex-common überall eingebunden ist
Jakarta EE / JAX-RSJakarta RESTful Web ServicesEigene Klasse implementiert ExceptionMapper<T>, markiert mit @Provider, vom Container automatisch registriertNicht genutzt – PROCUREX hat keine JAX-RS-Ressourcen, nur Spring-@RestController
Servlet-Ebene (Legacy)Servlet API<error-page>-Mapping in web.xml auf Exception-Typ oder HTTP-Status, meist auf eine JSP/HTML-FehlerseiteNicht genutzt – gehört zur klassischen WAR-Deployment-Welt aus dem Java-Anwendungsserver-Exkurs, nicht zu REST-APIs

Beide modernen Ansätze (Spring-Advice, JAX-RS-ExceptionMapper) verfolgen dieselbe Idee: Controller/Ressourcen bleiben von Fehlerbehandlungscode frei, ein zentraler Ort entscheidet über das HTTP-Antwortformat.

RFC 7807 Problem Details für HTTP APIs

RFC 7807 (mittlerweile abgelöst durch RFC 9457, inhaltlich nahezu unverändert) standardisiert ein JSON-Format für Fehlerantworten mit den Feldern type, title, status, detail und instance, erweiterbar um beliebige zusätzliche Felder. Seit Spring Framework 6 / Boot 3 gibt es dafür die eingebaute Klasse org.springframework.http.ProblemDetail – kein externes Zusatzpaket mehr nötig. So sieht die reale Antwort aus, wenn CatalogService.findOrThrow(id) eine NotFoundException wirft:

HTTP/1.1 404 Not Found
Content-Type: application/problem+json

{
  "type": "about:blank",
  "title": "Not Found",
  "status": 404,
  "detail": "Katalogartikel nicht gefunden: 3fa8...e21b",
  "instance": "/api/v1/catalog-items/3fa8...e21b",
  "correlationId": "9c2e4b7a-1f3d-4a2e-8b6c-7d0a5e9f1c22"
}

detail stammt direkt aus ex.getMessage(), instance aus der Request-URI, und correlationId ist ein von GlobalExceptionHandler.problem(...) zusätzlich gesetztes Property – ProblemDetail erlaubt beliebige weitere Properties über setProperty(...), genau wie bei der Validierungsantwort, die zusätzlich ein errors-Array mit Feldfehlern anhängt.

Jakarta Bean Validation: die Exception, die eigentlich ein Sammelbecken ist

Woher kommt die Validierungsantwort überhaupt? CreateCatalogItemRequest annotiert seine Felder mit Jakarta Bean Validation (JSR 380, Referenzimplementierung Hibernate Validator) – derselbe Standard, den auch klassische Jakarta-EE-/JAX-RS-Anwendungen nutzen:

public record CreateCatalogItemRequest(
        @NotBlank String sku,
        @NotBlank String name,
        @NotBlank String category,
        @NotNull UUID supplierId,
        @NotNull @DecimalMin(value = "0.0", inclusive = false) BigDecimal priceAmount,
        @NotBlank String priceCurrency,
        @NotBlank String unit) {
}

Der @Valid-Parameter in CatalogController.create(...) löst die Validierung aus, bevor die Controller-Methode überhaupt betreten wird. Wichtig dabei: Bean Validation sammelt alle verletzten Constraints eines Objekts (fehlender sku und negativer Preis gleichzeitig) und wirft danach eine einzige Exception mit der vollständigen Liste – kein Fail-Fast bei der ersten Verletzung. Das gibt dem Client in einer Antwort alle Korrekturen mit, statt ihn mehrfach nachfragen zu lassen.

AspektSpring MVCReines Jakarta EE / JAX-RS
Auslösende Annotation@Valid am @RequestBody-Parameter@Valid am JAX-RS-Ressourcenparameter (identischer Jakarta-Validation-Standard)
Geworfene ExceptionMethodArgumentNotValidException (Spring-eigener Wrapper um die Constraint-Verletzungen)jakarta.validation.ConstraintViolationException direkt vom Bean-Validation-Provider
Wo abgefangenGlobalExceptionHandler.handleValidation(...) (@RestControllerAdvice)Eigener ExceptionMapper<ConstraintViolationException>
AntwortformatProblemDetail mit errors-Array (siehe GlobalExceptionHandler)Frei wählbar, oft ebenfalls RFC 7807/9457

Die Constraint-Annotationen selbst (@NotBlank, @NotNull, @DecimalMin – Package jakarta.validation.constraints) sind in beiden Welten identisch; nur die Exception-Klasse, die sie am Ende auslösen, unterscheidet sich, weil Spring MVC seinen eigenen Wrapper um den Jakarta-Validation-Provider legt.

Wie eine Exception in PROCUREX zur HTTP-Antwort wird

CorrelationIdFilter erzeugt/liest X-Correlation-Id → MDC CatalogService wirft NotFoundException (unchecked) CatalogController kein try/catch – lässt Exception durch GlobalExceptionHandler @RestControllerAdvice procurex-common HTTP 404 ProblemDetail (RFC 7807/9457) correlationId via MDC

Die Exception propagiert unverändert durch Service und Controller – erst GlobalExceptionHandler entscheidet über Status-Code und Antwortformat. Das hält die Fachlogik frei von HTTP-Details und macht neue Exceptions an einer einzigen Stelle ergänzbar.

Weitere Best Practices im PROCUREX-Code – und eine bewusste Lücke

  • Nicht schlucken, sondern loggen und weitermachen: AbstractOutboxRelay.relay() fängt pro Event ein Exception e, loggt es mit vollem Stacktrace (log.error(..., e)) und lässt den Kafka-Versand beim nächsten Scheduler-Lauf erneut versuchen – ein defekter Event blockiert nicht die ganze Relay-Schleife, aber der Fehler verschwindet auch nicht spurlos.
  • Aufräumen im finally: CorrelationIdFilter entfernt den MDC-Eintrag in einem finally-Block – notwendig, weil Tomcat-Threads aus einem Pool wiederverwendet werden und ein vergessener MDC.remove(...) sonst Correlation-IDs zwischen unabhängigen Requests vermischen würde.
  • Fachliche und technische Fehler trennen: ForbiddenException wird bewusst nicht mit Spring Securitys eigenem 403 (fehlende Rolle am Endpunkt) vermischt, sondern signalisiert einen datenabhängigen Fachregel-Verstoß (z. B. Bestellwert über dem Schwellenwert der Rolle) – zwei verschiedene Ursachen, zwei verschiedene Exception-Typen.
  • Bewusste Lücke: GlobalExceptionHandler hat keinen @ExceptionHandler(Exception.class)-Catch-all. Eine wirklich unerwartete RuntimeException (z. B. eine NullPointerException) fällt dadurch auf Spring Boots Standard-Fehlerbehandlung zurück statt auf ein konsistentes ProblemDetail mit Correlation-ID. Das ist kein Sicherheitsproblem – Spring Boot versteckt Stacktrace und Nachricht standardmäßig (server.error.include-stacktrace=never) –, aber ein inkonsistentes Antwortformat für genau den Fall, in dem Konsistenz am meisten zählt. Ein generischer Catch-all-Handler mit HTTP 500 wäre der naheliegende nächste Schritt.

Exceptions in asynchronen Kafka-Consumern: ein anderes Spiel als REST

Alles bisher Gezeigte gilt für synchrone REST-Aufrufe, bei denen am Ende ein HTTP-Client auf eine Antwort wartet. Sobald PROCUREX über Kafka kommuniziert, gibt es diesen Client nicht mehr – eine Exception kann nicht einfach in eine HTTP-Antwort übersetzt werden, weil keine offene Verbindung mehr existiert, in die sie zurückfließen könnte. NotificationConsumer.onEvent(...) (Modul procurex-notification) zeigt, wie PROCUREX damit umgeht:

@KafkaListener(groupId = "procurex-notification", topics = { ... })
@Transactional
public void onEvent(ConsumerRecord<String, String> record) {
    ...
    try {
        repository.save(new InAppNotification(eventId, ...));
    } catch (DataIntegrityViolationException ignored) {
        // Redelivery is expected; the unique event id makes the consumer idempotent.
    }
}

Der catch-Block hier verstößt bewusst gegen die übliche Regel „nicht pauschal fangen“ – aber richtig angewendet: Er fängt genau eine, sehr spezifische Exception (keine allgemeine Exception), die einen erwarteten Fall abbildet (Kafka liefert Nachrichten mindestens einmal, ggf. auch doppelt aus), und der Kommentar erklärt, warum das Verschlucken hier sicher ist – der eindeutige eventId-Constraint macht den Consumer idempotent, ein zweiter Insert-Versuch derselben Event-ID darf einfach verworfen werden. Für jeden anderen, nicht abgefangenen Fehler gibt es dagegen keinen eigenen Mechanismus wie GlobalExceptionHandler: Die Exception verlässt onEvent(...), Spring Kafka übernimmt mit seiner eigenen Fehlerbehandlung (CommonErrorHandler/DefaultErrorHandler) – standardmäßig wird die Nachricht mit Backoff erneut zugestellt.

AspektREST-synchron (GlobalExceptionHandler)Kafka-asynchron (@KafkaListener)
Wer wartet auf eine Antwort?Ein HTTP-Client mit offener VerbindungNiemand – der Producer hat längst weitergemacht
Zentraler Übersetzungsmechanismus@RestControllerAdviceProblemDetailKein PROCUREX-eigener; Spring Kafkas CommonErrorHandler greift
Standardverhalten bei unbehandelter ExceptionFällt auf Spring Boots generische Fehlerantwort zurück (siehe Lücke oben)Erneute Zustellung mit Backoff – ohne konfigurierten DeadLetterPublishingRecoverer potenziell endlos
Umgang mit doppelter ZustellungKein Thema (Request-Response ist 1:1)Muss aktiv behandelt werden – hier über Unique-Constraint auf eventId + gezielten catch
PROCUREX-StatusVier Domain-Exceptions abgedeckt, Catch-all fehlt (dokumentierte Lücke)Idempotenz gelöst; kein Dead-Letter-Topic für „giftige“ Nachrichten konfiguriert (weitere dokumentierte Lücke)

Ohne Dead-Letter-Topic kann eine Nachricht, die bei jedem Verarbeitungsversuch dieselbe (nicht abgefangene) Exception auslöst, den Consumer dauerhaft blockieren („Poison Pill“) – ein bekanntes Risiko bei Kafka-Consumern ohne konfigurierten DeadLetterPublishingRecoverer, das für den aktuellen PROCUREX-Umfang bewusst noch nicht adressiert ist.

Weitere gängige Spring-/Jakarta-Exceptions und ihr Status in PROCUREX

Über die vier eigenen Domain-Exceptions hinaus wirft der Stack unter der Haube laufend weitere, framework-eigene Exceptions. Diese Übersicht zeigt ehrlich, welche davon GlobalExceptionHandler heute in ein konsistentes ProblemDetail übersetzt – und welche (noch) nicht.

ExceptionTypischer AuslöserVon GlobalExceptionHandler abgedeckt?
MethodArgumentNotValidExceptionJakarta-Bean-Validation-Verstoß im Request-Body (@Valid)Ja – HTTP 400 mit errors-Array
DataIntegrityViolationExceptionVerletzter Unique-/Foreign-Key-Constraint in PostgreSQLNein, außer punktuell im Kafka-Consumer selbst abgefangen (siehe oben) – im REST-Pfad ungemappt
OptimisticLockingFailureExceptionGleichzeitige Änderung derselben Zeile (@Version-Konflikt auf PurchaseOrder), von den Integrationstests geprüftTeilweise – nicht global in GlobalExceptionHandler, aber lokal in OrderService.decide(...) gefangen und in ConflictException übersetzt (z. B. „Bestellung wurde zeitgleich bereits entschieden“). Andere Module ohne diese lokale Übersetzung wären ungemappt.
HttpMessageNotReadableExceptionFehlerhaftes/leeres JSON im Request-BodyNein – fällt auf Spring Boots Standardantwort zurück
MethodArgumentTypeMismatchExceptionFalscher Typ in einem @PathVariable/@RequestParam (z. B. keine gültige UUID)Nein
AccessDeniedException (Spring Security)Fehlende Rolle am EndpunktNein – separater Security-Filterchain-Mechanismus, bewusst nicht mit dem fachlichen ForbiddenException vermischt

Kein einziges dieser Zeilen ist ein Sicherheitsrisiko – Spring Boots Default-Fehlerbehandlung leckt keine internen Details. Es ist aber ein Konsistenz-Thema: Die Antwortform (RFC-7807-ProblemDetail mit Correlation-ID) gilt heute nur für die fünf explizit behandelten Fälle, nicht für den gesamten Fehlerraum der Anwendung.

Allgemeine Java-Faustregeln, unabhängig vom Framework

RegelBegründung
Nicht pauschal catch (Exception e) oder catch (Throwable t) fangenVerschluckt auch Programmierfehler (NullPointerException, ClassCastException); nur an der äußersten Systemgrenze (z. B. genau einem Catch-all-Handler) sinnvoll
Ursache immer mit cause verketten (new RuntimeException(msg, cause))Ohne verkettete cause geht im Stacktrace die eigentliche Fehlerquelle verloren – reines Rethrow der Nachricht macht Fehler unauffindbar
Exceptions nicht für normalen Kontrollfluss missbrauchenDas Erzeugen eines Stacktraces ist teuer; ein if/Optional für erwartbare Fälle (z. B. „nicht gefunden“ bei einer Cache-Prüfung) ist günstiger als eine Exception
Nur wrappen, wenn es neuen Mehrwert bringtEine SQLException 1:1 in eine gleich benannte eigene Exception zu wrappen, ohne zusätzlichen Kontext, bläht nur den Stacktrace auf
try-with-resources statt manuellem finally-CloseSchließt AutoCloseable-Ressourcen (Streams, Connections) garantiert auch bei einer Exception, ohne dass der Entwickler daran denken muss
So spezifisch wie möglich fangenEin einzelner breiter catch-Block über mehrere Exception-Typen verwischt, welcher Fehlerfall tatsächlich eingetreten ist und wie er behandelt werden sollte
Einordnung für PROCUREX: Der Wechsel von checked zu unchecked Exceptions, eine zentrale @RestControllerAdvice-Übersetzung und native RFC-7807-Unterstützung sind die drei größten Fortschritte gegenüber klassischen J2EE-Anwendungen – und PROCUREX nutzt alle drei konsequent, auch für Jakarta Bean Validation. Bei asynchronen Kafka-Consumern gilt dasselbe Grundprinzip (spezifisch fangen, Rest durchreichen), aber ohne das REST-Äquivalent von GlobalExceptionHandler – Idempotenz über einen Unique-Constraint ist gelöst, ein Dead-Letter-Topic für Poison-Pill-Nachrichten fehlt noch. Die fehlende Catch-all-Behandlung im REST-Pfad und die unvollständige Abdeckung gängiger Spring-Exceptions zeigen zugleich, dass „Best Practice“ in einem Lernprojekt ein Ziel ist, kein Endzustand: Die nächsten sinnvollen Schritte sind dokumentiert, aber noch nicht umgesetzt.
Empfehlung:
  • Eigene Exceptions grundsätzlich unchecked (RuntimeException) modellieren und pro fachlicher Bedeutung eine eigene Klasse anlegen (wie NotFoundException, ConflictException) statt eine generische Exception mit Statuscode-Parameter.
  • Fehlerübersetzung an genau einer Stelle bündeln (@RestControllerAdvice/ExceptionMapper) statt try/catch in einzelnen Controllern zu verteilen – neue Exceptions lassen sich dann an einem Ort ergänzen.
  • RFC 7807/9457 (ProblemDetail in Spring) als Antwortformat nutzen, sobald mehr als ein Client-Team die API konsumiert – maschinenlesbar und selbsterklärend statt projektspezifischer Ad-hoc-JSON-Fehler.
  • Einen Catch-all-Handler für Exception.class ergänzen, sobald eine API produktiv mehrere Clients bedient – ohne ihn liefert ein unerwarteter Fehler ein inkonsistentes Antwortformat statt eines sauberen 500er-ProblemDetail.
  • Bei jedem neuen Kafka-Consumer vorab festlegen, wie Wiederholungen und dauerhaft fehlschlagende Nachrichten behandelt werden (Idempotenz-Schlüssel, Retry-Backoff, Dead-Letter-Topic) – das lässt sich im Nachhinein deutlich schwerer nachrüsten als beim ersten Entwurf.
Wichtig zu merken:
  • Alle vier PROCUREX-Domain-Exceptions sind unchecked (extends RuntimeException) – nirgends im Code steht für sie eine throws-Klausel.
  • GlobalExceptionHandler hat keinen Catch-all-Handler – eine wirklich unerwartete Exception (z. B. eine NullPointerException) fällt auf Spring Boots generische Standardantwort zurück, nicht auf das eigene ProblemDetail-Format.
  • Eine Exception in einem Kafka-Consumer lässt sich nicht „als HTTP-Antwort beantworten“ – es gibt keinen wartenden Client mehr. Ohne konfigurierten Dead-Letter-Handler kann eine dauerhaft fehlschlagende Nachricht den Consumer blockieren („Poison Pill“).
Exkurs · Messaging & Event-Driven Architecture

Transactional Outbox, Kafka-Topics und Event-Versionierung in PROCUREX

Wie PROCUREX sieben Module über Kafka lose koppelt, ohne Events zu verlieren – Outbox-Pattern, Themenkatalog und Idempotenz im echten Code.

PROCUREX besteht aus sieben fachlichen Maven-Modulen, die nicht direkt REST-Aufrufe untereinander machen, sondern über Kafka lose gekoppelt sind: Ein Modul schreibt ein Event, ein anderes reagiert darauf, ohne dass beide gleichzeitig erreichbar sein müssen. Kern davon ist das Transactional-Outbox-Pattern in procurex-common/.../outbox/ (AbstractOutboxEvent, AbstractOutboxRelay), bereits im Entwurfsmuster-Exkurs als Template-Method-Beispiel gezeigt – dieser Exkurs geht auf die Messaging-Seite selbst ein: welche Events es gibt, wie sie serialisiert und versioniert werden, und wie Konsumenten mit doppelter Zustellung umgehen.

Transactional OutboxFachliche Änderung und Event-Eintrag landen in derselben DB-Transaktion – kein Event geht verloren, keins wird ohne echte Änderung verschickt.
Themenkatalog (Topics)Sechs Topics nach dem Schema procurex.<modul>.<ereignis>.v1, produziert von vier Fachmodulen.
Idempotente KonsumentenJeder Consumer erkennt doppelt zugestellte Events über eine eindeutige Event-ID – Kafka garantiert nur „mindestens einmal“.
JSON statt Schema RegistryEvents sind einfache Java-Records, per Jackson zu JSON-Strings serialisiert – keine Avro-/Protobuf-Schemaverwaltung.

Das Transactional-Outbox-Pattern: Problem und Lösung

Ohne Outbox-Pattern entsteht ein klassisches verteiltes Konsistenzproblem: Schreibt ein Service zuerst in die Datenbank und danach auf Kafka, kann der Prozess zwischen beiden Schritten abstürzen – die fachliche Änderung ist da, das Event fehlt (oder umgekehrt bei vertauschter Reihenfolge). PROCUREX löst das, indem OrderService.publish(...) das Event nicht direkt an Kafka sendet, sondern als Zeile in einer outbox_event-Tabelle in derselben Transaktion wie die fachliche Änderung speichert:

private void publish(String topic, UUID aggregateId, Object event) {
    try {
        String payload = objectMapper.writeValueAsString(event);
        outboxRepository.save(new OrderingOutboxEvent(topic, aggregateId.toString(), payload));
    } catch (JsonProcessingException e) {
        throw new IllegalStateException("Event konnte nicht serialisiert werden: " + topic, e);
    }
}

Bemerkenswert: JsonProcessingException ist eine checked Exception (siehe Exception-Exkurs) – sie wird hier bewusst in eine unchecked IllegalStateException gewrappt, weil eine fehlgeschlagene Serialisierung eines internen Records ein Programmierfehler ist, den kein Aufrufer sinnvoll behandeln kann. Ein separater, per @Scheduled laufender OrderingOutboxRelay liest unveröffentlichte Zeilen alle zwei Sekunden aus und sendet sie erst dann an Kafka – Datenbank-Commit und Kafka-Versand sind damit entkoppelt, aber garantiert konsistent zueinander.

Der Themenkatalog: alle sechs PROCUREX-Topics

TopicProduzentKonsumenten
procurex.ordering.order-submitted.v1procurex-orderingprocurex-notification, procurex-audit
procurex.approval.order-approved.v1procurex-approvalprocurex-notification, procurex-audit
procurex.approval.order-rejected.v1procurex-approvalprocurex-notification, procurex-audit
procurex.receiving.goods-received.v1procurex-receivingprocurex-notification, procurex-audit
procurex.invoicing.invoice-matched.v1procurex-invoicingprocurex-notification, procurex-audit
procurex.invoicing.invoice-discrepancy-flagged.v1procurex-invoicingprocurex-notification, procurex-audit

Namenskonvention: procurex.<produzierendes Modul>.<Ereignis in Vergangenheitsform>.v1. Die .v1-Endung ist PROCUREX' Versionierungsstrategie – ein inkompatibler Schema-Wechsel bekäme ein neues Topic (.v2) statt das bestehende stillschweigend zu ändern, sodass alte Konsumenten nicht plötzlich unlesbare Nachrichten erhalten.

Wie zwei Konsumenten dieselben Events unterschiedlich nutzen

procurex-notification und procurex-audit hören auf dieselben sechs Topics, aber mit komplett unterschiedlichem Zweck: Notification baut aus dem Topic-Namen eine rollenspezifische Benachrichtigung (NotificationText.from(topic, orderId)), Audit schreibt jedes Event unverändert und vollständig in ein Audit-Log – reines Event Sourcing „light“, ohne den fachlichen Ablauf zu beeinflussen. Beide lösen dieselbe Herausforderung – Kafka liefert Nachrichten mindestens einmal, nie exakt einmal – mit demselben Muster: einer eindeutigen Event-ID im Kafka-Header plus einem Unique-Constraint in der jeweiligen Konsumenten-Tabelle.

AspektNotificationConsumerAuditLogConsumer
ZweckRollenspezifische In-App-Benachrichtigung erzeugenUnveränderte Kopie jedes Events als Audit-Trail
Consumer-Groupprocurex-notificationprocurex-audit
DuplikaterkennungexistsByEventId(...)-Vorprüfung + catch (DataIntegrityViolationException)Dieselbe Doppelabsicherung – Vorprüfung und Constraint-Catch
Fehlender event-id-HeaderEvent wird verworfen (eventId == null → return)Wird geloggt (log.warn) und verworfen, nicht stillschweigend ignoriert
Startverhaltenauto-offset-reset: earliest gilt global – auch Notification sieht bei Erststart die volle HistorieBewusst gewählt, damit der erste Audit-Consumer (PRX-34) auch bereits publizierte Alt-Events erfasst
OrderService.submitOrder(...) 1 DB-Transaktion: PurchaseOrder speichern + OutboxEvent speichern OrderingOutboxRelay @Scheduled, alle 2000ms liest unveröffentlichte Zeilen, sendet an Kafka Kafka procurex.ordering. order-submitted.v1 Header: event-id NotificationConsumer idempotent via Event-ID + Unique-Constraint AuditLogConsumer schreibt Event 1:1 in audit_log_entry

Zwischen Outbox-Tabelle und Kafka-Versand liegt bewusst eine Verzögerung von bis zu zwei Sekunden (Scheduler-Intervall) – das ist der Preis für die Konsistenzgarantie: lieber leicht verzögert als inkonsistent.

Alternativen zum Transactional Outbox

AnsatzFunktionsweiseTrade-off ggü. PROCUREX' Outbox-Lösung
Direktes Publizieren (kein Outbox)Service ruft nach dem DB-Commit direkt kafkaTemplate.send(...) aufEinfacher, aber ein Absturz zwischen DB-Commit und Kafka-Versand verliert das Event unwiderruflich
Change Data Capture (z. B. Debezium)Liest das PostgreSQL-WAL (Write-Ahead-Log) direkt und erzeugt Kafka-Events aus DB-Änderungen, ganz ohne AnwendungscodeKein Relay-Code nötig, dafür zusätzliche Infrastruktur (Kafka-Connect-Cluster) und eine engere Kopplung an das DB-Schema als Vertrag
2-Phase-Commit (XA-Transaktionen)Eine verteilte Transaktion über DB und Message-Broker hinweg (klassisches J2EE-JTA-Muster)Theoretisch “exakt”, in der Praxis selten unterstützt (Kafka hat kein natives XA) und performancekritisch – heute weitgehend verlassen
Avro/Protobuf + Schema RegistryEvents als binäres, schemavalidiertes Format statt JSON-StringsErzwingt Schema-Kompatibilität zur Build-Zeit, aber zusätzliche Infrastruktur (Schema Registry) und weniger einfach von Hand zu inspizieren als JSON
Einordnung für PROCUREX: Das Transactional-Outbox-Pattern passt gut zum Lernprojekt-Umfang: keine zusätzliche Infrastruktur wie Debezium, aber trotzdem verlustsichere Event-Zustellung. Die Versionierung über Topic-Suffix (.v1) statt Schema Registry ist bewusst einfach gehalten und für sechs Topics gut wartbar – bei deutlich mehr Events oder mehreren Teams würde eine Schema Registry die Konsistenz zwischen Produzent und Konsument robuster erzwingen.
Empfehlung:
  • Transactional Outbox als Standardlösung ansetzen, sobald eine fachliche Änderung garantiert ein Event nach sich ziehen muss – die Komplexität lohnt sich schon ab dem ersten Konsumenten, der sich auf Zustellung verlassen soll.
  • Jedes Event-Topic von Anfang an versionieren (Suffix oder Schema Registry) – ein nachträglich eingeführtes Versionsschema zwingt alle bestehenden Konsumenten zu einer koordinierten Migration.
  • Jeden Consumer von Anfang an idempotent bauen (eindeutige Event-ID + Unique-Constraint oder vergleichbarer Mechanismus) – „Kafka liefert mindestens einmal“ ist keine Ausnahme, sondern der Normalfall.
  • Change Data Capture (Debezium) erst erwägen, wenn viele Services aus vielen Tabellen Events ableiten sollen – für wenige, bewusst modellierte Events ist ein expliziter Outbox-Eintrag klarer als implizite Ableitung aus dem WAL.
Wichtig zu merken:
  • Kafka garantiert „at-least-once“, nie „exactly-once“ auf Anwendungsebene – Idempotenz ist Aufgabe des Consumers, nicht des Brokers.
  • PROCUREX nutzt kein Avro, kein Protobuf, keine Schema Registry – Events sind einfache JSON-Strings, versioniert nur über den Topic-Namen (.v1).
  • Zwischen Outbox-Eintrag und tatsächlichem Kafka-Versand liegt eine Verzögerung von bis zu zwei Sekunden (Scheduler-Intervall) – PROCUREX ist damit „eventually consistent“, nicht sofort konsistent.
Exkurs · Testing-Strategien

Die Testpyramide in PROCUREX: sechs Ebenen, ein Zweck

Von der reinen Domain-Unit bis zum k6-Lasttest – sechs verschiedene Testarten in PROCUREX und wofür jede einzelne wirklich da ist.

„Wir haben Tests“ sagt wenig darüber aus, was diese Tests eigentlich prüfen und wie teuer sie in Laufzeit und Wartung sind. PROCUREX nutzt sechs unterscheidbare Testarten, jede mit einem anderen Zweck, einer anderen Laufzeit und einer anderen Grenze dessen, was sie beweisen kann. Dieser Exkurs ordnet sie in die klassische Testpyramide ein und zeigt für jede Ebene ein reales PROCUREX-Beispiel.

Domain-Unit-TestsReine JUnit-5-/AssertJ-Tests ohne Spring-Kontext und ohne Mocks – die Basis der Pyramide.
Service-Unit-Tests mit MockitoApplication-Services isoliert testen, indem ihre Port-Abhängigkeiten gemockt werden.
Contract- und IntegrationstestsSpring Cloud Contract prüft die HTTP-Schnittstelle, Testcontainers prüft echtes DB-Verhalten.
E2E- und LasttestsPlaywright fährt echte Browser-Flows, k6 prüft, ob die API unter Last die Antwortzeit hält.

Die sechs Ebenen im Überblick

EbeneWerkzeugWas sie prüftPROCUREX-BeispielTypische Laufzeit
1 · Domain-UnitJUnit 5 + AssertJReine Geschäftslogik eines einzelnen Domain-Objekts, ganz ohne FrameworkCatalogItemTest – prüft Preis/Einheit eines CatalogItemMillisekunden, kein Spring-Kontext
2 · Service-UnitJUnit 5 + MockitoApplication-Service-Logik, Abhängigkeiten (Ports) durch Mocks ersetztCartSubmissionServiceTest – mockt SupplierLookupPort, CatalogLookupPort, OrderCreationPortMillisekunden, kein Spring-Kontext
3 · Web-Slice-Test@WebMvcTest + Spring Security TestController- und Security-Verhalten isoliert, ohne vollen AnwendungskontextBuyerContractBase – simuliert ROLE_BUYER per SecurityMockMvcRequestPostProcessors.jwt()Sekundenbereich, Teil-Spring-Kontext
4 · Contract-TestSpring Cloud Contract (Groovy-DSL)Ob ein Endpunkt exakt der vereinbarten Anfrage-/Antwortform entsprichtcontracts/requisition/shouldSubmitCart.groovy – POST /api/v1/cart/submitSekundenbereich, generiert zugleich Stubs
5 · IntegrationstestTestcontainers + @SpringBootTestEchtes DB-Verhalten: Optimistic Locking, Unique-Constraints, TransaktionsgrenzenIntegrationTestBase – startet postgres:16-alpine als echten ContainerSekunden bis niedrige Minuten, echter Docker-Container
6a · E2E-TestPlaywrightKompletter Nutzerfluss im echten Browser, inkl. UI und Logine2e/login.spec.ts, e2e/procure-to-pay.spec.tsSekunden bis Minuten pro Szenario, echter Chromium
6b · Lasttestk6Antwortzeit und Fehlerrate der API unter simulierter Nebenlastloadtests/01-katalogsuche.js – 20 virtuelle Nutzer, Schwellenwert p95<500msMinuten, gegen eine laufende Umgebung

Warum Ebene 1 und 2 ohne Spring-Kontext auskommen

CatalogItemTest braucht überhaupt keine Mocks, weil CatalogItem ein reines Domain-Objekt ohne Abhängigkeiten ist. CartSubmissionServiceTest braucht Mocks, aber keinen Spring-Kontext:

@ExtendWith(MockitoExtension.class)
class CartSubmissionServiceTest {
    @Mock private SupplierLookupPort supplierLookupPort;
    @Mock private CatalogLookupPort catalogLookupPort;
    @Mock private OrderCreationPort orderCreationPort;

    private CartSubmissionService service;

    @BeforeEach
    void setUp() {
        service = new CartSubmissionService(supplierLookupPort, catalogLookupPort, orderCreationPort);
    }
}

Das ist direkt eine Konsequenz aus dem Entwurfsmuster-Exkurs: Weil CartSubmissionService seine Abhängigkeiten über den Konstruktor bekommt (Dependency Injection) statt sie sich selbst über einen Service Locator zu besorgen, lässt sich der Service in Zeile new CartSubmissionService(...) direkt mit drei Mocks statt drei echten Modulen instanziieren – ganz ohne Spring, ohne Datenbank, ohne Netzwerk. Genau diese Testbarkeit ist der praktische Grund, warum Dependency Injection den Service Locator verdrängt hat.

Contract-Test und Web-Slice-Test in einem: BuyerContractBase

Spring Cloud Contract generiert aus shouldSubmitCart.groovy zur Build-Zeit sowohl einen JUnit-Test als auch einen WireMock-Stub. Der generierte Test läuft gegen eine reale, aber isolierte @WebMvcTest-Instanz von CartController – die fachliche Logik (CartSubmissionService) ist gemockt, aber HTTP-Routing, JSON-Serialisierung und die Spring-Security-Kette sind echt:

@WebMvcTest(CartController.class)
@Import(SecurityConfig.class)
public abstract class BuyerContractBase {

    @MockBean
    protected CartSubmissionService cartSubmissionService;

    @BeforeEach
    void setUpContractBase() {
        when(cartSubmissionService.submit(any(), any(), any(), any(), any())).thenReturn(ORDER_ID);
        RestAssuredMockMvc.mockMvc(MockMvcBuilders.webAppContextSetup(context)
            .apply(SecurityMockMvcConfigurers.springSecurity())
            .defaultRequest(MockMvcRequestBuilders.get("/").with(
                SecurityMockMvcRequestPostProcessors.jwt()
                    .authorities(new SimpleGrantedAuthority("ROLE_BUYER"))))
            .build());
    }
}

Bemerkenswert: JwtDecoder wird zwar als @MockBean für die Bean-Verdrahtung von SecurityConfig benötigt, aber nie tatsächlich aufgerufen – SecurityMockMvcRequestPostProcessors.jwt() speist die Authentifizierung direkt in den SecurityContext ein und umgeht damit den echten Token-Validierungspfad. Der Contract-Test prüft also „reagiert der Endpunkt für einen ROLE_BUYER korrekt“, nicht „funktioniert die Keycloak-Anbindung“ – dafür ist Ebene 5/6 zuständig.

Was jede Ebene NICHT beweist

EbeneBeweist NICHT
Domain-/Service-Unit-TestsOb die echten Adapter (DB, REST, Kafka) korrekt zusammenspielen – alle Abhängigkeiten sind Mocks
Web-Slice-/Contract-TestOb die Datenbank die fachliche Operation tatsächlich persistiert oder ob Optimistic-Locking-Konflikte korrekt behandelt werden
Integrationstest (Testcontainers)Ob der komplette Nutzerfluss durch das Angular-Frontend inklusive echtem Keycloak-Login funktioniert
E2E-Test (Playwright)Ob die API unter realistischer Nebenlast performant bleibt – ein E2E-Test läuft mit genau einem Nutzer
Lasttest (k6)Ob die fachliche Logik korrekt ist – k6 prüft nur Antwortzeit/Fehlerrate, nicht den Inhalt der Antwort im Detail

Keine einzelne Ebene ersetzt eine andere – ein grüner mvn test-Lauf (Ebenen 1–4) beweist beispielsweise nicht automatisch die Datenbankintegration, wie auch im Build-Performance-Nachweis ausdrücklich festgehalten.

Einordnung für PROCUREX: Die Kombination aus sechs Ebenen ist für ein Lernprojekt bewusst umfangreich gewählt, um die Bandbreite realer Enterprise-Testpraxis zu zeigen – von der Millisekunden-Unit bis zum Docker-gestützten Integrationstest und dem Lasttest gegen eine laufende Umgebung. Der Preis dafür ist Laufzeit: die Testcontainers-Tests sind in procurex-app/pom.xml deshalb standardmäßig vom regulären mvn test-Lauf ausgeschlossen und laufen separat.
Empfehlung:
  • So viel wie möglich auf Ebene 1/2 testen (schnell, kein Spring-Kontext) – erst wenn die Logik Framework-Verhalten selbst betrifft (Routing, Security, Serialisierung), auf eine höhere Ebene wechseln.
  • Contract-Tests einführen, sobald mehr als ein Team/Service von einer API-Form abhängt – sie verhindern stille Breaking Changes zwischen Producer und Consumer.
  • Testcontainers statt In-Memory-Datenbanken (H2 & Co.) für Integrationstests nutzen, wenn produktiv PostgreSQL läuft – DB-spezifisches Verhalten wie Unique-Constraints oder Optimistic Locking unterscheidet sich zwischen Datenbanken.
  • Lasttests (k6 o. ä.) so früh wie möglich mit festen Schwellenwerten (thresholds) statt nur informativ laufen lassen – ein Schwellenwert wie p95<500ms macht Performance-Regressionen automatisch sichtbar statt sie erst im Betrieb zu bemerken.
Wichtig zu merken:
  • Konstruktor-Injection macht Service-Unit-Tests trivial mockbar – das ist der praktische Beweis, warum Dependency Injection gegenüber Service Locator gewinnt (siehe Entwurfsmuster-Exkurs).
  • Ein Contract-Test mit gemocktem JwtDecoder beweist Endpunkt- und Rollenverhalten, aber nicht die echte Keycloak-Anbindung – dafür sind Integrations- oder E2E-Tests zuständig.
  • Die Testcontainers-Integrationstests laufen nicht automatisch mit mvn test – ein grüner Standardlauf beweist die Datenbankintegration nicht.
Exkurs · Authentifizierung & Autorisierung

OAuth2, OIDC und Keycloak: wie PROCUREX Identität und Rollen prüft

OAuth2 vs. OIDC, Keycloak vs. Auth0/Okta, und warum PROCUREX zwei verschiedene URLs für denselben Identity-Provider braucht.

PROCUREX prüft jede API-Anfrage als OAuth2 Resource Server gegen Keycloak (SecurityConfig.java, procurex-app): zustandslos, per JWT, mit rollenbasierter Autorisierung pro Endpunkt. Dieser Exkurs erklärt den Unterschied zwischen OAuth2 und OIDC, ordnet Keycloak gegen kommerzielle Alternativen ein und zeigt zwei PROCUREX-spezifische Details, die in Tutorials selten vorkommen: die getrennten Issuer-/JWK-URLs wegen Docker-Netzwerken, und die eigene Rollen-Extraktion aus Keycloaks realm_access-Claim.

OAuth2Autorisierungs-Framework: regelt, wie ein Client im Namen eines Nutzers ein Zugriffs-Token bekommt – sagt nichts über die Identität des Nutzers aus.
OpenID Connect (OIDC)Identitätsschicht oben auf OAuth2: ergänzt ein ID-Token und einen Userinfo-Endpunkt, damit der Client auch weiß, wer sich angemeldet hat.
Resource ServerPROCUREX validiert eingehende JWTs selbst gegen Keycloaks öffentlichen Schlüssel – keine Rückfrage bei jedem Request nötig.
KeycloakSelbst gehosteter, quelloffener Identity- und Access-Management-Server (Red Hat/CNCF) – Alternative zu SaaS-Diensten wie Auth0 oder Okta.

OAuth2 ist kein Login-Protokoll – OIDC macht es dazu

Ein verbreitetes Missverständnis: OAuth2 selbst definiert kein „Login“. Es beantwortet nur die Frage „darf dieser Client mit diesem Zugriffs-Token diese Aktion ausführen“ – über wen das Token ursprünglich ausgestellt wurde, macht der Standard keine verbindliche Aussage. OpenID Connect (OIDC, 2014) setzt exakt hier an: Es standardisiert zusätzlich ein ID-Token (ein JWT mit Identitätsclaims wie sub, email, name) und einen /userinfo-Endpunkt. PROCUREX nutzt beide Ebenen: Das Angular-Frontend führt den OIDC-Login durch (angular-oauth2-oidc, Authorization-Code-Flow mit PKCE), procurex-app selbst prüft anschließend nur noch das resultierende Access-Token als reiner OAuth2 Resource Server.

AspektOAuth2OpenID Connect (OIDC)
Grundfrage„Darf dieser Client das?“ (Autorisierung)„Wer ist dieser Nutzer?“ (Authentifizierung)
TokenAccess Token (Format nicht vorgeschrieben)Zusätzlich: ID Token (immer ein JWT)
StandardjahrRFC 6749 (2012)OpenID Connect Core 1.0 (2014), baut auf OAuth2 auf
PROCUREX-Nutzungprocurex-app validiert nur das Access Token (Resource-Server-Rolle)Angular-Frontend führt den Login-Flow gegen Keycloak durch (angular-oauth2-oidc)

Zwei URLs für einen Identity-Provider: das Docker-„Zwei-Adressen-Problem“

Ein Detail, das in keinem Standard-Tutorial vorkommt, weil es rein aus der lokalen Docker-Topologie von PROCUREX entsteht:

security:
  oauth2:
    resourceserver:
      jwt:
        issuer-uri: ${PROCUREX_OIDC_ISSUER_URI:http://keycloak.localhost/realms/procurex}
        jwk-set-uri: ${PROCUREX_OIDC_JWK_SET_URI:.../protocol/openid-connect/certs}

issuer-uri validiert den iss-Claim im Token und muss exakt der öffentlichen, browserseitigen Adresse entsprechen (keycloak.localhost, über Traefik geroutet), weil der Browser das Token mit genau dieser Adresse als Aussteller bekommen hat. jwk-set-uri ist bewusst separat gesetzt, weil der Server-Container selbst *.localhost nicht auflösen kann – innerhalb von Docker gibt es dafür keinen automatischen DNS-Trick, weshalb procurex-app für den öffentlichen Schlüssel den internen Servicenamen (shared-keycloak) im gemeinsamen proxy-Netzwerk anspricht. Zwei URLs für denselben logischen Server, weil Browser und Server-Container unterschiedliche Netzwerksichten auf denselben Keycloak haben.

Rollen statt Scopes: Keycloaks realm_access-Claim

Spring Security erwartet standardmäßig OAuth2-scope-Claims für Autorisierung. Keycloak modelliert Berechtigungen aber primär über Realm-Rollen im Claim realm_access.roles – PROCUREX ersetzt deshalb den Standard-Converter durch einen eigenen:

private JwtAuthenticationConverter jwtAuthenticationConverter() {
    JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
    converter.setJwtGrantedAuthoritiesConverter(this::realmRolesFrom);
    return converter;
}

private Collection<GrantedAuthority> realmRolesFrom(Jwt jwt) {
    Map<String, Object> realmAccess = jwt.getClaim("realm_access");
    if (realmAccess == null || !(realmAccess.get("roles") instanceof List<?> roles)) {
        return List.of();
    }
    return roles.stream().map(Object::toString).map(SimpleGrantedAuthority::new).toList();
}

Wichtig dabei: Die Keycloak-Rollen heißen bereits ROLE_BUYER, ROLE_APPROVER usw. – der Converter fügt kein zusätzliches ROLE_-Präfix hinzu (Spring Security würde das bei der Standard-Scope-Konvertierung sonst tun), sonst entstünde ein doppeltes ROLE_ROLE_BUYER. Jeder Endpunkt bekommt danach eine explizite Least-Privilege-Regel:

.requestMatchers(HttpMethod.POST, "/api/v1/orders/*/approval/**").hasAnyAuthority("ROLE_APPROVER", "ROLE_ADMIN")
.requestMatchers(HttpMethod.POST, "/api/v1/orders/*/goods-receipts").hasAnyAuthority("ROLE_RECEIVING", "ROLE_ADMIN")
.requestMatchers(HttpMethod.POST, "/api/v1/orders/*/invoices").hasAnyAuthority("ROLE_FINANCE", "ROLE_ADMIN")

Sechs fachliche Rollen (ROLE_BUYER, ROLE_APPROVER, ROLE_SUPPLIER_ADMIN, ROLE_RECEIVING, ROLE_FINANCE) plus ein universeller ROLE_ADMIN-Zugriff pro Endpunkt bilden das komplette Autorisierungsmodell. Wichtig ist die Abgrenzung zum Exception-Exkurs: Ein 403 wegen fehlender Rolle an dieser Stelle ist ein technischer Security-Fehler (Spring Security selbst antwortet), während ForbiddenException einen fachlichen, datenabhängigen Regelverstoß signalisiert (z. B. ein Bestellwert über dem Schwellenwert der Rolle) – zwei verschiedene Schichten, bewusst nicht vermischt.

Keycloak im Vergleich zu SaaS-Alternativen

Identity-ProviderHerstellerBetriebsmodellLizenz/KostenTypischer Einsatz
KeycloakRed Hat (CNCF-Projekt)Selbst gehostet (Docker/Kubernetes)Apache 2.0, kostenlos – Kosten entstehen nur durch eigenen BetriebVolle Kontrolle über Daten/Infrastruktur gewünscht, Kostendruck bei vielen Nutzern
Auth0Okta (nach Übernahme 2021)SaaSKommerziell, Preis nach aktiven Nutzern (MAU)Schneller Start ohne eigenen Betrieb, breites Social-Login-Angebot
Okta (eigenständig)OktaSaaSKommerziell, oft Enterprise-VerträgeGroße Unternehmen mit komplexen Compliance-/SSO-Anforderungen
Microsoft Entra ID (Azure AD)MicrosoftSaaSKommerziell, oft in Microsoft-365-Verträge gebündeltUnternehmen, die bereits tief im Microsoft-365-/Azure-Ökosystem sind
Amazon CognitoAWSSaaS (verwaltet)Kommerziell, nach aktiven NutzernAnwendungen, die ohnehin komplett auf AWS laufen

Technisch sind alle fünf OIDC-kompatibel – der von PROCUREX genutzte JwtDecoder/issuer-uri-Mechanismus in Spring Security funktioniert mit jedem davon nahezu unverändert. Der Unterschied liegt in Betriebsmodell, Kosten und Datenhoheit, nicht im Protokoll.

Einordnung für PROCUREX: Keycloak passt zum Lernprojekt-Charakter von PROCUREX – kostenlos, selbst betrieben, voll unter eigener Kontrolle, ohne nutzerbasierte SaaS-Abrechnung. Die getrennten Issuer-/JWK-URLs sind kein Keycloak-Spezifikum, sondern eine direkte Folge der lokalen Docker-Topologie (Traefik-Reverse-Proxy für den Browser, internes Docker-Netzwerk für Container-zu-Container) und würden in einer Cloud-Umgebung mit öffentlich erreichbarem Identity-Provider meist entfallen.
Empfehlung:
  • Keycloak wählen, wenn Datenhoheit, fehlende SaaS-Abrechnung oder volle Anpassbarkeit (Custom-Themes, eigene Protokoll-Mappers) wichtiger sind als ein Zero-Ops-Start.
  • Auth0/Okta/Entra ID wählen, wenn kein eigenes Ops-Team für einen Identity-Provider vorhanden ist oder Enterprise-Features (erweiterte MFA, Compliance-Zertifizierungen) sofort gebraucht werden.
  • Rollen/Berechtigungen immer serverseitig am Endpunkt durchsetzen (wie hasAnyAuthority(...) in PROCUREX) – ein im Frontend verstecktes Menü ist keine Zugriffskontrolle, nur UX.
  • Technische (Security-Layer-)Fehler und fachliche Berechtigungsfehler bewusst in getrennten Exception-Typen abbilden – vermischt man beide, wird ein 403 später schwer nachvollziehbar.
Wichtig zu merken:
  • OAuth2 regelt Autorisierung, OIDC ergänzt Authentifizierung (ID-Token) – „OAuth2-Login“ ohne OIDC ist streng genommen kein standardisiertes Konzept.
  • PROCUREX validiert JWTs zustandslos gegen Keycloaks öffentlichen Schlüssel (jwk-set-uri) – keine Rückfrage bei Keycloak pro Request nötig, daher SessionCreationPolicy.STATELESS.
  • Ein 403 wegen fehlender Rolle (Spring Security) und ein 403 wegen ForbiddenException (Fachregel) sehen für den Client gleich aus, haben in PROCUREX aber unterschiedliche Ursachen und Code-Pfade.

← Zurück zum Dokumentationsportal

⌂ Cockpit