Refactor ban management with domain models and mappers

- Add ban domain model for core business logic separation
- Implement mapper pattern for DTO/domain conversions
- Update ban service with new domain-driven approach
- Refactor router endpoints to use new architecture
- Add comprehensive mapper tests
- Update documentation with architecture changes

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-04-28 07:46:02 +02:00
parent 507f153ab9
commit 3888c5eb3f
11 changed files with 640 additions and 68 deletions

View File

@@ -185,6 +185,43 @@ The HTTP interface layer. Each router maps URL paths to handler functions. Route
The business logic layer. Services orchestrate operations, enforce rules, and coordinate between repositories, the fail2ban client, and external APIs. Each service covers a single domain.
**Service Layer Responsibilities:**
Services **must be independent of HTTP concerns**. They work with domain models (DTOs), not response models. This ensures:
- Domain logic can evolve without affecting API shape
- Services are reusable across different frontends
- Testing is simpler (no mocking HTTP response types)
- Changes to endpoint responses don't require service changes
**Domain Models and Response Mapping:**
Services return **domain models** (e.g., `DomainActiveBanList`, `DomainBansByCountry`) that represent pure business logic. Response models (e.g., `ActiveBanListResponse`, `BansByCountryResponse`) are defined in `app/models/` and used only by routers.
Conversion happens at the **router boundary**:
1. Router calls service → receives domain model
2. Router calls mapper function to convert domain model → response model
3. Router returns response model to HTTP client
Example:
```python
# In ban_service.py
async def get_active_bans(...) -> DomainActiveBanList:
"""Service returns domain model (not HTTP-aware)."""
...
# In routers/bans.py (router boundary)
domain_result = await ban_service.get_active_bans(...)
return map_domain_active_ban_list_to_response(domain_result)
```
Mapper functions live in `app/mappers/` and are thin, mechanical translations between structures.
**Motivation:**
- The Fail2ban domain doesn't care about field names like `country_code` (snake_case) vs `countryCode` (camelCase)
- If the API needs pagination metadata added to the response, only the mapper changes
- If repositories change their output schema, only services need updating (routers are unaffected)
- Services can be tested with simple dataclasses; no need for Pydantic serialization overhead
| Service | Purpose |
|---|---|
| `auth_service.py` | Hashes and verifies the master password, creates and validates session tokens, enforces session expiry |
@@ -255,6 +292,43 @@ blocklist_service.py (Public API)
- Logging is contextual and tied to the appropriate layer
- Retry logic and transient error handling are isolated
#### Mappers (`app/mappers/`)
The response mapping layer. Mappers convert domain models (returned by services) to response models (consumed by HTTP routers). This layer enforces the separation between business logic and API shape.
**Location:** `app/mappers/`
**Responsibilities:**
- Convert service domain models to API response models
- Mechanical, thin translation — no business logic
- Used exclusively at the router boundary
**Pattern:**
Each domain model has a corresponding mapper function:
```python
# Domain model (from service)
DomainActiveBan map_domain_active_ban_to_response() ActiveBan (response)
# Service returns domain models:
async def get_active_bans(...) -> DomainActiveBanList
# Router converts at the boundary:
domain_result = await ban_service.get_active_bans(...)
return map_domain_active_ban_list_to_response(domain_result)
```
**Why separate?**
When API requirements change (e.g., new field added, field renamed), only:
1. Response model in `app/models/` changes
2. Mapper function in `app/mappers/` updates
3. Routers stay the same
4. Services don't change
Without this layer, changes to API shape would require modifying services and their tests.
#### Repositories (`app/repositories/`)
The data access layer. Repositories execute raw SQL queries against the application SQLite database. They return plain data or domain models — they never raise HTTP exceptions or contain business logic.

View File

@@ -1,22 +1,3 @@
## 6) Raw DB connection exposed as dependency for all routes
- Where found:
- [backend/app/dependencies.py](backend/app/dependencies.py)
- Why this is needed:
- Architectural boundary relies on convention, not enforcement.
- Goal:
- Enforce repository boundary for persistence access.
- What to do:
- Prefer repository dependencies in routers.
- Restrict direct DB usage to repository/service internals.
- Possible traps and issues:
- Large refactor may touch many endpoint signatures.
- Docs changes needed:
- Add dependency layering rule to backend guidelines.
- Doc references:
- [Docs/Backend-Development.md](Docs/Backend-Development.md)
---
## 7) Service layer coupled to response/presentation models
- Where found:
- [backend/app/services/ban_service.py](backend/app/services/ban_service.py)