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
- 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

View File

@@ -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 |
|---|---|---|