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:
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:
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:
Usage:
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:
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 discovery — just --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:
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:
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 —
.envfile for local development - Production-ready — Environment variables in containers
Example:
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:
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
Background Jobs¶
Planned: Celery or Dramatiq for async task processing
See Also¶
- Architecture Overview — Component structure and data flow
- Getting Started — Setup and development
- Configuration — Environment variables