4. Systemarchitektur - Dxme98/Dokumentation GitHub Wiki
Systemarchitektur
Diese Seite beschreibt das "Wie" – die Design-Entscheidungen, Muster und Strukturen, auf denen TaskTrackr aufgebaut ist. Das primäre Ziel war die Schaffung einer sauber entkoppelten, wartbaren und erweiterbaren Anwendung.
1. High-Level-Architektur
Das Diagramm zeigt die logischen Hauptkomponenten des Systems und ihre Kommunikationswege:
- Kommunikation: Das Frontend (React) initiiert alle Anfragen. Es kommuniziert über eine REST-API (JSON) mit dem Backend.
- Stateless-Design (Schlüssel zur Skalierbarkeit): Das TaskTrackr API (Spring Boot) Backend ist stateless (zustandslos).
- Es hält keine Server-Session (kein Gedächtnis).
- Stattdessen sendet das Frontend bei jeder einzelnen Anfrage ein JWT (JSON Web Token) mit.
- Das Backend validiert dieses Token (den "Ausweis"), indem es die Signatur gegen den AWS Cognito (Auth Server) prüft.
- Vorteil: Da der Server den Status nicht speichern muss, ist es für das Deployment (siehe Punkt 5: Skalierbarkeit) egal, welcher Server-Container die Anfrage erhält. Dies ist die Grundlage für eine einfache horizontale Skalierbarkeit in der Cloud.
2. Schichten-Architektur
Das interne Design des Backends folgt dem Prinzip der strengen Schichtentrennung (Separation of Concerns). Dies garantiert, dass die Business-Logik (Service) von den technischen Aufgaben (Web, Datenbank) isoliert ist, was die Wartbarkeit und Testbarkeit maximiert.
Das folgende Diagramm demonstriert den strikten Datenfluss, anhand des Beispiels eines createTask-Flows:
3. Modulare Paketstruktur (Domain-Fokus)
Ein zentrales Architekturziel war die Vermeidung von "Package-by-Layer" (z.B. globale .../controllers/, .../services/ Ordner). Dieses Muster führt zu niedriger Kohäsion, da Code, der logisch zu einem Feature gehört, über das gesamte Projekt verstreut wird.
TaskTrackr ist stattdessen konsequent nach Fachlichkeit (Domain-Fokus) strukturiert, auch bekannt als "Package by Feature".
Fachliche Code-Trennung
Die Paketstruktur spiegelt exakt die in der Datenbank-Dokumentation gezeigte modulare Aufteilung wider:
.../project/: Das Kernmodul ("Project Management"). Verwaltet übergreifende Logik (Mitglieder, Rollen, Einladungen)..../scrumdetails/: Enthält nur Code für Scrum-Projekte (Sprints, User Stories, Board)..../basicdetails/: Enthält nur Code für Basic-Projekte (Tasks, Infos)..../activity/: Das Event-Modul, das den Aktivitätsverlauf behandelt..../user/: Logik, die sich nur um das globale User-Profil kümmert..../shared/: Wiederverwendbarer Code, z.B. globale Exception-Handler.
Hohe Kohäsion durch vertikale Slices
Jedes dieser Fach-Pakete (z.B. .../scrumdetails/) ist ein vertikaler "Slice" und bündelt seine eigenen, internen Schichten. Die Struktur des api-Pakets fasst die gesamte öffentliche Schnittstelle des Moduls zusammen:
/scrumdetails
├── /api
│ ├── /controller (Scrum-spezifische Endpunkte)
│ └── /dto (Data Transfer Objects)
│ ├── /request
│ ├── /response
│ └── /mapper
├── /service (Scrum-spezifische Business-Logik)
├── /repository (Scrum-spezifische JPA-Queries)
└── /domain (Scrum-spezifische Entitäten & Domain-Logik)
Die Vorteile dieses Designs
Dieser Ansatz maximiert die Wartbarkeit und Skalierbarkeit der Codebasis:
- Hohe Kohäsion: Aller Code, der für "Scrum" benötigt wird, ist an einem Ort.
- Niedrige Kopplung: Änderungen am
scrumdetails-Modul (z.B. Hinzufügen von "Epics") haben keinerlei Einfluss auf dasbasicdetails-Modul. - Hervorragende Wartbarkeit: Muss ein Bug im "Sprint Planning" behoben werden, muss der Entwickler nur in das
scrumdetails-Paket schauen. - Einfache Erweiterbarkeit: Ein neuer Projekttyp (z.B. "Kanban") kann durch einfaches Hinzufügen eines neuen
.../kanbandetails/-Pakets implementiert werden, ohne den bestehenden Code zu beeinträchtigen.
4. DTO-Strategie & Sicheres API-Design
TaskTrackr folgt der strikten Regel: JPA-Entitäten verlassen niemals die Service-Schicht. Jede Kommunikation mit dem Frontend (oder externen Clients) erfolgt ausschließlich über Data Transfer Objects (DTOs).
Dieses "Secure by Design"-Prinzip ist aus drei Gründen entscheidend:
- Sicherheit (API-Abschirmung): Es wird verhindert, dass sensible Daten (z.B. interne Flags, User-Hashes oder Datenbank-Strukturen) versehentlich an das Frontend "durchsickern" (Data Leaking).
- API-Stabilität (Der "Vertrag"): Die DTOs definieren eine stabile API-Schnittstelle ("Contract") für das Frontend. Interne Datenbank-Entitäten können refaktorisiert (z.B. Spalten umbenannt oder aufgeteilt) werden, ohne dass das Frontend bricht, solange der DTO-Vertrag erfüllt wird.
- Performance & Effizienz (View-Models): Anstatt dem Frontend 10 verschiedene Endpunkte anzubieten (z.B. "getSprintDetails", "getTodoTasks", "getDoneTasks"), aggregiert das Backend die Daten in passgenaue, View-spezifische DTOs.
Implementierungs-Strategie
Die Umsetzung dieser Strategie erfolgt durch zwei Mechanismen:
-
1. Klare DTO-Trennung:
...Request-DTOs: Werden vom Controller entgegengenommen und imapi-Paket validiert (@Valid)....Response-DTOs: Werden im Service-Layer erstellt und zurückgegeben.
-
2. Hybride Mapping-Strategie:
- MapStruct: Wird für alle simplen 1:1-Mappings (z.B.
User->UserDto) verwendet, um repetitiven Boilerplate-Code zu eliminieren. - Custom Mapper: Werden für komplexe, aggregierte DTOs (wie das
ScrumBoardResponseDto) verwendet. Hier ist manuelle Logik nötig, um Daten aus mehreren Repositories zu sammeln, Felder zu berechnen (z.B.completedStoryPoints) und Listen vorzufiltern.
- MapStruct: Wird für alle simplen 1:1-Mappings (z.B.
Beispiel: Das View-Model "ScrumBoardResponseDto"
Das folgende DTO ist kein simples 1:1-Mapping einer Entität. Es ist ein maßgeschneidertes View-Model, das exakt auf die Anforderungen des Frontend-Boards zugeschnitten ist und dem Client enorme Arbeit abnimmt:
public class ScrumBoardResponseDto {
@Schema(...)
private String sprintName;
@Schema(...)
private String sprintGoal;
// ... (Weitere Sprint-Metadaten) ...
@Schema(...)
private int totalStoryPoints;
@Schema(...)
private int completedStoryPoints;
@Schema(...)
private List<ProjectMemberDto> projectMembers = new ArrayList<>();
// Vorkategorisierte Listen:
@Schema(description = "Liste der Backlog-Items im Status 'TO DO'.")
private List<SprintBacklogItemResponse> todo = new ArrayList<>();
@Schema(description = "Liste der Backlog-Items im Status 'IN PROGRESS'.")
private List<SprintBacklogItemResponse> inProgress = new ArrayList<>();
@Schema(description = "Liste der Backlog-Items im Status 'REVIEW'.")
private List<SprintBacklogItemResponse> review = new ArrayList<>();
@Schema(description = "Liste der Backlog-Items im Status 'DONE'.")
private List<SprintBacklogItemResponse> done = new ArrayList<>();
}
Die dargestellten Daten im folgenden Frontend-Screenshot des Scrum Boards spiegeln direkt die Struktur und die vorkategorisierten Listen dieses ScrumBoardResponseDto wider:
5. Security-Architektur (Stateless & OIDC)
Die Sicherheitsarchitektur ist bewusst zweigeteilt: Sie trennt die Authentifizierung ("Wer bist du?") von der dynamischen Autorisierung ("Was darfst du?").
1. Authentifizierung (AuthN): AWS Cognito
Die Authentifizierung (Login, Registrierung) wird vollständig an AWS Cognito (einen OIDC-Identity-Provider) delegiert. Das Spring Boot Backend agiert als stateless Resource Server.
- Grobe Filterung: Die
SecurityConfigstellt sicher, dass alle gesichterten Endpunkte ein gültiges JWT (Bearer Token) erfordern (.anyRequest().authenticated()). - Stateless: Das Backend erzwingt (
SessionCreationPolicy.NEVER), dass keine HTTP-Sessions gespeichert werden. - JWT-Validierung: Das Backend validiert das von Cognito ausgestellte Token bei jeder Anfrage (
.oauth2ResourceServer(...)).
// SecurityConfig.java
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http
.csrf(AbstractHttpConfigurer::disable)
// 1. Grobe Filterung:
.authorizeHttpRequests(authorizeRequests -> authorizeRequests
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**", "/api/v1/login").permitAll()
.anyRequest().authenticated()
)
// 2. JWT Validierung:
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(...)
.authenticationEntryPoint(authenticationEntryPoint) // 401 Handler
)
// 3. Fehlerbehandlung:
.exceptionHandling(exceptions -> exceptions
.accessDeniedHandler(accessDeniedHandler) // 403 Handler
)
// 4. STATELESS:
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.NEVER)
)
.build();
}
}
2. Autorisierung (AuthZ): Dynamisches Custom RBAC
Die feingranulare Berechtigungsprüfung (z.B. "Darf dieser User einen Sprint erstellen?") wird von der Business-Logik in der Service-Schicht übernommen.
Dieser Ansatz nutzt "Guardrail"-Methoden im Domain-Modell:
- Im Service-Layer: Vor der Ausführung der Logik wird die Berechtigung geprüft.
- Im Domain-Objekt: Die Methode prüft das Recht und wirft eine
PermissionDeniedException, wenn es fehlt.
Beispiel: "Guardrail"-Aufruf im Service (SprintServiceImpl.java)
@Transactional
public SprintResponseDto createSprint(...) {
// 1. LADEN (inkl. Mitglied mit Rechten)
// Die Methode '...WithPermissionsRolesAndUser' lädt alle nötigen Daten (Member, Role, Permissions)
// in einer optimierten Query, um N+1-Probleme zu vermeiden.
// (Siehe Kapitel 5: Performance & Skalierbarkeit)
ProjectMember member = projectAccessService.findProjectMemberWithPermissionsRolesAndUser(...);
// 2. BERECHTIGUNGSPRÜFUNG (Guardrail)
member.canPlanSprint(); // Wirft Exception, wenn nicht erlaubt
// 3. BUSINESS-LOGIK (wird nur bei Erfolg erreicht)
Sprint createdSprint = Sprint.create(...);
// ...
return sprintMapper.toDto(perisistedSprint);
}
6.Einheitliches Service-Muster
Um die Wartbarkeit, Testbarkeit und Robustheit zu maximieren, folgen alle kritischen Service-Methoden einer einheitlichen Muster. Dieses Muster stellt sicher, dass jede Aktion sicher, valide und nachvollziehbar ist.
Konsistentes Service Design
Jede Service-Methode ist wie folgt aufgebaut:
- Laden: Lade alle notwendigen Daten und den Kontext (z.B.
Project,ProjectMember). - Berechtigung (Guardrail): Prüfe, ob der User die Aktion ausführen darf (z.B.
member.canCreateUserStory()). - Validierung: Prüfe die Geschäftsregeln (z.B.
checkForUniqueUserStoryTitle(...)). - Business-Logik: Führe die Kernlogik aus (oft im Domain-Modell, z.B.
UserStory.create(...)) und persistiere das Ergebnis (repository.save(...)). - Event (Entkopplung): Veröffentliche ein asynchrones Event für den Aktivitätsverlauf (
applicationEventPublisher.publishEvent(...)). - Return (DTO-Mapping): Mappe die persistierte Entität sicher auf ein Response-DTO (
userStoryMapper.toDto(...)).
Code-Beispiel: createUserStory
Die createUserStory-Methode ist ein perfektes Beispiel für die Umsetzung dieses Musters in der Praxis:
@Override
@Transactional
public UserStoryResponseDto createUserStory(Long projectId,
CreateUserStoryRequest createUserStoryRequest,
String jwtUserId) {
// 1. LADEN
ScrumDetails scrumDetails = projectAccessService.findProjectById(projectId).getScrumDetails();
ProjectMember member = projectAccessService.findProjectMemberWithPermissionsRolesAndUser(jwtUserId, projectId);
// 2. BERECHTIGUNG (Guardrail)
member.canCreateUserStory();
// 3. VALIDIERUNG
checkForUniqueUserStoryTitle(createUserStoryRequest.getTitle(), projectId);
// 4. BUSINESS-LOGIK
UserStory createdUserStory = UserStory.create(createUserStoryRequest, scrumDetails);
UserStory perisistedUserStory = userStoryRepository.save(createdUserStory);
// 5. EVENT (Entkopplung)
var event = new ProjectActivityEvents.UserStoryCreatedEvent(
projectId, member.getId(), member.getUser().getUsername(),
perisistedUserStory.getId(), perisistedUserStory.getTitle());
applicationEventPublisher.publishEvent(event);
// 6. RETURN (DTO-Mapping)
return userStoryMapper.toDto(perisistedUserStory);
}
7. Frontend-freundliche Fehlerbehandlung
Die Architektur folgt einem klaren "Backend for Frontend"-Ansatz: Das Backend ist "schlau", das Frontend bleibt "dumm" und muss keine komplexe Fehlerlogik implementieren.
Um dies zu erreichen, wird ein zentraler @RestControllerAdvice (GlobalExceptionHandler) verwendet.
Vorteil: Dieser Handler fängt automatisch jede Exception, die von jedem Controller oder Service geworfen wird (z.B. Validierungsfehler, Business-Regeln, Security-Fehler).
1. Das standardisierte Fehler-JSON
Der GlobalExceptionHandler wandelt jeden Fehler in ein einheitliches, standardisiertes ErrorResponse-JSON um. Das Frontend muss also nur einen Weg implementieren, um Fehler anzuzeigen, egal was im Backend fehlschlägt.
Beispiel eines Fehler-JSONs (was das Frontend erhält):
{
"timestamp": "2025-11-07T13:00:00.000Z",
"status": 400,
"errorCode": "INVALID_ROLE_DELETION",
"message": "Remove Role from ProjectMember before deleting the Role",
"path": "/api/v1/projects/1/roles/5"
}
2. Die Business-Exception-Hierarchie (AppException)
Um Business-Fehler (z.B. "Rolle wird noch verwendet") klar von Server-Fehlern (500) zu trennen, wird eine eigene AppException-Hierarchie verwendet.
2. Die Basis-Exception
Jede Business-Exception erbt von AppException und definiert eine klare Nachricht, einen ErrorCode und einen HttpStatus.
// Die Basis-Exception, die Status und ErrorCode trägt
@Getter
public abstract class AppException extends RuntimeException {
private final ErrorCode errorCode;
private final HttpStatus httpStatus;
public AppException(String message, ErrorCode errorCode, HttpStatus httpStatus) {
super(message);
this.errorCode = errorCode;
this.httpStatus = httpStatus;
}
}
3. Die spezifische Business-Exception
Der Service-Layer wirft bei einem Geschäftsregelverstoß eine spezifische, benannte Exception.
// Eine spezifische Business-Exception
public class TaskNotFoundException extends AppException {
public TaskNotFoundException(Long taskId) {
super("Task with ID: " + taskId + " not found", ErrorCode.TASK_NOT_FOUND, HttpStatus.NOT_FOUND);
}
}
}
4. Der Handler & Das "Wörterbuch" (ErrorCode)
Ein GlobalExceptionHandler (@RestControllerAdvice) fängt diese AppException und nutzt die enthaltenen Informationen (HttpStatus, ErrorCode), um eine standardisierte JSON-Fehlerantwort zu bauen.
Der Handler:
@ExceptionHandler(AppException.class)
public ResponseEntity<ErrorResponse> handleIAppException(
AppException ex, HttpServletRequest request) {
log.info("AppException on: {}: {}", request.getRequestURI(), ex.getMessage());
// Baut die standardisierte Antwort
ErrorResponse error = new ErrorResponse(
LocalDateTime.now(),
ex.getHttpStatus().value(),
ex.getErrorCode(), // <--- Holt den spezifischen Code (z.B. INVALID_ROLE_DELETION)
ex.getMessage(),
request.getRequestURI()
);
return ResponseEntity.status(ex.getHttpStatus()).body(error);
}
Das ErrorCode-Enum dient dabei als zentrales "Wörterbuch" aller Business-Fehler. Es ermöglicht dem Frontend, auf spezifische Fehlercodes zu reagieren.
Auszug aus ErrorCode.java:
public enum ErrorCode {
VALIDATION_ERROR,
ACCESS_DENIED,
PROJECT_NOT_FOUND,
INVITE_ALREADY_EXISTS,
INVALID_ROLE_DELETION,
SPRINT_NOT_FOUND,
USERSTORY_TITLE_ALREADY_EXISTS,
// ... viele mehr
}