Skip to content

Decision Log

A chronological record of key technology and architecture decisions, including the context, options considered, and rationale behind each choice.


Technology Stack

FastAPI (Web Framework)

Chosen: FastAPI Alternatives Considered: Flask, Django REST Framework, Starlette

Rationale:

  • Async-first design for high performance and concurrency
  • Automatic OpenAPI documentation generation
  • Pydantic integration for automatic request/response validation
  • Type hints enable excellent IDE support and type checking
  • Modern Python (3.8+) with native async/await support

Trade-offs:

  • ✅ Best-in-class performance for Python frameworks
  • ✅ Reduced boilerplate compared to Django
  • ❌ Smaller ecosystem than Django
  • ❌ Less opinionated (requires more architectural decisions)

SQLModel (ORM)

Chosen: SQLModel Alternatives Considered: SQLAlchemy, Tortoise ORM, raw SQL

Rationale:

  • Unified models — Single definition for database tables and Pydantic schemas
  • Type safety — Full type hints with IDE autocomplete
  • SQLAlchemy foundation — Built on battle-tested SQLAlchemy
  • Async support — Native async/await for database operations
  • Pydantic integration — Automatic validation and serialization

Example:

class User(SQLModel, table=True):
    id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
    email: str = Field(unique=True, index=True)

Same model used for database table and API response.

Trade-offs:

  • ✅ Less code duplication (single model definition)
  • ✅ Type-safe queries with autocomplete
  • ❌ Newer library (less mature than pure SQLAlchemy)
  • ❌ Some SQLAlchemy features not exposed

PostgreSQL (Database)

Chosen: PostgreSQL Alternatives Considered: MySQL, MongoDB, SQLite

Rationale:

  • ACID compliance — Strong data consistency guarantees
  • Rich data types — JSON, arrays, UUIDs natively supported
  • Performance — Excellent for concurrent reads/writes
  • Full-text search — Built-in search capabilities
  • Extensions — PostGIS, pg_trgm, and many others

Trade-offs:

  • ✅ Industry standard for production applications
  • ✅ Excellent tooling and ecosystem
  • ❌ More setup complexity than SQLite
  • ❌ Resource overhead (not suitable for embedded use)

asyncpg (Database Driver)

Chosen: asyncpg Alternatives Considered: psycopg2, psycopg3

Rationale:

  • Native async — Built specifically for async PostgreSQL operations
  • Performance — Fastest Python PostgreSQL driver (C implementation)
  • Connection pooling — Efficient connection management
  • SQLAlchemy compatible — Works with create_async_engine

Connection String:

POSTGRES_DRIVER=postgresql+asyncpg

Trade-offs:

  • ✅ 3-5x faster than psycopg2
  • ✅ Native async (no thread pools needed)
  • ❌ Less feature-rich than psycopg3
  • ❌ Separate install required

Alembic (Migrations)

Chosen: Alembic Alternatives Considered: Django migrations, raw SQL scripts

Rationale:

  • SQLAlchemy integration — Native support for SQLModel/SQLAlchemy
  • Autogenerate — Detect schema changes automatically
  • Versioned migrations — Git-friendly migration history
  • Upgrade/downgrade — Bidirectional migrations

Trade-offs:

  • ✅ Industry standard for SQLAlchemy projects
  • ✅ Flexible and powerful
  • ❌ Requires manual review of autogenerated migrations
  • ❌ Steeper learning curve than simple SQL scripts

uv (Package Manager)

Chosen: uv Alternatives Considered: pip, poetry, pipenv

Rationale:

  • Speed — 10-100x faster than pip
  • Deterministic — Lockfile ensures reproducible installs
  • Modern — Written in Rust, actively developed
  • pip-compatible — Drop-in replacement for pip

Usage:

uv sync                 # Install dependencies
uv add fastapi          # Add package
uv add --group dev ruff # Add dev dependency

Trade-offs:

  • ✅ Extremely fast dependency resolution
  • ✅ Simple interface (similar to pip)
  • ❌ Newer tool (less battle-tested)
  • ❌ Smaller community than poetry

Ruff (Linter & Formatter)

Chosen: Ruff Alternatives Considered: Flake8 + Black + isort, Pylint

Rationale:

  • Speed — 10-100x faster than Black (written in Rust)
  • All-in-one — Linting + formatting + import sorting
  • Compatible — Implements Flake8, Black, isort rules
  • Configurable — Extensive rule customization

Configuration:

line-length = 80

Trade-offs:

  • ✅ Single tool replaces multiple tools
  • ✅ Nearly instant execution
  • ❌ Newer tool (less plugin ecosystem)
  • ❌ Some advanced Pylint features missing

ty (Type Checker)

Chosen: ty Alternatives Considered: mypy, pyright, Pyre

