Files
BanGUI/Docs/DOMAIN_MODELS.md
Lukas 0d5882b32f Fix HIGH priority issues: unbounded queries, rate limiting, health checks
Issue #3 - Unbounded Query Results (OOM):
- get_all_archived_history() now uses keyset pagination with bounded max_rows (50k default)
- Added 'id' field to records from get_archived_history() and get_archived_history_keyset()
- Protocol signature updated with page_size, max_rows, last_ban_id params

Issue #7 - Docker Health Check Fails:
- Added curl to Dockerfile.backend runtime image
- HEALTHCHECK now uses 'curl -f http://localhost:8000/api/health'
- compose.prod.yml: increased start_period to 40s, timeout to 10s
- Frontend healthcheck proxies to backend /api/health

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-01 21:47:36 +02:00

125 lines
4.2 KiB
Markdown

# Domain Models — Reference Guide
This document explains the domain model pattern used in BanGUI's backend and where to find examples.
---
## What Are Domain Models?
Domain models (e.g., `DomainActiveBanList`, `DomainJailConfig`) are **frozen dataclasses** that represent pure business logic. They are defined in `app/models/{domain}_domain.py` and are **returned by services**.
Response models (e.g., `ActiveBanListResponse`, `JailConfigResponse`) are **Pydantic models** defined in `app/models/{domain}.py`. They are used **only by routers** for HTTP serialization.
---
## Why This Separation?
```
Service (returns domain model)
Router (converts domain → response via mapper)
HTTP Response (Pydantic model)
```
**Benefits:**
- Domain logic evolves without affecting API shape
- Services are reusable across different frontends (GraphQL, gRPC, CLI)
- Testing is simpler (no Pydantic overhead)
- Changes to endpoint responses don't require service changes
---
## Existing Domain Models
| Domain | Domain Model(s) | Mapper Module |
|--------|----------------|---------------|
| **Ban** | `DomainActiveBanList`, `DomainActiveBan`, `DomainBansByCountry` | `ban_mappers.py` |
| **Jail** | `DomainJailList`, `DomainJailDetail`, `DomainJailBannedIps`, `DomainActiveBan` | `jail_mappers.py` |
| **Config** | `DomainJailConfig`, `DomainJailConfigList`, `DomainGlobalConfig`, `DomainServiceStatus`, `DomainBantimeEscalation`, `DomainFilterConfig`, `DomainFilterList`, `DomainRegexTest`, `DomainMapColorThresholds` | `config_mappers.py` |
| **History** | `DomainHistoryList`, `DomainHistoryBanItem`, `DomainIpDetail`, `DomainIpTimelineEvent` | `history_mappers.py` |
| **Server** | `DomainServerSettings`, `DomainServerSettingsResult` | `server_mappers.py` |
| **Blocklist** | `DomainBlocklistSource`, `DomainImportLogEntry`, `DomainImportLogList`, `DomainImportSourceResult`, `DomainImportRunResult`, `DomainPreviewResult`, `DomainScheduleConfig`, `DomainScheduleInfo` | `blocklist_mappers.py` |
---
## The Pattern — Step by Step
### Step 1: Define Domain Model in `app/models/{domain}_domain.py`
```python
from dataclasses import dataclass
@dataclass(frozen=True)
class DomainJailConfig:
"""Configuration snapshot of a single jail (domain model)."""
name: str
ban_time: int
max_retry: int
find_time: int
fail_regex: list[str]
actions: list[str] # ← no default BEFORE default = FIELD ORDER ERROR
date_pattern: str | None = None # ← all fields with defaults come AFTER
log_encoding: LogEncoding = "UTF-8"
```
**⚠️ Field Order Rule:** All fields without defaults must appear before all fields with defaults.
### Step 2: Add Mapper in `app/mappers/{domain}_mappers.py`
```python
def map_domain_jail_config_to_response(domain: DomainJailConfig) -> JailConfig:
"""Convert domain jail config to response model."""
return JailConfig(
name=domain.name,
ban_time=domain.ban_time,
...
)
```
### Step 3: Service Returns Domain Model
```python
# In app/services/jail_service.py
from app.models.config_domain import DomainJailConfig, DomainJailConfigList
async def get_jail_config(socket_path: str, name: str) -> DomainJailConfig:
...
return DomainJailConfig(...) # ← return domain model
```
### Step 4: Router Uses Mapper at Boundary
```python
# In app/routers/jail_config.py
from app.mappers import config_mappers
@router.get("/{name}", response_model=JailConfigResponse)
async def get_jail_config(...) -> JailConfigResponse:
domain_result = await config_service.get_jail_config(socket_path, name)
return config_mappers.map_domain_jail_config_to_response(domain_result)
```
---
## Reference Implementation
`ban_service.py` + `ban_mappers.py` is the canonical example of the correct pattern. Study it first when adding a new service.
---
## Common Issues
### Field Ordering Error
```
TypeError: non-default argument 'actions' follows default argument
```
**Fix:** Move all fields with defaults (`field: T | None = None`) after all fields without defaults.
### Forgetting the Mapper
If you refactor a service to return a domain model but forget to update the router, you'll get a type mismatch at the boundary. Always update router + service together.