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>
4.2 KiB
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
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
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
# 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
# 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.