Files
omsorg/omsorgCore/CLAUDE.md
T
Felix KemmlerandClaude Sonnet 5 b6c1389c55 Reorganize into monorepo layout, move mitarbeiter-app to legacy reference
Consolidates the previously separate omsorgapp and omsorgCore repos
(each had their own nested .git with GitHub history) plus the old
root-level website/mitarbeiter-app into a single monorepo, matching
the structure already documented in the root CLAUDE.md. Also moves
the PHP employee app aside as omsorgWeb/mitarbeiter-app-legacy/ to
serve as a template for a ground-up rewrite.

Fixes .gitignore in the same pass: the config-secrets/uploads/data
patterns were unanchored (relative to repo root, not depth-agnostic),
so they silently stopped matching once the app moved under omsorgWeb/.
Patterns are now **/-prefixed and cover both mitarbeiter-app and
mitarbeiter-app-legacy, keeping DB/SMTP credentials and uploaded
employee documents out of version control.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-07 14:21:37 +02:00

180 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# CLAUDE.md — omsorgCore (OMSORG Backend: Core + Engine)
Gilt zusätzlich zur Root-`CLAUDE.md`. Das Grundgerüst ist angelegt und baut (`dotnet build` läuft grün); der fachliche Umfang ist bewusst noch minimal (Fundament, nicht Vollständigkeit).
## Auftrag dieses Projekts
`omsorgCore` ist die gemeinsame Datenbasis + Automatisierungsschicht für die gesamte Plattform (siehe `REQUIREMENTS.md` Abschnitt 2 und 6, Blueprint Kap. 19/20). Es soll schrittweise ersetzen:
- die MySQL-Datenhaltung in `omsorgWeb/mitarbeiter-app`
- die lokale JSON-Datenhaltung in `omsorgapp`
Beide bestehenden Projekte sollen künftig gegen dieses Backend sprechen statt eigene, getrennte Datenspeicher zu pflegen. Diese Anbindung ist noch **nicht** erfolgt — aktueller Schritt ist nur das Backend-Fundament selbst.
## Tech-Stack (umgesetzt)
- C# / **.NET 8** (LTS)
- ASP.NET Core Web API mit **Controllern** (kein Minimal-API-Stil)
- **PostgreSQL** über `Npgsql.EntityFrameworkCore.PostgreSQL` 8.0.10 (bewusst auf net8-kompatible Version gepinnt — neuere Paketversionen zielen auf .NET 10)
- **JWT Bearer Tokens** für Auth (`Microsoft.AspNetCore.Authentication.JwtBearer`)
- Passwort-Hashing über `Microsoft.AspNetCore.Identity.PasswordHasher<T>` (PBKDF2, kein eigenes Krypto-Rad)
- **Core + Engine = eine Backend-Komponente**, kein separates Deployable für die Engine — die Event-Schicht läuft im selben Prozess wie die Datenschicht (siehe Root-`CLAUDE.md` und `REQUIREMENTS.md` Abschnitt 1.2)
## Reale Projektstruktur
```
omsorgCore/
OmsorgCore.sln
.config/dotnet-tools.json # lokales dotnet-ef Tool (dotnet tool restore)
src/
OmsorgCore.Domain/ # Entitäten, Enums. Keine Abhängigkeit auf andere Projekte.
Common/ # Entity, AuditableEntity (Basisklassen)
Enums/ # ModuleType, PermissionAction, PermissionEffect
Entities/ # Employee, Facility, Contract, Order, TimeEntry, Invoice,
# User, Role, RolePermission, UserPermissionOverride
OmsorgCore.Application/ # Business-Logik. Abhängig von Domain.
Abstractions/ # Interfaces: IEmployeeRepository, IUserRepository,
# IPasswordHasher, IJwtTokenGenerator, ICurrentUserService,
# IPermissionService
Services/ # PermissionService, AuthService, EmployeeService, SessionAdminService
DependencyInjection.cs # AddApplication()
OmsorgCore.Infrastructure/ # Technische Umsetzung. Abhängig von Domain + Application.
Persistence/
OmsorgCoreDbContext.cs
Configurations/ # ein IEntityTypeConfiguration<T> pro Entität
Migrations/ # EF-Core-Migrationen (InitialCreate bereits erzeugt)
Repositories/ # EmployeeRepository, UserRepository, RefreshTokenRepository (implementieren Application-Interfaces)
Security/ # PasswordHasher, JwtOptions, JwtTokenGenerator, RefreshTokenOptions, RefreshTokenGenerator
DependencyInjection.cs # AddInfrastructure(configuration)
OmsorgCore.Engine/ # Event-Schicht. Abhängig von Domain + Application.
Events/ # IDomainEvent, IDomainEventHandler<T>, IDomainEventDispatcher,
# DomainEventDispatcher (In-Process, kein Message-Bus), Beispiel-Event
Handlers/ # Beispiel-Handler (EmployeeCreatedHandler)
DependencyInjection.cs # AddEngine()
OmsorgCore.Api/ # ASP.NET Core Web API. Abhängig von Application+Infrastructure+Engine.
Controllers/ # AuthController, EmployeesController, HealthController, AdminSessionsController
Contracts/ # Request-/Response-DTOs (LoginRequest, EmployeeResponse, ...)
Security/ # CurrentUserService, RequirePermissionAttribute
Program.cs # einziger Ort, an dem alle Schichten verdrahtet werden
tests/
OmsorgCore.Tests/ # xUnit, referenziert Domain + Application
```
**Konvention: eine Klasse/ein Interface/ein Enum pro Datei.** Ausnahme: keine — auch kleine DTOs (Requests/Responses als `record`) bekommen eine eigene Datei.
**Abhängigkeitsrichtung ist strikt:** Domain kennt niemanden. Application kennt nur Domain und definiert Interfaces, die Infrastructure implementiert (Ports-and-Adapters). Engine kennt Domain + Application, nicht Infrastructure oder Api. Api verdrahtet alles ausschließlich in `Program.cs` — Controller rufen nur Application-Services und den `IDomainEventDispatcher` (Engine) auf, **niemals** direkt `OmsorgCoreDbContext`/EF Core.
## Rechtesystem
Rolle liefert Standard-Rechte (`RolePermission`: Modul × Aktion), ein individueller `UserPermissionOverride` (Grant/Revoke) gewinnt immer gegen den Rollen-Default — siehe `PermissionService.HasPermissionAsync` (`src/OmsorgCore.Application/Services/PermissionService.cs`). Deckt Blueprint 6.5 ("Rolle als Vorlage + individuelle Rechte") ab.
Die aufgelösten Rechte eines Users (nicht nur eine einzelne Prüfung) liefert `PermissionService.GetGrantedPermissionsAsync` als Liste von `PermissionGrant(Module, Action)`. Exponiert über `GET /api/auth/me` (`AuthController.Me`, `[Authorize]`) als `MeResponse { username, role, permissions: [{ module, action }, ...] }` — der einzige Weg, wie granulare Rechte den Client erreichen (das JWT trägt nur den Rollennamen). `omsorgapp` ruft diesen Endpunkt nach Login/Refresh auf (siehe `omsorgapp/CLAUDE.md`, "Rechtesystem im Client") und trifft UI-Entscheidungen darüber statt über einen Rollennamen-Vergleich.
Rechteprüfung auf Controller-Actions:
```csharp
[RequirePermission(ModuleType.Employees, PermissionAction.Create)]
```
Das Attribut (`src/OmsorgCore.Api/Security/RequirePermissionAttribute.cs`) prüft serverseitig über `IPermissionService` — nicht nur im Client (REQUIREMENTS.md NFR-9). Jeder Controller außer `AuthController` trägt zusätzlich `[Authorize]`.
## Auth-Flow
1. `POST /api/auth/login` (`AuthController`) → `AuthService.LoginAsync` prüft Username/Passwort-Hash, widerruft **alle bisherigen aktiven Refresh-Tokens dieses Users und würfelt seinen `SecurityStamp` neu** (`EndOtherSessionsAsync` — ein User hat immer nur eine aktive Session; ältere Sessions werden per Killswitch sofort ungültig, siehe "Session-Killswitch" unten), dann erzeugt `JwtTokenGenerator` ein Access-Token (Claims `sub`/`name`/`role`) + `RefreshTokenGenerator` einen langlebigen Refresh-Token. Response (`LoginResponse`): `accessToken`, `refreshToken`, `expiresAt` (camelCase, Default-JSON-Serialisierung von ASP.NET Core).
2. Client sendet Access-Token als `Authorization: Bearer <token>`.
3. `Program.cs` validiert das Token gegen `Jwt:Issuer`/`Jwt:Audience`/`Jwt:Secret` aus der Konfiguration.
4. `POST /api/auth/refresh` (kein `[Authorize]` — der Refresh-Token selbst ist das Credential): `AuthService.RefreshAsync` prüft den Refresh-Token per Hash-Lookup, **rotiert** ihn (alten Token widerrufen, neuen ausstellen, per `ReplacedByTokenId` verkettet) und liefert ein neues Token-Paar. Erlaubt langlebige Sessions ohne täglichen Passwort-Login (siehe `omsorgapp/CLAUDE.md`).
5. `POST /api/auth/logout` widerruft den vorgelegten Refresh-Token (`AuthService.RevokeAsync`, idempotent).
**Refresh-Token:** kein Rohtoken wird gespeichert, nur sein SHA-256-Hash (`RefreshToken`-Entity, `IRefreshTokenGenerator`). Gültigkeit über `RefreshToken:ExpiryDays` in `appsettings.json` (Default 60 Tage, sliding — jede Nutzung verlängert effektiv die Session), überschreibbar per `RefreshToken__ExpiryDays`.
**Secret-Handling:** `appsettings.json` enthält nur Issuer/Audience/ExpiryMinutes/RefreshToken:ExpiryDays. `appsettings.Development.json` enthält einen **lokalen Platzhalter** für `Jwt:Secret` und den Connection-String (`CHANGE_ME_...`) — für echte Umgebungen über Umgebungsvariable (`Jwt__Secret`) oder `dotnet user-secrets` überschreiben, nie ein echtes Secret einchecken.
**Standard-Admin-Seed:** `DbSeeder.SeedDefaultAdminAsync` (`src/OmsorgCore.Infrastructure/Persistence/DbSeeder.cs`) legt beim Start **nur im Development-Modus** (`Program.cs`, `app.Environment.IsDevelopment()`) einen Benutzer `admin`/`abersicher` mit einer neuen Rolle `Administrator` (alle `ModuleType`×`PermissionAction`-Kombinationen als `RolePermission`) an — idempotent, läuft nur wenn noch **kein** `User` existiert. DB-Fehler dabei (z. B. keine Verbindung — Migrationen sind zu diesem Zeitpunkt bereits automatisch angewendet, siehe "Datenbank" unten) sind nicht fatal, werden nur geloggt (`try/catch` um den Seed-Aufruf). Bewusst **nicht** in Produktion aktiv, um kein bekanntes Standard-Passwort auszuliefern — für einen echten Produktivbetrieb muss ein richtiger User-Anlage-Flow her.
## Session-Killswitch (SecurityStamp)
Ein Access-Token ist als JWT zustandslos gültig bis zum Ablauf (60 Min) — ein reiner Refresh-Token-Widerruf verhindert nur das *stille Verlängern*, der bereits ausgestellte Access-Token bliebe sonst bis zu 60 Minuten gültig. Für einen echten Not-Aus-Schalter (Debug-Sicht in `omsorgapp`, "alle Nutzer sofort zum Neu-Login zwingen") trägt `User.SecurityStamp` (Guid) einen Wert, der:
1. bei jedem Access-Token-Ausstellen als Claim `"sst"` mit eingebettet wird (`JwtTokenGenerator`),
2. bei **jedem** authentifizierten Request per `JwtBearerEvents.OnTokenValidated` (`Program.cs`) gegen den aktuellen `User.SecurityStamp` in der DB geprüft wird (`IUserRepository.GetByIdAsync`, schlanker Lookup ohne Includes) — bei Mismatch oder fehlendem Claim `context.Fail(...)`, sofort 401, unabhängig von der Token-Restlaufzeit.
`ISessionAdminService`/`SessionAdminService` (`src/OmsorgCore.Application/Services/`) kapselt die Admin-Operationen:
- `GetActiveSessionsAsync()` — alle nicht widerrufenen/nicht abgelaufenen Refresh-Tokens (`IRefreshTokenRepository.GetAllActiveAsync`) als `SessionInfo` (Id, Username, CreatedAt, ExpiresAt).
- `RevokeSessionAsync(sessionId)` — widerruft genau diesen Refresh-Token und würfelt den `SecurityStamp` nur des zugehörigen Users neu (sofortiger Kick für genau diesen Nutzer).
- `RevokeAllSessionsAsync()` — widerruft alle aktiven Refresh-Tokens und würfelt `SecurityStamp` **aller** Nutzer neu (globaler Killswitch).
Exponiert über `AdminSessionsController` (`GET /api/admin/sessions`, `POST /api/admin/sessions/{id}/revoke`, `POST /api/admin/sessions/revoke-all`), geschützt über `[RequirePermission(ModuleType.UserManagement, ...)]` — per Default nur die `Administrator`-Rolle aus dem `DbSeeder`. Im Frontend: `omsorgapp/src/modules/debug/DebugSessionsPage.jsx`, im Sidebar-Menü nur sichtbar, wenn `hasPermission("UserManagement", "View")` (siehe `GET /api/auth/me` unten) — dieselbe Rechteprüfung wie serverseitig, kein reiner Rollennamen-Vergleich mehr im Client.
**Kosten:** ein zusätzlicher DB-Read pro authentifiziertem Request (`GetByIdAsync`). Für die aktuelle Nutzerzahl vernachlässigbar — bei relevantem Traffic-Wachstum wäre ein Cache (z. B. In-Memory mit kurzer TTL) der nächste Schritt, aber kein Caching ohne echten Bedarf, um die Sofortigkeit des Killswitches nicht zu unterlaufen.
## Passwort-Reset / E-Mail-Versand
Vollständig implementiert: `PasswordResetCode`-Entity + `PasswordResetService` (PIN anfordern/verifizieren, Passwort setzen, siehe Auth-Flow-Analogie: Session-Revoke + `SecurityStamp`-Rotation nach erfolgreichem Reset), exponiert über `AuthController` (`POST /api/auth/forgot-password/{request,verify,reset}`).
**E-Mail-Versand:** `IEmailSender` (`OmsorgCore.Application.Abstractions`) hat zwei Implementierungen in `OmsorgCore.Email`, Auswahl über `Email:Provider` zur Startzeit (`DependencyInjection.AddEmail`):
- `"Console"` (Default) — schreibt nur ins Log, kein echter Versand. Sicherer Default für Umgebungen ohne SMTP-Konfiguration.
- `"Smtp"` — echter Versand über MailKit (`SmtpEmailSender`). **Ein gemeinsamer SMTP-Server (`Email:Smtp:*`), darüber mehrere benannte Accounts (`Email:Accounts:<Name>:*`)** — z. B. ein Account, der exklusiv für Passwort-Reset-Mails verwendet wird, getrennt von einem künftigen allgemeinen Absender. Welcher Account für den Reset-Flow gilt, steht in `Email:PasswordResetAccount` (Default `"PasswordReset"`); `EmailMessage.FromAccountKey` transportiert die Auswahl vom Aufrufer bis zum `SmtpEmailSender`. Details der Keys: `CONFIGURATION.md`. **Zugangsdaten (Host/Username/Passwort) kommen ausschließlich aus appsettings/Env-Var/user-secrets, nie über eine API — es gibt bewusst keinen Settings-Controller oder UI-Formular dafür.**
**Templating:** Betreff/Text der Passwort-Reset-Mail sind über `Email:PasswordResetTemplate:{Subject,BodyTemplate}` konfigurierbar, Platzhalter im Format `$NAME$` (`$RESET_PIN$`, `$RESET_PIN_EXPIRY_MINUTES$`), ersetzt von `EmailTemplateRenderer.Render` (pure Funktion, unit-getestet in `EmailTemplateRendererTests`). Die Zusammensetzung passiert in `PasswordResetEmailComposer` (`OmsorgCore.Email`, implementiert `IPasswordResetEmailComposer` aus `Application.Abstractions`) — bewusst nicht direkt im `Engine`-Handler, weil `Engine` nur `Domain`+`Application` kennen darf, nicht `Email` (Abhängigkeitsrichtung, siehe oben). `PasswordResetRequestedHandler` (`Engine/Handlers/`) ruft nur `IPasswordResetEmailComposer.Compose(...)` + `IEmailSender.SendAsync(...)` auf.
**Testversand ohne Zugangsdaten-Leak:** `AdminEmailController` (`POST /api/admin/email/test-send`, `[RequirePermission(ModuleType.UserManagement, PermissionAction.Edit)]`) verschickt eine feste Testmail über den aktuell konfigurierten `IEmailSender` — Host/Username/Passwort verlassen dabei nie den Server, nur Erfolg/Fehlschlag geht an den Client. Im Frontend: `omsorgapp/src/modules/debug/DebugSessionsPage.jsx`, gleicher Rechteschutz wie die Sessions-Verwaltung dort.
## Datenbank
- Connection-String-Key: `ConnectionStrings:OmsorgCore` (Format `Host=...;Port=5432;Database=omsorg_core;Username=...;Password=...`).
- `dotnet-ef` ist als lokales Tool eingerichtet (`.config/dotnet-tools.json`) — vor erster Nutzung `dotnet tool restore`. Wird nur noch zum **Erzeugen** neuer Migrationen gebraucht (`dotnet ef migrations add ...`), nicht mehr zum Anwenden.
- **`Program.cs` ruft bei jedem Start `db.Database.MigrateAsync()` auf, in jeder Umgebung** (nicht nur Development) — ausstehende Migrationen werden automatisch angewendet, bevor der Server Requests annimmt. Ein manuelles `dotnet ef database update` ist dadurch nur noch zum gezielten Vorab-Prüfen/Debuggen einer Migration nötig, nicht mehr für den normalen Start/Deploy. Schlägt die Migration fehl, crasht der Start bewusst fatal (fail-fast) statt mit einem veralteten Schema weiterzulaufen.
- Migrationen `InitialCreate`, `AddRefreshTokens`, `AddUserSecurityStamp`, `AddEmployeeContactFieldConstraints` und `AddAuditableSoftDelete` existieren (`src/OmsorgCore.Infrastructure/Persistence/Migrations/`) und wurden erfolgreich gegen eine echte PostgreSQL-Instanz angewendet.
## Build- und Run-Befehle
```bash
cd omsorgCore
dotnet build # ganze Solution
dotnet tool restore # einmalig, für dotnet-ef
# Neue Migration erzeugen, nachdem sich das Entity-Modell geändert hat (wird NICHT automatisch angewendet):
dotnet tool run dotnet-ef migrations add <Name> \
--project src/OmsorgCore.Infrastructure/OmsorgCore.Infrastructure.csproj \
--startup-project src/OmsorgCore.Api/OmsorgCore.Api.csproj
# Angewendet wird sie automatisch beim nächsten Start der API (siehe oben) — kein manueller
# `database update`-Schritt im Normalfall mehr nötig.
# API starten (lädt appsettings.Development.json):
ASPNETCORE_ENVIRONMENT=Development dotnet run --project src/OmsorgCore.Api
# → GET /api/health, POST /api/auth/login, GET/POST /api/employees (Bearer-Token nötig)
# Swagger UI unter /swagger im Development-Modus
```
## Verifiziert
- `dotnet build` für alle 6 Projekte: grün. `dotnet test`: grün (u. a. `AuthServiceTests` mit Fake-Repositories für Login/Refresh-Rotation/Revoke).
- `dotnet tool run dotnet-ef database update` erfolgreich gegen echte PostgreSQL-Instanz ausgeführt (`InitialCreate` + `AddRefreshTokens`).
- API-Start mit `ASPNETCORE_ENVIRONMENT=Development`: `GET /api/health``{"status":"ok","databaseReachable":true}`. `POST /api/auth/login`/`refresh` mit falschen/unbekannten Credentials → 401, `POST /api/auth/logout` → 204. `GET /api/employees` ohne Token → 401 (Auth-Pipeline greift korrekt).
- `omsorgapp`s `authClient.cjs` erfolgreich gegen den laufenden Server getestet (Login/Refresh/Logout-Fehlerfälle).
- **Kompletter Login-Flow end-to-end mit echtem Postgres verifiziert:** Login mit `admin`/`abersicher` (Seed) → gültiges Token-Paar; `refresh` rotiert korrekt (neues Paar, alter Refresh-Token danach 401 bei Wiederverwendung); `GET /api/employees` mit frischem Access-Token → 200 (Administrator-Rolle hat volle Rechte über den Seed).
- **Session-Killswitch end-to-end verifiziert:** `GET /api/admin/sessions` liefert aktive Sessions; `POST /api/admin/sessions/revoke-all` → 204, danach liefert **derselbe, zuvor gültige Access-Token sofort 401** (nicht erst nach Ablauf) und der zugehörige Refresh-Token liefert bei `POST /api/auth/refresh` ebenfalls 401. Erneuter Login mit `admin`/`abersicher` funktioniert danach wieder normal.
## Offene Punkte
- Facility/Contract/Order/TimeEntry/Invoice haben noch keine Controller/Repositories/Services — nur Domain-Entitäten + DB-Konfiguration. Nächste Schritte folgen demselben Muster wie `Employee` (Repository-Interface in Application, Implementierung in Infrastructure, Service in Application, Controller in Api).
- Keine E-Mail-Verifizierung bei User-Anlage (Passwort-Reset per E-Mail ist fertig, siehe "Passwort-Reset / E-Mail-Versand" oben).
- `omsorgapp` spricht seit Kurzem gegen dieses Backend (Login-Screen + Refresh-Token-Session, siehe `omsorgapp/CLAUDE.md`) — `omsorgWeb` ist noch nicht angebunden.
- Kein Docker-/CI-Setup.
## Die sechs Core-Objekte (Domain-Entitäten)
Aus `REQUIREMENTS.md` Abschnitt 6 / Blueprint Kap. 19: **Mitarbeiter, Einrichtung, Vertrag, Auftrag, Zeiterfassung, Rechnung** — als `Employee`, `Facility`, `Contract`, `Order`, `TimeEntry`, `Invoice` in `src/OmsorgCore.Domain/Entities/` angelegt, mit Kernfeldern (nicht vollständig ausmodelliert).
Verbindliche Regeln für das Datenmodell:
1. Jede Entität hat eine eindeutige `Guid Id` (siehe `Entity`-Basisklasse).
2. Beziehungen ausschließlich über Foreign Keys/IDs, keine redundante Texteingabe verwandter Daten.
3. Stammdaten nur an einer Stelle — kein Feld, das auch in `omsorgWeb` oder `omsorgapp` unabhängig gepflegt wird, sobald die Migration dorthin begonnen hat.
4. Änderungen an geschäftsrelevanten Daten müssen nachvollziehbar sein — `AuditableEntity` liefert `CreatedAt`/`UpdatedAt`; ein vollständiger Audit-Trail (wer hat was geändert) ist noch nicht gebaut.
5. Kein Hard-Delete für sensible/geschäftsrelevante Daten — Soft-Delete/Archivierung (noch nicht implementiert, bei Bedarf einbauen statt Datensätze zu löschen).
6. Berechtigungsprüfung auf Daten- und Funktionsebene (siehe Rechtesystem oben und Rechtematrix in `REQUIREMENTS.md` Abschnitt 7).
7. Rechnungen entstehen ausschließlich aus freigegebener Zeiterfassung (FR-ZE-3/FR-RE-1) — muss bei Ausbau von `TimeEntry`/`Invoice` auf Domain-/Application-Ebene erzwungen werden, nicht nur als UI-Regel im Client.
## Event-Schicht (Engine) — Funktionsweise
`IDomainEventDispatcher` (Singleton, In-Process) löst über den DI-Container alle registrierten `IDomainEventHandler<TEvent>` für ein Event auf und ruft sie auf. Beispiel: `EmployeesController.Create` ruft nach dem Speichern `_dispatcher.DispatchAsync(new EmployeeCreatedEvent(created.Id))` auf, `EmployeeCreatedHandler` reagiert darauf (aktuell nur Logging). Neue Trigger aus `REQUIREMENTS.md` Abschnitt 4.10 (Krankmeldung, fehlender Tätigkeitsnachweis, Vertragsende, ...) folgen demselben Muster: Event-Klasse in `Engine/Events/`, Handler in `Engine/Handlers/`, Registrierung in `Engine/DependencyInjection.cs`, Dispatch-Aufruf an der Stelle im Api-Layer, wo das auslösende Ereignis passiert.
**Wichtig:** `Application`-Services dispatchen bewusst *nicht* selbst (sie kennen `Engine` nicht, das würde die Abhängigkeitsrichtung verletzen) — das Dispatchen passiert im Api-Layer, der als einziger alle Schichten kennt.