Domain isolation: typed contracts, service registry, and composable wiring

Add typed service contracts (Protocols + frozen DTOs) in shared/contracts/
for cross-domain communication. Each domain exposes a service interface
(BlogService, CalendarService, MarketService, CartService) backed by SQL
implementations in shared/services/. A singleton registry with has() guards
enables composable startup — apps register their own domain service and
stubs for absent domains.

Absorbs glue layer: navigation, relationships, event handlers (login,
container, order) now live in shared/ with has()-guarded service calls.
Factory gains domain_services_fn parameter for per-app service registration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
giles
2026-02-19 04:29:10 +00:00
parent ea7dc9723a
commit 70b1c7de10
19 changed files with 1375 additions and 5 deletions

31
contracts/__init__.py Normal file
View File

@@ -0,0 +1,31 @@
"""Typed contracts (DTOs + Protocols) for cross-domain service interfaces."""
from .dtos import (
PostDTO,
CalendarDTO,
CalendarEntryDTO,
MarketPlaceDTO,
ProductDTO,
CartItemDTO,
CartSummaryDTO,
)
from .protocols import (
BlogService,
CalendarService,
MarketService,
CartService,
)
__all__ = [
"PostDTO",
"CalendarDTO",
"CalendarEntryDTO",
"MarketPlaceDTO",
"ProductDTO",
"CartItemDTO",
"CartSummaryDTO",
"BlogService",
"CalendarService",
"MarketService",
"CartService",
]

113
contracts/dtos.py Normal file
View File

@@ -0,0 +1,113 @@
"""Frozen dataclasses for cross-domain data transfer.
These are the *only* shapes that cross domain boundaries. Consumers never
see ORM model instances from another domain — only these DTOs.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime
from decimal import Decimal
# ---------------------------------------------------------------------------
# Blog domain
# ---------------------------------------------------------------------------
@dataclass(frozen=True, slots=True)
class PostDTO:
id: int
slug: str
title: str
status: str
visibility: str
is_page: bool = False
feature_image: str | None = None
html: str | None = None
excerpt: str | None = None
custom_excerpt: str | None = None
published_at: datetime | None = None
# ---------------------------------------------------------------------------
# Calendar / Events domain
# ---------------------------------------------------------------------------
@dataclass(frozen=True, slots=True)
class CalendarDTO:
id: int
container_type: str
container_id: int
name: str
slug: str
description: str | None = None
@dataclass(frozen=True, slots=True)
class CalendarEntryDTO:
id: int
calendar_id: int
name: str
start_at: datetime
state: str
cost: Decimal
end_at: datetime | None = None
user_id: int | None = None
session_id: str | None = None
order_id: int | None = None
slot_id: int | None = None
ticket_price: Decimal | None = None
ticket_count: int | None = None
calendar_name: str | None = None
calendar_slug: str | None = None
# ---------------------------------------------------------------------------
# Market domain
# ---------------------------------------------------------------------------
@dataclass(frozen=True, slots=True)
class MarketPlaceDTO:
id: int
container_type: str
container_id: int
name: str
slug: str
description: str | None = None
@dataclass(frozen=True, slots=True)
class ProductDTO:
id: int
slug: str
title: str | None = None
image: str | None = None
description_short: str | None = None
rrp: Decimal | None = None
regular_price: Decimal | None = None
special_price: Decimal | None = None
# ---------------------------------------------------------------------------
# Cart domain
# ---------------------------------------------------------------------------
@dataclass(frozen=True, slots=True)
class CartItemDTO:
id: int
product_id: int
quantity: int
product_title: str | None = None
product_slug: str | None = None
product_image: str | None = None
unit_price: Decimal | None = None
market_place_id: int | None = None
@dataclass(frozen=True, slots=True)
class CartSummaryDTO:
count: int = 0
total: Decimal = Decimal("0")
calendar_count: int = 0
calendar_total: Decimal = Decimal("0")
items: list[CartItemDTO] = field(default_factory=list)

95
contracts/protocols.py Normal file
View File

@@ -0,0 +1,95 @@
"""Protocol classes defining each domain's service interface.
All cross-domain callers program against these Protocols. Concrete
implementations (Sql*Service) and no-op stubs both satisfy them.
"""
from __future__ import annotations
from typing import Protocol, runtime_checkable
from sqlalchemy.ext.asyncio import AsyncSession
from .dtos import (
PostDTO,
CalendarDTO,
CalendarEntryDTO,
MarketPlaceDTO,
ProductDTO,
CartItemDTO,
CartSummaryDTO,
)
@runtime_checkable
class BlogService(Protocol):
async def get_post_by_slug(self, session: AsyncSession, slug: str) -> PostDTO | None: ...
async def get_post_by_id(self, session: AsyncSession, id: int) -> PostDTO | None: ...
async def get_posts_by_ids(self, session: AsyncSession, ids: list[int]) -> list[PostDTO]: ...
@runtime_checkable
class CalendarService(Protocol):
async def calendars_for_container(
self, session: AsyncSession, container_type: str, container_id: int,
) -> list[CalendarDTO]: ...
async def pending_entries(
self, session: AsyncSession, *, user_id: int | None, session_id: str | None,
) -> list[CalendarEntryDTO]: ...
async def entries_for_page(
self, session: AsyncSession, page_id: int, *, user_id: int | None, session_id: str | None,
) -> list[CalendarEntryDTO]: ...
async def entry_by_id(self, session: AsyncSession, entry_id: int) -> CalendarEntryDTO | None: ...
async def associated_entries(
self, session: AsyncSession, content_type: str, content_id: int, page: int,
) -> tuple[list[CalendarEntryDTO], bool]: ...
async def toggle_entry_post(
self, session: AsyncSession, entry_id: int, content_type: str, content_id: int,
) -> bool: ...
async def adopt_entries_for_user(
self, session: AsyncSession, user_id: int, session_id: str,
) -> None: ...
async def claim_entries_for_order(
self, session: AsyncSession, order_id: int, user_id: int | None,
session_id: str | None, page_post_id: int | None,
) -> None: ...
async def confirm_entries_for_order(
self, session: AsyncSession, order_id: int, user_id: int | None,
session_id: str | None,
) -> None: ...
async def get_entries_for_order(
self, session: AsyncSession, order_id: int,
) -> list[CalendarEntryDTO]: ...
@runtime_checkable
class MarketService(Protocol):
async def marketplaces_for_container(
self, session: AsyncSession, container_type: str, container_id: int,
) -> list[MarketPlaceDTO]: ...
async def product_by_id(self, session: AsyncSession, product_id: int) -> ProductDTO | None: ...
@runtime_checkable
class CartService(Protocol):
async def cart_summary(
self, session: AsyncSession, *, user_id: int | None, session_id: str | None,
page_slug: str | None = None,
) -> CartSummaryDTO: ...
async def cart_items(
self, session: AsyncSession, *, user_id: int | None, session_id: str | None,
) -> list[CartItemDTO]: ...
async def adopt_cart_for_user(
self, session: AsyncSession, user_id: int, session_id: str,
) -> None: ...