From 9a43123b3a28433c29b6f757a4d9022ed9146872 Mon Sep 17 00:00:00 2001 From: Lukas Date: Wed, 29 Apr 2026 20:25:25 +0200 Subject: [PATCH] docs: Define explicit DI container strategy for backend service graph MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add comprehensive 'Dependency Wiring and Service Composition' section to Architekture.md (§ 2.3) documenting: * The lightweight FastAPI Depends() pattern used as composition root * Service composition through explicit parameter passing * Service context dependencies pattern (SessionServiceContext, etc.) * Repository boundary enforcement * Lifecycle and scope management * Checklist for adding new services - Update Backend-Development.md to reference the new Architecture section from the 'Dependency Layering' section - Enhance dependencies.py module docstring with clear explanation of: * Composition root pattern * Explicit over implicit principles * Service context dependencies * Repository boundary enforcement This resolves issue #39 by providing clear guidance on dependency wiring without over-engineering. The pattern uses FastAPI's built-in Depends() framework and avoids heavyweight container libraries, keeping the solution lightweight and maintainable. Fixes: #39 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Docs/Architekture.md | 165 ++++++++++++++++++++++++++++++++++++ Docs/Backend-Development.md | 4 +- backend/app/dependencies.py | 36 ++++++-- 3 files changed, 195 insertions(+), 10 deletions(-) diff --git a/Docs/Architekture.md b/Docs/Architekture.md index 7b5bd8a..c96d280 100644 --- a/Docs/Architekture.md +++ b/Docs/Architekture.md @@ -544,6 +544,171 @@ The FastAPI app factory. Responsibilities: - Registers global exception handlers that map domain exceptions to HTTP status codes - Applies the setup-redirect middleware (returns `423 Locked` for all API requests when no configuration exists, except for `/api/setup` and `/api/health`) +### 2.3 Dependency Wiring and Service Composition + +BanGUI uses a **lightweight dependency injection (DI) pattern** based on FastAPI's `Depends()` framework. There is no heavy container library — the composition root is implicit and managed through simple provider functions in `app/dependencies.py`. + +#### The DI Pattern + +Every injectable dependency follows this structure: + +1. **Provider Function** — An async function in `app/dependencies.py` that creates and returns a dependency: + ```python + async def get_settings(app_context: ...) -> Settings: + """Provide application settings.""" + return app_context.runtime_settings or app_context.settings + ``` + +2. **Type Alias** — An `Annotated` alias that decorates the provider for use in route signatures: + ```python + SettingsDep = Annotated[Settings, Depends(get_settings)] + ``` + +3. **Injection Point** — Routers declare their dependencies using the type alias: + ```python + async def my_route(settings: SettingsDep) -> Response: + # FastAPI automatically calls get_settings() and injects the result + ... + ``` + +#### Service Composition Root + +Services are **not instantiated by a container**. Instead, they are **composed by routers and tasks through explicit parameter passing**. This keeps dependencies visible and avoids implicit side effects. + +**Example: How `ban_service.get_active_bans()` is wired:** + +```python +# Step 1: Router declares what it needs (dependencies.py) +async def get_ban_service_context( + db: Annotated[aiosqlite.Connection, Depends(get_db)], + fail2ban_db_repo: Annotated[Fail2BanDbRepository, Depends(get_fail2ban_db_repo)], +) -> BanServiceContext: + """Combine database connection and repository.""" + return BanServiceContext(db=db, fail2ban_db_repo=fail2ban_db_repo) + +BanServiceContextDep = Annotated[BanServiceContext, Depends(get_ban_service_context)] + +# Step 2: Router uses the context and calls the service +@router.get("/active") +async def get_active_bans( + ban_ctx: BanServiceContextDep, + socket_path: Fail2BanSocketDep, + geo_cache: GeoCacheDep, +) -> ActiveBanListResponse: + # Router explicitly passes everything the service needs + domain_result = await ban_service.get_active_bans( + socket_path, + geo_cache=geo_cache, + app_db=ban_ctx.db, # ← Explicit, no magic + ) + return map_domain_active_ban_list_to_response(domain_result) + +# Step 3: Service function accepts dependencies as parameters +async def get_active_bans( + socket_path: str, + geo_cache: GeoCache, + app_db: aiosqlite.Connection, +) -> DomainActiveBanList: + """Retrieve active bans. All dependencies are explicit parameters.""" + # Service logic here + ... +``` + +**Why this pattern?** +- **Explicit**: No hidden coupling. Every dependency is visible in function signatures. +- **Testable**: Easy to mock dependencies by passing test doubles. +- **Lightweight**: No heavyweight DI container library needed. FastAPI's `Depends()` is sufficient. +- **Debuggable**: Stack traces and type checkers understand the full dependency chain. + +#### Service Context Dependencies + +For convenience, related repositories and the database connection are bundled into **context objects**. These prevent routers from depending on the raw database connection (which violates the repository boundary). + +**Available Service Contexts:** + +| Context | Includes | Used By | +|---------|----------|---------| +| `SessionServiceContext` | `db`, `session_repo` | auth router | +| `BlocklistServiceContext` | `db`, `blocklist_repo`, `import_log_repo`, `settings_repo` | blocklist router | +| `SettingsServiceContext` | `db`, `settings_repo` | server settings router | +| `BanServiceContext` | `db`, `fail2ban_db_repo` | ban router | +| `HistoryServiceContext` | `db`, `fail2ban_db_repo`, `history_archive_repo` | history router | + +Each context is created by a provider function: +```python +async def get_ban_service_context( + db: Annotated[aiosqlite.Connection, Depends(get_db)], + fail2ban_db_repo: Annotated[Fail2BanDbRepository, Depends(get_fail2ban_db_repo)], +) -> BanServiceContext: + return BanServiceContext(db=db, fail2ban_db_repo=fail2ban_db_repo) +``` + +#### Adding a New Service + +Follow this checklist when creating a new service: + +1. **Create the service module** — `app/services/my_service.py` +2. **Define the service functions** — Each function takes its dependencies as explicit parameters (no imports of other services at the same layer) +3. **Export key functions** — Only the public API functions are called by routers +4. **If database access is needed:** + - Routers depend on the appropriate `ServiceContextDep` (e.g., `BanServiceContextDep`) + - Pass `context.db` and `context.repository` to the service function +5. **If a new context is needed:** + - Create a `@dataclass` in `app/dependencies.py` to hold the related resources + - Create a provider function `get__context()` that combines them + - Create a type alias `ContextDep` for router injection +6. **Register the service** — No registration step; FastAPI discovers it via `Depends()` + +**Example: Adding a new service that needs blocklist and settings repos:** + +```python +# app/services/my_new_service.py +async def do_something( + db: aiosqlite.Connection, + blocklist_repo: BlocklistRepository, + settings_repo: SettingsRepository, +) -> MyResult: + """Do something with blocklist and settings data.""" + sources = await blocklist_repo.list_sources(db) + settings = await settings_repo.load(db) + # Business logic + return ... + +# app/routers/my_router.py +from app.dependencies import BlocklistServiceContextDep +from app.services import my_new_service + +@router.get("/something") +async def my_endpoint( + ctx: BlocklistServiceContextDep, # ← Already has db, blocklist_repo, settings_repo +) -> MyResponse: + result = await my_new_service.do_something( + db=ctx.db, + blocklist_repo=ctx.blocklist_repo, + settings_repo=ctx.settings_repo, + ) + return MyResponse(...) +``` + +#### The Repository Boundary + +Services **must not** depend on raw database connections. The repository boundary is enforced by **not exporting `DbDep` to routers**. Instead: + +- Routers declare a `ServiceContextDep` which includes both the `db` and the needed repositories +- Services receive the `db` connection and repositories as parameters +- Repositories are the **only modules** that execute SQL; services never call SQL directly + +This ensures: +- Queries are centralized and testable +- Changes to the database layer don't leak into business logic +- Repositories can be mocked independently for testing + +#### Lifecycle and Scope + +- **Request-scoped**: Database connections are created fresh for each request and closed after the response is sent. This prevents contention and locking issues with SQLite. +- **Application-scoped**: Shared resources like `aiohttp.ClientSession`, the scheduler, and the `GeoCache` are created at startup and reused across all requests. +- **Singleton**: Some services (e.g., `Fail2BanMetadataService`) are instantiated once and cached in `app.state` or imported as module-level instances. + --- ## 3. Frontend Architecture diff --git a/Docs/Backend-Development.md b/Docs/Backend-Development.md index 5953279..ff3390a 100644 --- a/Docs/Backend-Development.md +++ b/Docs/Backend-Development.md @@ -312,7 +312,9 @@ async def list_jails(service: JailService = Depends()) -> JailListResponse: ### Dependency Layering: Enforcing the Repository Boundary -The **repository boundary** separates database-aware code from application logic. This is enforced through dependency injection: +The **repository boundary** separates database-aware code from application logic. This is enforced through dependency injection. + +For a complete overview of BanGUI's DI pattern, including the composition root, service wiring, and lifecycle management, see [Architekture.md § 2.3 Dependency Wiring and Service Composition](Architekture.md#23-dependency-wiring-and-service-composition). | Layer | Responsibilities | Dependencies | |---|---|---| diff --git a/backend/app/dependencies.py b/backend/app/dependencies.py index 55b7d2a..95bd1ed 100644 --- a/backend/app/dependencies.py +++ b/backend/app/dependencies.py @@ -1,14 +1,32 @@ -"""FastAPI dependency providers. +"""FastAPI dependency providers and composition root. -All ``Depends()`` callables that inject shared resources (database -connection, settings, services, auth guard) are defined here. -Routers import directly from this module — never from ``app.state`` -directly — to keep coupling explicit and testable. +This module is BanGUI's dependency injection composition root. All injectable +resources — database connections, settings, services, repositories, and +authentication guards — are defined here as provider functions. -IMPORTANT: Routers should depend on repository dependencies (e.g., SessionRepoDep, -BlocklistRepositoryDep) rather than on database connections. This enforces the -repository boundary: only repositories and services access the database directly. -See Backend-Development.md § 6 for the dependency layering rules. +**Key Principles:** + +1. **Composition Root Pattern**: No heavyweight DI container is used. Instead, + FastAPI's `Depends()` framework manages all dependencies, keeping the pattern + lightweight and explicit. + +2. **Explicit Over Implicit**: Every dependency is declared in function signatures. + There is no hidden coupling or magic. This makes the dependency graph visible + to type checkers, debuggers, and developers. + +3. **Service Context Dependencies**: Related resources (e.g., db + repository) are + bundled into context objects (SessionServiceContext, BlocklistServiceContext) + to prevent routers from accessing raw database connections. + +4. **Repository Boundary Enforcement**: Routers must NOT import DbDep. They depend + on service context dependencies instead, which contain both the database + connection and the necessary repositories. This ensures repositories are the + only modules executing SQL. + +See Architekture.md § 2.3 (Dependency Wiring and Service Composition) for a +complete guide to the DI pattern, including examples of adding new services. + +See Backend-Development.md § 6 for dependency layering rules. """ import datetime