Skip to content
Back to skills

Sqlalchemy Patterns

ASecurity

Enforces SQLAlchemy patterns with DDD and Clean Architecture: domain entities, Repository Protocol, Unit of Work, async sessions, Alembic migrations. Activates when working with database models, repositories, queries, or migrations.

  • 5 stars
  • 0 votes
  • 0 copies
  • 2 views
  • Added May 28, 2026
developmentpythongobashsqlfastapitestinggitapidatabaseperformance

Works with

  • cli
  • api

Security analysis

A100/100

Scanned May 28, 2026

npx -y skills add AppVerk/av-marketplace --skill sqlalchemy-patterns --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Sqlalchemy Patterns?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Sqlalchemy Patterns
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/appverk-sqlalchemy-patterns/badge)](https://www.skillsdirectory.com/skills/appverk-sqlalchemy-patterns)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: sqlalchemy-patterns
description: Enforces SQLAlchemy patterns with DDD and Clean Architecture: domain entities, Repository Protocol, Unit of Work, async sessions, Alembic migrations. Activates when working with database models, repositories, queries, or migrations.
allowed-tools: Read, Grep, Glob, Bash(alembic:*), Bash(make:*), Bash(uv:*), Bash(python:*), Bash(pytest:*)
---

# SQLAlchemy Patterns (DDD / Clean Architecture)

<HARD-RULES>
These rules are NON-NEGOTIABLE. Violating any of them is a bug.

**Architecture & Domain:**
- Domain entities MUST be pure Python dataclasses or Pydantic BaseModel — NEVER inherit from SQLAlchemy `Base`
- Repository interfaces MUST be defined as `Protocol` in the domain layer — infrastructure implements them
- NEVER call `session.commit()`, `session.flush()`, or `session.rollback()` outside the Unit of Work — services use the UoW abstraction
- NEVER import SQLAlchemy in domain or application layers — only infrastructure touches the ORM

**SQLAlchemy ORM:**
- NEVER use synchronous SQLAlchemy sessions — ALWAYS use `AsyncSession` and `async_sessionmaker`
- NEVER use legacy Query API (`session.query(...)`) — ALWAYS use `select()` with `session.execute()` / `session.scalars()`
- NEVER access lazy-loaded relationships in async code — ALWAYS use eager loading (`selectinload`, `joinedload`) or explicit queries
- NEVER use `backref` — ALWAYS use explicit `back_populates` on both sides of a relationship
- ALWAYS define ORM models with `Mapped[T]` type annotations and `mapped_column()` — NEVER use legacy `Column()` syntax in Declarative classes
- ALWAYS use `DeclarativeBase` — NEVER use legacy `declarative_base()` function
- ALWAYS use `await` with `AsyncSession.delete()` — it is async in SQLAlchemy 2.0+
- ALWAYS install `sqlalchemy[asyncio]` — greenlet is no longer bundled since SQLAlchemy 2.1

**Data Integrity:**
- NEVER write raw SQL strings for schema changes — ALWAYS use Alembic migrations
- NEVER use `session.execute(text(...))` for CRUD operations — use the ORM or core constructs
- ALWAYS apply `selectinload()` for collection relationships in async queries
- ALWAYS run the project's typecheck and test commands after any model or migration change

**Note:** `Column()` in `Table()` constructs (e.g., many-to-many association tables) is correct and expected — `mapped_column()` applies only to Declarative class attributes.
</HARD-RULES>

These are the SQLAlchemy patterns for async database access in AppVerk projects, following **Domain-Driven Design** and **Clean Architecture**. All patterns target SQLAlchemy 2.0+ with async extensions.

## Architecture Overview

```
┌─────────────────────────────────────────────────┐
│  Presentation Layer (FastAPI endpoints)          │
│  Depends on: Application Layer                   │
├─────────────────────────────────────────────────┤
│  Application Layer (Services / Use Cases)        │
│  Depends on: Domain Layer only                   │
│  Uses: UnitOfWork Protocol, Repository Protocol  │
├─────────────────────────────────────────────────┤
│  Domain Layer (Entities, Value Objects,           │
│    Repository Protocols, UoW Protocol)           │
│  Depends on: NOTHING                             │
├─────────────────────────────────────────────────┤
│  Infrastructure Layer (ORM models, SA repos,     │
│    SA UoW, Data Mappers, Alembic)                │
│  Depends on: Domain Layer + SQLAlchemy           │
└─────────────────────────────────────────────────┘
```

**Dependency direction:** ALWAYS inward. Domain layer has zero external dependencies. SQLAlchemy is confined to infrastructure.

### Directory Layout

```
app/
    domain/
        entities/           # Pure dataclasses / Pydantic models
        value_objects/       # Immutable value types
        repositories/        # Protocol interfaces
        unit_of_work.py      # UoW Protocol
    services/                # Application layer (use cases)
    infrastructure/
        persistence/
            models/          # SQLAlchemy ORM models (*ORM)
            mappers/         # Domain ↔ ORM conversion
            repositories/    # SQLAlchemy Repository implementations
            unit_of_work.py  # SQLAlchemy UoW implementation
            database.py      # Engine, session factory
    api/                     # Presentation layer (FastAPI)
```

## Domain Layer — Entities

Domain entities are **pure Python** with no ORM dependencies. Use `dataclass` or `pydantic.BaseModel`.

```python
# app/domain/entities/market.py
from dataclasses import dataclass, field
from datetime import datetime
from uuid import UUID

from app.domain.value_objects.market_status import MarketStatus


@dataclass
class Market:
    id: UUID
    name: str
    description: str
    slug: str
    status: MarketStatus
    end_date: datetime
    created_by_id: UUID
    created_at: datetime | None = None
    updated_at: datetime | None = None
    outcomes: list["Outcome"] = field(default_factory=list)
```

### Conventions

- **Identity**: every entity has an `id: UUID` field.
- **Timestamps**: `created_at` and `updated_at` are optional (populated by infrastructure).
- **Relationships**: plain Python lists — no SQLAlchemy `relationship()` here.
- **Behavior**: domain logic lives as methods on the entity.

## Domain Layer — Value Objects

Value Objects have **no identity** and are **immutable**. Use Pydantic `BaseModel` with `frozen=True` or `@dataclass(frozen=True)`.

```python
# app/domain/value_objects/money.py
from decimal import Decimal

from pydantic import BaseModel, ConfigDict


class Money(BaseModel):
    model_config = ConfigDict(frozen=True)

    amount: Decimal
    currency: str = "PLN"
```

```python
# app/domain/value_objects/market_status.py
from enum import StrEnum


class MarketStatus(StrEnum):
    DRAFT = "draft"
    ACTIVE = "active"
    CLOSED = "closed"
    EXPIRED = "expired"
```

## Domain Layer — Repository Protocol

Repository interfaces live in the domain layer. They define **what** operations are available, not **how** they are implemented.

```python
# app/domain/repositories/market.py
from typing import Protocol
from uuid import UUID

from app.domain.entities.market import Market


class MarketRepository(Protocol):
    async def get_by_id(self, entity_id: UUID) -> Market | None: ...
    async def get_by_slug(self, slug: str) -> Market | None: ...
    async def save(self, entity: Market) -> Market: ...
    async def save_many(self, entities: list[Market]) -> list[Market]: ...
    async def delete(self, entity: Market) -> None: ...
    async def get_all(
        self, *, offset: int = 0, limit: int = 100
    ) -> list[Market]: ...
```

### Conventions

- Use `Protocol` (not `ABC`) — structural typing, no inheritance required.
- Method names: `save` (create or update), `get_by_*`, `delete`, `get_all`.
- Return domain entities, never ORM models.
- No SQLAlchemy imports in this file.

## Domain Layer — Unit of Work Protocol

The Unit of Work coordinates transactions across repositories. The protocol lives in the domain layer.

```python
# app/domain/unit_of_work.py
from typing import Protocol

from app.domain.repositories.market import MarketRepository
from app.domain.repositories.user import UserRepository


class UnitOfWork(Protocol):
    markets: MarketRepository
    users: UserRepository

    async def __aenter__(self) -> "UnitOfWork": ...
    async def __aexit__(self, *args: object) -> None: ...
    async def commit(self) -> None: ...
    async def rollback(self) -> None: ...
```

## Infrastructure Layer — ORM Models

ORM models live in infrastructure and are suffixed with `ORM` to distinguish from domain entities.

### Base Model

```python
# app/infrastructure/persistence/models/base.py
from sqlalchemy import MetaData, String
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column

convention = {
    "ix": "ix_%(column_0_label)s",
    "uq": "uq_%(table_name)s_%(column_0_name)s",
    "ck": "ck_%(table_name)s_%(constraint_name)s",
    "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
    "pk": "pk_%(table_name)s",
}


class Base(DeclarativeBase):
    metadata = MetaData(naming_convention=convention)

    type_annotation_map = {
        str: String(255),
    }
```

### Entity ORM Model

```python
# app/infrastructure/persistence/models/market.py
import uuid
from datetime import datetime

from sqlalchemy import ForeignKey, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column, relationship

from app.infrastructure.persistence.models.base import Base


class MarketORM(Base):
    __tablename__ = "market"

    id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
    name: Mapped[str] = mapped_column(String(200))
    description: Mapped[str] = mapped_column(Text)
    slug: Mapped[str] = mapped_column(String(200), unique=True)
    status: Mapped[str] = mapped_column(String(50), default="draft")
    end_date: Mapped[datetime]
    created_at: Mapped[datetime] = mapped_column(server_default=func.now())
    updated_at: Mapped[datetime] = mapped_column(
        server_default=func.now(), onupdate=func.now()
    )
    created_by_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("user.id"))

    created_by: Mapped["UserORM"] = relationship(back_populates="markets")
    outcomes: Mapped[list["OutcomeORM"]] = relationship(
        back_populates="market", cascade="all, delete-orphan"
    )
```

### ORM Model Conventions

- **Suffix**: `MarketORM`, `UserORM` — never `Market` (that's the domain entity).
- **Primary keys**: `Mapped[uuid.UUID]` with `default=uuid.uuid4`.
- **Timestamps**: use `server_default=func.now()` so the DB generates them.
- **Nullable columns**: use `Mapped[str | None]`. Non-nullable is `Mapped[str]`.
- **String lengths**: always specify explicit lengths via `String(N)` for indexed/constrained columns.
- **Table names**: singular, lowercase (`market`, `user`, `trade`).

### Relationships

#### One-to-Many

```python
class UserORM(Base):
    __tablename__ = "user"

    id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
    email: Mapped[str] = mapped_column(String(320), unique=True)

    markets: Mapped[list["MarketORM"]] = relationship(
        back_populates="created_by", cascade="all, delete-orphan"
    )


class MarketORM(Base):
    __tablename__ = "market"

    created_by_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("user.id"))
    created_by: Mapped["UserORM"] = relationship(back_populates="markets")
```

#### Many-to-Many

```python
from sqlalchemy import Column, ForeignKey, Table

# Association table — Column() in Table() constructs is correct
market_tag = Table(
    "market_tag",
    Base.metadata,
    Column("market_id", ForeignKey("market.id"), primary_key=True),
    Column("tag_id", ForeignKey("tag.id"), primary_key=True),
)


class MarketORM(Base):
    __tablename__ = "market"

    tags: Mapped[list["TagORM"]] = relationship(
        secondary=market_tag, back_populates="markets"
    )


class TagORM(Base):
    __tablename__ = "tag"

    id: Mapped[int] = mapped_column(primary_key=True)
    name: Mapped[str] = mapped_column(String(100), unique=True)

    markets: Mapped[list["MarketORM"]] = relationship(
        secondary=market_tag, back_populates="tags"
    )
```

### Eager Loading in Async Context

Lazy loading raises `MissingGreenlet` in async. Always load relationships explicitly:

```python
from sqlalchemy.orm import joinedload, raiseload, selectinload

# selectinload — preferred for collections (one-to-many, many-to-many)
stmt = select(UserORM).options(selectinload(UserORM.markets))

# joinedload — preferred for scalar relationships (many-to-one)
stmt = select(MarketORM).options(joinedload(MarketORM.created_by))

# Nested eager loading
stmt = select(UserORM).options(
    selectinload(UserORM.markets).selectinload(MarketORM.outcomes)
)

# raiseload("*") — enforce explicit loading (recommended default for async)
stmt = (
    select(MarketORM)
    .options(selectinload(MarketORM.outcomes), raiseload("*"))
)
```

### Loading Strategy Reference

| Strategy | Use Case |
|---|---|
| `selectinload` | Collections (one-to-many, many-to-many). Second SELECT with IN clause. |
| `joinedload` | Scalar relationships (many-to-one). Single JOIN query. |
| `subqueryload` | Large collections where IN clause would be too large. |
| `raiseload` | Prevent lazy loading. Recommended default in async repositories. |
| `WriteOnlyMapped` | Very large collections that should never be fully loaded. |

#### WriteOnlyMapped for Large Collections

For collections with thousands of items, use `WriteOnlyMapped` to prevent accidental full loads:

```python
from sqlalchemy.orm import WriteOnlyMapped

class UserORM(Base):
    __tablename__ = "user"

    # Large collection — never load all at once
    audit_logs: WriteOnlyMapped[list["AuditLogORM"]] = relationship()
```

Query write-only collections explicitly:

```python
# Read with explicit query
stmt = user.audit_logs.select().order_by(AuditLogORM.created_at.desc()).limit(50)
logs = (await session.scalars(stmt)).all()

# Add to write-only collection
user.audit_logs.add(AuditLogORM(action="login"))
```

## Infrastructure Layer — Data Mappers

Data mappers convert between domain entities and ORM models. They live in infrastructure.

```python
# app/infrastructure/persistence/mappers/market.py
from app.domain.entities.market import Market
from app.domain.value_objects.market_status import MarketStatus
from app.infrastructure.persistence.models.market import MarketORM


class MarketMapper:
    @staticmethod
    def to_domain(orm: MarketORM) -> Market:
        return Market(
            id=orm.id,
            name=orm.name,
            description=orm.description,
            slug=orm.slug,
            status=MarketStatus(orm.status),
            end_date=orm.end_date,
            created_by_id=orm.created_by_id,
            created_at=orm.created_at,
            updated_at=orm.updated_at,
        )

    @staticmethod
    def to_orm(entity: Market) -> MarketORM:
        return MarketORM(
            id=entity.id,
            name=entity.name,
            description=entity.description,
            slug=entity.slug,
            status=entity.status.value,
            end_date=entity.end_date,
            created_by_id=entity.created_by_id,
        )

    @staticmethod
    def update_orm(orm: MarketORM, entity: Market) -> MarketORM:
        orm.name = entity.name
        orm.description = entity.description
        orm.slug = entity.slug
        orm.status = entity.status.value
        orm.end_date = entity.end_date
        return orm
```

### Mapper Conventions

- One mapper class per aggregate root.
- `to_domain()`: ORM model → domain entity. Maps ORM string fields to Value Objects (e.g., `MarketStatus(orm.status)`).
- `to_orm()`: domain entity → new ORM model (for inserts).
- `update_orm()`: apply domain entity changes to an existing ORM model (for updates).
- Relationships: map in dedicated mapper methods if the aggregate includes child entities.

## Infrastructure Layer — Repository Implementation

Repositories in infrastructure implement the domain Protocol using SQLAlchemy.

```python
# app/infrastructure/persistence/repositories/market.py
from uuid import UUID

from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import raiseload, selectinload

from app.domain.entities.market import Market
from app.infrastructure.persistence.mappers.market import MarketMapper
from app.infrastructure.persistence.models.market import MarketORM


class SqlAlchemyMarketRepository:
    def __init__(self, session: AsyncSession) -> None:
        self.session = session

    async def get_by_id(self, entity_id: UUID) -> Market | None:
        orm = await self.session.get(MarketORM, entity_id)
        return MarketMapper.to_domain(orm) if orm else None

    async def get_by_slug(self, slug: str) -> Market | None:
        stmt = select(MarketORM).where(MarketORM.slug == slug)
        orm = await self.session.scalar(stmt)
        return MarketMapper.to_domain(orm) if orm else None

    async def save(self, entity: Market) -> Market:
        existing = await self.session.get(MarketORM, entity.id)
        if existing:
            orm = MarketMapper.update_orm(existing, entity)
        else:
            orm = MarketMapper.to_orm(entity)
            self.session.add(orm)
        await self.session.flush()
        return MarketMapper.to_domain(orm)

    async def save_many(self, entities: list[Market]) -> list[Market]:
        result = []
        for entity in entities:
            result.append(await self.save(entity))
        return result

    async def delete(self, entity: Market) -> None:
        orm = await self.session.get(MarketORM, entity.id)
        if orm:
            await self.session.delete(orm)
            await self.session.flush()

    async def get_all(
        self, *, offset: int = 0, limit: int = 100
    ) -> list[Market]:
        stmt = (
            select(MarketORM)
            .options(raiseload("*"))
            .offset(offset)
            .limit(limit)
        )
        result = await self.session.scalars(stmt)
        return [MarketMapper.to_domain(orm) for orm in result.all()]

    async def get_with_outcomes(self, entity_id: UUID) -> Market | None:
        stmt = (
            select(MarketORM)
            .options(selectinload(MarketORM.outcomes), raiseload("*"))
            .where(MarketORM.id == entity_id)
        )
        orm = await self.session.scalar(stmt)
        if orm is None:
            return None
        market = MarketMapper.to_domain(orm)
        market.outcomes = [
            OutcomeMapper.to_domain(o) for o in orm.outcomes
        ]
        return market

    async def count_by_status(self, status: str) -> int:
        stmt = (
            select(func.count())
            .select_from(MarketORM)
            .where(MarketORM.status == status)
        )
        result = await self.session.scalar(stmt)
        return result or 0
```

### Repository Implementation Conventions

- Class name: `SqlAlchemy<Entity>Repository`.
- All methods return **domain entities**, never ORM models.
- Use `raiseload("*")` by default, add explicit `selectinload`/`joinedload` only when needed.
- Use `await self.session.delete(orm)` — it is async in SQLAlchemy 2.0+.
- Use `flush()` (not `commit()`) — the Unit of Work owns the transaction.
- Use `get_one()` when the entity must exist (raises `NoResultFound`):

```python
from sqlalchemy import select

# Strict lookup — raises if not found
orm = await self.session.get_one(MarketORM, entity_id)
```

## Infrastructure Layer — Unit of Work Implementation

The Unit of Work wraps `AsyncSession` and exposes repository instances.

```python
# app/infrastructure/persistence/unit_of_work.py
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker

from app.infrastructure.persistence.repositories.market import (
    SqlAlchemyMarketRepository,
)
from app.infrastructure.persistence.repositories.user import (
    SqlAlchemyUserRepository,
)


class SqlAlchemyUnitOfWork:
    markets: SqlAlchemyMarketRepository
    users: SqlAlchemyUserRepository

    def __init__(
        self, session_factory: async_sessionmaker[AsyncSession]
    ) -> None:
        self.session_factory = session_factory

    async def __aenter__(self) -> "SqlAlchemyUnitOfWork":
        self.session = self.session_factory()
        self.markets = SqlAlchemyMarketRepository(self.session)
        self.users = SqlAlchemyUserRepository(self.session)
        return self

    async def __aexit__(self, *args: object) -> None:
        await self.session.rollback()
        await self.session.close()

    async def commit(self) -> None:
        await self.session.commit()

    async def rollback(self) -> None:
        await self.session.rollback()
```

### Usage in Services

```python
# app/services/market.py
from uuid import uuid4

from app.domain.entities.market import Market
from app.domain.unit_of_work import UnitOfWork
from app.domain.value_objects.market_status import MarketStatus
from app.schemas.market import MarketCreate


class MarketService:
    def __init__(self, uow: UnitOfWork) -> None:
        self.uow = uow

    async def create_market(
        self, data: MarketCreate, user_id: UUID
    ) -> Market:
        market = Market(
            id=uuid4(),
            name=data.name,
            description=data.description,
            slug=generate_slug(data.name),
            status=MarketStatus.DRAFT,
            end_date=data.end_date,
            created_by_id=user_id,
        )
        async with self.uow:
            created = await self.uow.markets.save(market)
            await self.uow.commit()
            return created

    async def close_market(self, market_id: UUID) -> Market:
        async with self.uow:
            market = await self.uow.markets.get_by_id(market_id)
            if market is None:
                raise EntityNotFoundError(f"Market {market_id} not found")
            market.status = MarketStatus.CLOSED
            updated = await self.uow.markets.save(market)
            await self.uow.commit()
            return updated
```

**Key points:**
- Service depends on `UnitOfWork` Protocol, not `AsyncSession`.
- `async with self.uow` opens session + creates repositories.
- `await self.uow.commit()` commits the transaction.
- If no `commit()` is called, `__aexit__` rolls back automatically.

## Async Engine & Session Factory

```python
# app/infrastructure/persistence/database.py
from sqlalchemy.ext.asyncio import (
    AsyncSession,
    async_sessionmaker,
    create_async_engine,
)

from app.config import settings

# NOTE: SQLAlchemy 2.1 defaults to psycopg (v3) for PostgreSQL.
# Install: uv add "sqlalchemy[asyncio]" asyncpg
# URL format: postgresql+asyncpg://user:pass@host/db
engine = create_async_engine(
    settings.database_url,
    echo=settings.debug,
    pool_size=20,
    max_overflow=10,
    pool_pre_ping=True,
)

async_session_factory = async_sessionmaker(
    engine,
    class_=AsyncSession,
    expire_on_commit=False,
)
```

### Key Settings

- **`expire_on_commit=False`**: prevents lazy-load errors when accessing attributes after commit in async context.
- **`pool_pre_ping=True`**: detects stale connections before use.
- **Never create engines per-request**. Use a single engine per application.
- **`sqlalchemy[asyncio]`**: required since SA 2.1 — greenlet is no longer auto-installed.

## FastAPI Integration

### UoW Dependency

```python
# app/api/dependencies.py
from typing import Annotated
from collections.abc import AsyncIterator

from fastapi import Depends

from app.domain.unit_of_work import UnitOfWork
from app.infrastructure.persistence.database import async_session_factory
from app.infrastructure.persistence.unit_of_work import SqlAlchemyUnitOfWork


async def get_unit_of_work() -> AsyncIterator[SqlAlchemyUnitOfWork]:
    uow = SqlAlchemyUnitOfWork(async_session_factory)
    yield uow


UoW = Annotated[UnitOfWork, Depends(get_unit_of_work)]
```

### Using in Endpoints

```python
# app/api/routes/markets.py
from app.api.dependencies import UoW
from app.services.market import MarketService


@router.post("/", response_model=MarketResponse, status_code=201)
async def create_market(
    body: MarketCreate,
    uow: UoW,
    user: CurrentUser,
) -> MarketResponse:
    service = MarketService(uow)
    market = await service.create_market(body, user_id=user.id)
    return MarketResponse.model_validate(market, from_attributes=True)
```

## Alembic Migrations

### Project Setup

```
alembic/
    env.py
    versions/
        001_create_user_table.py
        002_create_market_table.py
alembic.ini
```

**Important:** Alembic works with ORM models (`*ORM`), not domain entities. Import ORM models in `env.py`.

### Async env.py

```python
# alembic/env.py
import asyncio
from logging.config import fileConfig

from alembic import context
from sqlalchemy.ext.asyncio import create_async_engine

from app.config import settings
from app.infrastructure.persistence.models.base import Base

# Import all ORM models so metadata is populated
import app.infrastructure.persistence.models  # noqa: F401

config = context.config
if config.config_file_name is not None:
    fileConfig(config.config_file_name)

target_metadata = Base.metadata


def run_migrations_offline() -> None:
    context.configure(
        url=settings.database_url,
        target_metadata=target_metadata,
        literal_binds=True,
        dialect_opts={"paramstyle": "named"},
    )
    with context.begin_transaction():
        context.run_migrations()


def do_run_migrations(connection):
    context.configure(connection=connection, target_metadata=target_metadata)
    with context.begin_transaction():
        context.run_migrations()


async def run_migrations_online() -> None:
    connectable = create_async_engine(settings.database_url)
    async with connectable.connect() as connection:
        await connection.run_sync(do_run_migrations)
    await connectable.dispose()


if context.is_offline_mode():
    run_migrations_offline()
else:
    asyncio.run(run_migrations_online())
```

### Migration Commands

```bash
# Auto-generate migration from ORM model changes
uv run alembic revision --autogenerate -m "add market table"

# Apply all pending migrations
uv run alembic upgrade head

# Downgrade one revision
uv run alembic downgrade -1

# Show current revision
uv run alembic current
```

### Migration Conventions

- **File naming**: auto-generated revision IDs are fine. Use descriptive messages: `"add market table"`, `"add index on market slug"`.
- **Always review auto-generated migrations** before applying. Autogenerate does not detect: renamed columns, changes to constraints on existing columns, or data migrations.
- **Data migrations**: write explicit `op.execute()` statements. Never import ORM models in migration files.
- **One logical change per migration**: do not combine unrelated table changes.

## Query Patterns

All queries live **inside repository implementations**, never in services or endpoints.

### Basic Select

```python
# Inside a repository method
from sqlalchemy import select

# Single entity by primary key (returns ORM, mapper converts to domain)
orm = await self.session.get(MarketORM, market_id)

# Strict lookup — raises NoResultFound if not found
orm = await self.session.get_one(MarketORM, market_id)

# Single entity by condition
stmt = select(MarketORM).where(MarketORM.slug == slug)
orm = await self.session.scalar(stmt)

# Multiple entities
stmt = select(MarketORM).where(MarketORM.status == "active")
result = await self.session.scalars(stmt)
orms = list(result.all())
```

### Filtering

```python
from sqlalchemy import and_, or_

# Multiple conditions
stmt = select(MarketORM).where(
    and_(
        MarketORM.status == "active",
        MarketORM.end_date > datetime.now(tz=UTC),
    )
)

# OR conditions
stmt = select(MarketORM).where(
    or_(MarketORM.status == "active", MarketORM.status == "pending")
)

# IN clause
stmt = select(MarketORM).where(MarketORM.status.in_(["active", "pending"]))

# LIKE / ILIKE
stmt = select(MarketORM).where(MarketORM.name.ilike(f"%{search_term}%"))
```

### Pagination and Sorting

```python
from sqlalchemy import desc, func

stmt = (
    select(MarketORM)
    .options(raiseload("*"))
    .where(MarketORM.status == "active")
    .order_by(desc(MarketORM.created_at))
    .offset(offset)
    .limit(limit)
)
result = await self.session.scalars(stmt)
orms = list(result.all())

# Count for pagination metadata
count_stmt = (
    select(func.count())
    .select_from(MarketORM)
    .where(MarketORM.status == "active")
)
total = await self.session.scalar(count_stmt) or 0
```

### Joins

```python
# Implicit join via relationship (with eager loading)
stmt = (
    select(MarketORM)
    .options(joinedload(MarketORM.created_by))
    .where(MarketORM.status == "active")
)

# Explicit join
stmt = (
    select(MarketORM)
    .join(MarketORM.created_by)
    .where(UserORM.email == "admin@example.com")
)
```

### Aggregations

```python
from sqlalchemy import func

# Count
stmt = select(func.count()).select_from(MarketORM)
total = await self.session.scalar(stmt) or 0

# Group by
stmt = (
    select(MarketORM.status, func.count().label("count"))
    .group_by(MarketORM.status)
)
result = await self.session.execute(stmt)
status_counts = result.all()  # list of Row(status, count)
```

### Streaming Large Result Sets

For queries returning thousands of rows, use `stream()` to avoid loading everything into memory:

```python
# Stream results instead of loading all at once
stmt = select(MarketORM).where(MarketORM.status == "active")
async with self.session.stream_scalars(stmt) as stream:
    async for orm in stream:
        yield MarketMapper.to_domain(orm)
```

### Bulk Operations

Bulk operations bypass the ORM identity map for performance. Use inside repositories with UoW coordination.

```python
from sqlalchemy import delete, update

# Bulk update without loading objects
stmt = (
    update(MarketORM)
    .where(MarketORM.status == "draft")
    .where(MarketORM.end_date < datetime.now(tz=UTC))
    .values(status="expired")
)
await self.session.execute(stmt)

# Bulk delete
stmt = delete(MarketORM).where(MarketORM.status == "cancelled")
await self.session.execute(stmt)

# Bulk insert with conflict handling (PostgreSQL)
from sqlalchemy.dialects.postgresql import insert

stmt = insert(MarketORM).values(
    [{"name": "M1", "slug": "m1"}, {"name": "M2", "slug": "m2"}]
)
stmt = stmt.on_conflict_do_nothing(index_elements=["slug"])
await self.session.execute(stmt)
```

### Selecting Only Needed Columns

```python
# Load only specific columns to reduce data transfer
stmt = (
    select(MarketORM.id, MarketORM.name, MarketORM.status)
    .where(MarketORM.status == "active")
)
result = await self.session.execute(stmt)
rows = result.all()  # list of Row(id, name, status)
```

## Avoiding N+1 Queries

The N+1 problem occurs when accessing relationships triggers individual queries per row.

```python
# BAD: N+1 — each market.outcomes triggers a separate query (and fails in async)
stmt = select(MarketORM)
orms = (await self.session.scalars(stmt)).all()
for orm in orms:
    print(orm.outcomes)  # raises MissingGreenlet in async

# GOOD: eager load collections
stmt = select(MarketORM).options(selectinload(MarketORM.outcomes))
orms = (await self.session.scalars(stmt)).all()
for orm in orms:
    print(orm.outcomes)  # already loaded

# BEST: raiseload("*") as default, explicit load only what you need
stmt = (
    select(MarketORM)
    .options(selectinload(MarketORM.outcomes), raiseload("*"))
)
```

## Testing

### pytest-asyncio Configuration

**Modern setup (pytest-asyncio 1.x+)**. The `event_loop` fixture is removed — use `loop_scope` instead.

```toml
# pyproject.toml
[tool.pytest.ini_options]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "session"
```

### Test Database Setup

```python
# tests/conftest.py
import pytest
from sqlalchemy.ext.asyncio import (
    AsyncSession,
    async_sessionmaker,
    create_async_engine,
)

from app.infrastructure.persistence.models.base import Base

TEST_DATABASE_URL = "postgresql+asyncpg://test:test@localhost:5432/test_db"


@pytest.fixture(scope="session")
async def test_engine():
    engine = create_async_engine(TEST_DATABASE_URL, echo=False)
    async with engine.begin() as conn:
        await conn.run_sync(Base.metadata.create_all)
    yield engine
    async with engine.begin() as conn:
        await conn.run_sync(Base.metadata.drop_all)
    await engine.dispose()


@pytest.fixture
async def test_session(test_engine):
    """Per-test session with automatic rollback."""
    session_factory = async_sessionmaker(
        test_engine, class_=AsyncSession, expire_on_commit=False
    )
    async with session_factory() as session:
        async with session.begin():
            yield session
        # Exiting begin() without commit triggers automatic rollback
```

### Three-Tier Testing Strategy

#### 1. Domain Tests (pure unit tests, no DB)

```python
# tests/unit/domain/test_market.py
from uuid import uuid4

from app.domain.entities.market import Market
from app.domain.value_objects.market_status import MarketStatus


class TestMarket:
    def test_create_market_with_draft_status(self) -> None:
        market = Market(
            id=uuid4(),
            name="Test Market",
            description="A test",
            slug="test-market",
            status=MarketStatus.DRAFT,
            end_date=datetime(2025, 12, 31, tzinfo=UTC),
            created_by_id=uuid4(),
        )
        assert market.status == MarketStatus.DRAFT
        assert market.name == "Test Market"
```

#### 2. Repository Tests (integration, real DB)

```python
# tests/integration/test_market_repository.py
from uuid import uuid4

from app.domain.entities.market import Market
from app.domain.value_objects.market_status import MarketStatus
from app.infrastructure.persistence.repositories.market import (
    SqlAlchemyMarketRepository,
)


class TestSqlAlchemyMarketRepository:
    async def test_save_and_get_by_id(self, test_session) -> None:
        repo = SqlAlchemyMarketRepository(test_session)
        market = Market(
            id=uuid4(),
            name="Test Market",
            description="A test",
            slug="test-market",
            status=MarketStatus.DRAFT,
            end_date=datetime(2025, 12, 31, tzinfo=UTC),
            created_by_id=uuid4(),
        )

        created = await repo.save(market)
        assert created.id == market.id

        found = await repo.get_by_id(created.id)
        assert found is not None
        assert found.name == "Test Market"
        assert found.status == MarketStatus.DRAFT

    async def test_get_by_slug_returns_none_for_missing(
        self, test_session
    ) -> None:
        repo = SqlAlchemyMarketRepository(test_session)
        result = await repo.get_by_slug("nonexistent")
        assert result is None
```

#### 3. Service Tests (unit tests with Fake UoW)

```python
# tests/fakes.py
from uuid import UUID

from app.domain.entities.market import Market


class FakeMarketRepository:
    def __init__(self) -> None:
        self.markets: dict[UUID, Market] = {}

    async def get_by_id(self, entity_id: UUID) -> Market | None:
        return self.markets.get(entity_id)

    async def save(self, entity: Market) -> Market:
        self.markets[entity.id] = entity
        return entity

    async def delete(self, entity: Market) -> None:
        self.markets.pop(entity.id, None)

    async def get_by_slug(self, slug: str) -> Market | None:
        for market in self.markets.values():
            if market.slug == slug:
                return market
        return None

    async def save_many(self, entities: list[Market]) -> list[Market]:
        for entity in entities:
            self.markets[entity.id] = entity
        return entities

    async def get_all(
        self, *, offset: int = 0, limit: int = 100
    ) -> list[Market]:
        all_markets = list(self.markets.values())
        return all_markets[offset : offset + limit]


class FakeUnitOfWork:
    def __init__(self) -> None:
        self.markets = FakeMarketRepository()
        self.committed = False

    async def __aenter__(self) -> "FakeUnitOfWork":
        return self

    async def __aexit__(self, *args: object) -> None:
        pass

    async def commit(self) -> None:
        self.committed = True

    async def rollback(self) -> None:
        pass
```

```python
# tests/unit/services/test_market_service.py
from app.services.market import MarketService
from tests.fakes import FakeUnitOfWork


class TestMarketService:
    async def test_create_market(self) -> None:
        uow = FakeUnitOfWork()
        service = MarketService(uow)

        market = await service.create_market(
            data=MarketCreate(
                name="Test",
                description="A test",
                end_date=datetime(2025, 12, 31, tzinfo=UTC),
            ),
            user_id=uuid4(),
        )

        assert market.name == "Test"
        assert uow.committed is True
        assert market.id in uow.markets.markets
```

### Overriding the FastAPI Dependency

```python
# tests/conftest.py
from app.api.dependencies import get_unit_of_work
from app.main import create_app
from tests.fakes import FakeUnitOfWork


@pytest.fixture
def fake_uow():
    return FakeUnitOfWork()


@pytest.fixture
async def app(fake_uow):
    app = create_app()
    app.dependency_overrides[get_unit_of_work] = lambda: fake_uow
    yield app
    app.dependency_overrides.clear()


@pytest.fixture
async def client(app):
    from httpx import ASGITransport, AsyncClient

    transport = ASGITransport(app=app)
    async with AsyncClient(transport=transport, base_url="http://test") as ac:
        yield ac
```

### Testing Conventions

- **Domain tests**: pure Python, no DB, no mocks. Test entity behavior and value object invariants.
- **Repository tests**: integration tests against real database. Use `test_session` fixture with automatic rollback.
- **Service tests**: unit tests with `FakeUnitOfWork` and `FakeRepository`. Verify business logic without DB.
- **Endpoint tests**: use `FakeUnitOfWork` via `dependency_overrides`. Test HTTP interface.
- Create tables once per session (`scope="session"`) for performance.
- Use factories for complex test data, not raw model construction.
- See the `tdd-workflow` skill for full testing rules.

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.