Skip to content

Commit 4c1f919

Browse files
committed
Introduce domain exception hierarchy for transport-agnostic services
- Add base exceptions in app/domain/exceptions.py (NotFoundError, ValidationError, ThrottledError, ConflictError, UnauthorizedError, ForbiddenError, InvalidStateError, InfrastructureError) - Create domain-specific exceptions for execution, saga, notification, saved_script, replay, and user/auth modules - Update all services to throw domain exceptions instead of HTTPException - Add single exception handler middleware that maps to HTTP status codes - Add documentation in docs/architecture/domain-exceptions.md Services no longer know about HTTP semantics. The middleware handles all mapping from domain exceptions to JSON responses.
1 parent 6b26179 commit 4c1f919

File tree

210 files changed

+4988
-8042
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

210 files changed

+4988
-8042
lines changed

backend/app/api/routes/admin/events.py

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,9 @@
99

1010
from app.api.dependencies import admin_user
1111
from app.core.correlation import CorrelationContext
12+
from app.domain.admin import ReplayQuery
1213
from app.domain.enums.events import EventType
13-
from app.infrastructure.mappers import (
14-
AdminReplayApiMapper,
15-
EventFilterMapper,
16-
)
14+
from app.domain.events.event_models import EventFilter
1715
from app.schemas_pydantic.admin_events import (
1816
EventBrowseRequest,
1917
EventBrowseResponse,
@@ -28,6 +26,13 @@
2826
from app.schemas_pydantic.user import UserResponse
2927
from app.services.admin import AdminEventsService
3028

29+
30+
def _to_event_filter(pydantic_filter: AdminEventFilter) -> EventFilter:
31+
"""Convert Pydantic EventFilter to domain EventFilter."""
32+
data = pydantic_filter.model_dump(mode="json") # auto-converts enums to strings
33+
data["text_search"] = data.pop("search_text", None)
34+
return EventFilter(**data)
35+
3136
router = APIRouter(
3237
prefix="/admin/events", tags=["admin-events"], route_class=DishkaRoute, dependencies=[Depends(admin_user)]
3338
)
@@ -36,7 +41,7 @@
3641
@router.post("/browse")
3742
async def browse_events(request: EventBrowseRequest, service: FromDishka[AdminEventsService]) -> EventBrowseResponse:
3843
try:
39-
event_filter = EventFilterMapper.from_admin_pydantic(request.filters)
44+
event_filter = _to_event_filter(request.filters)
4045

4146
result = await service.browse_events(
4247
event_filter=event_filter,
@@ -79,7 +84,7 @@ async def export_events_csv(
7984
limit: int = Query(default=10000, le=50000),
8085
) -> StreamingResponse:
8186
try:
82-
export_filter = EventFilterMapper.from_admin_pydantic(
87+
export_filter = _to_event_filter(
8388
AdminEventFilter(
8489
event_types=event_types,
8590
start_time=start_time,
@@ -111,7 +116,7 @@ async def export_events_json(
111116
) -> StreamingResponse:
112117
"""Export events as JSON with comprehensive filtering."""
113118
try:
114-
export_filter = EventFilterMapper.from_admin_pydantic(
119+
export_filter = _to_event_filter(
115120
AdminEventFilter(
116121
event_types=event_types,
117122
aggregate_id=aggregate_id,
@@ -159,7 +164,13 @@ async def replay_events(
159164
) -> EventReplayResponse:
160165
try:
161166
replay_correlation_id = f"replay_{CorrelationContext.get_correlation_id()}"
162-
rq = AdminReplayApiMapper.request_to_query(request)
167+
rq = ReplayQuery(
168+
event_ids=request.event_ids,
169+
correlation_id=request.correlation_id,
170+
aggregate_id=request.aggregate_id,
171+
start_time=request.start_time,
172+
end_time=request.end_time,
173+
)
163174
try:
164175
result = await service.prepare_or_schedule_replay(
165176
replay_query=rq,

backend/app/api/routes/admin/settings.py

Lines changed: 33 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,39 @@
66
from pydantic import ValidationError
77

88
from app.api.dependencies import admin_user
9-
from app.infrastructure.mappers import SettingsMapper
9+
from app.domain.admin import (
10+
ExecutionLimits,
11+
LogLevel,
12+
MonitoringSettings,
13+
SecuritySettings,
14+
SystemSettings as DomainSystemSettings,
15+
)
1016
from app.schemas_pydantic.admin_settings import SystemSettings
1117
from app.schemas_pydantic.user import UserResponse
1218
from app.services.admin import AdminSettingsService
1319

20+
21+
def _domain_to_pydantic(domain: DomainSystemSettings) -> SystemSettings:
22+
"""Convert domain SystemSettings to Pydantic schema."""
23+
return SystemSettings.model_validate(domain, from_attributes=True)
24+
25+
26+
def _pydantic_to_domain(schema: SystemSettings) -> DomainSystemSettings:
27+
"""Convert Pydantic schema to domain SystemSettings."""
28+
data = schema.model_dump()
29+
mon = data.get("monitoring_settings", {})
30+
return DomainSystemSettings(
31+
execution_limits=ExecutionLimits(**data.get("execution_limits", {})),
32+
security_settings=SecuritySettings(**data.get("security_settings", {})),
33+
monitoring_settings=MonitoringSettings(
34+
metrics_retention_days=mon.get("metrics_retention_days", 30),
35+
log_level=LogLevel(mon.get("log_level", "INFO")),
36+
enable_tracing=mon.get("enable_tracing", True),
37+
sampling_rate=mon.get("sampling_rate", 0.1),
38+
),
39+
)
40+
41+
1442
router = APIRouter(
1543
prefix="/admin/settings", tags=["admin", "settings"], route_class=DishkaRoute, dependencies=[Depends(admin_user)]
1644
)
@@ -23,8 +51,7 @@ async def get_system_settings(
2351
) -> SystemSettings:
2452
try:
2553
domain_settings = await service.get_system_settings(admin.username)
26-
settings_mapper = SettingsMapper()
27-
return SystemSettings(**settings_mapper.system_settings_to_pydantic_dict(domain_settings))
54+
return _domain_to_pydantic(domain_settings)
2855

2956
except Exception:
3057
raise HTTPException(status_code=500, detail="Failed to retrieve settings")
@@ -37,8 +64,7 @@ async def update_system_settings(
3764
service: FromDishka[AdminSettingsService],
3865
) -> SystemSettings:
3966
try:
40-
settings_mapper = SettingsMapper()
41-
domain_settings = settings_mapper.system_settings_from_pydantic(settings.model_dump())
67+
domain_settings = _pydantic_to_domain(settings)
4268
except (ValueError, ValidationError, KeyError) as e:
4369
raise HTTPException(status_code=422, detail=f"Invalid settings: {str(e)}")
4470
except Exception:
@@ -52,9 +78,7 @@ async def update_system_settings(
5278
user_id=admin.user_id,
5379
)
5480

55-
# Convert back to pydantic schema for response
56-
settings_mapper = SettingsMapper()
57-
return SystemSettings(**settings_mapper.system_settings_to_pydantic_dict(updated_domain_settings))
81+
return _domain_to_pydantic(updated_domain_settings)
5882

5983
except Exception:
6084
raise HTTPException(status_code=500, detail="Failed to update settings")
@@ -67,8 +91,7 @@ async def reset_system_settings(
6791
) -> SystemSettings:
6892
try:
6993
reset_domain_settings = await service.reset_system_settings(admin.username, admin.user_id)
70-
settings_mapper = SettingsMapper()
71-
return SystemSettings(**settings_mapper.system_settings_to_pydantic_dict(reset_domain_settings))
94+
return _domain_to_pydantic(reset_domain_settings)
7295

7396
except Exception:
7497
raise HTTPException(status_code=500, detail="Failed to reset settings")

backend/app/api/routes/auth.py

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,15 @@
1-
from datetime import datetime, timedelta, timezone
2-
from uuid import uuid4
1+
import logging
2+
from datetime import timedelta
33

44
from dishka import FromDishka
55
from dishka.integrations.fastapi import DishkaRoute
66
from fastapi import APIRouter, Depends, HTTPException, Request, Response
77
from fastapi.security import OAuth2PasswordRequestForm
88

9-
from app.core.logging import logger
109
from app.core.security import security_service
1110
from app.core.utils import get_client_ip
1211
from app.db.repositories import UserRepository
13-
from app.domain.user import User as DomainAdminUser
12+
from app.domain.user import DomainUserCreate
1413
from app.schemas_pydantic.user import (
1514
LoginResponse,
1615
MessageResponse,
@@ -29,6 +28,7 @@ async def login(
2928
request: Request,
3029
response: Response,
3130
user_repo: FromDishka[UserRepository],
31+
logger: FromDishka[logging.Logger],
3232
form_data: OAuth2PasswordRequestForm = Depends(),
3333
) -> LoginResponse:
3434
logger.info(
@@ -126,6 +126,7 @@ async def register(
126126
request: Request,
127127
user: UserCreate,
128128
user_repo: FromDishka[UserRepository],
129+
logger: FromDishka[logging.Logger],
129130
) -> UserResponse:
130131
logger.info(
131132
"Registration attempt",
@@ -151,19 +152,15 @@ async def register(
151152

152153
try:
153154
hashed_password = security_service.get_password_hash(user.password)
154-
now = datetime.now(timezone.utc)
155-
domain_user = DomainAdminUser(
156-
user_id=str(uuid4()),
155+
create_data = DomainUserCreate(
157156
username=user.username,
158157
email=str(user.email),
158+
hashed_password=hashed_password,
159159
role=user.role,
160160
is_active=True,
161161
is_superuser=False,
162-
hashed_password=hashed_password,
163-
created_at=now,
164-
updated_at=now,
165162
)
166-
created_user = await user_repo.create_user(domain_user)
163+
created_user = await user_repo.create_user(create_data)
167164

168165
logger.info(
169166
"Registration successful",
@@ -204,6 +201,7 @@ async def get_current_user_profile(
204201
request: Request,
205202
response: Response,
206203
auth_service: FromDishka[AuthService],
204+
logger: FromDishka[logging.Logger],
207205
) -> UserResponse:
208206
current_user = await auth_service.get_current_user(request)
209207

@@ -227,6 +225,7 @@ async def get_current_user_profile(
227225
async def verify_token(
228226
request: Request,
229227
auth_service: FromDishka[AuthService],
228+
logger: FromDishka[logging.Logger],
230229
) -> TokenValidationResponse:
231230
current_user = await auth_service.get_current_user(request)
232231
logger.info(
@@ -278,6 +277,7 @@ async def verify_token(
278277
async def logout(
279278
request: Request,
280279
response: Response,
280+
logger: FromDishka[logging.Logger],
281281
) -> MessageResponse:
282282
logger.info(
283283
"Logout attempt",

backend/app/api/routes/events.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import asyncio
2+
import logging
23
from datetime import datetime, timedelta, timezone
34
from typing import Annotated, Any, Dict, List
45

@@ -8,7 +9,6 @@
89

910
from app.api.dependencies import admin_user, current_user
1011
from app.core.correlation import CorrelationContext
11-
from app.core.logging import logger
1212
from app.core.utils import get_client_ip
1313
from app.domain.enums.common import SortOrder
1414
from app.domain.events.event_models import EventFilter
@@ -283,6 +283,7 @@ async def delete_event(
283283
event_id: str,
284284
admin: Annotated[UserResponse, Depends(admin_user)],
285285
event_service: FromDishka[EventService],
286+
logger: FromDishka[logging.Logger],
286287
) -> DeleteEventResponse:
287288
result = await event_service.delete_event_with_archival(event_id=event_id, deleted_by=str(admin.email))
288289

@@ -309,6 +310,7 @@ async def replay_aggregate_events(
309310
admin: Annotated[UserResponse, Depends(admin_user)],
310311
event_service: FromDishka[EventService],
311312
kafka_event_service: FromDishka[KafkaEventService],
313+
logger: FromDishka[logging.Logger],
312314
target_service: str | None = Query(None, description="Service to replay events to"),
313315
dry_run: bool = Query(True, description="If true, only show what would be replayed"),
314316
) -> ReplayAggregateResponse:

0 commit comments

Comments
 (0)