Rationale: - Strict type checking — Enforced across entire codebase - Fast execution — Incremental type checking for large codebases - Better error messages — More actionable than mypy - Modern tooling — Designed for Python 3.8+ type hints - 100% typed — Project follows strict typing standards

Configuration:

# pyproject.toml
[tool.ty]
strict = true

Usage:

just typecheck  # Run type checker

Trade-offs: - ✅ Catches type errors before runtime - ✅ Faster than mypy for incremental checks - ✅ Excellent IDE integration - ❌ Newer tool (less ecosystem than mypy) - ❌ Some mypy plugins not available


Pytest (Testing Framework)

Chosen: Pytest Alternatives Considered: unittest, nose2

Rationale:

  • Fixtures — Powerful dependency injection for tests
  • Async support — pytest-asyncio for async tests
  • Plugins — Rich ecosystem (coverage, mock, etc.)
  • Simple syntax — Less boilerplate than unittest

Example:

async def test_create_user(client: AsyncClient):
    response = await client.post("/api/v1/users/", json={...})
    assert response.status_code == 201

Trade-offs:

  • ✅ Industry standard for Python testing
  • ✅ Excellent async support
  • ❌ Magic can be confusing for beginners
  • ❌ Fixture discovery can be non-obvious

prek (Git Hooks)

Chosen: prek Alternatives Considered: pre-commit, husky

Rationale: - Lightweight — Minimal dependencies compared to pre-commit - Fast — No Python overhead, just runs commands - Simple configuration — Direct command execution - Rust-based — Fast installation and execution

Usage:

just pi   # Install hooks (prek install)
just pr   # Run hooks manually (prek run)

Configuration:

# prek.toml
[[repos]]
repo = "local"
hooks = [
    { id = "ruff-check", name = "ruff check", entry = "uv run ruff check", language = "system", types = ["python"], args = ["--fix"] },
    { id = "ruff-format", name = "ruff format", entry = "uv run ruff format", language = "system", types = ["python"] },
    { id = "ty", name = "ty", entry = "uv run ty check", language = "system", types = ["python"], pass_filenames = false }
]

Trade-offs: - ✅ Faster than pre-commit framework - ✅ Simple, direct command execution - ✅ No Python environment required - ❌ Fewer plugins than pre-commit - ❌ Less widely adopted


justfile (Task Runner)

Chosen: just Alternatives Considered: make, invoke, poetry scripts

Rationale: - Rust-based — Fast execution, cross-platform - Simple syntax — Cleaner than Makefile - No POSIX quirks — Unlike make, just has consistent behavior - Built-in features — Variables, conditionals, string manipulation - Command discoveryjust --list shows all commands

Example:

# Run development server
run:
    uv run fastapi dev app/main.py

# Run all quality checks
check:
    just format
    just lint
    just typecheck
    just test

Trade-offs: - ✅ Faster and simpler than make - ✅ Cross-platform (no shell differences) - ✅ Self-documenting commands - ❌ Less universal than make - ❌ Requires installation (not system default)

Architectural Decisions

Zensical (Documentation)

Chosen: Zensical Alternatives Considered: MkDocs, Sphinx, Docusaurus

Rationale: - Static site generator — Fast, lightweight documentation sites - Material theme — Beautiful, modern design out of the box - MkDocs compatible — Drop-in replacement for MkDocs with enhancements - Markdown-based — Simple content authoring - Search built-in — Full-text search without configuration - GitHub Pages — Easy deployment to GitHub Pages

Usage:

just docb  # Build documentation
just ds    # Serve locally on localhost:8001

Configuration:

# zensical.toml
[project]
site_name = "QuoinAPI"
site_url = "https://balakmran.github.io/quoin-api/"

[project.theme]
name = "modern"

Trade-offs: - ✅ Beautiful default theme - ✅ Fast build times - ✅ MkDocs ecosystem compatibility - ❌ Newer project (less established than Sphinx) - ❌ Smaller plugin ecosystem


Architectural Decisions

Domain Exception Pattern

Decision: Use custom exception classes instead of HTTP exceptions

Rationale:

  • HTTP-agnostic services — Business logic doesn't know about HTTP
  • Testability — Easy to test exception handling in isolation
  • Consistency — Centralized error response formatting
  • Flexibility — Easy to add custom exception metadata

Implementation:

# Service raises domain exception
raise ConflictError(message="Email already registered")

# Global handler converts to HTTP response
return JSONResponse(status_code=409, content={"detail": message})

Alternatives Rejected:

  • ❌ HTTPException in services (couples business logic to HTTP)
  • ❌ Return error tuples (less Pythonic)

Repository Pattern

Decision: Separate database operations into repository classes

Rationale:

  • Separation of concerns — Services don't write SQL
  • Testability — Easy to mock database layer
  • Reusability — Common queries defined once
  • Maintainability — Database logic centralized

