Опирается на правила:
R-SQLA-MAP-1,R-SQLA-MAP-2,R-SQLA-MAP-X1,R-SQLA-MODEL-2,R-SQLA-MODEL-3из SQLAlchemy Style Guide → раздел 3. Маппинг ORM ↔ domain.
Важно знать
- ORM-модель (
DeclarativeBase+Mapped) — анемичная persistence-структура. Она живёт вadapters/out/persistence/, не вcore/.- Маппер — явные функции
to_domain(model) -> Aggregateиto_model(aggregate) -> Modelв том же файле, что и репозиторий, или в отдельном<entity>_mapper.pyрядом.- Наружу из репозитория уходит только доменный объект. ORM-модель — внутренняя деталь.
- Сборка агрегата с вложенными коллекциями (
order.items) — целиком в маппере, не размазана по методам репозитория.expire_on_commit=FalseнаAsyncSessionобязателен; маппинг в domain должен происходить до выхода из транзакции, чтобы не нарваться наMissingGreenletпри доступе к lazy-атрибутам послеcommit.Decimalдля денег, awaredatetimeдля времени,uuid.UUIDчерезUUID(as_uuid=True)— типы ORM-модели задают правилоR-SQLA-MODEL-2.__dict__/vars()и прямое использование ORM-модели как domain-объекта запрещены (правилоR-SQLA-MAP-X1).
Маппер — это граница, на которой persistence-детали перестают существовать для бизнес-логики. ORM-модель отражает схему PostgreSQL: колонки, типы, foreign keys. Доменный объект отражает бизнес-инварианты: Order с confirm(), Product с apply_discount(), Customer с identity. Два этих представления живут в разных слоях и встречаются ровно в одном месте — в маппере.
Структура файлов
Маппер живёт рядом с репозиторием в adapters/out/persistence/:
adapters/out/persistence/
models.py # DeclarativeBase + ORM-модели
order_mapper.py # to_domain + to_model
order_repository.py # SqlAlchemyOrderRepository
product_mapper.py
product_repository.py
Альтернатива — маппер внутри модуля репозитория (непубличные функции в том же файле). Выбор зависит от объёма: если маппер превышает 40 строк, выносить в отдельный <entity>_mapper.py.
ORM-модели
ORM-модели — в adapters/out/persistence/models.py, не в core/. Они знают о таблицах, типах PostgreSQL, индексах. Domain-модуль (core/) их не видит.
# adapters/out/persistence/models.py
from __future__ import annotations
import uuid
from datetime import datetime
from decimal import Decimal
from sqlalchemy import DateTime, ForeignKey, Numeric, String
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
class Base(DeclarativeBase):
pass
class OrderModel(Base):
__tablename__ = "orders"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
customer_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False)
status: Mapped[str] = mapped_column(String(32), nullable=False)
total_amount: Mapped[Decimal] = mapped_column(Numeric(19, 2), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
items: Mapped[list[OrderItemModel]] = relationship(
"OrderItemModel",
back_populates="order",
lazy="raise",
)
class OrderItemModel(Base):
__tablename__ = "order_items"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
order_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("orders.id"), nullable=False)
product_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False)
quantity: Mapped[int] = mapped_column(nullable=False)
unit_price: Mapped[Decimal] = mapped_column(Numeric(19, 2), nullable=False)
order: Mapped[OrderModel] = relationship("OrderModel", back_populates="items", lazy="raise")
lazy="raise" на relationship гарантирует: если репозиторий забыл сделать selectinload, атрибут бросит ошибку немедленно — не молча уйдёт в N+1 и не упадёт с MissingGreenlet.
Простой случай: one-to-one маппинг
Доменные объекты для core/order/:
# core/order/entities.py
import uuid
from dataclasses import dataclass, field
from datetime import datetime
from decimal import Decimal
from enum import Enum
class OrderStatus(str, Enum):
PENDING = "pending"
CONFIRMED = "confirmed"
CANCELLED = "cancelled"
@dataclass
class OrderItem:
id: uuid.UUID
product_id: uuid.UUID
quantity: int
unit_price: Decimal
@dataclass
class Order:
id: uuid.UUID
customer_id: uuid.UUID
status: OrderStatus
total_amount: Decimal
created_at: datetime
items: list[OrderItem] = field(default_factory=list)
def confirm(self) -> None:
if self.status != OrderStatus.PENDING:
raise ValueError(f"Cannot confirm order in status {self.status}")
self.status = OrderStatus.CONFIRMED
Маппер — в adapters/out/persistence/order_mapper.py:
# adapters/out/persistence/order_mapper.py
from core.order.entities import Order, OrderItem, OrderStatus
from adapters.out.persistence.models import OrderModel, OrderItemModel
def to_domain(model: OrderModel) -> Order:
return Order(
id=model.id,
customer_id=model.customer_id,
status=OrderStatus(model.status),
total_amount=model.total_amount,
created_at=model.created_at,
items=[_item_to_domain(item) for item in model.items],
)
def to_model(order: Order) -> OrderModel:
model = OrderModel(
id=order.id,
customer_id=order.customer_id,
status=order.status.value,
total_amount=order.total_amount,
created_at=order.created_at,
)
model.items = [_item_to_model(item) for item in order.items]
return model
def _item_to_domain(model: OrderItemModel) -> OrderItem:
return OrderItem(
id=model.id,
product_id=model.product_id,
quantity=model.quantity,
unit_price=model.unit_price,
)
def _item_to_model(item: OrderItem) -> OrderItemModel:
return OrderItemModel(
id=item.id,
product_id=item.product_id,
quantity=item.quantity,
unit_price=item.unit_price,
)
to_domain и to_model — не методы, а функции. Они не хранят состояние и не нуждаются в классе. Если маппер требует внешних зависимостей (например, сервис конверсии валют или ObjectMapper для JSONB-колонки), тогда оправдан класс с инжектируемыми зависимостями.
Сборка агрегата — в маппере, не в репозитории
Правило R-SQLA-MAP-2: сборка агрегата из строк целиком в маппере, репозиторий только вызывает to_domain(model).
Репозиторий загружает OrderModel с eager-loaded items через selectinload:
# adapters/out/persistence/order_repository.py
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from core.order.entities import Order
from core.order.port import OrderRepository
from adapters.out.persistence.models import OrderModel
from adapters.out.persistence.order_mapper import to_domain, to_model
class SqlAlchemyOrderRepository(OrderRepository):
async def get(self, session: AsyncSession, order_id: uuid.UUID) -> Order | None:
stmt = (
select(OrderModel)
.where(OrderModel.id == order_id)
.options(selectinload(OrderModel.items))
)
result = await session.execute(stmt)
model = result.scalar_one_or_none()
if model is None:
return None
return to_domain(model)
async def save(self, session: AsyncSession, order: Order) -> None:
model = to_model(order)
await session.merge(model)
session.merge работает для insert + upsert. Для insert-only используется session.add(model). Детали — в python/session-transactions.md.
Маппинг до commit
R-SQLA-SESS-X2 запрещает использовать ORM-объект после commit в async-контексте: expire_on_commit=True (по умолчанию) помечает атрибуты устаревшими, обращение к ним вне сессии вызывает MissingGreenlet.
Решение — маппить в domain до выхода из транзакции:
async def handle(self, cmd: ConfirmOrder) -> uuid.UUID:
async with self._session_factory() as session, session.begin():
order = await self._orders.get(session, cmd.order_id)
if order is None:
raise OrderNotFound(cmd.order_id)
order.confirm()
await self._orders.save(session, order)
return order.id
Или настроить AsyncSession с expire_on_commit=False:
async_session_factory = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False,
)
Оба подхода приемлемы. Маппинг-до-commit чище: он делает явным, что ORM-объект не переживает транзакцию.
Customer: JSONB-адрес как value object
Когда колонка хранит JSONB (например, delivery_address), маппер распаковывает её в value object:
# adapters/out/persistence/models.py
from sqlalchemy.dialects.postgresql import JSONB
class CustomerModel(Base):
__tablename__ = "customers"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True)
name: Mapped[str] = mapped_column(String(256), nullable=False)
delivery_address: Mapped[dict] = mapped_column(JSONB, nullable=True)
# adapters/out/persistence/customer_mapper.py
from core.customer.entities import Customer, Address
def to_domain(model: CustomerModel) -> Customer:
address = None
if model.delivery_address:
raw = model.delivery_address
address = Address(
city=raw["city"],
street=raw["street"],
zip_code=raw.get("zip_code"),
)
return Customer(
id=model.id,
name=model.name,
delivery_address=address,
)
def to_model(customer: Customer) -> CustomerModel:
address_dict = None
if customer.delivery_address:
address_dict = {
"city": customer.delivery_address.city,
"street": customer.delivery_address.street,
"zip_code": customer.delivery_address.zip_code,
}
return CustomerModel(
id=customer.id,
name=customer.name,
delivery_address=address_dict,
)
SQLAlchemy автоматически сериализует/десериализует dict в JSONB. Маппер отвечает только за трансформацию dict ↔ Address.
Product: enum-конверсия
Enum в БД хранится строкой. Маппер переводит явно — не через __dict__:
# core/product/entities.py
class ProductStatus(str, Enum):
ACTIVE = "active"
ARCHIVED = "archived"
OUT_OF_STOCK = "out_of_stock"
# adapters/out/persistence/product_mapper.py
def to_domain(model: ProductModel) -> Product:
return Product(
id=model.id,
name=model.name,
status=ProductStatus(model.status),
price=model.price,
)
def to_model(product: Product) -> ProductModel:
return ProductModel(
id=product.id,
name=product.name,
status=product.status.value,
price=product.price,
)
Если model.status содержит значение, отсутствующее в ProductStatus, ProductStatus(model.status) бросит ValueError — сразу, на слое persistence. Это правильное поведение: схема БД и доменная модель разошлись, и это ошибка, которую нужно фиксировать явно.
Что запрещено
| Антипаттерн | Правило | Что взамен |
|---|---|---|
OrderModel.__dict__ или vars(model) для конвертации в domain | R-SQLA-MAP-X1 | Явная функция to_domain(model: OrderModel) -> Order |
Возврат OrderModel из публичного метода репозитория | R-SQLA-REPO-X1 | Маппинг до выхода из репозитория, наружу — только Order |
Бизнес-логика в маппере (if model.status == "cancelled": ...) | R-SQLA-MODEL-X1 | Логика в доменных методах; маппер — только структурная конвертация |
| Сборка агрегата размазана по методам репозитория | R-SQLA-MAP-2 | Один to_domain(model) собирает агрегат целиком |
lazy="select" на relationship в async | R-SQLA-QRY-X2 | lazy="raise" по умолчанию + явный selectinload в запросе |
Доступ к атрибутам ORM-модели после commit без expire_on_commit=False | R-SQLA-SESS-X2 | Маппинг в domain до выхода из транзакции |
Куда дальше
- python/repository-pattern.md — как организован
SqlAlchemyOrderRepository, порт вcore/, инжекцияAsyncSession. - python/session-transactions.md — Unit of Work, граница транзакции на handler'е,
async with session.begin(). - Маппинг record ↔ domain в jOOQ — Java-аналог:
assembleAggregate, plain Java@Component, JSONB черезJooqJsonbHelper; архитектурные решения те же, инструменты разные.