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:
@@ -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
|
||||||
|
|||||||
@@ -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 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user