Structure:

Service Layer     → Business logic, orchestration
Repository Layer  → Database CRUD operations
Model Layer       → SQLModel table definitions

Alternatives Rejected:

  • ❌ Active Record pattern (couples model and DB logic)
  • ❌ Services directly using SQLAlchemy (less maintainable)

App State for Database Engine

Decision: Store database engine on app.state.engine

Rationale:

  • Lifespan management — Engine created on startup, disposed on shutdown
  • No globals — Avoids module-level global variables
  • Request-scoped sessions — Each request gets a fresh session
  • Testability — Easy to inject test database engines

Implementation:

@asynccontextmanager
async def lifespan(app: FastAPI):
    app.state.engine = create_db_engine()
    yield
    await app.state.engine.dispose()

app = FastAPI(lifespan=lifespan)

Alternatives Rejected:

  • ❌ Global engine variable (harder to test, not request-safe)
  • ❌ Creating engine per request (inefficient)

Structured Logging

Decision: Use Structlog for all application logging

Rationale:

  • Machine-readable — JSON logs for log aggregation
  • Contextual — Attach request ID, user ID, etc.
  • Consistent — Standardized log format
  • Queryable — Easy to search and filter logs

Example:

logger.info(
    "user_created",
    user_id=str(user.id),
    email=user.email,
)

Alternatives Rejected:

  • ❌ Standard logging module (less structured)
  • ❌ Print statements (not production-ready)

OpenTelemetry for Tracing

Decision: Integrate OpenTelemetry for distributed tracing

Rationale:

  • Vendor-neutral — Works with Jaeger, Tempo, Datadog, etc.
  • Auto-instrumentation — Minimal code changes required
  • Industry standard — CNCF graduated project
  • Debugging — Visualize request flow and bottlenecks

Trade-offs:

  • ✅ Excellent observability in production
  • ✅ Vendor-agnostic
  • ❌ Performance overhead (~5-10%)
  • ❌ Additional infrastructure required (OTEL collector)

Configuration: Toggleable via OTEL_ENABLED environment variable.


Environment-Based Configuration

Decision: Use .env files and Pydantic Settings

Rationale:

  • 12-factor app — Configuration via environment variables
  • Type safety — Pydantic validates configuration
  • Developer-friendly.env file for local development
  • Production-ready — Environment variables in containers

Example:

# .env
QUOIN_ENV=development
QUOIN_POSTGRES_HOST=localhost
QUOIN_POSTGRES_PORT=5432

Alternatives Rejected:

  • ❌ YAML/TOML config files (less portable)
  • ❌ Hardcoded configuration (not environment-specific)

API Versioning

Decision: URL-based versioning (/api/v1/)

Rationale:

  • Explicit — Version visible in URL
  • Simplicity — No header parsing needed
  • Cacheable — CDNs can cache by URL
  • Client-friendly — Easy to test with curl/Postman

Structure:

/api/v1/users/      # Current version
/api/v2/users/      # Future version (breaking changes)

Alternatives Rejected:

  • ❌ Header versioning (less visible, harder to cache)
  • ❌ No versioning (breaks clients on changes)

Monorepo Structure

Decision: Keep all application code in a single repository

Rationale:

  • Simplicity — Single codebase, single deployment
  • Atomic changes — Database + API changes in one commit
  • Easier development — No cross-repo coordination
  • Smaller scale — Team size doesn't warrant microservices

Trade-offs:

  • ✅ Simple deployment and versioning
  • ✅ Easy to refactor across modules
  • ❌ Scales poorly to very large teams
  • ❌ All-or-nothing deployment

Future: May split to microservices if:

  • Team grows beyond 20 developers
  • Modules have very different scaling needs
  • Independent deployment critical

Non-Functional Requirements

Performance

  • Target: <100ms response time (p95)
  • Async I/O: Non-blocking database and HTTP operations
  • Connection pooling: 20 connections, max 10 overflow
  • Indexes: All foreign keys and frequently queried fields

Scalability

  • Horizontal scaling: Stateless application (load balance)
  • Database: PostgreSQL with read replicas (future)
  • Caching: Redis for session storage (future)

Security

  • Input validation: Pydantic schemas on all inputs
  • SQL injection: Parameterized queries via SQLAlchemy
  • Secrets: Environment variables, never committed

Observability

  • Logging: Structured JSON logs for aggregation
  • Tracing: OpenTelemetry spans for request flow
  • Metrics: Prometheus metrics (future)

Future Considerations

Caching

Planned: Redis for caching frequent queries

@cache(ttl=300)
async def get_user_by_id(user_id: UUID) -> User:
    # Cache for 5 minutes

Background Jobs

Planned: Celery or Dramatiq for async task processing

@task
async def send_welcome_email(user_id: UUID):
    # Process in background

See Also