Exkurs · Werkzeug-Installation
Was bedeutet „lokal installiert“?
Betriebssystem, Projekt, Ausführung und Browser-Cache bei npm/Playwright sauber unterschieden.
Was bedeutet „lokal installiert“?
Bei Node- und Playwright-Projekten werden mehrere Installationsebenen leicht verwechselt. Der folgende Überblick zeigt, was auf dem Betriebssystem, im Projekt und im Benutzerprofil liegt.
npm ci installiert Playwright und andere Pakete in node_modules dieses Projekts.npx playwright test verwendet zuerst die lokale Projektversion und hält die Version reproduzierbar.npx 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
| Befehl | Wirkung | Ebene |
|---|---|---|
npm ci | Installiert exakt die im Lockfile festgelegten Projektabhängigkeiten. | Projekt |
npx playwright test | Startet die lokal installierte Playwright-Testversion. | Projekt ausführen |
npx playwright install chromium | Installiert 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 |
- In CI und für reproduzierbare Builds immer
npm cistattnpm installverwenden – es respektiert das Lockfile exakt und schlägt fehl, wennpackage.json/package-lock.jsonauseinanderlaufen. - Bei „Executable doesn't exist“-Fehlern zuerst
npx playwright install chromiumerneut 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 eigenepackage.jsonhat – sie unterlaufen die Versionsfestlegung im Lockfile und führen zu „funktioniert nur auf meinem Rechner“.
node_modulesund der Playwright-Browser-Cache landen nie im Git-Repo (.gitignore) – ein frischer Checkout hat beides nicht und braucht erstnpm ciplusnpx playwright install.npxbevorzugt immer die lokal innode_modulesinstallierte 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.
Exkurs · Werkzeug-Landschaft
GitHub, GitLab, Bitbucket & Co. im Vergleich
GitHub, GitLab, Bitbucket & Co. im Direktvergleich – und warum PROCUREX auf GitHub liegt.
GitHub, GitLab, Bitbucket & Co. im Vergleich
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.
Direktvergleich
| Plattform | Eigentümer | Schwerpunkt | Self-Hosting | Kosten (ca., pro Nutzer/Monat) | Beliebtheit |
|---|---|---|---|---|---|
| GitHub | Microsoft | Open Source, Community, Ökosystem, Copilot | Nur Enterprise Server | Free / Team ~4 $ / Enterprise ~21 $ | Sehr hoch – über 100 Mio. Entwickler, De-facto-Standard |
| GitLab | GitLab Inc. (börsennotiert) | Durchgängige DevOps-/DevSecOps-Pipeline in einer Anwendung | Ja, Community Edition kostenlos | Free / Premium ~29 $ / Ultimate ~99 $ | Hoch – stark bei Unternehmen mit eigenem Betrieb |
| Bitbucket | Atlassian | Enge Jira-/Confluence-/Trello-Anbindung | Nein (Data Center wurde eingestellt) | Free bis 5 Nutzer / Standard ~3 $ / Premium ~6 $ | Mittel – vor allem in Atlassian-Firmen |
| Azure DevOps | Microsoft | Boards, Pipelines, Repos für .NET-/Microsoft-Umfelder | Bedingt (Azure DevOps Server) | Free bis 5 Nutzer, danach ~6 $ | Mittel – primär Microsoft-Konzerne |
| Gitea / Forgejo | Open-Source-Community | Schlankes, selbst gehostetes Git-Hosting | Ja, einziger Zweck | Kostenlos, nur Serverkosten | Wachsend – beliebt bei Selfhostern |
| Codeberg | gemeinnütziger Verein (e. V.) | Kostenloses, werbefreies Hosting auf Forgejo-Basis | Nein (nutzt selbst Forgejo) | Kostenlos, spendenfinanziert | Nische – FOSS- und Datenschutz-Community |
| SourceHut | Drew DeVault / SourceHut LLC | Minimalistisch, E-Mail-/CLI-zentrierter Workflow | Ja | „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.
- 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.
- 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-urlmö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.
WebSphere, WebLogic, JBoss & Co. im Vergleich
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.
Direktvergleich
| Server | Hersteller | Aktuelle Version | Jakarta/Java-EE-Level | Mindest-Java (JDK) | Lizenzmodell | Kosten (ca.) | Verbreitung |
|---|---|---|---|---|---|---|---|
| IBM WebSphere (traditional) | IBM | 9.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 11 | Kommerziell, pro Core | Oft fünfstellig pro Core/Jahr | Hoch in Banken/Versicherungen (Legacy), rückläufig bei Neuprojekten |
| IBM WebSphere Liberty | IBM | Continuous 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-Set | Kommerziell (ILAN) oder kostenlos als Open Liberty | Deutlich günstiger als traditional; Open Liberty kostenlos | Wachsend als IBM-Migrationspfad weg von WebSphere classic |
| Oracle WebLogic Server | Oracle | 12.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 Prozessor | Oft mehrere 10.000 $/Prozessor + ca. 22 %/Jahr Support | Hoch in Konzern-IT mit Oracle-Stack, stark rückläufig |
| Red Hat JBoss EAP | Red 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 Core | Ca. 3.500–7.000 $/Core/Jahr | Mittel-hoch, v. a. Red-Hat-Kunden |
| WildFly | Red Hat (Community) | ca. 34.x (fortlaufende Releases, Upstream von EAP) | Jakarta EE 10 | Java SE 11 oder 17+ | Kostenlos, Open Source | Kostenlos | Mittel – beliebt zum Lernen und für kleinere Deployments |
| Payara Server / GlassFish | Payara Services / Eclipse Foundation | Payara 6 / GlassFish 7 | Jakarta EE 10 | Java SE 11, 17 oder 21 | Community kostenlos, Enterprise-Support optional | Kostenlos; Support optional ab ca. 3.000–5.000 $/Jahr | Nische, oft ehemalige GlassFish-Nutzer |
| Apache Tomcat | Apache Software Foundation | 10.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 Source | Kostenlos | Sehr hoch – Standard-Unterbau u. a. für Spring Boot |
| Eclipse Jetty | Eclipse Foundation | 12.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 Source | Kostenlos | Hoch als eingebetteter Server in Microservices |
| Undertow | Red Hat | 2.3.x (Standard-Embedded-Server in Spring Boot 3) | Servlet 6.0 / Jakarta-EE-kompatibel | Java SE 17+ (2.3.x); Java SE 8+ (ältere 2.2.x-Linie) | Kostenlos, Open Source | Kostenlos | Hoch 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.
| Server | Aktuelle Version | Empfohlene Java-Version | (Standard-)Support-Ende | Wichtiger Hinweis |
|---|---|---|---|---|
| IBM WebSphere (traditional) 9.0.5.x | 9.0.5.x | Java 11 | Standard-Support ausgelaufen; erweiterter Support bis ca. 2030 gegen Aufpreis | Kein neuer Major seit 2016 – IBM treibt Migration auf WebSphere Liberty aktiv voran |
| IBM WebSphere Liberty | Rolling (z. B. 24.0.0.x/25.0.0.x) | Java 17 oder 21 | Kein fixes EOL, aber nur die jeweils aktuelle CD-Version wird unterstützt | Continuous-Delivery-Modell wie Spring Boot; Open Liberty als kostenloser Zwilling |
| Oracle WebLogic Server 12.2.1.4 | 12.2.1.4 | Java 11 | Premier Support bereits ausgelaufen; erweiterter Support bis ca. Ende 2026 | De-facto-Auslaufmodell; Migration auf 14.1.2 oder Weg von WebLogic empfohlen |
| Oracle WebLogic Server 14.1.2 | 14.1.2 | Java 17 oder 21 | Premier Support bis ca. 2032 | Erste WebLogic-Linie mit Jakarta-EE-10-Unterstützung (Namespace-Wechsel javax→jakarta) |
| Red Hat JBoss EAP 8.0 | 8.0 | Java 17 | Volle Unterstützung bis ca. 2028, Maintenance bis ca. 2031 | Erste EAP-Generation mit Jakarta EE 10; deutlicher Sprung ggü. EAP 7.4 |
| Red Hat JBoss EAP 7.4 | 7.4 | Java 11 | Bereits in Maintenance-Phase, ausgelaufen ca. 2025–2027 | Letzte EAP-Generation auf javax.*-Namespace; Ablösung durch EAP 8 empfohlen |
| WildFly | ca. 34.x | Java 17 oder neuer | Kein formales EOL; nur jeweils neueste Version wird von der Community gepflegt | Upstream-Projekt von JBoss EAP – neue Features erscheinen hier zuerst |
| Payara Server / GlassFish | Payara 6 / GlassFish 7 | Java 17 oder 21 | Community: kein festes EOL; Payara Enterprise ca. 5 Jahre pro Major | GlassFish ist die Jakarta-EE-Referenzimplementierung, Payara der produktionsnahe Fork |
| Apache Tomcat 10.1.x | 10.1.x | Java 17 | Kein festes EOL, aktiv gepflegt (Stand 2025/26) | Erste Tomcat-Linie auf Jakarta-Namespace (jakarta.servlet.*) |
| Apache Tomcat 9.0.x | 9.0.x | Java 11 | EOL mehrfach verschoben, aktuell ca. 2027 angesetzt | Letzte Linie auf javax.servlet.*; für Alt-Anwendungen ohne Jakarta-Migration |
| Eclipse Jetty 12 | 12.0.x | Java 17 | Aktiv gepflegt, kein festes EOL bekannt | Modulares „Environments"-Konzept erlaubt Jakarta EE 8/9/10/11 nebeneinander |
| Undertow | 2.3.x | Java 17 | Folgt dem Support-Zyklus von WildFly/JBoss EAP | Standard-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.
| Server | Versionssprung | Was sich geändert hat | Warum er wichtig ist |
|---|---|---|---|
| Alle Jakarta-EE-Server (übergreifend) | Java EE 8 → Jakarta EE 9 (2020) | Reiner Namespace-Wechsel javax.* → jakarta.*, keine neuen Features | Größter Breaking Change der Plattformgeschichte; jede Anwendung braucht Code-Anpassung oder ein Migrationstool (z. B. Eclipse Transformer) |
| Apache Tomcat | 9.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 an | Wer von Tomcat 9 kommt, muss Anwendungscode UND Third-Party-Libraries (z. B. JSP-Taglibs) aktualisieren |
| Red Hat JBoss EAP | EAP 7.4 → EAP 8.0 (2023) | Jakarta EE 10, Java-17-Baseline, MicroProfile 6, neue Management-CLI-Features | Erster EAP-Major seit 2017; markiert das Ende der javax-Ära bei Red Hat |
| WildFly | WildFly 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 Server | 12.2.1.4 → 14.1.2 (2024) | Erstmals Jakarta EE 10 statt Java EE 8, zusätzlich Java-17/21-Support und schlankere Container-Images | Zehn Jahre ohne echten Major-Sprung (12c-Linie seit 2014) – 14.1.2 ist Oracles verspäteter Anschluss an Jakarta EE |
| IBM WebSphere | WebSphere traditional 9.0 → WebSphere Liberty | Kein Versions-, sondern ein Architektursprung: vom monolithischen Vollserver zum modularen, cloud-nativen Leichtserver mit Jakarta-EE-10-Unterstützung | IBM investiert neue Features fast ausschließlich in Liberty; traditional gilt intern als Legacy-Pfad |
| GlassFish | GlassFish 5 → GlassFish 6/7 | Projekt wechselt 2019 von Oracle zur Eclipse Foundation, GlassFish 6 bringt Jakarta EE 9, GlassFish 7 Jakarta EE 10 | Als Jakarta-EE-Referenzimplementierung markiert dieser Sprung offiziell das Ende von „Java EE" als Markenname |
| Eclipse Jetty | Jetty 11 → Jetty 12 (2023) | Neues „Environments"-Konzept: Jakarta EE 8, 9, 10 und 11 laufen parallel im selben Server, Java-17-Baseline | Löst das javax/jakarta-Dilemma dauerhaft, indem beide Welten gleichzeitig unterstützt werden |
| Undertow | Undertow 2.2.x → 2.3.x | Anhebung auf Servlet 6.0 / Jakarta EE 10, Java-17-Baseline passend zu Spring Boot 3 | Wer 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.
- 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.
- 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.
Elasticsearch & Volltextsuche im Vergleich
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.
tsvector/tsquery-Suche plus pg_trgm-Erweiterung für Fuzzy-Matching – kein separater Server nötig.Direktvergleich
| Technologie | Projekt/Hersteller | Aktuelle Version | Laufzeit | Empfohlene Java-Version | Lizenzmodell |
|---|---|---|---|---|---|
| Elasticsearch | Elastic NV | 9.x (Hauptlinie seit 2025); 8.x weiterhin gepflegt | JVM (Lucene-basiert), bringt eigenes Bundled-JDK mit | Java 21 (im Server-Distributionspaket enthalten) | Elastic License 2.0 / SSPL, seit 2024/8.16+ zusätzlich AGPL v3 als Open-Source-Option |
| OpenSearch | AWS / OpenSearch Software Foundation | 2.x (Hauptlinie); 3.x als neueste Linie (seit 2025) | JVM (Lucene-basiert), bringt eigenes Bundled-JDK mit | Java 21 (im Server-Distributionspaket enthalten) | Apache 2.0 (durchgehend quelloffen) |
| Apache Solr | Apache Software Foundation | 9.x | JVM (Lucene-basiert) | Java 11 minimal, Java 17/21 empfohlen | Apache 2.0 |
| PostgreSQL-Volltextsuche | PostgreSQL Global Development Group | Fester Bestandteil seit PostgreSQL 8.3; PROCUREX nutzt PostgreSQL 16/17 | Kein separater Server – Feature der ohnehin genutzten Datenbank | Nicht 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.
- 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.
- 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_trgmist 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.
PostgreSQL, MySQL, Oracle & Co. im Vergleich
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.
Direktvergleich: relationale Datenbanken
| Datenbank | Hersteller/Projekt | Aktuelle Version | Java-Anbindung (JDBC) | Lizenzmodell | Kosten (ca.) |
|---|---|---|---|---|---|
| PostgreSQL | PostgreSQL Global Development Group | 17.x (aktuelle Hauptlinie); 16.x weiterhin unterstützt (PROCUREX nutzt 16 in Tests) | org.postgresql:postgresql-Treiber | PostgreSQL-Lizenz (sehr permissiv, MIT-/BSD-ähnlich) | Kostenlos; Kosten entstehen höchstens durch verwaltete Cloud-Dienste (RDS, Cloud SQL) |
| MySQL | Oracle | 8.4 LTS bzw. 9.x als „Innovation Release“ | mysql-connector-j | GPL v2 (Community) oder kommerziell (Enterprise Edition) | Community kostenlos; Enterprise-Support ab ca. 2.000–5.000 $/Jahr |
| MariaDB | MariaDB Foundation | 11.x | mariadb-java-client (auch mysql-connector-j kompatibel) | GPL v2, durchgehend quelloffen | Kostenlos; Enterprise-Support bei MariaDB plc optional |
| Oracle Database | Oracle | 23ai (aktuelle Linie seit 2024) | ojdbc-Treiber | Kommerziell, pro Prozessor/NUP; Express Edition kostenlos, aber stark limitiert | Oft fünf- bis sechsstellig pro Jahr in Enterprise-Umgebungen |
| Microsoft SQL Server | Microsoft | SQL Server 2022 | mssql-jdbc | Kommerziell, pro Core oder Server+CAL; Express Edition kostenlos, limitiert | Standard 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.
| Technologie | Typ | Aktuelle Version | Java-Anbindung | Lizenzmodell |
|---|---|---|---|---|
| MongoDB | Dokumentenorientierte NoSQL-Datenbank | 8.x | mongodb-driver-sync / Spring Data MongoDB | SSPL (seit 2018, nicht OSI-anerkannt als Open Source) |
| Redis | In-Memory Key-Value-Store / Cache | 7.x / 8.x | Lettuce oder Jedis als Java-Client | Seit 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.
- 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.
- 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, Datenbankprocurex_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.
Java-/EE-Entwurfsmuster: von J2EE-Core-Patterns zu PROCUREX' hexagonaler Architektur
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.
Klassische J2EE-Muster und ihre moderne Entsprechung
| J2EE-Core-Pattern (2001) | Ursprünglicher Zweck | Moderne Entsprechung | Beispiel in PROCUREX |
|---|---|---|---|
| Data Access Object (DAO) | Kapselt SQL/Persistenz hinter einer Schnittstelle | Spring-Data-Repository-Interface | CatalogItemRepository (adapter/out) |
| Transfer Object / Value Object | Datenbündel ohne Verhalten für den Transport über Schichtgrenzen | Java Record als DTO, oft mit statischer from(...)-Factory | CatalogItemResponse, CreateCatalogItemRequest, CatalogItemSummary |
| Business Delegate | Versteckt den komplexen EJB-Lookup vor dem Client | Direkt injiziertes Port-Interface | CatalogLookupPort, konsumiert in CartSubmissionService (Modul procurex-requisition) |
| Session Facade | Grobkörnige Fassade über mehrere feingranulare EJBs | Application-Service als Fassade über Domäne und Repository | CatalogService (application) |
| Service Locator | Zentrale Registry zum manuellen Auflösen von Abhängigkeiten (JNDI) | Dependency Injection über den Spring-Container | Konstruktor-Injection in allen @Service-/@RestController-Klassen |
| Front Controller | Ein zentraler Servlet-Einstiegspunkt für alle Requests | DispatcherServlet (Spring MVC) + ein @RestController pro Ressource | CatalogController (adapter/in) |
| Intercepting Filter | Vor-/nachgelagerte Querschnittslogik (Auth, Logging) im Request-Pfad | Servlet-/Security-Filterchain, @ControllerAdvice | Keycloak-OIDC-Filterchain in procurex-app |
| Composite Entity / Value List Handler | EJB-2.x-Workarounds gegen teure Entity-Bean-Zugriffe | Entfällt strukturell | Nicht 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.
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)
| Muster | Zweck | Im PROCUREX-Code? | Beleg/Begründung |
|---|---|---|---|
| Singleton | Genau eine Instanz einer Klasse global zugänglich machen | Nicht handgeschrieben | Überflüssig – Spring verwaltet jeden @Service/@Component standardmäßig im Singleton-Scope des IoC-Containers |
| Factory Method | Objekterzeugung hinter einer Methode statt einem Konstruktor verstecken | Ja | CatalogItemResponse.from(item), Money.of(amount, currency) als statische Factory-Methoden |
| Abstract Factory | Ganze Familien zueinander passender Objekte erzeugen | Nicht genutzt | Kein Fall mit mehreren austauschbaren Produktfamilien im Domänenmodell |
| Builder | Komplexe Objekte schrittweise aus vielen (oft optionalen) Teilen zusammensetzen | Nicht im PROCUREX-Code | Java Records mit wenigen Pflichtfeldern (z. B. CreateCatalogItemRequest) machen einen Builder unnötig; relevant erst bei sehr vielen optionalen Feldern |
| Prototype | Neue Objekte durch Klonen eines Prototyps statt Neuerzeugung gewinnen | Nicht genutzt | Für Domänenobjekte mit Datenbank-Identität (UUID, Flyway-Schema) unüblich und unpassend |
Strukturmuster (Structural)
| Muster | Zweck | Im PROCUREX-Code? | Beleg/Begründung |
|---|---|---|---|
| Adapter | Eine vorhandene Schnittstelle an eine erwartete anpassen | Ja, zentral | Die Pakete adapter/in und adapter/out sind wörtlich dieser Gedanke: REST bzw. JPA werden an die Port-/Domänenschnittstelle angepasst (siehe Diagramm oben) |
| Facade | Vereinfachten Zugang zu einem komplexeren Subsystem bieten | Ja | CatalogService bündelt Repository-Zugriff und Domänenlogik hinter wenigen öffentlichen Methoden |
| Bridge | Abstraktion und Implementierung unabhängig voneinander variieren lassen | Konzeptionell ja | Ein Port (Abstraktion, z. B. CatalogLookupPort) ist von seiner Adapter-Implementierung entkoppelt – derselbe Trennungsgedanke wie bei Bridge, nur unter dem Namen „Ports & Adapters“ |
| Decorator | Verhalten zur Laufzeit um ein Objekt herum ergänzen, ohne die Klasse zu ändern | Indirekt, über das Framework | Spring-AOP-Proxies, die @Transactional auf CatalogService umsetzen, sind strukturell Decorator/Proxy – deklarativ per Annotation erzeugt, nicht handgeschrieben |
| Proxy | Stellvertreter mit Zugriffskontrolle, Lazy Loading oder Remote-Aufruf | Indirekt | Hibernate-Lazy-Loading-Proxies für JPA-Assoziationen sowie die Spring-Security-Interceptorkette vor jedem Controller |
| Composite | Teil-Ganzes-Baumstrukturen einheitlich behandeln | Nicht genutzt | Keine rekursiven Hierarchien im Datenmodell (Kataloge/Bestellungen sind flach modelliert) |
| Flyweight | Speicher sparen, indem viele kleine Objekte Zustand teilen | Nicht genutzt | Keine Objektmengen in einer Größenordnung, die geteilten Zustand rechtfertigen würde |
Verhaltensmuster (Behavioral)
| Muster | Zweck | Im PROCUREX-Code? | Beleg/Begründung |
|---|---|---|---|
| Template Method | Algorithmus-Grundgerüst in einer Basisklasse festlegen, Details in Unterklassen füllen | Ja, explizit | AbstractOutboxRelay.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 Responsibility | Eine Anfrage durch eine Kette potenzieller Bearbeiter reichen, bis einer zuständig ist | Ja, über das Framework | Die Spring-Security-Filterchain (OIDC/JWT-Prüfung vor Keycloak-Anbindung) ist ein Lehrbuchbeispiel dieses Musters |
| Command | Eine Anfrage als eigenständiges Objekt kapseln | Teilweise | DTOs wie CreateCatalogItemRequest kapseln eine Anfrage als Datenobjekt, aber ohne die volle Command-Schnittstelle (kein execute()/undo()) |
| Observer | Abhängige Objekte automatisch über Zustandsänderungen benachrichtigen | Konzeptionell, verteilt | Kein ApplicationEventPublisher im Code, aber das Kafka-Outbox-Publishing ist der verteilte Cousin von Observer: Module abonnieren Topics, statt Listener direkt zu registrieren |
| Strategy | Austauschbare Algorithmen zur Laufzeit hinter einer gemeinsamen Schnittstelle | Nicht genutzt | Jeder Port hat genau eine Adapter-Implementierung – Ports & Adapters zieht Architekturgrenzen, wählt aber zur Laufzeit nichts zwischen mehreren Algorithmen aus |
| State | Zustandsabhängiges Verhalten in eigene Zustandsklassen auslagern | Nicht im GoF-Sinn | OrderStatus ist ein reines Enum ohne zustandsspezifische Methoden – für den aktuellen Umfang ausreichend, aber kein echtes State-Pattern |
| Visitor | Neue Operationen auf einer Objektstruktur ergänzen, ohne die Klassen selbst zu ändern | Nicht genutzt | Keine heterogene Objektstruktur, die unterschiedliche „Besucher“ bräuchte |
| Iterator, Mediator, Memento, Interpreter | Kollektionsdurchlauf, Objekt-Kommunikation entkoppeln, Zustand sichern/wiederherstellen, Grammatiken interpretieren | Nicht genutzt | Java-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.
- 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.
- 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.
Exception-Best-Practices: Java, Jakarta EE und Spring im Vergleich
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.
throws-Klauseln zu Boilerplate; moderne Spring-/Jakarta-Anwendungen setzen fast durchgängig auf RuntimeException.ProblemDetail unterstützt.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.
| Aspekt | Checked Exception | Unchecked Exception (RuntimeException) |
|---|---|---|
| Compiler-Zwang | Muss gefangen oder deklariert werden | Optional – Aufrufer kann ignorieren |
| Typischer Einsatz | Wiederherstellbare Zustände, die der Aufrufer aktiv behandeln kann/muss | Programmier- oder Zustandsfehler, die meist zentral statt lokal behandelt werden |
| Historisches Beispiel | SQLException, NamingException, RemoteException (klassisches Java EE/RMI) | DataAccessException-Hierarchie (Spring), alle vier PROCUREX-Domain-Exceptions |
| Praxisproblem | Boilerplate-catch-Blöcke, Signaturen „verschmutzen“ über Schichtgrenzen hinweg | Erfordert Disziplin: ohne zentrale Behandlung drohen unbehandelte 500er |
| Heutige Empfehlung | Nur 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.
| Ansatz | Technologie | Mechanik | PROCUREX-Bezug |
|---|---|---|---|
| Spring MVC / WebFlux | Spring Framework 6 / Boot 3 | @RestControllerAdvice-Klasse mit einer @ExceptionHandler-Methode pro Exception-Typ, Rückgabe als ProblemDetail | GlobalExceptionHandler – eine Klasse für alle sieben Fachmodule, da procurex-common überall eingebunden ist |
| Jakarta EE / JAX-RS | Jakarta RESTful Web Services | Eigene Klasse implementiert ExceptionMapper<T>, markiert mit @Provider, vom Container automatisch registriert | Nicht 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-Fehlerseite | Nicht 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.
| Aspekt | Spring MVC | Reines Jakarta EE / JAX-RS |
|---|---|---|
| Auslösende Annotation | @Valid am @RequestBody-Parameter | @Valid am JAX-RS-Ressourcenparameter (identischer Jakarta-Validation-Standard) |
| Geworfene Exception | MethodArgumentNotValidException (Spring-eigener Wrapper um die Constraint-Verletzungen) | jakarta.validation.ConstraintViolationException direkt vom Bean-Validation-Provider |
| Wo abgefangen | GlobalExceptionHandler.handleValidation(...) (@RestControllerAdvice) | Eigener ExceptionMapper<ConstraintViolationException> |
| Antwortformat | ProblemDetail 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
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 einException 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:CorrelationIdFilterentfernt den MDC-Eintrag in einemfinally-Block – notwendig, weil Tomcat-Threads aus einem Pool wiederverwendet werden und ein vergessenerMDC.remove(...)sonst Correlation-IDs zwischen unabhängigen Requests vermischen würde. - Fachliche und technische Fehler trennen:
ForbiddenExceptionwird 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:
GlobalExceptionHandlerhat keinen@ExceptionHandler(Exception.class)-Catch-all. Eine wirklich unerwarteteRuntimeException(z. B. eineNullPointerException) fällt dadurch auf Spring Boots Standard-Fehlerbehandlung zurück statt auf ein konsistentesProblemDetailmit 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.
| Aspekt | REST-synchron (GlobalExceptionHandler) | Kafka-asynchron (@KafkaListener) |
|---|---|---|
| Wer wartet auf eine Antwort? | Ein HTTP-Client mit offener Verbindung | Niemand – der Producer hat längst weitergemacht |
| Zentraler Übersetzungsmechanismus | @RestControllerAdvice → ProblemDetail | Kein PROCUREX-eigener; Spring Kafkas CommonErrorHandler greift |
| Standardverhalten bei unbehandelter Exception | Fällt auf Spring Boots generische Fehlerantwort zurück (siehe Lücke oben) | Erneute Zustellung mit Backoff – ohne konfigurierten DeadLetterPublishingRecoverer potenziell endlos |
| Umgang mit doppelter Zustellung | Kein Thema (Request-Response ist 1:1) | Muss aktiv behandelt werden – hier über Unique-Constraint auf eventId + gezielten catch |
| PROCUREX-Status | Vier 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.
| Exception | Typischer Auslöser | Von GlobalExceptionHandler abgedeckt? |
|---|---|---|
MethodArgumentNotValidException | Jakarta-Bean-Validation-Verstoß im Request-Body (@Valid) | Ja – HTTP 400 mit errors-Array |
DataIntegrityViolationException | Verletzter Unique-/Foreign-Key-Constraint in PostgreSQL | Nein, außer punktuell im Kafka-Consumer selbst abgefangen (siehe oben) – im REST-Pfad ungemappt |
OptimisticLockingFailureException | Gleichzeitige Änderung derselben Zeile (@Version-Konflikt auf PurchaseOrder), von den Integrationstests geprüft | Teilweise – 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. |
HttpMessageNotReadableException | Fehlerhaftes/leeres JSON im Request-Body | Nein – fällt auf Spring Boots Standardantwort zurück |
MethodArgumentTypeMismatchException | Falscher Typ in einem @PathVariable/@RequestParam (z. B. keine gültige UUID) | Nein |
AccessDeniedException (Spring Security) | Fehlende Rolle am Endpunkt | Nein – 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
| Regel | Begründung |
|---|---|
Nicht pauschal catch (Exception e) oder catch (Throwable t) fangen | Verschluckt 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 missbrauchen | Das 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 bringt | Eine 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-Close | Schließt AutoCloseable-Ressourcen (Streams, Connections) garantiert auch bei einer Exception, ohne dass der Entwickler daran denken muss |
| So spezifisch wie möglich fangen | Ein einzelner breiter catch-Block über mehrere Exception-Typen verwischt, welcher Fehlerfall tatsächlich eingetreten ist und wie er behandelt werden sollte |
@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.- Eigene Exceptions grundsätzlich unchecked (
RuntimeException) modellieren und pro fachlicher Bedeutung eine eigene Klasse anlegen (wieNotFoundException,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 (
ProblemDetailin 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.classergä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.
- Alle vier PROCUREX-Domain-Exceptions sind unchecked (
extends RuntimeException) – nirgends im Code steht für sie einethrows-Klausel. GlobalExceptionHandlerhat keinen Catch-all-Handler – eine wirklich unerwartete Exception (z. B. eineNullPointerException) fällt auf Spring Boots generische Standardantwort zurück, nicht auf das eigeneProblemDetail-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.
Transactional Outbox, Kafka-Topics und Event-Versionierung in PROCUREX
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.
procurex.<modul>.<ereignis>.v1, produziert von vier Fachmodulen.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
| Topic | Produzent | Konsumenten |
|---|---|---|
procurex.ordering.order-submitted.v1 | procurex-ordering | procurex-notification, procurex-audit |
procurex.approval.order-approved.v1 | procurex-approval | procurex-notification, procurex-audit |
procurex.approval.order-rejected.v1 | procurex-approval | procurex-notification, procurex-audit |
procurex.receiving.goods-received.v1 | procurex-receiving | procurex-notification, procurex-audit |
procurex.invoicing.invoice-matched.v1 | procurex-invoicing | procurex-notification, procurex-audit |
procurex.invoicing.invoice-discrepancy-flagged.v1 | procurex-invoicing | procurex-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.
| Aspekt | NotificationConsumer | AuditLogConsumer |
|---|---|---|
| Zweck | Rollenspezifische In-App-Benachrichtigung erzeugen | Unveränderte Kopie jedes Events als Audit-Trail |
| Consumer-Group | procurex-notification | procurex-audit |
| Duplikaterkennung | existsByEventId(...)-Vorprüfung + catch (DataIntegrityViolationException) | Dieselbe Doppelabsicherung – Vorprüfung und Constraint-Catch |
Fehlender event-id-Header | Event wird verworfen (eventId == null → return) | Wird geloggt (log.warn) und verworfen, nicht stillschweigend ignoriert |
| Startverhalten | auto-offset-reset: earliest gilt global – auch Notification sieht bei Erststart die volle Historie | Bewusst gewählt, damit der erste Audit-Consumer (PRX-34) auch bereits publizierte Alt-Events erfasst |
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
| Ansatz | Funktionsweise | Trade-off ggü. PROCUREX' Outbox-Lösung |
|---|---|---|
| Direktes Publizieren (kein Outbox) | Service ruft nach dem DB-Commit direkt kafkaTemplate.send(...) auf | Einfacher, 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 Anwendungscode | Kein 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 Registry | Events als binäres, schemavalidiertes Format statt JSON-Strings | Erzwingt Schema-Kompatibilität zur Build-Zeit, aber zusätzliche Infrastruktur (Schema Registry) und weniger einfach von Hand zu inspizieren als JSON |
.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.- 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.
- 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.
Die Testpyramide in PROCUREX: sechs Ebenen, ein Zweck
„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.
Die sechs Ebenen im Überblick
| Ebene | Werkzeug | Was sie prüft | PROCUREX-Beispiel | Typische Laufzeit |
|---|---|---|---|---|
| 1 · Domain-Unit | JUnit 5 + AssertJ | Reine Geschäftslogik eines einzelnen Domain-Objekts, ganz ohne Framework | CatalogItemTest – prüft Preis/Einheit eines CatalogItem | Millisekunden, kein Spring-Kontext |
| 2 · Service-Unit | JUnit 5 + Mockito | Application-Service-Logik, Abhängigkeiten (Ports) durch Mocks ersetzt | CartSubmissionServiceTest – mockt SupplierLookupPort, CatalogLookupPort, OrderCreationPort | Millisekunden, kein Spring-Kontext |
| 3 · Web-Slice-Test | @WebMvcTest + Spring Security Test | Controller- und Security-Verhalten isoliert, ohne vollen Anwendungskontext | BuyerContractBase – simuliert ROLE_BUYER per SecurityMockMvcRequestPostProcessors.jwt() | Sekundenbereich, Teil-Spring-Kontext |
| 4 · Contract-Test | Spring Cloud Contract (Groovy-DSL) | Ob ein Endpunkt exakt der vereinbarten Anfrage-/Antwortform entspricht | contracts/requisition/shouldSubmitCart.groovy – POST /api/v1/cart/submit | Sekundenbereich, generiert zugleich Stubs |
| 5 · Integrationstest | Testcontainers + @SpringBootTest | Echtes DB-Verhalten: Optimistic Locking, Unique-Constraints, Transaktionsgrenzen | IntegrationTestBase – startet postgres:16-alpine als echten Container | Sekunden bis niedrige Minuten, echter Docker-Container |
| 6a · E2E-Test | Playwright | Kompletter Nutzerfluss im echten Browser, inkl. UI und Login | e2e/login.spec.ts, e2e/procure-to-pay.spec.ts | Sekunden bis Minuten pro Szenario, echter Chromium |
| 6b · Lasttest | k6 | Antwortzeit und Fehlerrate der API unter simulierter Nebenlast | loadtests/01-katalogsuche.js – 20 virtuelle Nutzer, Schwellenwert p95<500ms | Minuten, 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
| Ebene | Beweist NICHT |
|---|---|
| Domain-/Service-Unit-Tests | Ob die echten Adapter (DB, REST, Kafka) korrekt zusammenspielen – alle Abhängigkeiten sind Mocks |
| Web-Slice-/Contract-Test | Ob 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.
procurex-app/pom.xml deshalb standardmäßig vom regulären mvn test-Lauf ausgeschlossen und laufen separat.- 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.
- 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
JwtDecoderbeweist 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.
OAuth2, OIDC und Keycloak: wie PROCUREX Identität und Rollen prüft
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.
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.
| Aspekt | OAuth2 | OpenID Connect (OIDC) |
|---|---|---|
| Grundfrage | „Darf dieser Client das?“ (Autorisierung) | „Wer ist dieser Nutzer?“ (Authentifizierung) |
| Token | Access Token (Format nicht vorgeschrieben) | Zusätzlich: ID Token (immer ein JWT) |
| Standardjahr | RFC 6749 (2012) | OpenID Connect Core 1.0 (2014), baut auf OAuth2 auf |
| PROCUREX-Nutzung | procurex-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-Provider | Hersteller | Betriebsmodell | Lizenz/Kosten | Typischer Einsatz |
|---|---|---|---|---|
| Keycloak | Red Hat (CNCF-Projekt) | Selbst gehostet (Docker/Kubernetes) | Apache 2.0, kostenlos – Kosten entstehen nur durch eigenen Betrieb | Volle Kontrolle über Daten/Infrastruktur gewünscht, Kostendruck bei vielen Nutzern |
| Auth0 | Okta (nach Übernahme 2021) | SaaS | Kommerziell, Preis nach aktiven Nutzern (MAU) | Schneller Start ohne eigenen Betrieb, breites Social-Login-Angebot |
| Okta (eigenständig) | Okta | SaaS | Kommerziell, oft Enterprise-Verträge | Große Unternehmen mit komplexen Compliance-/SSO-Anforderungen |
| Microsoft Entra ID (Azure AD) | Microsoft | SaaS | Kommerziell, oft in Microsoft-365-Verträge gebündelt | Unternehmen, die bereits tief im Microsoft-365-/Azure-Ökosystem sind |
| Amazon Cognito | AWS | SaaS (verwaltet) | Kommerziell, nach aktiven Nutzern | Anwendungen, 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.
- 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.
- 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, daherSessionCreationPolicy.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.