fin-infra is a stateless library - your application owns its database schema, migrations, and data storage. This design philosophy makes fin-infra flexible, framework-agnostic, and easy to integrate into any application architecture.
Table of Contents
- Why fin-infra is Stateless
- Scaffold + add_sql_resources() Workflow
- When to Use Scaffold vs Manual Templates
- Step-by-Step Scaffold Guide
- Integration with svc-infra
- Multi-Tenancy Patterns
- Soft Delete Patterns
- Testing Strategies
- Example Workflows
- Troubleshooting
Why fin-infra is Stateless
Library vs Framework
fin-infra is a library, like stripe-python or plaid-python:
- ✅ Provides financial provider integrations (Plaid, Alpaca, market data)
- ✅ Provides financial calculations (NPV, IRR, portfolio analytics)
- ✅ Provides domain models (Transaction, Account, NetWorthSnapshot schemas)
- ✅ Does NOT manage your database
- ✅ Does NOT require specific database schema
- ✅ Does NOT run migrations on your behalf
Contrast with frameworks like Django or Rails:
- ❌ Impose ORM (ActiveRecord, Django ORM)
- ❌ Manage database migrations
- ❌ Couple application to framework database layer
- ❌ Require specific schema conventions
Benefits of Stateless Design
- No Database Dependency: Use any database (PostgreSQL, MySQL, SQLite, MongoDB) or no database at all
- Application Flexibility: Choose your own ORM (SQLAlchemy, Prisma, raw SQL)
- No Version Coupling: Upgrade fin-infra without database migration conflicts
- Testing Simplicity: Mock provider responses, use in-memory storage for unit tests
- Deployment Freedom: Deploy as library in monolith, microservice, serverless, or edge runtime
Comparison: Libraries vs Frameworks
| Feature | fin-infra (Library) | stripe-python | plaid-python | Django (Framework) | Rails (Framework) |
|---|---|---|---|---|---|
| Database Management | ❌ No | ❌ No | ❌ No | ✅ Yes (ORM) | ✅ Yes (ActiveRecord) |
| Schema Ownership | ✅ Your app | ✅ Your app | ✅ Your app | ❌ Framework | ❌ Framework |
| Migration Tool | ✅ Your choice | ✅ Your choice | ✅ Your choice | ❌ Django migrations | ❌ Rails migrations |
| ORM Flexibility | ✅ Any ORM | ✅ Any ORM | ✅ Any ORM | ❌ Django ORM only | ❌ ActiveRecord only |
| Provider Integrations | ✅ Yes | ✅ Yes | ✅ Yes | ❌ No | ❌ No |
| Financial Calculations | ✅ Yes | ❌ No | ❌ No | ❌ No | ❌ No |
Scaffold + add_sql_resources() Workflow
The PRIMARY PATTERN for persistence in fin-infra is:
- Scaffold models/schemas/repository from templates
- Migrate with svc-infra Alembic integration
- Wire CRUD with ONE function call:
add_sql_resources()
This pattern gives you production-ready CRUD APIs with zero manual router code.
Complete Example: Budget CRUD in 3 Steps
Step 1: Generate Models with Scaffold CLI
# Scaffold budgets with multi-tenancy
fin-infra scaffold budgets --dest-dir app/models/budgets --include-tenantGenerated files:
app/models/budgets/
├── budget.py # SQLAlchemy model (Budget)
├── budget_schemas.py # Pydantic schemas (BudgetCreate, BudgetRead, BudgetUpdate)
├── budget_repository.py # Repository pattern (BudgetRepository)
└── __init__.py # Re-exportsWhat you get:
- ✅ SQLAlchemy model with
tenant_idfield, indexes, constraints - ✅ Pydantic schemas for validation (Create, Read, Update)
- ✅ Repository with full CRUD:
create(),get(),list(),update(),delete() - ✅ Type hints and docstrings throughout
- ✅ Production-ready patterns (UUID primary keys, timestamps, soft delete support)
Step 2: Run svc-infra Migrations
# Create migration (auto-detects models)
svc-infra revision -m "add budgets table" --autogenerate
# Apply migration
svc-infra upgrade headWhat happens:
- svc-infra discovers
Budgetmodel viaModelBaseinheritance - Alembic auto-generates migration from model changes
- Migration creates
budgetstable with all fields, indexes, constraints
Step 3: Wire CRUD with ONE Function Call
from fastapi import FastAPI
from svc_infra.api.fastapi.db.sql import add_sql_resources, SqlResource
from app.models.budgets import Budget
app = FastAPI()
# ONE FUNCTION CALL → Full CRUD API
add_sql_resources(app, [
SqlResource(
model=Budget,
prefix="/budgets",
search_fields=["name", "description"],
order_fields=["name", "created_at"],
soft_delete=False,
)
])What you get (automatic, zero code):
POST /budgets/ # Create budget
GET /budgets/ # List budgets (paginated, searchable, orderable)
GET /budgets/{id} # Get budget by ID
PATCH /budgets/{id} # Update budget
DELETE /budgets/{id} # Delete budget
GET /budgets/search # Search budgets (q=name, category, etc)Benefits of This Workflow
- Automatic Pagination:
?page=1&page_size=20(configurable) - Automatic Search:
?q=groceriessearches acrosssearch_fields - Automatic Ordering:
?order_by=name&order_dir=asc - Automatic Filtering:
?category=personal&active=true - Automatic Soft Delete:
?include_deleted=false(if enabled) - OpenAPI Schema: Full Swagger/ReDoc documentation auto-generated
- Type Safety: Pydantic validation on all requests/responses
- Dependency Injection: Easy to mock repository for testing
Reference: svc-infra SqlResource Documentation
For full details on add_sql_resources() configuration, see:
- svc-infra SQL README
- Available options:
search_fields,order_fields,filter_fields,soft_delete,tenant_field,permission_rules
When to Use Scaffold vs Manual Templates
Use Scaffold CLI When:
✅ Quick start: Need working models/schemas/repository in seconds
✅ Standard patterns: Budget, Goal, NetWorthSnapshot follow common patterns
✅ Rapid prototyping: Iterate on schema quickly without manual boilerplate
✅ Learning: Understand best practices from generated code
✅ Consistency: Ensure all domains follow same conventions
Example scenarios:
- New fintech startup building MVP
- Adding new domain to existing application
- Creating proof-of-concept with real database
- Teaching team svc-infra patterns
Use Manual Templates When:
✅ Full customization: Need complex business logic not in scaffold templates
✅ Existing codebase: Integrating fin-infra into legacy system with established patterns
✅ Complex schemas: Many-to-many relationships, polymorphic associations, JSON fields
✅ Performance optimization: Hand-tuned queries, custom indexes, materialized views
✅ Non-standard ORM: Using Prisma, Tortoise ORM, raw SQL instead of SQLAlchemy
Example scenarios:
- Migrating from Django/Rails to FastAPI
- Enterprise application with strict schema conventions
- High-performance trading platform requiring custom queries
- Using MongoDB or DynamoDB instead of SQL
Hybrid Approach
Scaffold first, customize later:
# 1. Scaffold to get 80% of the way there
fin-infra scaffold budgets --dest-dir app/models/budgets
# 2. Customize generated files
# - Add custom fields (e.g., `budget.py`: add approval_workflow field)
# - Add custom methods (e.g., `budget_repository.py`: add get_pending_approval())
# - Add custom validation (e.g., `budget_schemas.py`: add business rules)
# 3. Maintain customizations through scaffold updates
# - Use version control to see scaffold changes
# - Merge updates while preserving customizationsStep-by-Step Scaffold Guide
1. Choose Domain
Available domains:
- budgets: Monthly/yearly budgets with categories, rollover, tracking
- goals: Financial goals with progress tracking, milestones, status
- net_worth: Immutable snapshots with time-series queries, growth calculations
2. Run Scaffold Command with Flags
# Basic scaffold (no flags)
fin-infra scaffold budgets --dest-dir app/models/budgets
# With multi-tenancy (adds tenant_id field)
fin-infra scaffold budgets --dest-dir app/models/budgets --include-tenant
# With soft delete (adds deleted_at field)
fin-infra scaffold budgets --dest-dir app/models/budgets --include-soft-delete
# Combined flags
fin-infra scaffold budgets --dest-dir app/models/budgets \
--include-tenant \
--include-soft-delete
# Without repository (use svc-infra SqlRepository directly)
fin-infra scaffold budgets --dest-dir app/models/budgets --no-with-repository
# Custom filenames
fin-infra scaffold budgets --dest-dir app/models/budgets \
--models-filename my_budget.py \
--schemas-filename my_schemas.py \
--repository-filename my_repo.py
# Overwrite existing files
fin-infra scaffold budgets --dest-dir app/models/budgets --overwrite3. Review Generated Files
budget.py (SQLAlchemy model):
from svc_infra.db.sql.models import ModelBase
from sqlalchemy import String, Numeric, DateTime, JSON
from sqlalchemy.orm import Mapped, mapped_column
class Budget(ModelBase):
__tablename__ = "budgets"
id: Mapped[str] = mapped_column(String(255), primary_key=True)
user_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
name: Mapped[str] = mapped_column(String(255), nullable=False)
period_start: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
period_end: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
categories: Mapped[dict] = mapped_column(JSON, nullable=False)
# ... more fieldsbudget_schemas.py (Pydantic schemas):
from pydantic import BaseModel, Field
from datetime import datetime
class BudgetBase(BaseModel):
name: str = Field(..., description="Budget name")
period_start: datetime
period_end: datetime
categories: dict[str, float]
class BudgetCreate(BudgetBase):
user_id: str
class BudgetRead(BudgetBase):
id: str
user_id: str
created_at: datetime
updated_at: datetime
class BudgetUpdate(BaseModel):
name: str | None = None
categories: dict[str, float] | None = Nonebudget_repository.py (Repository pattern):
from sqlalchemy.ext.asyncio import AsyncSession
from typing import Optional, List
class BudgetRepository:
def __init__(self, session: AsyncSession):
self.session = session
async def create(self, budget: BudgetCreate) -> BudgetRead:
"""Create new budget."""
# ... implementation
async def get(self, budget_id: str) -> Optional[BudgetRead]:
"""Get budget by ID."""
# ... implementation
async def list(self, user_id: str, limit: int = 100) -> List[BudgetRead]:
"""List user's budgets."""
# ... implementation
async def update(self, budget_id: str, updates: BudgetUpdate) -> BudgetRead:
"""Update budget."""
# ... implementation
async def delete(self, budget_id: str, soft: bool = False) -> None:
"""Delete budget (soft or hard)."""
# ... implementation4. Customize for Your Needs
Add custom fields:
# budget.py
class Budget(ModelBase):
# ... existing fields ...
# Add custom fields
approval_status: Mapped[str] = mapped_column(String(50), default="pending")
approved_by: Mapped[str | None] = mapped_column(String(255), nullable=True)
notes: Mapped[str | None] = mapped_column(String(1000), nullable=True)Add custom methods:
# budget_repository.py
class BudgetRepository:
# ... existing methods ...
async def get_pending_approval(self, user_id: str) -> List[BudgetRead]:
"""Get budgets pending approval."""
stmt = (
select(Budget)
.where(Budget.user_id == user_id)
.where(Budget.approval_status == "pending")
)
result = await self.session.execute(stmt)
budgets = result.scalars().all()
return [self._to_schema(b) for b in budgets]Add custom validation:
# budget_schemas.py
from pydantic import field_validator
class BudgetCreate(BudgetBase):
user_id: str
@field_validator('categories')
@classmethod
def validate_categories(cls, v):
if not v:
raise ValueError("Must have at least one category")
if any(amount < 0 for amount in v.values()):
raise ValueError("Category amounts must be positive")
if sum(v.values()) > 1_000_000:
raise ValueError("Total budget cannot exceed $1,000,000")
return v5. Create Alembic Migration
# Auto-generate migration from model changes
svc-infra revision -m "add budgets table" --autogenerate
# Review generated migration in migrations/versions/
# Edit if needed (add custom indexes, constraints, etc)6. Apply Migration
# Apply to database
svc-infra upgrade head
# Or rollback if needed
svc-infra downgrade -17. Use Repository in Application
from fastapi import Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.budgets import BudgetRepository, BudgetCreate
async def get_session() -> AsyncSession:
"""Dependency for database session."""
async with async_session_maker() as session:
yield session
@app.post("/budgets")
async def create_budget(
budget: BudgetCreate,
session: AsyncSession = Depends(get_session),
):
repo = BudgetRepository(session)
return await repo.create(budget)Integration with svc-infra
ModelBase Discovery Mechanism
All scaffolded models inherit from svc_infra.db.sql.models.ModelBase:
from svc_infra.db.sql.models import ModelBase
class Budget(ModelBase):
__tablename__ = "budgets"
# ... fieldsBenefits:
- ✅ Automatic Alembic discovery (no need to manually import models)
- ✅ Common base fields (id, created_at, updated_at)
- ✅ Consistent conventions across all models
- ✅ SQLAlchemy 2.0 modern style (Mapped, mapped_column)
Alembic env.py Configuration
svc-infra's env.py (in your application's migrations/ folder):
# migrations/env.py
import os
from svc_infra.db.sql.models import ModelBase
from svc_infra.db.alembic import discover_and_import_models
# Discover models from environment variable
DISCOVER_PACKAGES = os.getenv("DISCOVER_PACKAGES", "app.models")
discover_and_import_models(DISCOVER_PACKAGES)
# Use ModelBase.metadata for autogenerate
target_metadata = ModelBase.metadataDISCOVER_PACKAGES Environment Variable
Tell svc-infra where to find your models:
# .env
DISCOVER_PACKAGES=app.models,another_package.models
# Or in your shell
export DISCOVER_PACKAGES=app.models
# Or inline with svc-infra command
DISCOVER_PACKAGES=app.models svc-infra revision -m "add budgets" --autogenerateHow it works:
- svc-infra imports all modules in
DISCOVER_PACKAGES - Any class inheriting from
ModelBaseis registered - Alembic compares
ModelBase.metadatawith database - Auto-generates migration with all changes
Migration Workflow
# 1. Set discovery path (or use .env)
export DISCOVER_PACKAGES=app.models
# 2. Create migration (auto-detects changes)
svc-infra revision -m "add budgets table" --autogenerate
# 3. Review generated migration
cat migrations/versions/abc123_add_budgets_table.py
# 4. Edit migration if needed (add custom SQL, data migrations)
vim migrations/versions/abc123_add_budgets_table.py
# 5. Apply migration
svc-infra upgrade head
# 6. Check migration status
svc-infra current
svc-infra history
# 7. Rollback if needed
svc-infra downgrade -1 # Rollback one version
svc-infra downgrade abc123 # Rollback to specific versionMulti-Tenancy Patterns
When to Use --include-tenant Flag
Use multi-tenancy when:
- ✅ Building SaaS application with multiple customers
- ✅ Need data isolation between organizations
- ✅ Want to use PostgreSQL Row-Level Security (RLS)
- ✅ Application serves multiple teams/workspaces
Example: Budgeting SaaS where each company has separate budgets.
Tenant Isolation Strategies
1. Shared Database with tenant_id (Scaffold Default)
fin-infra scaffold budgets --dest-dir app/models/budgets --include-tenantGenerated model:
class Budget(ModelBase):
__tablename__ = "budgets"
id: Mapped[str] = mapped_column(String(255), primary_key=True)
tenant_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True) # Added
user_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
# ... other fieldsUnique constraint includes tenant:
__table_args__ = (
UniqueConstraint("tenant_id", "user_id", "name", name="uq_budget_tenant_user_name"),
)Repository queries filter by tenant:
async def list(self, tenant_id: str, user_id: str) -> List[BudgetRead]:
stmt = (
select(Budget)
.where(Budget.tenant_id == tenant_id) # Tenant isolation
.where(Budget.user_id == user_id)
)
# ...2. Row-Level Security (RLS) with PostgreSQL
Enable RLS on table:
-- In migration or manual SQL
ALTER TABLE budgets ENABLE ROW LEVEL SECURITY;
-- Policy: Users can only see their tenant's data
CREATE POLICY tenant_isolation ON budgets
USING (tenant_id = current_setting('app.current_tenant')::text);Set tenant in application:
from sqlalchemy import text
async def set_tenant(session: AsyncSession, tenant_id: str):
await session.execute(text(f"SET app.current_tenant = '{tenant_id}'"))
@app.middleware("http")
async def tenant_middleware(request: Request, call_next):
tenant_id = request.headers.get("X-Tenant-ID")
if tenant_id:
async with async_session_maker() as session:
await set_tenant(session, tenant_id)
return await call_next(request)3. Separate Databases per Tenant
Not recommended with scaffold - use shared database with tenant_id instead.
If you need separate databases:
- Use connection pooling with tenant-specific DSNs
- Manage migrations per tenant database
- Consider operational complexity (backups, monitoring, scaling)
Example: Multi-Tenant Budget Application
# app/main.py
from fastapi import FastAPI, Header, HTTPException
from svc_infra.api.fastapi.db.sql import add_sql_resources, SqlResource
from app.models.budgets import Budget
app = FastAPI()
# Multi-tenant CRUD (tenant_id from header)
add_sql_resources(app, [
SqlResource(
model=Budget,
prefix="/budgets",
tenant_field="tenant_id", # Enable tenant isolation
search_fields=["name"],
)
])
# Custom endpoint with tenant isolation
@app.get("/budgets/summary")
async def get_budget_summary(
x_tenant_id: str = Header(..., alias="X-Tenant-ID"),
session: AsyncSession = Depends(get_session),
):
repo = BudgetRepository(session)
budgets = await repo.list(tenant_id=x_tenant_id, user_id="current_user")
return {"tenant_id": x_tenant_id, "count": len(budgets)}Soft Delete Patterns
When to Use --include-soft-delete Flag
Use soft delete when:
- ✅ Need audit trail of deleted records
- ✅ Want to support "undo" or "restore" functionality
- ✅ Compliance requires retention of deleted data
- ✅ Need to preserve foreign key integrity after deletion
Example: Budgets that can be archived and later restored.
Generated Code with Soft Delete
fin-infra scaffold budgets --dest-dir app/models/budgets --include-soft-deleteModel with deleted_at:
class Budget(ModelBase):
__tablename__ = "budgets"
id: Mapped[str] = mapped_column(String(255), primary_key=True)
user_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
# ... other fields
deleted_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True, index=True
) # Added for soft deleteRepository with soft delete support:
async def delete(self, budget_id: str, soft: bool = True) -> None:
"""Delete budget (soft by default, hard if soft=False)."""
budget = await self.get(budget_id)
if not budget:
raise ValueError(f"Budget not found: {budget_id}")
if soft:
# Soft delete: set deleted_at timestamp
budget.deleted_at = datetime.now(timezone.utc)
await self.session.commit()
else:
# Hard delete: remove from database
await self.session.delete(budget)
await self.session.commit()
async def list(
self,
user_id: str,
include_deleted: bool = False,
) -> List[BudgetRead]:
"""List budgets (exclude soft-deleted by default)."""
stmt = select(Budget).where(Budget.user_id == user_id)
if not include_deleted:
stmt = stmt.where(Budget.deleted_at.is_(None)) # Filter soft-deleted
# ... rest of queryQuery Filtering
Default behavior (exclude deleted):
budgets = await repo.list(user_id="user123")
# Only returns budgets where deleted_at IS NULLInclude deleted:
all_budgets = await repo.list(user_id="user123", include_deleted=True)
# Returns all budgets, including soft-deletedOnly deleted:
async def list_deleted(self, user_id: str) -> List[BudgetRead]:
"""List only soft-deleted budgets."""
stmt = (
select(Budget)
.where(Budget.user_id == user_id)
.where(Budget.deleted_at.is_not(None)) # Only soft-deleted
)
# ...Hard Delete vs Soft Delete Tradeoffs
| Feature | Soft Delete | Hard Delete |
|---|---|---|
| Recoverability | ✅ Can restore | ❌ Permanent loss |
| Audit Trail | ✅ Full history | ❌ No record |
| Query Performance | ❌ Slower (more rows) | ✅ Faster |
| Storage | ❌ More space | ✅ Less space |
| Compliance | ✅ Better (retention) | ❌ Worse |
| Foreign Keys | ✅ No cascade issues | ❌ Cascade deletes |
| Unique Constraints | ❌ Complex (need NULL) | ✅ Simple |
Best practice: Use soft delete by default, add hard delete option for cleanup:
# Soft delete for normal operations
await repo.delete(budget_id, soft=True)
# Hard delete for admin cleanup (periodic job)
async def cleanup_old_deleted_budgets():
cutoff = datetime.now(timezone.utc) - timedelta(days=90)
stmt = select(Budget).where(Budget.deleted_at < cutoff)
old_budgets = await session.execute(stmt)
for budget in old_budgets.scalars():
await repo.delete(budget.id, soft=False) # Hard deleteExample: Recoverable Budget Deletion
@app.delete("/budgets/{budget_id}")
async def delete_budget(
budget_id: str,
session: AsyncSession = Depends(get_session),
):
repo = BudgetRepository(session)
await repo.delete(budget_id, soft=True) # Soft delete
return {"message": "Budget deleted (can be restored)"}
@app.post("/budgets/{budget_id}/restore")
async def restore_budget(
budget_id: str,
session: AsyncSession = Depends(get_session),
):
# Custom restore method
stmt = select(Budget).where(Budget.id == budget_id)
result = await session.execute(stmt)
budget = result.scalars().first()
if not budget:
raise HTTPException(404, "Budget not found")
if budget.deleted_at is None:
raise HTTPException(400, "Budget not deleted")
budget.deleted_at = None # Restore
await session.commit()
return {"message": "Budget restored"}Testing Strategies
Unit Tests with In-Memory Storage
Use the tracker pattern from fin-infra (BudgetTracker, NetWorthTracker):
# tests/unit/test_budget_logic.py
import pytest
from fin_infra.budgets.tracker import BudgetTracker
@pytest.mark.asyncio
async def test_budget_creation():
tracker = BudgetTracker() # In-memory storage
budget = await tracker.create_budget(
user_id="user123",
name="November 2025",
period_start=datetime(2025, 11, 1),
period_end=datetime(2025, 11, 30),
categories={"Groceries": 600.00, "Dining": 200.00},
)
assert budget.user_id == "user123"
assert budget.name == "November 2025"
assert budget.categories["Groceries"] == 600.00Benefits:
- ✅ Fast (no database I/O)
- ✅ Isolated (no test pollution)
- ✅ Simple (no fixtures or migrations)
Integration Tests with Test Database
Use aiosqlite for fast in-memory SQL database:
# tests/integration/test_budget_repository.py
import pytest
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
from app.models.budgets import Budget, BudgetRepository, BudgetCreate
@pytest.fixture
async def test_session():
# Create in-memory SQLite database
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
# Create tables
async with engine.begin() as conn:
await conn.run_sync(Budget.metadata.create_all)
# Create session
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async with async_session() as session:
yield session
await engine.dispose()
@pytest.mark.asyncio
async def test_budget_repository_create(test_session):
repo = BudgetRepository(test_session)
budget_create = BudgetCreate(
user_id="user123",
name="Test Budget",
period_start=datetime(2025, 11, 1),
period_end=datetime(2025, 11, 30),
categories={"Groceries": 600.00},
)
budget = await repo.create(budget_create)
assert budget.id is not None
assert budget.user_id == "user123"
assert budget.name == "Test Budget"Benefits:
- ✅ Fast (in-memory SQLite)
- ✅ Real database (tests SQL queries)
- ✅ Isolated (each test gets clean DB)
Acceptance Tests with Real Database
Use PostgreSQL test container for full integration:
# tests/acceptance/test_budget_api.py
import pytest
from testcontainers.postgres import PostgresContainer
from fastapi.testclient import TestClient
from app.main import app
@pytest.fixture(scope="session")
def postgres_container():
with PostgresContainer("postgres:15") as postgres:
yield postgres
@pytest.fixture
async def test_client(postgres_container):
# Apply migrations to test database
database_url = postgres_container.get_connection_url()
# Run: svc-infra upgrade head
client = TestClient(app)
yield client
def test_budget_crud_e2e(test_client):
# Create budget
response = test_client.post("/budgets", json={
"user_id": "user123",
"name": "November 2025",
"period_start": "2025-11-01T00:00:00Z",
"period_end": "2025-11-30T23:59:59Z",
"categories": {"Groceries": 600.00},
})
assert response.status_code == 200
budget_id = response.json()["id"]
# Get budget
response = test_client.get(f"/budgets/{budget_id}")
assert response.status_code == 200
assert response.json()["name"] == "November 2025"
# List budgets
response = test_client.get("/budgets")
assert response.status_code == 200
assert len(response.json()["items"]) == 1Benefits:
- ✅ Full integration (real PostgreSQL)
- ✅ Tests migrations
- ✅ Catches database-specific issues
Fixture Patterns for Repositories
# tests/conftest.py
import pytest
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from app.models.budgets import Budget, BudgetRepository
@pytest.fixture
async def budget_repo(test_session) -> BudgetRepository:
"""Fixture providing BudgetRepository."""
return BudgetRepository(test_session)
@pytest.fixture
async def sample_budget(budget_repo) -> Budget:
"""Fixture providing a sample budget."""
budget_create = BudgetCreate(
user_id="user123",
name="Test Budget",
period_start=datetime(2025, 11, 1),
period_end=datetime(2025, 11, 30),
categories={"Groceries": 600.00},
)
return await budget_repo.create(budget_create)
# Use in tests
@pytest.mark.asyncio
async def test_budget_update(budget_repo, sample_budget):
updated = await budget_repo.update(
sample_budget.id,
BudgetUpdate(name="Updated Budget")
)
assert updated.name == "Updated Budget"Example Workflows
Personal Finance App (Single-Tenant)
Architecture:
- Single user per database
- PostgreSQL for persistence
- No tenant_id needed
- Soft delete for recoverability
Scaffold:
# Budgets (no tenant, with soft delete)
fin-infra scaffold budgets --dest-dir app/models/budgets --include-soft-delete
# Goals (no tenant, no soft delete)
fin-infra scaffold goals --dest-dir app/models/goals
# Net worth (no tenant, with soft delete)
fin-infra scaffold net_worth --dest-dir app/models/net_worth --include-soft-deleteWire CRUD:
from fastapi import FastAPI
from svc_infra.api.fastapi.db.sql import add_sql_resources, SqlResource
from app.models.budgets import Budget
from app.models.goals import Goal
from app.models.net_worth import NetWorthSnapshot
app = FastAPI()
add_sql_resources(app, [
SqlResource(model=Budget, prefix="/budgets", search_fields=["name"], soft_delete=True),
SqlResource(model=Goal, prefix="/goals", search_fields=["name"]),
SqlResource(model=NetWorthSnapshot, prefix="/net-worth", search_fields=["user_id"]),
])SaaS Budgeting Platform (Multi-Tenant)
Architecture:
- Multiple companies per database
- PostgreSQL with Row-Level Security (RLS)
- tenant_id required on all tables
- Soft delete for compliance
Scaffold:
# Budgets (with tenant and soft delete)
fin-infra scaffold budgets --dest-dir app/models/budgets \
--include-tenant \
--include-soft-delete
# Goals (with tenant)
fin-infra scaffold goals --dest-dir app/models/goals --include-tenant
# Net worth (with tenant and soft delete)
fin-infra scaffold net_worth --dest-dir app/models/net_worth \
--include-tenant \
--include-soft-deleteEnable RLS:
-- migrations/versions/abc123_enable_rls.py
def upgrade():
op.execute("ALTER TABLE budgets ENABLE ROW LEVEL SECURITY")
op.execute("""
CREATE POLICY tenant_isolation ON budgets
USING (tenant_id = current_setting('app.current_tenant')::text)
""")
def downgrade():
op.execute("DROP POLICY IF EXISTS tenant_isolation ON budgets")
op.execute("ALTER TABLE budgets DISABLE ROW LEVEL SECURITY")Wire CRUD with Tenant Middleware:
from fastapi import FastAPI, Request, Header
from svc_infra.api.fastapi.db.sql import add_sql_resources, SqlResource
app = FastAPI()
@app.middleware("http")
async def set_tenant_context(request: Request, call_next):
tenant_id = request.headers.get("X-Tenant-ID")
if tenant_id:
# Set tenant in session for RLS
async with async_session_maker() as session:
await session.execute(text(f"SET app.current_tenant = '{tenant_id}'"))
return await call_next(request)
add_sql_resources(app, [
SqlResource(
model=Budget,
prefix="/budgets",
tenant_field="tenant_id", # Enforce tenant isolation
search_fields=["name"],
soft_delete=True,
),
])Wealth Management App (Multi-Tenant, MySQL)
Architecture:
- Multiple advisors and clients
- MySQL for persistence
- tenant_id for advisor isolation
- Soft delete for audit trail
Scaffold:
# Use same scaffold commands as SaaS example
# MySQL works identically (ModelBase supports all major databases)Database URL:
# .env
SQL_URL=mysql+aiomysql://user:pass@localhost/wealth_db
DISCOVER_PACKAGES=app.modelsMigrations:
# Works identically with MySQL
svc-infra revision -m "add budgets" --autogenerate
svc-infra upgrade headTroubleshooting
Common Scaffold Errors
Error: "Template not found"
Cause: Template package path incorrect in scaffold function.
Solution: Check template package name matches directory structure:
# Correct:
render_template("fin_infra.budgets.scaffold_templates", "models.py.tmpl", subs)
# Incorrect:
render_template("fin_infra.budgets.templates", "models.py.tmpl", subs)Error: "Failed to render template"
Cause: Missing variable in substitutions dict.
Solution: Ensure all template variables are defined:
subs = {
"Entity": "Budget",
"entity": "budget",
"table_name": "budgets",
"tenant_field": "", # Required even if empty
"soft_delete_field": "", # Required even if empty
# ... all other variables
}Error: "File already exists"
Cause: Scaffold won't overwrite by default.
Solution: Use --overwrite flag:
fin-infra scaffold budgets --dest-dir app/models/budgets --overwriteMigration Conflicts
Error: "Table already exists"
Cause: Migration created table, but running again.
Solution: Check migration history:
svc-infra current # Show current version
svc-infra history # Show all migrations
# If needed, mark migration as applied without running:
svc-infra stamp headError: "Multiple heads"
Cause: Conflicting migrations from different branches.
Solution: Merge migrations:
# Create merge migration
svc-infra merge heads -m "merge migrations"
# Apply merge
svc-infra upgrade headType Checking Issues
Error: "Incompatible types in assignment"
Cause: mypy strictness with SQLAlchemy 2.0 Mapped types.
Solution: Use correct type hints:
# Correct:
from sqlalchemy.orm import Mapped, mapped_column
class Budget(ModelBase):
id: Mapped[str] = mapped_column(String(255), primary_key=True)
user_id: Mapped[str] = mapped_column(String(255), nullable=False)
amount: Mapped[float] = mapped_column(Numeric(10, 2), nullable=False)
optional_field: Mapped[str | None] = mapped_column(String(255), nullable=True)
# Incorrect (old SQLAlchemy 1.x style):
id = Column(String(255), primary_key=True)Error: "Need type annotation"
Cause: Empty list without type hint.
Solution: Add type annotation:
# Correct:
transactions: list[Transaction] = []
# Incorrect:
transactions = [] # mypy error: Need type annotationPerformance Optimization
Slow Queries
Problem: List endpoint returns thousands of rows slowly.
Solution: Add pagination (automatic with add_sql_resources):
# Client request:
GET /budgets?page=1&page_size=20
# Or use custom limits in repository:
async def list(self, user_id: str, limit: int = 100, offset: int = 0):
stmt = (
select(Budget)
.where(Budget.user_id == user_id)
.limit(limit)
.offset(offset)
)Missing Indexes
Problem: Slow queries on foreign keys or search fields.
Solution: Add indexes in model:
class Budget(ModelBase):
user_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True) # Index
tenant_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True) # Index
__table_args__ = (
Index("ix_budget_user_tenant", "user_id", "tenant_id"), # Composite index
)N+1 Query Problem
Problem: Loading related data in loop.
Solution: Use eager loading:
from sqlalchemy.orm import selectinload
async def list_with_details(self, user_id: str):
stmt = (
select(Budget)
.where(Budget.user_id == user_id)
.options(selectinload(Budget.transactions)) # Eager load
)Summary
fin-infra's persistence philosophy:
- Stateless library - Your app owns the database
- Scaffold CLI - Generate production-ready models in seconds
- svc-infra integration - Wire CRUD with ONE function call
- Flexible patterns - Multi-tenancy, soft delete, custom logic
- Testing friendly - In-memory, SQLite, PostgreSQL test strategies
Getting started:
# 1. Scaffold models
fin-infra scaffold budgets --dest-dir app/models/budgets --include-tenant
# 2. Run migrations
svc-infra revision -m "add budgets" --autogenerate
svc-infra upgrade head
# 3. Wire CRUD (one function call)
add_sql_resources(app, [SqlResource(model=Budget, prefix="/budgets")])
# Done! Full CRUD API ready 🚀For more details, see: