Skip to content

    Chapter 62, Backend

    Complete API: from contract to deploy

    This chapter brings together everything the previous ones taught in a working end-to-end order API. Each file below has been executed: the tests pass against SQLite and against PostgreSQL, and strict mypy and ruff approve the code.

    The files for this chapter are in exemplos/api_pedidos/.

    What the API does

    It is an order API with five routes, designed to show production decisions, not just routes that work:

    RouteWhat it doesProduction detail
    POST /pedidosCreates an orderIdempotency-Key prevents duplicate orders
    GET /pedidos/{id}Reads an orderError 404 in the format problem+json
    GET /pedidosLists with paginationCursor, not offset
    POST /pedidos/{id}/cancelarCancels409 if already canceled
    GET /saude and GET /metricasHealth and metricsFor the orchestrator and Prometheus

    The layered structure

    Quem depende de quem
    src/api_pedidos/
      app.py            HTTP: rotas, status, erros      (conhece FastAPI)
      servico.py        Regras de negócio               (não conhece HTTP)
      repositorio.py    Consultas ao banco              (conhece SQLAlchemy)
      modelos.py        Tabelas                         (SQLAlchemy)
      esquemas.py       Contrato de entrada e saída     (Pydantic)
      config.py         Configuração do ambiente        (pydantic-settings)
      db.py             Engine e sessão
      observabilidade.py  Logs JSON, id de requisição, métricas
    migrations/         Histórico do esquema            (Alembic)
    tests/              Testes de API, de serviço e de migração
    

    The dependency arrow always goes from top to bottom: the route calls the service, the service calls the repository. The service does not care about FastAPI, and therefore it can be tested without HTTP and reused in a CLI or in a worker.

    Dependencies and tools

    The pyproject.toml declares the production dependencies, the development ones (in a separate group, which the Dockerfile does not install) and the configuration of pytest, ruff, and mypy:

    exemplos/api_pedidos/pyproject.toml
    [build-system]
    requires = ["hatchling"]
    build-backend = "hatchling.build"
    
    [project]
    name = "api-pedidos"
    version = "0.1.0"
    description = "API de pedidos do livro Python na Prática"
    readme = "README.md"
    requires-python = ">=3.12"
    dependencies = [
        "fastapi>=0.115",
        "uvicorn>=0.30",
        "sqlalchemy>=2.0",
        "alembic>=1.13",
        "psycopg[binary]>=3.2",
        "pydantic-settings>=2.4",
        "prometheus-client>=0.20",
    ]
    
    [dependency-groups]
    dev = [
        "pytest>=8",
        "httpx2>=2.0",
        "mypy>=1.10",
        "ruff>=0.6",
    ]
    
    [tool.pytest.ini_options]
    testpaths = ["tests"]
    
    [tool.ruff]
    line-length = 100
    target-version = "py312"
    
    [tool.ruff.lint]
    select = ["E", "F", "I", "B", "UP"]
    
    [tool.mypy]
    python_version = "3.12"
    strict = true
    plugins = ["pydantic.mypy"]
    files = ["src"]
    

    Configuration and database

    The configuration is from chapter 58, and the guard prod with SQLite prevents the most common deployment error:

    exemplos/api_pedidos/src/api_pedidos/config.py
    from functools import lru_cache
    from typing import Literal, Self
    
    from pydantic import SecretStr, model_validator
    from pydantic_settings import BaseSettings, SettingsConfigDict
    
    
    class Configuracao(BaseSettings):
        model_config = SettingsConfigDict(env_prefix="API_", env_file=".env", extra="ignore")
    
        ambiente: Literal["dev", "test", "prod"] = "dev"
        database_url: SecretStr = SecretStr("sqlite:///./dev.db")
        log_nivel: str = "INFO"
    
        @model_validator(mode="after")
        def exigir_banco_de_servidor_em_producao(self) -> Self:
            if self.ambiente == "prod" and self.database_url.get_secret_value().startswith("sqlite"):
                raise ValueError("produção exige um banco de dados de servidor, não SQLite")
            return self
    
    
    @lru_cache
    def obter_configuracao() -> Configuracao:
        return Configuracao()
    

    The model declares the database constraints: unique idempotency key, index by client, and foreign key with ON DELETE CASCADE. The money stays in whole cents:

    exemplos/api_pedidos/src/api_pedidos/modelos.py
    from datetime import UTC, datetime
    
    from sqlalchemy import DateTime, ForeignKey, Index, String
    from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
    
    
    class Base(DeclarativeBase):
        pass
    
    
    def agora() -> datetime:
        return datetime.now(UTC)
    
    
    class Pedido(Base):
        __tablename__ = "pedidos"
        __table_args__ = (Index("ix_pedidos_cliente", "cliente"),)
    
        id: Mapped[int] = mapped_column(primary_key=True)
        cliente: Mapped[str] = mapped_column(String(120))
        status: Mapped[str] = mapped_column(String(20), default="aberto")
        chave_idempotencia: Mapped[str | None] = mapped_column(String(80), unique=True)
        criado_em: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=agora)
    
        itens: Mapped[list["ItemPedido"]] = relationship(
            back_populates="pedido", cascade="all, delete-orphan"
        )
    
        @property
        def total_centavos(self) -> int:
            return sum(item.quantidade * item.preco_centavos for item in self.itens)
    
    
    class ItemPedido(Base):
        __tablename__ = "itens_pedido"
    
        id: Mapped[int] = mapped_column(primary_key=True)
        pedido_id: Mapped[int] = mapped_column(ForeignKey("pedidos.id", ondelete="CASCADE"))
        produto: Mapped[str] = mapped_column(String(120))
        quantidade: Mapped[int]
        preco_centavos: Mapped[int]
    
        pedido: Mapped[Pedido] = relationship(back_populates="itens")
    
    exemplos/api_pedidos/src/api_pedidos/db.py
    from collections.abc import Iterator
    from typing import Any
    
    from fastapi import Request
    from sqlalchemy import Engine, create_engine
    from sqlalchemy.orm import Session, sessionmaker
    
    
    def criar_engine(url: str) -> Engine:
        argumentos: dict[str, Any] = {}
        if url.startswith("sqlite"):
            argumentos["check_same_thread"] = False
        return create_engine(url, connect_args=argumentos, pool_pre_ping=True)
    
    
    def criar_fabrica_de_sessoes(engine: Engine) -> sessionmaker[Session]:
        return sessionmaker(engine, expire_on_commit=False)
    
    
    def obter_sessao(request: Request) -> Iterator[Session]:
        with request.app.state.fabrica() as sessao:
            yield sessao
    

    The contract

    The Pydantic schemas are the contract: they validate the input (positive quantity, at least one item) and define exactly what the output contains. FastAPI derives the OpenAPI documentation from them:

    exemplos/api_pedidos/src/api_pedidos/esquemas.py
    from datetime import datetime
    from typing import Literal
    
    from pydantic import BaseModel, ConfigDict, Field
    
    
    class ItemCriar(BaseModel):
        produto: str = Field(min_length=1, max_length=120)
        quantidade: int = Field(gt=0, le=1000)
        preco_centavos: int = Field(gt=0)
    
    
    class PedidoCriar(BaseModel):
        cliente: str = Field(min_length=1, max_length=120)
        itens: list[ItemCriar] = Field(min_length=1)
    
    
    class ItemLer(ItemCriar):
        model_config = ConfigDict(from_attributes=True)
    
    
    class PedidoLer(BaseModel):
        model_config = ConfigDict(from_attributes=True)
    
        id: int
        cliente: str
        status: Literal["aberto", "cancelado"]
        total_centavos: int
        criado_em: datetime
        itens: list[ItemLer]
    
    
    class Pagina(BaseModel):
        itens: list[PedidoLer]
        proximo_cursor: int | None
    

    Repository and service

    The repository only knows how to query. The service decides. Note in criar: the key check and the handling of IntegrityError coexist, because the check avoids work most of the time and the restriction UNIQUE of the database ensures correctness when two requests compete for the same key:

    exemplos/api_pedidos/src/api_pedidos/repositorio.py
    from sqlalchemy import select
    from sqlalchemy.orm import Session
    
    from api_pedidos.modelos import Pedido
    
    
    class RepositorioPedidos:
        def __init__(self, sessao: Session) -> None:
            self._sessao = sessao
    
        def adicionar(self, pedido: Pedido) -> Pedido:
            self._sessao.add(pedido)
            self._sessao.flush()
            return pedido
    
        def obter(self, pedido_id: int) -> Pedido | None:
            return self._sessao.get(Pedido, pedido_id)
    
        def por_chave(self, chave: str) -> Pedido | None:
            consulta = select(Pedido).where(Pedido.chave_idempotencia == chave)
            return self._sessao.scalars(consulta).first()
    
        def listar(self, depois_de: int, limite: int) -> list[Pedido]:
            consulta = select(Pedido).where(Pedido.id > depois_de).order_by(Pedido.id).limit(limite)
            return list(self._sessao.scalars(consulta))
    
    exemplos/api_pedidos/src/api_pedidos/servico.py
    from sqlalchemy.exc import IntegrityError
    from sqlalchemy.orm import Session
    
    from api_pedidos.esquemas import PedidoCriar
    from api_pedidos.modelos import ItemPedido, Pedido
    from api_pedidos.repositorio import RepositorioPedidos
    
    
    class PedidoNaoEncontrado(Exception):
        def __init__(self, pedido_id: int) -> None:
            super().__init__(f"pedido {pedido_id} não encontrado")
            self.pedido_id = pedido_id
    
    
    class PedidoJaCancelado(Exception):
        def __init__(self, pedido_id: int) -> None:
            super().__init__(f"pedido {pedido_id} já está cancelado")
            self.pedido_id = pedido_id
    
    
    class ServicoPedidos:
        def __init__(self, sessao: Session) -> None:
            self._sessao = sessao
            self._repositorio = RepositorioPedidos(sessao)
    
        def criar(self, dados: PedidoCriar, chave: str | None = None) -> tuple[Pedido, bool]:
            """Devolve o pedido e um indicador de criação (False quando a chave já existia)."""
            if chave and (existente := self._repositorio.por_chave(chave)):
                return existente, False
            pedido = Pedido(
                cliente=dados.cliente,
                chave_idempotencia=chave,
                itens=[ItemPedido(**item.model_dump()) for item in dados.itens],
            )
            try:
                self._repositorio.adicionar(pedido)
                self._sessao.commit()
            except IntegrityError:
                # Duas requisições com a mesma chave podem passar pela verificação acima ao mesmo
                # tempo. Quem garante a unicidade é o banco, e aqui tratamos o erro dele.
                self._sessao.rollback()
                if chave and (existente := self._repositorio.por_chave(chave)):
                    return existente, False
                raise
            return pedido, True
    
        def obter(self, pedido_id: int) -> Pedido:
            pedido = self._repositorio.obter(pedido_id)
            if pedido is None:
                raise PedidoNaoEncontrado(pedido_id)
            return pedido
    
        def cancelar(self, pedido_id: int) -> Pedido:
            pedido = self.obter(pedido_id)
            if pedido.status == "cancelado":
                raise PedidoJaCancelado(pedido_id)
            pedido.status = "cancelado"
            self._sessao.commit()
            return pedido
    
        def listar(self, cursor: int, limite: int) -> tuple[list[Pedido], int | None]:
            # Busca um item a mais para saber se existe próxima página.
            encontrados = self._repositorio.listar(cursor, limite + 1)
            pagina = encontrados[:limite]
            proximo = pagina[-1].id if len(encontrados) > limite else None
            return pagina, proximo
    

    Observability

    The middleware assigns the X-Request-ID, measures the duration, counts the request per route standard (low cardinality) and writes a line of log in JSON:

    exemplos/api_pedidos/src/api_pedidos/observabilidade.py
    import json
    import logging
    import sys
    from collections.abc import Awaitable, Callable
    from contextvars import ContextVar
    from time import perf_counter
    from uuid import uuid4
    
    from fastapi import FastAPI, Request, Response
    from prometheus_client import CONTENT_TYPE_LATEST, Counter, Histogram, generate_latest
    
    id_requisicao: ContextVar[str] = ContextVar("id_requisicao", default="-")
    
    REQUISICOES = Counter("api_requisicoes_total", "Total de requisições", ["metodo", "rota", "status"])
    LATENCIA = Histogram("api_latencia_segundos", "Latência das requisições", ["rota"])
    
    log = logging.getLogger("api")
    
    
    class FormatoJson(logging.Formatter):
        CAMPOS_EXTRAS = ("metodo", "rota", "status", "duracao_ms")
    
        def format(self, record: logging.LogRecord) -> str:
            dados: dict[str, object] = {
                "nivel": record.levelname,
                "mensagem": record.getMessage(),
                "id_requisicao": id_requisicao.get(),
            }
            for campo in self.CAMPOS_EXTRAS:
                if hasattr(record, campo):
                    dados[campo] = getattr(record, campo)
            return json.dumps(dados, ensure_ascii=False)
    
    
    def configurar_logs(nivel: str = "INFO") -> None:
        manipulador = logging.StreamHandler(sys.stdout)
        manipulador.setFormatter(FormatoJson())
        log.handlers = [manipulador]
        log.setLevel(nivel)
        log.propagate = False
    
    
    def instalar_observabilidade(app: FastAPI) -> None:
        @app.middleware("http")
        async def medir(
            request: Request, call_next: Callable[[Request], Awaitable[Response]]
        ) -> Response:
            identificador = request.headers.get("X-Request-ID") or uuid4().hex
            token = id_requisicao.set(identificador)
            inicio = perf_counter()
            status = 500
            try:
                resposta = await call_next(request)
                status = resposta.status_code
            finally:
                duracao = perf_counter() - inicio
                rota_encontrada = request.scope.get("route")
                rota = getattr(rota_encontrada, "path", "desconhecida")
                REQUISICOES.labels(request.method, rota, str(status)).inc()
                LATENCIA.labels(rota).observe(duracao)
                log.info(
                    "requisição concluída",
                    extra={
                        "metodo": request.method,
                        "rota": rota,
                        "status": status,
                        "duracao_ms": round(duracao * 1000, 2),
                    },
                )
                id_requisicao.reset(token)
            resposta.headers["X-Request-ID"] = identificador
            return resposta
    
        @app.get("/metricas", include_in_schema=False)
        def metricas() -> Response:
            return Response(generate_latest(), media_type=CONTENT_TYPE_LATEST)
    

    The application

    The route only translates HTTP. Domain errors turn into problem+json in one place, and the function criar_app receives the engine as a parameter, which allows tests to inject a disposable database:

    exemplos/api_pedidos/src/api_pedidos/app.py
    from typing import Annotated
    
    from fastapi import Depends, FastAPI, Header, Query, Request, Response
    from fastapi.responses import JSONResponse
    from sqlalchemy import Engine, text
    from sqlalchemy.orm import Session
    
    from api_pedidos.config import obter_configuracao
    from api_pedidos.db import criar_engine, criar_fabrica_de_sessoes, obter_sessao
    from api_pedidos.esquemas import Pagina, PedidoCriar, PedidoLer
    from api_pedidos.observabilidade import configurar_logs, instalar_observabilidade
    from api_pedidos.servico import PedidoJaCancelado, PedidoNaoEncontrado, ServicoPedidos
    
    SessaoDep = Annotated[Session, Depends(obter_sessao)]
    
    
    def obter_servico(sessao: SessaoDep) -> ServicoPedidos:
        return ServicoPedidos(sessao)
    
    
    ServicoDep = Annotated[ServicoPedidos, Depends(obter_servico)]
    
    
    def problema(status: int, titulo: str, detalhe: str) -> JSONResponse:
        """Formato de erro único para toda a API (inspirado no RFC 9457)."""
        return JSONResponse(
            {"titulo": titulo, "status": status, "detalhe": detalhe},
            status_code=status,
            media_type="application/problem+json",
        )
    
    
    def criar_app(engine: Engine | None = None) -> FastAPI:
        config = obter_configuracao()
        configurar_logs(config.log_nivel)
        engine = engine or criar_engine(config.database_url.get_secret_value())
        app = FastAPI(title="API de pedidos", version="0.1.0")
        app.state.engine = engine
        app.state.fabrica = criar_fabrica_de_sessoes(engine)
        instalar_observabilidade(app)
    
        @app.exception_handler(PedidoNaoEncontrado)
        def nao_encontrado(_: Request, erro: PedidoNaoEncontrado) -> JSONResponse:
            return problema(404, "Pedido não encontrado", str(erro))
    
        @app.exception_handler(PedidoJaCancelado)
        def ja_cancelado(_: Request, erro: PedidoJaCancelado) -> JSONResponse:
            return problema(409, "Conflito de estado", str(erro))
    
        @app.post("/pedidos", response_model=PedidoLer, status_code=201)
        def criar_pedido(
            dados: PedidoCriar,
            servico: ServicoDep,
            resposta: Response,
            idempotency_key: Annotated[str | None, Header(max_length=80)] = None,
        ) -> object:
            pedido, criado = servico.criar(dados, idempotency_key)
            if not criado:
                resposta.status_code = 200
            return pedido
    
        @app.get("/pedidos/{pedido_id}", response_model=PedidoLer)
        def obter_pedido(pedido_id: int, servico: ServicoDep) -> object:
            return servico.obter(pedido_id)
    
        @app.get("/pedidos", response_model=Pagina)
        def listar_pedidos(
            servico: ServicoDep,
            limite: Annotated[int, Query(ge=1, le=100)] = 20,
            cursor: Annotated[int, Query(ge=0)] = 0,
        ) -> object:
            pagina, proximo = servico.listar(cursor, limite)
            return {"itens": pagina, "proximo_cursor": proximo}
    
        @app.post("/pedidos/{pedido_id}/cancelar", response_model=PedidoLer)
        def cancelar_pedido(pedido_id: int, servico: ServicoDep) -> object:
            return servico.cancelar(pedido_id)
    
        @app.get("/saude")
        def saude(sessao: SessaoDep) -> JSONResponse:
            try:
                sessao.execute(text("SELECT 1"))
            except Exception:
                return JSONResponse({"banco": "indisponível"}, status_code=503)
            return JSONResponse({"banco": "ok"})
    
        return app
    

    The tests

    API tests use the real database (SQLite in memory by default). Set TEST_DATABASE_URL to run the same suite against PostgreSQL, and what passes in both is what gives confidence:

    exemplos/api_pedidos/tests/conftest.py
    import os
    from collections.abc import Iterator
    
    import pytest
    from fastapi.testclient import TestClient
    from sqlalchemy import Engine, create_engine
    from sqlalchemy.pool import StaticPool
    
    from api_pedidos.app import criar_app
    from api_pedidos.modelos import Base
    
    
    @pytest.fixture
    def engine() -> Iterator[Engine]:
        """SQLite em memória por padrão. Defina TEST_DATABASE_URL para testar contra PostgreSQL."""
        url = os.environ.get("TEST_DATABASE_URL")
        if url:
            eng = create_engine(url)
        else:
            eng = create_engine(
                "sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool
            )
        Base.metadata.drop_all(eng)
        Base.metadata.create_all(eng)
        yield eng
        Base.metadata.drop_all(eng)
        eng.dispose()
    
    
    @pytest.fixture
    def cliente(engine: Engine) -> TestClient:
        return TestClient(criar_app(engine))
    
    exemplos/api_pedidos/tests/test_api.py
    from fastapi.testclient import TestClient
    
    CORPO = {
        "cliente": "Ana",
        "itens": [
            {"produto": "caneta", "quantidade": 2, "preco_centavos": 350},
            {"produto": "caderno", "quantidade": 1, "preco_centavos": 1890},
        ],
    }
    
    
    def test_criar_pedido(cliente: TestClient) -> None:
        resposta = cliente.post("/pedidos", json=CORPO)
        assert resposta.status_code == 201
        dados = resposta.json()
        assert dados["total_centavos"] == 2590
        assert dados["status"] == "aberto"
        assert len(dados["itens"]) == 2
    
    
    def test_idempotencia_devolve_o_mesmo_pedido(cliente: TestClient) -> None:
        cabecalho = {"Idempotency-Key": "abc-123"}
        primeira = cliente.post("/pedidos", json=CORPO, headers=cabecalho)
        segunda = cliente.post("/pedidos", json=CORPO, headers=cabecalho)
        assert primeira.status_code == 201
        assert segunda.status_code == 200
        assert primeira.json()["id"] == segunda.json()["id"]
        assert len(cliente.get("/pedidos").json()["itens"]) == 1
    
    
    def test_validacao_recusa_corpo_invalido(cliente: TestClient) -> None:
        resposta = cliente.post("/pedidos", json={"cliente": "", "itens": []})
        assert resposta.status_code == 422
    
    
    def test_pedido_inexistente_devolve_problema(cliente: TestClient) -> None:
        resposta = cliente.get("/pedidos/999")
        assert resposta.status_code == 404
        assert resposta.headers["content-type"].startswith("application/problem+json")
        assert resposta.json()["titulo"] == "Pedido não encontrado"
    
    
    def test_cancelar_duas_vezes_devolve_conflito(cliente: TestClient) -> None:
        pedido_id = cliente.post("/pedidos", json=CORPO).json()["id"]
        assert cliente.post(f"/pedidos/{pedido_id}/cancelar").json()["status"] == "cancelado"
        assert cliente.post(f"/pedidos/{pedido_id}/cancelar").status_code == 409
    
    
    def test_paginacao_por_cursor(cliente: TestClient) -> None:
        for _ in range(5):
            cliente.post("/pedidos", json=CORPO)
        primeira = cliente.get("/pedidos", params={"limite": 2}).json()
        assert len(primeira["itens"]) == 2
        assert primeira["proximo_cursor"] == 2
        ultima = cliente.get("/pedidos", params={"limite": 2, "cursor": 4}).json()
        assert [p["id"] for p in ultima["itens"]] == [5]
        assert ultima["proximo_cursor"] is None
    
    
    def test_saude_e_metricas(cliente: TestClient) -> None:
        assert cliente.get("/saude").json() == {"banco": "ok"}
        cliente.get("/pedidos")
        assert "api_requisicoes_total" in cliente.get("/metricas").text
    
    
    def test_id_de_requisicao_e_devolvido(cliente: TestClient) -> None:
        resposta = cliente.get("/saude", headers={"X-Request-ID": "req-42"})
        assert resposta.headers["x-request-id"] == "req-42"
    

    The service test reproduces the race of two requests with the same key: it forces the check to "not see" the existing request, and proves that the IntegrityError of the database leads to the original request, instead of an error 500:

    exemplos/api_pedidos/tests/test_servico.py
    import pytest
    from sqlalchemy import Engine
    from sqlalchemy.orm import Session
    
    from api_pedidos.esquemas import ItemCriar, PedidoCriar
    from api_pedidos.modelos import Pedido
    from api_pedidos.repositorio import RepositorioPedidos
    from api_pedidos.servico import PedidoNaoEncontrado, ServicoPedidos
    
    DADOS = PedidoCriar(
        cliente="Ana", itens=[ItemCriar(produto="caneta", quantidade=1, preco_centavos=100)]
    )
    
    
    def test_obter_pedido_inexistente(engine: Engine) -> None:
        with Session(engine) as sessao, pytest.raises(PedidoNaoEncontrado):
            ServicoPedidos(sessao).obter(1)
    
    
    def test_corrida_de_chaves_devolve_o_pedido_existente(
        engine: Engine, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        with Session(engine) as sessao:
            original, criado = ServicoPedidos(sessao).criar(DADOS, "chave-1")
            assert criado
            original_id = original.id
    
        chamadas = {"total": 0}
        por_chave_real = RepositorioPedidos.por_chave
    
        def por_chave_atrasada(self: RepositorioPedidos, chave: str) -> Pedido | None:
            chamadas["total"] += 1
            if chamadas["total"] == 1:
                return None  # simula a outra requisição ainda não ter gravado
            return por_chave_real(self, chave)
    
        monkeypatch.setattr(RepositorioPedidos, "por_chave", por_chave_atrasada)
        with Session(engine) as sessao:
            pedido, criado = ServicoPedidos(sessao).criar(DADOS, "chave-1")
            assert criado is False
            assert pedido.id == original_id
    

    The migration test ensures that the models and migrations have not diverged: it applies the migrations to an empty database and runs the equivalent of alembic check. If someone changes a model without creating the migration, the suite fails:

    exemplos/api_pedidos/tests/test_migracoes.py
    from pathlib import Path
    
    import pytest
    from alembic import command
    from alembic.config import Config
    
    from api_pedidos.config import obter_configuracao
    
    RAIZ = Path(__file__).resolve().parent.parent
    
    
    def test_migracoes_estao_sincronizadas_com_os_modelos(
        tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        monkeypatch.setenv("API_DATABASE_URL", f"sqlite:///{tmp_path / 'migracao.db'}")
        obter_configuracao.cache_clear()
        cfg = Config(str(RAIZ / "alembic.ini"))
        try:
            command.upgrade(cfg, "head")
            command.check(cfg)  # falha se os modelos mudaram sem uma migração correspondente
        finally:
            obter_configuracao.cache_clear()
    

    Run everything

    Terminal
    cd exemplos/api_pedidos
    uv sync
    uv run pytest
    
    Output
    ...........                                                              [100%]
    11 passed
    

    Against PostgreSQL, with the same suite (the variable points to a disposable test database):

    Terminal
    TEST_DATABASE_URL="postgresql+psycopg://usuario:senha@localhost:5432/pedidos_teste" uv run pytest
    

    And the static checks:

    Terminal
    uv run mypy
    uv run ruff check .
    uv run ruff format --check .
    
    Output
    Success: no issues found in 9 source files
    All checks passed!
    

    Use the API

    Apply the migrations and start the server. With the Idempotency-Key, the second call returns the original request, with status 200 (the first was 201):

    Terminal
    uv run alembic upgrade head
    uv run uvicorn api_pedidos.app:criar_app --factory --port 8000
    
    Terminal
    curl -s -X POST localhost:8000/pedidos \
      -H "Idempotency-Key: k1" -H "Content-Type: application/json" \
      -d '{"cliente": "Ana", "itens": [{"produto": "caneta", "quantidade": 2, "preco_centavos": 350}]}'
    
    Output
    {"id":1,"cliente":"Ana","status":"aberto","total_centavos":700,"criado_em":"2026-10-06T15:48:21.782501Z","itens":[{"produto":"caneta","quantidade":2,"preco_centavos":350}]}
    

    A non-existent request returns the error in a unique format, and canceling twice returns 409:

    Respostas (execução real, com PostgreSQL)
    GET /pedidos/9999  ->  404  {"titulo":"Pedido não encontrado","status":404,"detalhe":"pedido 9999 não encontrado"}
    POST /pedidos/1/cancelar  ->  200 (status "cancelado")
    POST /pedidos/1/cancelar  ->  409
    

    The decisions, and why

    DecisionAlternativeWhy I chose this way
    Money in whole centsfloat, or DecimalNo rounding error and no conversion in the database
    Cursor paginationBy shiftingStable under concurrent writes, and uses the key index
    Idempotency with key and UNIQUEJust check beforehandThe check alone has a race condition. The database guarantees it
    Error in problem+jsonFree text by routeThe client handles by type, not by text
    Synchronous SQLAlchemyasyncFastAPI runs routes def in threads, and the synchronous driver is simpler and sufficient for this load
    Service without FastAPIRule within the routeTestable without HTTP and reusable
    Migration as a separate stepMigrate at the API startupTwo replicas do not compete for migration

    What breaks first under ten times the load

    I apply the review I do on any service before approving it:

    • Connections with the database. The default SQLAlchemy pool has 5 connections and 10 excess. With many threads and slow queries, requests wait for a connection. It is the first bottleneck, and it is measured by observing the wait time.
    • Idempotency key table. It only grows. In production, keys need to expire (a routine that deletes the old ones).
    • No authentication or request limit. This API is open. The next real step is OAuth2 with JWT and a rate limiter.
    • No timeout on queries. A slow query holds a connection. Configure statement_timeout in PostgreSQL.
    • Events outside the transaction. If the request needs to notify another system, writing to the database and publishing a message is not atomic. The standard for this is the outbox: record the event in the same transaction and publish it later.

    What this project does not prove

    The tests show that the code is correct and consistent with the database. They does not show that the Docker image builds in your environment nor that the system can handle the real load. For this, the next step is a load test (with locust or k6) against an environment similar to production.