Skip to content

Commit 5818d6c

Browse files
authored
chore: improved DDD layering (#432)
* fix: improved DDD layering * Update test_player_service.py
1 parent f2cc955 commit 5818d6c

12 files changed

Lines changed: 92 additions & 73 deletions

File tree

README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,18 @@ Below is the current list of TTL values configured for the API cache. The latest
123123

124124
## 🐍 Architecture
125125

126+
The codebase follows strict DDD layering — dependencies only flow inward. `domain` has no knowledge of FastAPI, Valkey, or Postgres; it only sees `typing.Protocol` ports, which `adapters` implement.
127+
128+
```mermaid
129+
flowchart TD
130+
api["api — routers, DI, response models"] --> domain
131+
api --> adapters
132+
api --> infrastructure
133+
adapters["adapters — Blizzard client, Valkey cache, Postgres storage, taskiq"] --> domain
134+
adapters --> infrastructure
135+
domain["domain — parsers, services (SWR), ports"] -.->|logger only| infrastructure["infrastructure — logger, singletons, error helpers"]
136+
```
137+
126138
### Request flow (Stale-While-Revalidate)
127139

128140
Every cached response is stored in Valkey as an **SWR envelope** containing the payload and three timestamps: `stored_at`, `staleness_threshold`, and `stale_while_revalidate`. Nginx/OpenResty inspects the envelope on every request:

app/domain/exceptions.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Set of custom exceptions used in the API"""
22

33
from http import HTTPStatus
4+
from typing import Any
45

56

67
class RateLimitedError(Exception):
@@ -22,10 +23,10 @@ class OverfastError(Exception):
2223
"""Generic OverFast API Exception"""
2324

2425
status_code = HTTPStatus.INTERNAL_SERVER_ERROR.value
25-
message = "OverFast API Error"
26+
message: str | dict[str, Any] = "OverFast API Error"
2627

2728
def __str__(self):
28-
return self.message
29+
return str(self.message)
2930

3031

3132
class ParserBlizzardError(OverfastError):
@@ -34,9 +35,9 @@ class ParserBlizzardError(OverfastError):
3435
"""
3536

3637
status_code = HTTPStatus.INTERNAL_SERVER_ERROR.value
37-
message = "Parser Blizzard Error"
38+
message: str | dict[str, Any] = "Parser Blizzard Error"
3839

39-
def __init__(self, status_code: int, message: str):
40+
def __init__(self, status_code: int, message: str | dict[str, Any]):
4041
super().__init__()
4142
self.status_code = status_code
4243
self.message = message

app/domain/parsers/hero.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
11
"""Stateless parser functions for single hero details"""
22

33
import re
4+
from http import HTTPStatus
45
from typing import TYPE_CHECKING
56

6-
from fastapi import status
7-
87
from app.config import settings
98
from app.domain.parsers.utils import (
109
parse_html_root,
@@ -58,7 +57,7 @@ def parse_hero_html(html: str, locale: Locale = Locale.ENGLISH_US) -> dict:
5857
abilities_section = root_tag.css_first("div.abilities-container")
5958
if not abilities_section:
6059
raise ParserBlizzardError( # noqa: TRY301
61-
status_code=status.HTTP_404_NOT_FOUND,
60+
status_code=HTTPStatus.NOT_FOUND.value,
6261
message="Hero not found or not released yet",
6362
)
6463

app/domain/parsers/hero_stats_summary.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
"""Stateless parser functions for hero stats summary (pickrate/winrate from Blizzard API)"""
22

3+
from http import HTTPStatus
34
from typing import TYPE_CHECKING
45

5-
from fastapi import status
6-
76
from app.config import settings
87
from app.domain.enums import PlayerGamemode, PlayerPlatform, PlayerRegion
98
from app.domain.exceptions import (
@@ -111,7 +110,7 @@ def parse_hero_stats_json(
111110
# Validate map matches gamemode (outside try so this is never caught above).
112111
if map_filter != selected_map:
113112
raise ParserBlizzardError(
114-
status_code=status.HTTP_400_BAD_REQUEST,
113+
status_code=HTTPStatus.BAD_REQUEST.value,
115114
message=f"Selected map '{map_filter}' is not compatible with '{gamemode}' gamemode.",
116115
)
117116

app/domain/parsers/player_helpers.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,9 @@ def get_computed_stat_value(input_str: str) -> str | float | int:
7474
def get_division_from_icon(rank_url: str) -> CompetitiveDivision:
7575
division_name = (
7676
rank_url.rsplit("/", maxsplit=1)[-1] # filename or inline-SVG symbol id
77-
.split(".", maxsplit=1)[0] # drop content hash / ".png": "Rank_DiamondTier" / "goldtier"
77+
.split(".", maxsplit=1)[
78+
0
79+
] # drop content hash / ".png": "Rank_DiamondTier" / "goldtier"
7880
.split("-", maxsplit=1)[0]
7981
.rsplit("_", maxsplit=1)[-1] # drop "Rank_" prefix: "DiamondTier" / "goldtier"
8082
)

app/domain/parsers/player_profile.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,9 @@
77
- Career stats (detailed statistics per hero)
88
"""
99

10+
from http import HTTPStatus
1011
from typing import TYPE_CHECKING
1112

12-
from fastapi import status
13-
1413
from app.config import settings
1514
from app.domain.exceptions import ParserBlizzardError, ParserParsingError
1615
from app.domain.parsers.utils import (
@@ -138,7 +137,7 @@ def parse_player_profile_html(
138137
# Check if player exists
139138
if not root_tag.css_first("blz-section.Profile-masthead"):
140139
raise ParserBlizzardError(
141-
status_code=status.HTTP_404_NOT_FOUND,
140+
status_code=HTTPStatus.NOT_FOUND.value,
142141
message="Player not found",
143142
)
144143

app/domain/services/hero_service.py

Lines changed: 6 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,10 @@
33
import json
44
from typing import TYPE_CHECKING, Any
55

6-
from fastapi import HTTPException
7-
86
from app.config import settings
97
from app.domain.enums import Locale, PlayerGamemode, SubRole
108
from app.domain.exceptions import (
119
InvalidGamemodeFilterError,
12-
ParserBlizzardError,
1310
ParserInternalError,
1411
ParserParsingError,
1512
)
@@ -115,18 +112,12 @@ def _hero_detail_config(
115112
"""Build a StaticFetchConfig for a single hero detail."""
116113

117114
async def _fetch() -> str:
118-
try:
119-
hero_html = await fetch_hero_html(
120-
self.blizzard_client, hero_key, locale
121-
)
122-
# Validate hero exists before making the second Blizzard request.
123-
# parse_hero_html raises ParserBlizzardError (404) for unknown heroes.
124-
parse_hero_html(hero_html, locale)
125-
heroes_html = await fetch_heroes_html(self.blizzard_client, locale)
126-
except ParserBlizzardError as exc:
127-
raise HTTPException(
128-
status_code=exc.status_code, detail=exc.message
129-
) from exc
115+
hero_html = await fetch_hero_html(self.blizzard_client, hero_key, locale)
116+
# Validate hero exists before making the second Blizzard request.
117+
# parse_hero_html raises ParserBlizzardError (404) for unknown heroes,
118+
# which propagates to the API layer's registered OverfastError handler.
119+
parse_hero_html(hero_html, locale)
120+
heroes_html = await fetch_heroes_html(self.blizzard_client, locale)
130121
return json.dumps(
131122
{"hero_html": hero_html, "heroes_html": heroes_html},
132123
separators=(",", ":"),
@@ -297,10 +288,6 @@ async def _get_hero_stats(
297288
competitive_division=competitive_division,
298289
order_by=order_by,
299290
)
300-
except ParserBlizzardError as exc:
301-
raise HTTPException(
302-
status_code=exc.status_code, detail=exc.message
303-
) from exc
304291
except ParserParsingError as exc:
305292
blizzard_url = f"{settings.blizzard_host}{settings.hero_stats_path}"
306293
raise ParserInternalError(blizzard_url, exc) from exc

app/domain/services/player_service.py

Lines changed: 13 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,6 @@
77
if TYPE_CHECKING:
88
from collections.abc import Callable
99

10-
from fastapi import HTTPException
11-
1210
from app.config import settings
1311
from app.domain.enums import HeroKeyCareerFilter, PlayerGamemode, PlayerPlatform
1412
from app.domain.exceptions import (
@@ -590,7 +588,7 @@ def _calculate_retry_after(self, check_count: int) -> int:
590588
async def _mark_player_unknown(
591589
self,
592590
blizzard_id: str,
593-
exception: HTTPException,
591+
exception: ParserBlizzardError,
594592
battletag: str | None = None,
595593
) -> None:
596594
if not settings.unknown_players_cache_enabled:
@@ -607,7 +605,7 @@ async def _mark_player_unknown(
607605
blizzard_id, check_count, retry_after, battletag=battletag
608606
)
609607

610-
exception.detail = { # ty: ignore[invalid-assignment]
608+
exception.message = {
611609
"error": "Player not found",
612610
"retry_after": retry_after,
613611
"next_check_at": next_check_at,
@@ -627,41 +625,37 @@ async def _handle_player_exceptions(
627625
player_id: str,
628626
identity: PlayerIdentity,
629627
) -> Never:
630-
"""Translate all player exceptions to HTTPException and always raise."""
628+
"""Translate known parser exceptions to a client-facing error and always raise.
629+
630+
Raised errors are ``ParserBlizzardError``/``ParserInternalError`` — the API
631+
layer's registered ``OverfastError`` handler turns them into HTTP responses.
632+
"""
631633
effective_id = identity.blizzard_id or player_id
632634
battletag_input = identity.battletag_input
633635
player_summary = identity.player_summary
634636

635637
if isinstance(error, ParserBlizzardError):
636-
exc = HTTPException(status_code=error.status_code, detail=error.message)
637638
if error.status_code == HTTPStatus.NOT_FOUND.value:
638639
await self._mark_player_unknown(
639-
effective_id, exc, battletag=battletag_input
640+
effective_id, error, battletag=battletag_input
640641
)
641-
raise exc from error
642+
raise error
642643

643644
if isinstance(error, ParserParsingError):
644645
if "Could not find main content in HTML" in str(error):
645-
exc = HTTPException(
646+
not_found = ParserBlizzardError(
646647
status_code=HTTPStatus.NOT_FOUND.value,
647-
detail="Player not found",
648+
message="Player not found",
648649
)
649650
await self._mark_player_unknown(
650-
effective_id, exc, battletag=battletag_input
651+
effective_id, not_found, battletag=battletag_input
651652
)
652-
raise exc from error
653+
raise not_found from error
653654

654655
blizzard_url = (
655656
f"{settings.blizzard_host}{settings.career_path}/"
656657
f"{player_summary.get('url', effective_id) if player_summary else effective_id}/"
657658
)
658659
raise ParserInternalError(blizzard_url, error) from error
659660

660-
if isinstance(error, HTTPException):
661-
if error.status_code == HTTPStatus.NOT_FOUND.value:
662-
await self._mark_player_unknown(
663-
effective_id, error, battletag=battletag_input
664-
)
665-
raise error
666-
667661
raise error

pyproject.toml

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,11 +143,19 @@ ignore = [
143143
allowed-confusables = ["", "", ""]
144144

145145
[tool.ruff.lint.per-file-ignores]
146-
"tests/**" = ["SLF001"] # Ignore private member access in tests
146+
"tests/**" = ["SLF001", "TID251"] # Ignore private member access + fastapi.TestClient in tests
147147
"app/api/models/**" = ["TC001"] # Ignore type-checking block (Pydantic models definition)
148148
"app/api/routers/**" = ["TC001"] # Enums used in FastAPI path/query parameters need runtime import
149149
"app/api/dependencies.py" = ["TC001"] # Port types needed at runtime for FastAPI Depends
150150
"app/domain/services/**" = ["TC001"] # Locale/enums used in runtime method annotations
151+
"app/api/**" = ["TID251"] # API layer owns FastAPI
152+
"app/adapters/**" = ["TID251"] # BlizzardClient raises HTTPException for transport errors
153+
"app/infrastructure/**" = ["TID251"] # Shared HTTPException helpers (Discord alerts, etc.)
154+
"app/monitoring/**" = ["TID251"] # Prometheus/FastAPI router glue
155+
"app/main.py" = ["TID251"] # App assembly
156+
157+
[tool.ruff.lint.flake8-tidy-imports.banned-api]
158+
"fastapi".msg = "Domain layer must stay framework-agnostic. Raise app.domain.exceptions instead of fastapi.HTTPException, use http.HTTPStatus instead of fastapi.status."
151159

152160
[tool.ruff.lint.isort]
153161
# Consider app as first-party for imports in tests

tests/heroes/parsers/test_hero_stats_summary.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -111,8 +111,10 @@ async def test_parse_hero_stats_summary_invalid_map_error_message(
111111
map_filter="hanaoka",
112112
)
113113

114-
assert "hanaoka" in exc_info.value.message
115-
assert "compatible" in exc_info.value.message.lower()
114+
message = str(exc_info.value.message)
115+
116+
assert "hanaoka" in message
117+
assert "compatible" in message.lower()
116118

117119

118120
def test_parse_hero_stats_json_raises_invalid_gamemode_filter_error():

0 commit comments

Comments
 (0)