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 das basicdetails-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:

  1. 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).
  2. 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.
  3. 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 im api-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.

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 SecurityConfig stellt 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:

  1. Laden: Lade alle notwendigen Daten und den Kontext (z.B. Project, ProjectMember).
  2. Berechtigung (Guardrail): Prüfe, ob der User die Aktion ausführen darf (z.B. member.canCreateUserStory()).
  3. Validierung: Prüfe die Geschäftsregeln (z.B. checkForUniqueUserStoryTitle(...)).
  4. Business-Logik: Führe die Kernlogik aus (oft im Domain-Modell, z.B. UserStory.create(...)) und persistiere das Ergebnis (repository.save(...)).
  5. Event (Entkopplung): Veröffentliche ein asynchrones Event für den Aktivitätsverlauf (applicationEventPublisher.publishEvent(...)).
  6. 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
}