Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
name: CI

on:
push:
branches: [main]
pull_request:

jobs:
typecheck:
name: mypy (dependency rule + type layer)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
- name: Install
run: pip install -e . mypy
- name: mypy
run: mypy src

test:
name: pytest (domain, application, infrastructure, integration)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
- name: Install
run: pip install -e . pytest
- name: pytest
# e2e requires the full compose stack (etcd, Kafka, ES); everything
# else runs infrastructure-free since app composition moved into
# the create_app factory
run: pytest tests/domain tests/application tests/infrastructure tests/integration -q
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,4 @@ EXPOSE 8000
# One worker per container: scaling is horizontal via API container
# replicas. A single process keeps circuit breaker state and
# /health/circuits coherent per container.
CMD ["uvicorn", "src.presentation.api.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]
CMD ["uvicorn", "--factory", "src.presentation.api.main:create_app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]
2 changes: 1 addition & 1 deletion main.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,4 @@
# keeps circuit breaker state and /health/circuits coherent — with
# multiple workers, each process holds its own breaker registry and
# health checks sample a random one.
uvicorn.run("src.presentation.api.main:app", host="0.0.0.0", port=8000, workers=1, backlog=8192, limit_concurrency=10000)
uvicorn.run("src.presentation.api.main:create_app", factory=True, host="0.0.0.0", port=8000, workers=1, backlog=8192, limit_concurrency=10000)
14 changes: 14 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,17 @@ asyncio_default_fixture_loop_scope = "session"
[build-system]
requires = ["setuptools", "wheel"]
build-backend = "setuptools.build_meta"

[tool.mypy]
python_version = "3.12"
ignore_missing_imports = true
warn_unused_ignores = true
no_implicit_optional = true
exclude = ["tests/"]

# SQLAlchemy's legacy Column() declarative style types instance attributes
# as Column[...] rather than their values; silence value-vs-column noise in
# the persistence adapters without losing import/name/annotation checking
[[tool.mypy.overrides]]
module = "src.infrastructure.adapters.*"
disable_error_code = ["arg-type", "assignment", "return-value", "index", "attr-defined"]
4 changes: 2 additions & 2 deletions src/application/command_handlers/add_book.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from src.domain.catalog import Author, Book, BookId, Title

if TYPE_CHECKING:
from src.domain.catalog import UnitOfWork
from src.domain.catalog import ICatalogUnitOfWork
from src.domain.shared_kernel import ILogger


Expand Down Expand Up @@ -41,7 +41,7 @@ class AddBookHandler:
and may emit domain events for read model synchronization.
"""

def __init__(self, uow: UnitOfWork, logger: ILogger):
def __init__(self, uow: ICatalogUnitOfWork, logger: ILogger):
self.uow = uow
self.logger = logger

Expand Down
10 changes: 4 additions & 6 deletions src/application/command_handlers/borrow_book.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,8 @@
from src.domain.catalog import BookNotFoundException, BorrowerNotEligibleException

if TYPE_CHECKING:
from src.domain.catalog import UnitOfWork
from src.domain.patron.interfaces.patron_query_repository import (
IPatronQueryRepository,
)
from src.domain.catalog import ICatalogUnitOfWork
from src.application.query_handlers.interfaces import IPatronQueryRepository
from src.domain.shared_kernel import ILogger


Expand Down Expand Up @@ -46,7 +44,7 @@ class BorrowBookHandler:

def __init__(
self,
uow: UnitOfWork,
uow: ICatalogUnitOfWork,
patron_query_repository: IPatronQueryRepository,
logger: ILogger,
):
Expand All @@ -65,7 +63,7 @@ async def handle(self, command: BorrowBookCommand) -> BorrowBookResult:
raise BorrowerNotEligibleException(
command.borrower_email, "no patron registered with this email"
)
if patron.get("is_suspended"):
if patron.is_suspended:
raise BorrowerNotEligibleException(
command.borrower_email, "patron is suspended"
)
Expand Down
4 changes: 2 additions & 2 deletions src/application/command_handlers/confirm_book_borrow.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
from src.domain.catalog import BookNotFoundException, BookStatus

if TYPE_CHECKING:
from src.domain.catalog import UnitOfWork
from src.domain.catalog import ICatalogUnitOfWork
from src.domain.shared_kernel import ILogger


Expand All @@ -36,7 +36,7 @@ class ConfirmBookBorrowCommand:
class ConfirmBookBorrowHandler:
"""Handles the ConfirmBookBorrowCommand."""

def __init__(self, uow: UnitOfWork, logger: ILogger):
def __init__(self, uow: ICatalogUnitOfWork, logger: ILogger):
self.uow = uow
self.logger = logger

Expand Down
4 changes: 2 additions & 2 deletions src/application/command_handlers/create_loan.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

if TYPE_CHECKING:
from src.domain.shared_kernel import ILogger
from src.infrastructure.adapters.lending import LoanUnitOfWork
from src.domain.lending import ILoanUnitOfWork


@dataclass(frozen=True)
Expand Down Expand Up @@ -42,7 +42,7 @@ class CreateLoanResult:
class CreateLoanHandler:
"""Handles loan creation."""

def __init__(self, uow: LoanUnitOfWork, logger: ILogger):
def __init__(self, uow: ILoanUnitOfWork, logger: ILogger):
self.uow = uow
self.logger = logger

Expand Down
4 changes: 2 additions & 2 deletions src/application/command_handlers/extend_loan.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

if TYPE_CHECKING:
from src.domain.shared_kernel import ILogger
from src.infrastructure.adapters.lending import LoanUnitOfWork
from src.domain.lending import ILoanUnitOfWork


@dataclass(frozen=True)
Expand All @@ -31,7 +31,7 @@ class ExtendLoanResult:
class ExtendLoanHandler:
"""Handles loan extensions."""

def __init__(self, uow: LoanUnitOfWork, logger: ILogger):
def __init__(self, uow: ILoanUnitOfWork, logger: ILogger):
self.uow = uow
self.logger = logger

Expand Down
4 changes: 2 additions & 2 deletions src/application/command_handlers/register_patron.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@

if TYPE_CHECKING:
from src.domain.shared_kernel import ILogger
from src.infrastructure.adapters.patron import PatronUnitOfWork
from src.domain.patron import IPatronUnitOfWork


@dataclass(frozen=True)
Expand All @@ -37,7 +37,7 @@ class RegisterPatronResult:
class RegisterPatronHandler:
"""Handles patron registration."""

def __init__(self, uow: PatronUnitOfWork, logger: ILogger):
def __init__(self, uow: IPatronUnitOfWork, logger: ILogger):
self.uow = uow
self.logger = logger

Expand Down
4 changes: 2 additions & 2 deletions src/application/command_handlers/reinstate_patron.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

if TYPE_CHECKING:
from src.domain.shared_kernel import ILogger
from src.infrastructure.adapters.patron import PatronUnitOfWork
from src.domain.patron import IPatronUnitOfWork


@dataclass(frozen=True)
Expand All @@ -29,7 +29,7 @@ class ReinstatePatronResult:
class ReinstatePatronHandler:
"""Handles patron reinstatement."""

def __init__(self, uow: PatronUnitOfWork, logger: ILogger):
def __init__(self, uow: IPatronUnitOfWork, logger: ILogger):
self.uow = uow
self.logger = logger

Expand Down
4 changes: 2 additions & 2 deletions src/application/command_handlers/release_book_reservation.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from src.domain.catalog import BookNotFoundException, BookStatus

if TYPE_CHECKING:
from src.domain.catalog import UnitOfWork
from src.domain.catalog import ICatalogUnitOfWork
from src.domain.shared_kernel import ILogger


Expand All @@ -26,7 +26,7 @@ class ReleaseBookReservationCommand:
class ReleaseBookReservationHandler:
"""Handles the ReleaseBookReservationCommand."""

def __init__(self, uow: UnitOfWork, logger: ILogger):
def __init__(self, uow: ICatalogUnitOfWork, logger: ILogger):
self.uow = uow
self.logger = logger

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
from typing import TYPE_CHECKING

if TYPE_CHECKING:
from src.domain.catalog import UnitOfWork
from src.domain.catalog import ICatalogUnitOfWork
from src.domain.shared_kernel import ILogger


Expand All @@ -36,7 +36,7 @@ class ReleaseExpiredReservationsResult:
class ReleaseExpiredReservationsHandler:
"""Handles the ReleaseExpiredReservationsCommand."""

def __init__(self, uow: UnitOfWork, logger: ILogger):
def __init__(self, uow: ICatalogUnitOfWork, logger: ILogger):
self.uow = uow
self.logger = logger

Expand Down
4 changes: 2 additions & 2 deletions src/application/command_handlers/return_book.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from src.domain.catalog import BookNotFoundException

if TYPE_CHECKING:
from src.domain.catalog import UnitOfWork
from src.domain.catalog import ICatalogUnitOfWork
from src.domain.shared_kernel import ILogger


Expand All @@ -36,7 +36,7 @@ class ReturnBookHandler:
Emits BookReturned domain event for read model sync.
"""

def __init__(self, uow: UnitOfWork, logger: ILogger):
def __init__(self, uow: ICatalogUnitOfWork, logger: ILogger):
self.uow = uow
self.logger = logger

Expand Down
5 changes: 3 additions & 2 deletions src/application/command_handlers/return_loan.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

if TYPE_CHECKING:
from src.domain.shared_kernel import ILogger
from src.infrastructure.adapters.lending import LoanUnitOfWork
from src.domain.lending import ILoanUnitOfWork


@dataclass(frozen=True)
Expand All @@ -31,7 +31,7 @@ class ReturnLoanResult:
class ReturnLoanHandler:
"""Handles loan returns."""

def __init__(self, uow: LoanUnitOfWork, logger: ILogger):
def __init__(self, uow: ILoanUnitOfWork, logger: ILogger):
self.uow = uow
self.logger = logger

Expand All @@ -44,6 +44,7 @@ async def handle(self, command: ReturnLoanCommand) -> ReturnLoanResult:
returned_at = datetime.now()
was_overdue = loan.due_date.is_overdue_as_of(returned_at)
loan.return_book(returned_at)
assert loan.returned_at is not None

await self.uow.loans.update(loan)
await self.uow.commit()
Expand Down
6 changes: 3 additions & 3 deletions src/application/command_handlers/suspend_patron.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

if TYPE_CHECKING:
from src.domain.shared_kernel import ILogger
from src.infrastructure.adapters.patron import PatronUnitOfWork
from src.domain.patron import IPatronUnitOfWork


@dataclass(frozen=True)
Expand All @@ -25,13 +25,13 @@ class SuspendPatronResult:
"""Result of suspending a patron."""
id: str
is_suspended: bool
reason: str
reason: "str | None"


class SuspendPatronHandler:
"""Handles patron suspension."""

def __init__(self, uow: PatronUnitOfWork, logger: ILogger):
def __init__(self, uow: IPatronUnitOfWork, logger: ILogger):
self.uow = uow
self.logger = logger

Expand Down
4 changes: 2 additions & 2 deletions src/application/command_handlers/upgrade_patron_tier.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

if TYPE_CHECKING:
from src.domain.shared_kernel import ILogger
from src.infrastructure.adapters.patron import PatronUnitOfWork
from src.domain.patron import IPatronUnitOfWork


@dataclass(frozen=True)
Expand All @@ -32,7 +32,7 @@ class UpgradePatronTierResult:
class UpgradePatronTierHandler:
"""Handles patron tier upgrades."""

def __init__(self, uow: PatronUnitOfWork, logger: ILogger):
def __init__(self, uow: IPatronUnitOfWork, logger: ILogger):
self.uow = uow
self.logger = logger

Expand Down
10 changes: 4 additions & 6 deletions src/application/event_handlers/create_loan_on_book_reserved.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,7 @@

if TYPE_CHECKING:
from src.domain.catalog import CatalogBookReserved
from src.domain.patron.interfaces.patron_query_repository import (
IPatronQueryRepository,
)
from src.application.query_handlers.interfaces import IPatronQueryRepository
from src.domain.shared_kernel import ILogger


Expand All @@ -61,16 +59,16 @@ async def handle(self, event: CatalogBookReserved) -> None:
event, f"no patron registered with email {event.borrower_email}"
)
return
if patron.get("is_suspended"):
await self._compensate(event, f"patron {patron['id']} is suspended")
if patron.is_suspended:
await self._compensate(event, f"patron {patron.id} is suspended")
return

# The catalog decided the due date; lending honors it instead of
# recomputing its own
loan_duration_days = max(1, (event.return_due_date - event.reserved_at).days)

command = CreateLoanCommand(
patron_id=patron["id"],
patron_id=patron.id,
patron_email=event.borrower_email,
catalog_book_id=event.book_id,
book_title=event.title,
Expand Down
13 changes: 12 additions & 1 deletion src/application/query_handlers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,23 @@
Queries read from optimized read models and never modify state.
"""
from .get_book import GetBookHandler, GetBookQuery
from .list_books import BookReadModel, ListBooksHandler, ListBooksQuery
from .interfaces import (
IBookQueryRepository,
ILoanQueryRepository,
IPatronQueryRepository,
)
from .list_books import ListBooksHandler, ListBooksQuery
from .read_models import BookReadModel, LoanReadModel, PatronReadModel

__all__ = [
"ListBooksQuery",
"ListBooksHandler",
"GetBookQuery",
"GetBookHandler",
"BookReadModel",
"PatronReadModel",
"LoanReadModel",
"IBookQueryRepository",
"IPatronQueryRepository",
"ILoanQueryRepository",
]
Loading
Loading