Paketstruktur
Unsere Backend-Dienste folgen dem Prinzip Package by Feature, organisiert über vier Geschwister-Module. Dieses Dokument ist die verbindliche Quelle dafür, wo neuer Code hingehört.
Sprachnotiz. Die Beispiele unten sind in Java/Spring Boot geschrieben, da dies unser primärer Backend-Stack ist, aber die Struktur selbst ist sprachunabhängig und für jede von uns verwendete Backend-Sprache verbindlich (Kotlin, Go, Python, Node usw.). Übersetzen Sie die Dateierweiterungen und Framework-Annotationen; behalten Sie die Ordnerstruktur, die Namenssuffixe (
*Db,*Repo,*RepoImpl,*Dao,*Controller,*ControllerImpl) und die Aufteilung in Schnittstelle/Implementierung unverändert bei.
Kernprinzip
Gruppieren nach Feature, nicht nach technischer Schicht. Eine Änderung am Feature "Bestellungen" sollte Dateien betreffen, die nebeneinander liegen, nicht Dateien, die über Pakete wie controllers/, services/, repositories/ verstreut sind. Die technische Schichtung erfolgt auf Modul-Ebene (db, client, capabilities, starter), und Package-by-Feature findet innerhalb jedes Moduls statt.
Die vier Module
Jeder Dienst besteht aus diesen vier Geschwister-Modulen:
<service>/
├── capabilities/ # Feature/Geschäftslogik — das Gehirn
├── db/ # Persistenz: Entitäten, Repositories, Migrationen
├── client/ # Ausgehende Integrationen mit externen Systemen
└── starter/ # Spring Boot Starter — verdrahtet alles und führt aus
capabilities/ — Geschäftslogik
Jedes Feature erhält ein eigenes Paket, und innerhalb jedes Features gibt es ein Unterpaket pro API-Endpunkt (getById, create, updateById, getByQuery, …). Jedes Unterfeature besitzt drei Geschwister-Ordner:
service/— die einzelne Service-Klasse für diese APIdto/—request/undresponse/(je nach Bedarf)mapper/— übersetzt*Db-Entitäten ↔ DTOs (beide Richtungen) nur für dieses Unterfeature
Der Controller für das Feature befindet sich im Stamm des Feature-Pakets, aufgeteilt in eine Schnittstelle und eine Implementierung, genau wie die Repositories:
capabilities/<feature>/<Feature>Controller.java— Schnittstelle; deklariert Routen, HTTP-Verben, Anfrage-/Antworttypen, API-Dokumentationsannotationencapabilities/<feature>/<Feature>ControllerImpl.java— Implementierung; trägt die Framework-Annotation (@RestController) und delegiert jede Route an den passenden Unterfeature-Service
Gemeinsam für alle Unterfeatures dieses Features. Siehe Controller.
- Abhängigkeiten:
db(nur über*Repo-Schnittstellen — siehe DB-Nutzungsregel),client - Hat niemals Abhängigkeiten zu:
starter - Enthält keine
main-Klasse, kein@SpringBootApplication - Besitzt: HTTP-Controller, API-Anfrage-/Antwort-DTOs, Mapper, Services
db/ — Persistenz
Organisiert ein Ordner pro Tabelle. Jeder Tabellenordner hat genau drei Unterordner:
db/<tableName>/
├── model/
│ ├── <EntityName>Db.java # Haupt-JPA-Entität
│ └── <Other>Db.java # Unter-Modelle (eingebettete Typen, Kind-Entitäten, Projektionen)
├── dao/
│ └── <EntityName>Dao.java # Low-Level-Datenzugriff (Criteria/JPQL/native Queries)
└── repository/
├── <EntityName>Repo.java # ÖFFENTLICHE Schnittstelle — der einzige Typ, den capabilities sieht
└── impl/
└── <EntityName>RepoImpl.java # Kombiniert Spring Data + Dao hinter der Schnittstelle
Namenskonvention
| Schicht | Suffix | Beispiel |
|---|---|---|
| JPA-Entität | Db | TestcaseDb |
| Repository-Schnittstelle | Repo | TestcaseRepo |
| Repository-Implementierung | RepoImpl | TestcaseRepoImpl |
| Datenzugriffs-Helfer | Dao | TestcaseDao |
DB-Nutzungsregel
Capabilities dürfen nur von *Repo-Schnittstellen abhängen. *RepoImpl und *Dao sind intern — niemals außerhalb des db/<tableName>/-Pakets injiziert oder referenziert. Dadurch bleibt die Persistenzschicht austauschbar und Capability-Tests konzentrieren sich auf den Vertrag.
- Abhängigkeiten: nichts in diesem Dienst
- Stellt bereit:
*Repo-Schnittstellen und*Db-Modelltypen - Enthält niemals: Geschäftslogik, Transformation zu API-DTOs
client/ — Ausgehende Integrationen
Enthält den Code, der andere Dienste aufruft, von denen dieser Dienst abhängt. Hauptsächlich ein Unterordner pro abhängigem Mikroservice in unserer eigenen Plattform (paymentService, userService, executionService, …). Wrapper für Drittanbieter-SDKs oder Message-Producer können ebenfalls hier liegen, aber das vorherrschende Muster — und der Grund für die Existenz dieses Moduls — sind abhängige Mikroservice-Clients.
client/<microserviceName>/
├── <Microservice>Client.java # Schnittstelle — worauf capabilities angewiesen ist
├── impl/
│ └── <Microservice>ClientImpl.java # Implementierung: HTTP/gRPC/SDK-Aufruf, Wiederholungen, Timeouts
├── dto/ # Anfrage-/Antwort-Formate für DIESEN Mikroservice
│ ├── request/
│ └── response/
└── mapper/ # optional: Übersetzung zwischen deren und unseren Formaten
Regeln
-
Ein Unterordner pro abhängigem Mikroservice, benannt nach dem Dienst. Fassen Sie nicht mehrere Mikroservices in einem Paket zusammen, selbst wenn sie "verwandt" sind.
-
Capabilities hängen nur von
*Client-Schnittstellen ab.*ClientImplist intern — dieselbe Regel wie bei*Repo/*RepoImpl. -
Die Client-Schnittstelle stellt die API des Mikroservices in Form seiner eigenen DTOs bereit; Capabilities können diese bei Bedarf weiter anpassen.
-
Übergreifende Belange (Wiederholungen, Circuit Breaker, Anfrage-/Antwort-Logging, Auth-Header) gehören in
impl/— Capabilities sollten sie niemals sehen. -
Abhängigkeiten: nichts in diesem Dienst
-
Stellt bereit:
*Client-Schnittstellen und deren Anfrage-/Antwort-DTOs -
Enthält niemals: Geschäftslogik, Capability-Orchestrierung
starter/ — Composition Root
Das einzige Modul, das ausgeführt wird. Enthält @SpringBootApplication, globale REST-Exception-Handler, Sicherheitskonfiguration, Filter und application.yml. Keine Controller — diese leben in capabilities/.
- Abhängigkeiten:
capabilities(und transitivdb,client) - Besitzt: das auslieferbare Artefakt, übergreifende Web-Konfiguration (Sicherheit, CORS, Fehlerbehandlung)
- Besitzt niemals: Feature-Controller oder Feature-DTOs
Abhängigkeitsregel
starter → capabilities → { db, client }
Pfeile gehen in eine Richtung. db und client importieren niemals aus capabilities oder starter. capabilities importiert niemals aus starter.
Controller
Es gibt einen Controller pro Feature, nicht pro Unterfeature, und er befindet sich im Stamm des Feature-Pakets innerhalb von capabilities/. Er ist in zwei Dateien aufgeteilt, die die *Repo / *RepoImpl-Aufteilung widerspiegeln:
capabilities/<feature>/<Feature>Controller.java— Schnittstelle. Deklariert die Routen, HTTP-Verben, Pfad-/Query-Parameter, Anfrage-/Antwort-DTO-Typen und API-Dokumentationsannotationen (OpenAPI/Swagger). Jeder, der die Schnittstelle liest, sollte den gesamten HTTP-Vertrag sehen.capabilities/<feature>/<Feature>ControllerImpl.java— Implementierung. Trägt die Framework-Annotation (@RestControllerin Spring), ist das Injektionsziel für Unterfeature-Services und enthält eine kurze Methode pro Route, die den passendenservice/aufruft und das Ergebnis zurückgibt. Keine Logik über die Delegation hinaus.
Warum die Aufteilung? Die Schnittstelle ist der API-Vertrag — stabil, dokumentiert, isoliert testbar. Die Implementierung ist Verkabelung — sie kann sich ändern, ohne den Vertrag zu ändern. Gleiche Begründung wie bei *Repo vs. *RepoImpl.
Der Controller delegiert jede HTTP-Route an den passenden Unterfeature-Service in capabilities/<feature>/<sub-feature>/service/. Anfrage-/Antwort-DTOs gehören zum Unterfeature (capabilities/<feature>/<sub-feature>/dto/); der Controller ist nur ein dünner HTTP-Adapter.
Warum innerhalb von capabilities/ und nicht in starter/? Der Controller, sein Service und seine DTOs ändern sich alle gemeinsam, wenn eine API geändert wird — die Zusammenlegung in einem Modul bewahrt das Package-by-Feature-Versprechen "ein Feature, ein Ort". starter/ ist für übergreifende Verkabelung reserviert (Sicherheit, Fehlerbehandler, App-Einstiegspunkt), nicht für per-Feature-Webcode.
Ausgearbeitetes Beispiel: ein testcase-Feature
Der folgende Baum verwendet .java-Erweiterungen zur Veranschaulichung. Ersetzen Sie die Dateierweiterung und Framework-Idiome Ihrer Sprache — Kotlin .kt, Go-Pakete, Python-Module usw. — aber behalten Sie die Ordnerstruktur und die Namenssuffixe identisch bei.
testcase-service/
├── capabilities/
│ └── com/example/testcase/capabilities/
│ └── testcase/ # Haupt-Feature
│ ├── TestcaseController.java # Schnittstelle — HTTP-Vertrag, Routendeklarationen, API-Dokumentation
│ ├── TestcaseControllerImpl.java # Implementierung — @RestController, delegiert an Unterfeature-Services
│ ├── getById/ # Unterfeature = eine API
│ │ ├── service/
│ │ │ └── GetTestcaseByIdService.java
│ │ ├── dto/
│ │ │ └── response/
│ │ │ └── GetTestcaseByIdResponse.java
│ │ └── mapper/
│ │ └── GetTestcaseByIdMapper.java # TestcaseDb → response
│ ├── create/
│ │ ├── service/
│ │ │ └── CreateTestcaseService.java
│ │ ├── dto/
│ │ │ ├── request/
│ │ │ │ └── CreateTestcaseRequest.java
│ │ │ └── response/
│ │ │ └── CreateTestcaseResponse.java
│ │ └── mapper/
│ │ └── CreateTestcaseMapper.java # request → TestcaseDb, TestcaseDb → response
│ ├── updateById/
│ │ ├── service/
│ │ │ └── UpdateTestcaseByIdService.java
│ │ ├── dto/
│ │ │ ├── request/
│ │ │ │ └── UpdateTestcaseByIdRequest.java
│ │ │ └── response/
│ │ │ └── UpdateTestcaseByIdResponse.java
│ │ └── mapper/
│ │ └── UpdateTestcaseByIdMapper.java
│ └── getByQuery/
│ ├── service/
│ │ └── GetTestcaseByQueryService.java
│ ├── dto/
│ │ ├── request/
│ │ │ └── GetTestcaseByQueryRequest.java
│ │ └── response/
│ │ └── GetTestcaseByQueryResponse.java
│ └── mapper/
│ └── GetTestcaseByQueryMapper.java
│
├── db/ # ein Ordner pro Tabelle
│ └── com/example/testcase/db/
│ └── testcase/ # Tabellenname
│ ├── model/
│ │ ├── TestcaseDb.java # JPA-Entität — Haupt
│ │ └── TestcaseStepDb.java # Unter-Modell (Kind/eingebettet)
│ ├── dao/
│ │ └── TestcaseDao.java # Query/Criteria-Helfer, paketprivater Gebrauch
│ └── repository/
│ ├── TestcaseRepo.java # Schnittstelle — capabilities hängt von DIESER ab
│ └── impl/
│ └── TestcaseRepoImpl.java # Kombiniert Spring Data + TestcaseDao
│
├── client/ # ein Ordner pro abhängigem Mikroservice
│ └── com/example/testcase/client/
│ ├── executionService/ # abhängiger Mikroservice, den wir aufrufen
│ │ ├── ExecutionServiceClient.java # Schnittstelle
│ │ ├── impl/
│ │ │ └── ExecutionServiceClientImpl.java # HTTP/gRPC-Aufruf, Wiederholungen, Timeouts
│ │ ├── dto/
│ │ │ ├── request/
│ │ │ │ └── RunTestcaseRequest.java
│ │ │ └── response/
│ │ │ └── RunTestcaseResponse.java
│ │ └── mapper/
│ │ └── ExecutionServiceMapper.java
│ └── userService/ # ein weiterer abhängiger Mikroservice
│ ├── UserServiceClient.java
│ └── impl/
│ └── UserServiceClientImpl.java
│
└── starter/ # nur übergreifende Verkabelung — keine Controller
└── com/example/testcase/
├── TestcaseServiceApplication.java
├── config/
│ ├── SecurityConfig.java
│ └── WebConfig.java
├── error/
│ └── GlobalExceptionHandler.java # @ControllerAdvice — übergreifend, nicht per Feature
└── resources/application.yml
Faustregeln
- Neue API zu einem bestehenden Feature hinzufügen? Erstellen Sie ein neues Unterfeature-Paket in
capabilities/<feature>/mit eigenemservice/,dto/{request,response}/undmapper/, fügen Sie dann die Routendeklaration in<Feature>Controller.java(Schnittstelle) und die delegierende Methode in<Feature>ControllerImpl.javahinzu. Erstellen Sie keinen zweiten Controller. - Ein brandneues Feature hinzufügen? Erstellen Sie das Feature-Paket in
capabilities/(Controller im Stamm, Unterfeatures pro API) und den passenden Tabellenordner indb/<tableName>/mitmodel/,dao/,repository/{impl/}. Fügen Sie einen Client nur hinzu, wenn Sie ein externes System aufrufen. Rühren Siestarter/nicht an. - Immer
*Repoinjizieren, niemals*RepoImploder*Dao. Capabilities hängen nur von der Schnittstelle ab.*RepoImplund*Daosind intern indb/<tableName>/— sie existieren, damit Sie die Abfragestrategie oder den zugrunde liegenden Speicher austauschen können, ohne Capability-Code zu ändern. - Immer
*Clientinjizieren, niemals*ClientImpl. Gleiche Regel für Aufrufe abhängiger Mikroservices. Capabilities hängen von der Schnittstelle ab; der Transport (HTTP, gRPC, SDK) lebt hinterimpl/. - Ein Ordner pro abhängigem Mikroservice in
client/. Benennen Sie den Ordner nach dem Mikroservice. Fassen Sie nicht "alle internen Dienste" in einem Paket zusammen. - Die Granularität der Unterfeatures endet bei
capabilities/.db/ist pro Tabelle organisiert;client/pro externem System. Eine Aufteilung pro API würde kohärenten Code zerstreuen. - Anfrage-/Antwort-DTOs und deren Mapper sind pro Unterfeature, niemals gemeinsam genutzt. Wenn
createundupdateByIdheute ähnlich aussehen, behalten Sie zwei DTOs und zwei Mapper. Die gemeinsame Nutzung koppelt zwei APIs, die unabhängig voneinander auseinanderdriften werden. - Mapper konvertieren Entität ↔ DTO direkt. Keine Zwischen-Domain-Modell-Schicht.
<SubFeature>Mapperkonvertiert*Db↔ die Anfrage-/Antwort-DTOs dieses Unterfeatures. - Drei Unterfeatures haben ähnlichen Code? Widerstehen Sie dem Drang, einen gemeinsam genutzten Service oder Mapper zu extrahieren, bis Sie dasselbe Muster an vier oder mehr Stellen haben.
- Wiederverwendung über Features hinweg? Legen Sie gemeinsam genutzte Hilfsprogramme in ein
common/-Paket innerhalb voncapabilities/(oder im entsprechenden Modul) und importieren Sie sie von dort. Vermeiden Sie zirkuläre Abhängigkeiten zwischen Features. - Validierungslogik gehört zu den DTOs (
dto/request/,dto/response/). Framework-spezifische Validierungsannotationen (z. B.@NotBlank,@Size,@Valid) direkt auf den DTO-Feldern. Unternehmensweite Validierungsregeln, die mehrere Felder betreffen, gehören in den Service (service/) dieses Unterfeatures.