Imported from santialemarino/renly (
.agents/skills/api-layering/SKILL.md). Install upstream withnpx skills add santialemarino/renly --skill api-layering. Copyright stays with the author.
API layering (Renly backend)
Flow and layers
Request flow: router → service → repository → DB.
- routers/ — HTTP only: validate body, call service, return response or raise HTTPException. Use schemas for request/response.
- services/ — Business logic: orchestrate use cases, call repositories. Use domain types and models; no HTTP or raw SQL. Owns the transaction boundary (
session.commit()once per use case). - repositories/ — Data access: run queries,
session.add()/session.flush(). Use models and session only. Never callsession.commit()— the service commits. - schemas/ — Pydantic request/response DTOs (HTTP contract). Request bodies inherit from
RequestBase(schemas/base.py) which auto-strips strings and converts empty optionals toNone. Response schemas inherit fromBaseModel. - domain/ — Types used by services only (value objects, enums, errors). Not HTTP, not DB.
- models/ — SQLModel tables (DB entities).
- deps/ — FastAPI dependencies (e.g. SessionDep, CurrentUser). Injected by the framework.
Do not put business logic in routers or SQL in services. Do not put HTTP types (request/response bodies) in domain or models.
Transaction rules
Repositories do NOT commit
Repositories call session.add() and optionally session.flush() (to get generated IDs), but never session.commit(). The commit happens at the service level — one commit per use case. This makes multi-step operations atomic by default.
Multi-step operations must be atomic
If a service function does multiple writes (e.g., create collection + set members), they must all succeed or all fail. With repository-level commits removed, a single session.commit() at the end of the service function achieves this. If an error occurs, the session rolls back on exit.
Rollback is handled by the session teardown
Services do NOT need try/except + session.rollback() around writes. The session dependency
(app/db.py get_session) yields from async with AsyncSessionLocal() as session: — when the
request scope exits (normally or via an exception), the async with closes the session, which
rolls back any transaction that was never committed. So an error raised before the service's
session.commit() persists nothing, and the connection returns to the pool clean. An explicit
session.rollback() is only warranted when a service wants to recover mid-request and continue
issuing queries on the same session after a failed write.
Error responses (codes, not localized prose)
The backend stays locale-agnostic: it returns a stable machine code per error and an English
detail (dev/fallback); the frontend maps code → a localized message. (Transactional emails are
the one backend-localized exception — no frontend renderer.)
- Domain errors (
app/domain/errors.py) subclassDomainErrorand set a class-levelcode(stable, unique kebab/snake string) andstatus_code; the constructor assignsself.message. Errors that carry structured data override theextraproperty (e.g.{"fields": [...]}). A single handler inapp/main.pyturns anyDomainErrorinto{"detail", "code", **extra}— do NOT add a per-error handler. Raise domain errors from services; the router just lets them bubble. - Ad-hoc HTTP errors raised in routers / deps / middleware (login, admin gate, request-too-large,
a param-validation 400) use
CodedHTTPException(status_code, detail, code)(app/http_errors.py) so they join the same{detail, code}contract; a plainHTTPExceptionstays{detail}(the frontend falls back to it). Prefer a domain error when the condition is a domain rule. - Reuse an existing
codewhen the condition is the same across sites (e.g. login and re-auth both useinvalid_credentials), so the frontend maps it once. - Success responses don't carry localized prose either — the frontend owns success copy per action. Give a success ack a machine field only when a caller must branch on it (e.g. a token type).
One refusal rule when a picker and a write must agree
Where a surface OFFERS a set of things and a write then refuses some of them, the offer and the refusal are the same rule and must be one function — otherwise every condition is a chance for the two to disagree, and each disagreement is a row the user picks and is then refused for.
Write it as a function that RETURNS the domain error (or None) rather than raising:
# Why this row cannot be used, or None when it can. The write RAISES it, the list FILTERS on it.
def contribution_refusal(row, base_currency, price) -> Exception | None:
if row.value is None:
return PotHoldingUnvaluedError()
...
return None
The write does refusal = rule(...), if refusal is not None: raise refusal; the list read does
[row for row in rows if rule(row, ...) is None]. Two consequences to keep:
- Any guard the list cannot express as a filter has to be excluded some other way, and the list's own comment should say which group each condition falls into and count them — a "seven conditions, all seven filtered" claim is checkable, "we filter the important ones" is not.
- A guard that raises about a whole batch cannot be reused by a picker. An existence check
(
exists_for_accounts -> bool) answers about the set, so a picker built on it drops every candidate the moment one qualifies. Return the offending ids instead and put the raising wrapper on top; the enumeration then lives in exactly one place.
Currency conversion (services own it)
Display-currency conversion is a service-layer concern. Routers never read the dollar
preference, never build rate lookups, and never convert values — they pass the currency
query param through and return the schema the service built.
exchange_rate_service.get_user_rate_lookup(session, user_id)is the single entry point: it reads the user's dollar-rate preference and returns aRateLookuppre-loaded with every stored rate. Build one per request and pass it down to composed service calls via theirlookup=parameter — never build a second lookup for the same request.app/utils/metrics.pystays pure (no DB):RateLookup(data structure),convert_value,convert_optional,can_convert.- Per-row converted response fields use
convert_optional(value, from_currency, target_currency, lookup, as_of_date)— historical rows (expenses, income, calendar items) convert at their own date; current-state rows (plans, card balances) at today's.
Performance rules
Never query inside a loop (N+1)
If you need data for N items, fetch it in one batch query before the loop, then look up from a dict/set. Never call a repository method inside a for loop.
# BAD — N+1: one query per collection
for c in collections:
ids = await collection_repository.get_investment_ids_by_collection(session, c.id)
# GOOD — batch load, then loop in memory
ids_by_collection = await collection_repository.get_investment_ids_by_collections(session, [c.id for c in collections])
for c in collections:
ids = ids_by_collection.get(c.id, [])
When adding a new repository method that will be called in a loop, always add a batch variant (accepts a list of IDs, returns a dict keyed by ID).
Parallelize independent external API calls
When fetching from multiple independent external APIs (e.g., prices for 20 tickers), use asyncio.gather() — not a sequential loop. SQLAlchemy async sessions are not thread-safe, so: parallelize the HTTP fetches, then store results sequentially.
# BAD — sequential, 20 tickers × 5s = 100s
for inv in investments:
await fetch_and_store_prices(session, inv.ticker, inv.category)
# GOOD — parallel fetch, sequential store
fetch_results = await asyncio.gather(*[provider.fetch(inv.ticker) for inv in investments])
for inv, results in zip(investments, fetch_results):
for price_date, price, currency in results:
await repository.upsert(session, ...)
Use bulk upserts for batch writes
When storing multiple rows (prices, exchange rates, ratios), prefer PostgreSQL INSERT ... ON CONFLICT DO UPDATE over individual SELECT + INSERT/UPDATE loops. Use sqlalchemy.dialects.postgresql.insert for this.
Prefer higher-order operations over manual loops
Use sum(), max(), min(), list comprehensions, and generators instead of manual accumulator loops when the intent maps naturally. Use a for loop only when you need early break, async iteration, side effects per iteration, or it's genuinely clearer.
See docs/backend-performance-audit.md for the full diagnostic and fix plans.
Where to create files
- New feature (e.g. investments): add one file per layer:
routers/investments.py,services/investment_service.py,repositories/investment_repository.py,schemas/investment.py, and optionallydomain/investment.py. - New endpoint in existing feature: add the route in the existing router; add service/repository functions in the existing service/repository files. Split into a new file only when a file gets too large or the domain is clearly separate.
External data providers
External data fetching (prices, exchange rates) uses a standardized provider pattern. See docs/technical/external-providers.md for the full spec. Key points:
- Provider files (
services/price_providers.py,services/exchange_rate_providers.py) own all external logic: URLs, HTTP calls, response parsing. - Services map categories to providers and handle storage — zero provider-specific code.
- All providers of the same type share a uniform function signature and return type.
- To swap or add a provider, change the mapping. No service or router changes needed.
Utils vs helpers
- utils — General-purpose, not tied to one entity or service. Used across multiple services or the app.
- Where:
app/utils/(e.g.app/utils/datetime.py,app/utils/validation.py). If only used by one layer,app/<layer>/utils.pyorapp/<layer>/utils/<name>.py(e.g.app/services/utils.pyfor service-wide utils).
- Where:
- helpers — Tied to a specific service or entity (e.g. auth, user, investment).
- Where: next to the code that uses it:
app/services/auth_helpers.py(used only by auth_service), orapp/repositories/user_repository_helpers.pyif needed. Prefer a single file per feature (e.g.auth_helpers.py) rather than a generic "helpers" folder.
- Where: next to the code that uses it:
Rule: if it's used by one service/entity only → helper file next to that feature. If it's generic and reusable across features → utils (under app/utils/ or under the layer if layer-scoped).
Order in init and multi-export files
In every __init__.py or file that imports/exports multiple symbols (e.g. singletons like user_repository, investment_repository), list them alphabetically (imports and __all__). Exception: when a specific order is explicitly defined for a specific file (that order will be defined in this skill), follow that order instead.
Directory layout (apps/api/app/)
app/
├── config.py
├── db.py
├── main.py
├── deps/
├── domain/ # domain/<feature>.py
├── models/
├── repositories/ # <entity>_repository.py
├── routers/ # <feature>.py
├── schemas/ # <feature>.py
├── services/ # <feature>_service.py
└── utils/ # optional; general-purpose