Repository Pattern
Port-adapter repository pattern: domain interface, SQLAlchemy implementation, and domain-to-database mapping.
Status: Extracted
Purpose: Repository pattern implementation, domain-to-database mapping, and repository interfaces.
Repository Pattern Overview
Purpose
- Encapsulates data access logic
- Provides abstraction over data storage
- Enables domain model independence from persistence
- Supports testability through interfaces
Port-Adapter Pattern
Port (Interface)
Location: domain/*/repository.py or application/
Definition: Interface defined in domain/application layer that defines what data access is needed.
Pattern:
# Domain layer defines repository interface
class IAccountRepository(Protocol):
"""Account repository interface."""
async def add(self, account: Account) -> None:
"""Add account to repository."""
...
async def get_by_id(self, account_id: str) -> Account | None:
"""Get account by ID."""
...
async def get_by_phone(self, phone: str) -> Account | None:
"""Get account by phone number."""
...Adapter (Implementation)
Location: infrastructure/database/adapters/sqlalchemy/repositories/
Definition: Implementation of repository interface in infrastructure layer.
Pattern:
# Infrastructure layer implements repository
class SQLAlchemyAccountRepository:
"""SQLAlchemy implementation of account repository."""
def __init__(self, session: AsyncSession):
self.session = session
async def add(self, account: Account) -> None:
"""Add account to repository."""
account_model = self._to_model(account)
self.session.add(account_model)
async def get_by_id(self, account_id: str) -> Account | None:
"""Get account by ID."""
result = await self.session.execute(
select(AccountModel).where(AccountModel.id == account_id)
)
model = result.scalar_one_or_none()
return self._to_model(account) if model else NoneDomain-to-Database Mapping
Mapping Pattern
Domain Models:
- Pure Python classes
- No ORM annotations
- Business logic and invariants
- Location:
domain/*/entities.py
Database Models:
- SQLAlchemy ORM models
- Database schema representation
- Location:
infrastructure/database/adapters/sqlalchemy/models/
Mappers:
- Convert between domain and database models
- Handle type conversions
- Location: In repository or separate mapper files
Mapping Functions
Pattern:
def _to_domain(self, model: AccountModel) -> Account:
"""Map database model to domain entity."""
return Account(
id=model.id,
phone=model.phone,
created_at=model.created_at,
# ... map all fields
)
def _to_model(self, account: Account) -> AccountModel:
"""Map domain entity to database model."""
return AccountModel(
id=account.id,
phone=account.phone,
created_at=account.created_at,
# ... map all fields
)Repository Responsibilities
Data Access
- Query Execution: Execute database queries
- Model Mapping: Convert between domain and database models
- Session Management: Use provided session (from Unit of Work)
Domain Abstraction
- Domain Interface: Repository interface defined in domain layer
- Infrastructure Implementation: Implementation in infrastructure layer
- No Domain Dependencies: Domain layer doesn't know about SQLAlchemy
Repository Integration with Unit of Work
Unit of Work Pattern
Pattern:
# Application layer uses Unit of Work
async def create_account(
command: CreateAccountCommand,
uow: IUnitOfWork,
) -> Account:
"""Create account use case."""
async with uow:
account = Account.create(...)
uow.accounts.add(account) # Repository accessed via Unit of Work
# Transaction commits automatically
return accountBenefits:
- Repositories share same session
- Transaction management handled by Unit of Work
- Automatic commit/rollback
Repository Best Practices
Interface Design
- Define in domain layer: Repository interfaces in domain
- Keep interfaces focused: One repository per aggregate
- Use async: All repository methods are async
- Return domain objects: Always return domain entities, not database models
Implementation
- Implement in infrastructure: Repository implementations in infrastructure
- Handle mapping: Convert between domain and database models
- Use session from Unit of Work: Don't create own sessions
- Handle errors: Convert database errors to domain exceptions
Testing
- Mock repositories: Easy to mock interfaces in tests
- Test implementations: Integration tests for repository implementations
- Test mapping: Verify domain-to-database mapping correctness
Related Documents
- Domain-Driven Design - DDD patterns
- Hexagonal Architecture - Ports & Adapters
- Database & Persistence - Detailed repository patterns
Reference implementation: infrastructure/database/adapters/sqlalchemy/repositories/
Framework implementations
See also:
Repository Interface (Domain Layer)
Each aggregate has an abstract repository interface in domain/<feature>/repository.py. This is the port — no SQLAlchemy, no database, just Python ABCs.
# domain/account/repository.py
from abc import ABC, abstractmethod
from uuid import UUID
from domain.account.aggregate import Account
class AccountRepository(ABC):
@abstractmethod
async def save(self, session: object, account: Account) -> None: ...
@abstractmethod
async def find_by_id(self, session: object, account_id: UUID) -> Account | None: ...
@abstractmethod
async def find_by_email(self, session: object, email: str) -> Account | None: ...
@abstractmethod
async def delete(self, session: object, account_id: UUID) -> None: ...
@abstractmethod
async def find_all(
self, session: object, limit: int = 50, offset: int = 0
) -> list[Account]: ...session is typed as object here — the interface does not know whether the session is a SQLAlchemy AsyncSession or an in-memory dict. Concrete implementations know their session type.
Unit of Work Interface (Application Layer)
# application/unit_of_work.py
from abc import ABC, abstractmethod
from typing import Any
class UnitOfWork(ABC):
@abstractmethod
async def __aenter__(self) -> Any:
"""Returns the session object (type depends on adapter)."""
...
@abstractmethod
async def __aexit__(self, exc_type, exc, tb) -> None:
"""Commits on success, rolls back on exception."""
...SQLAlchemy Implementations (Infrastructure Layer)
ORM Models (infrastructure/database/adapters/sqlalchemy/models/account.py)
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from sqlalchemy import String, DateTime
from uuid import UUID
import datetime
class Base(DeclarativeBase):
pass
class AccountModel(Base):
__tablename__ = "accounts"
id: Mapped[UUID] = mapped_column(primary_key=True)
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
display_name: Mapped[str] = mapped_column(String(255))
status: Mapped[str] = mapped_column(String(50), default="active")
created_at: Mapped[datetime.datetime] = mapped_column(DateTime(timezone=True))
updated_at: Mapped[datetime.datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)Mappers (infrastructure/database/adapters/sqlalchemy/mappers/account_mapper.py)
from domain.account.aggregate import Account
from infrastructure.database.adapters.sqlalchemy.models.account import AccountModel
def account_to_orm(account: Account) -> AccountModel:
return AccountModel(
id=account.id,
email=account.email,
display_name=account.display_name,
status=account.status.value,
created_at=account.created_at,
updated_at=account.updated_at,
)
def account_to_domain(model: AccountModel) -> Account:
return Account(
id=model.id,
email=model.email,
display_name=model.display_name,
status=AccountStatus(model.status),
created_at=model.created_at,
updated_at=model.updated_at,
)Repository Implementation (infrastructure/database/adapters/sqlalchemy/repositories/account_repository.py)
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from domain.account.aggregate import Account
from domain.account.repository import AccountRepository
from .mappers.account_mapper import account_to_orm, account_to_domain
class SQLAlchemyAccountRepository(AccountRepository):
async def save(self, session: AsyncSession, account: Account) -> None:
orm = account_to_orm(account)
await session.merge(orm) # insert or update
async def find_by_id(self, session: AsyncSession, account_id) -> Account | None:
result = await session.execute(
select(AccountModel).where(AccountModel.id == account_id)
)
model = result.scalar_one_or_none()
return account_to_domain(model) if model else None
async def find_by_email(self, session: AsyncSession, email: str) -> Account | None:
result = await session.execute(
select(AccountModel).where(AccountModel.email == email)
)
model = result.scalar_one_or_none()
return account_to_domain(model) if model else None
async def delete(self, session: AsyncSession, account_id) -> None:
model = await session.get(AccountModel, account_id)
if model:
await session.delete(model)
async def find_all(self, session: AsyncSession, limit=50, offset=0) -> list[Account]:
result = await session.execute(
select(AccountModel).limit(limit).offset(offset)
)
return [account_to_domain(m) for m in result.scalars().all()]UoW Implementation (infrastructure/database/adapters/sqlalchemy/unit_of_work.py)
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from application.unit_of_work import UnitOfWork
class SQLAlchemyUnitOfWork(UnitOfWork):
def __init__(self, session_factory: async_sessionmaker):
self._session_factory = session_factory
self._session: AsyncSession | None = None
async def __aenter__(self) -> AsyncSession:
self._session = self._session_factory()
return self._session
async def __aexit__(self, exc_type, exc, tb) -> None:
if exc:
await self._session.rollback()
else:
await self._session.commit()
await self._session.close()
self._session = NoneIn-Memory Implementations (for Testing)
# infrastructure/database/adapters/inmemory/repositories/account_repository.py
from domain.account.repository import AccountRepository
from copy import deepcopy
class InMemoryAccountRepository(AccountRepository):
async def save(self, session: dict, account: Account) -> None:
session[str(account.id)] = deepcopy(account)
async def find_by_id(self, session: dict, account_id) -> Account | None:
return deepcopy(session.get(str(account_id)))
async def find_by_email(self, session: dict, email: str) -> Account | None:
return next(
(deepcopy(a) for a in session.values() if a.email == email), None
)
async def delete(self, session: dict, account_id) -> None:
session.pop(str(account_id), None)
async def find_all(self, session: dict, limit=50, offset=0) -> list[Account]:
return [deepcopy(a) for a in list(session.values())[offset:offset + limit]]# infrastructure/database/adapters/inmemory/unit_of_work.py
from application.unit_of_work import UnitOfWork
class InMemoryUnitOfWork(UnitOfWork):
def __init__(self, store: dict):
self._store = store
async def __aenter__(self) -> dict:
return self._store # no transactions — simple dict
async def __aexit__(self, exc_type, exc, tb) -> None:
pass # nothing to commit or rollbackFactory (infrastructure/database/factories.py)
from config.settings import Settings
from application.unit_of_work import UnitOfWork
ADAPTER_REGISTRY = {
"sqlalchemy": _build_sqlalchemy_uow,
"inmemory": _build_inmemory_uow,
}
def build_unit_of_work(settings: Settings) -> UnitOfWork:
builder = ADAPTER_REGISTRY[settings.database_adapter]
return builder(settings)Usage in Application Handlers
# application/account/create.py
class CreateAccountHandler:
def __init__(self, account_repo: AccountRepository, unit_of_work: UnitOfWork):
self._repo = account_repo
self._uow = unit_of_work
async def execute(self, command: CreateAccountCommand) -> Account:
async with self._uow as session:
existing = await self._repo.find_by_email(session, command.email)
if existing:
raise ConflictError("Email already in use")
account = Account.create(
email=command.email,
display_name=command.display_name,
)
await self._repo.save(session, account)
return account
# UoW commits here on __aexit__ with no exceptionRules
- Repositories persist and retrieve aggregates only — never sub-entities directly.
- The
sessionparameter is always obtained from the UoW — never created inside a repository. - Mappers (
account_to_orm,account_to_domain) are standalone functions, not methods on the model or aggregate. - The in-memory adapter must have feature parity with the SQLAlchemy adapter — every test that passes with in-memory must be runnable against SQLAlchemy.
- Migrations live in
infrastructure/database/migrations/(Alembic). See06-database-persistence/01-migration-patterns.md.