docs: Define explicit DI container strategy for backend service graph

- 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>
This commit is contained in:
2026-04-29 20:25:25 +02:00
parent b6631b86e4
commit 9a43123b3a
3 changed files with 195 additions and 10 deletions

View File

@@ -544,6 +544,171 @@ The FastAPI app factory. Responsibilities:
- Registers global exception handlers that map domain exceptions to HTTP status codes - 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`) - 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_<service>_context()` that combines them
- Create a type alias `<Service>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 ## 3. Frontend Architecture

View File

@@ -312,7 +312,9 @@ async def list_jails(service: JailService = Depends()) -> JailListResponse:
### Dependency Layering: Enforcing the Repository Boundary ### 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 | | Layer | Responsibilities | Dependencies |
|---|---|---| |---|---|---|

View File

@@ -1,14 +1,32 @@
"""FastAPI dependency providers. """FastAPI dependency providers and composition root.
All ``Depends()`` callables that inject shared resources (database This module is BanGUI's dependency injection composition root. All injectable
connection, settings, services, auth guard) are defined here. resources — database connections, settings, services, repositories, and
Routers import directly from this module — never from ``app.state`` authentication guards — are defined here as provider functions.
directly — to keep coupling explicit and testable.
IMPORTANT: Routers should depend on repository dependencies (e.g., SessionRepoDep, **Key Principles:**
BlocklistRepositoryDep) rather than on database connections. This enforces the
repository boundary: only repositories and services access the database directly. 1. **Composition Root Pattern**: No heavyweight DI container is used. Instead,
See Backend-Development.md § 6 for the dependency layering rules. 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 import datetime