Install
$ agentstack add skill-ocbunknown-fastapi-claude-template-usecase ✓ scanned · ✓ verified, works with Claude Code, Cursor, and more.
Security review
✓ PassedNo issues found. Passed automated security review. · v0.1.0 How review works →
- ✓ Prompt-injection patterns
- ✓ Secret / credential exfiltration
- ✓ Dangerous shell & filesystem operations
- ✓ Untrusted network calls
- ✓ Known-malicious package signatures
What it can access
- ✓ Network access No
- ✓ Filesystem access No
- ✓ Shell / process execution No
- ✓ Environment & secrets No
- ✓ Dynamic code execution No
From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.
Verified badge
Passed review? Show it. Paste this badge into your README, it links to the public security report.
Reliability & compatibility
Declared compatibility
Compatibility is declared by the source manifest. End-to-end runtime verification is coming, see below.
We're building live execution health for every listing: tool-call success rate, median latency, uptime, and last-checked timestamps, measured, not self-reported. It isn't live yet, so we don't show numbers we can't stand behind.
How agent discovery & health will work →About
Writing use cases (src/application/v1/usecases//)
A use case is the transport-agnostic unit of business logic. Endpoints, consumers, and scheduled tasks all call request_bus.send(Request(...)) and get back a Result. Use cases never know who called them.
One file, two classes
Every use case file defines exactly one Request class and exactly one UseCase class. Keep them in the same file — they are a pair.
# src/application/v1/usecases/widget/create.py
from dataclasses import dataclass
from typing import Annotated
import uuid_utils.compat as uuid
from pydantic import Field
from src.application.common.interfaces.usecase import UseCase
from src.application.common.request import Request
from src.application.v1.results import WidgetResult
from src.database.psql import DBGateway
class CreateWidgetRequest(Request):
name: Annotated[str, Field(max_length=64)]
owner_uuid: uuid.UUID
@dataclass(slots=True)
class CreateWidgetUseCase(UseCase[CreateWidgetRequest, WidgetResult]):
database: DBGateway
async def __call__(self, request: CreateWidgetRequest) -> WidgetResult:
async with self.database:
widget = (
await self.database.widget.create(
name=request.name,
owner_uuid=request.owner_uuid,
)
).result()
return WidgetResult(**widget.as_dict())
Invariants
Requestinheritssrc.application.common.request.Request(Pydantic,frozen=True). Validation viaAnnotated[..., Field(...)]. Do not reuse HTTPContractclasses here — the Request describes the use-case input, not the HTTP input.UseCaseinheritsUseCase[RequestClass, ReturnType]. The base class auto-applies@dataclass(slots=True)— add your own@dataclass(slots=True)on the subclass too for mypy/readability.- Dependencies are dataclass fields:
database: DBGateway,hasher: Hasher,cache: StrCache,services: ServiceGateway, etc. Injected by Dishka via the request bus — do not instantiate manually. - Returns a
Resultsubclass fromsrc/application/v1/results/— never aContractfrompresentation/, never a bare ORM model, never a dict. - Register the use case in
src/application/v1/usecases/__init__.py::setup_use_cases():
``python request_bus.register(widget.CreateWidgetRequest, widget.CreateWidgetUseCase) ``
- Re-export from
src/application/v1/usecases/widget/__init__.pyso the presentation/consumers layer can importfrom src.application.v1.usecases.widget import CreateWidgetRequest.
DBGateway — the transaction rule
This is the single most common place people break production. Read carefully.
DBGateway owns one session per request. It exposes two context managers. You pick exactly one per use case — never both, never twice.
Pattern 1 — read-only
async def __call__(self, request: SelectWidgetRequest) -> WidgetResult:
async with self.database.manager.session:
widget = (
await self.database.widget.select(*request.loads, widget_uuid=request.widget_uuid)
).result()
return WidgetResult(**widget.as_dict())
- Use for pure reads: one or many
select/exists/countcalls, no mutations. - No transaction is opened —
session.begin()is never called. - Build the Result via
WidgetResult(**widget.as_dict())— see "Mapping ORM → Result" below for why.model_validate(widget)is forbidden. - Keep construction inside the
async withblock by convention — it localizes session-touching code and keeps the pattern consistent even when the use case later grows logic that genuinely needs the open session.
Pattern 2 — write
async def __call__(self, request: CreateWidgetRequest) -> WidgetResult:
async with self.database:
widget = (
await self.database.widget.create(
name=request.name,
owner_uuid=request.owner_uuid,
)
).result()
return WidgetResult(**widget.as_dict())
- Use for any mutation (
create/update/delete/upsert). async with self.database:callssession.begin()→ autocommit on success, autorollback on exception.- Every
self.database.Xcall and everyResultconstruction belongs inside this block.
Pattern 3 — read then write (single transaction)
When a write needs data that was just read, put both in one async with self.database: block:
async def __call__(self, request: ConfirmRegisterRequest) -> TokensExpire:
cached_data = await self.cache.get(key)
if not cached_data:
raise NotFoundError("Code has expired or invalid")
async with self.database:
role = (await self.database.role.select(name="User")).result() # read
user = (
await self.database.user.create( # write
login=cached_user.login,
password=self.hasher.hash_password(cached_user.password),
role_uuid=role.uuid,
)
).result()
return await self.services.auth.login(cached_user.fingerprint, user.uuid)
One session. One transaction. Both operations atomic.
Anti-patterns (hook will not catch these — you must self-check)
| ❌ Anti-pattern | Why it breaks | ✅ Fix | |---|---|---| | Opening the session twice: one async with self.database.manager.session: for the read, then a second async with self.database: for the write | The session is single-use — second async with fails on a closed session | Combine both into a single async with self.database: block | | Nesting async with self.database: and async with self.database.manager.session: | Double-entering the same session | Pick one — for writes, async with self.database: only | | WidgetResult.model_validate(widget) where widget is an ORM row | Pydantic's from_attributes=True does getattr(widget, "owner"), which triggers SQLAlchemy lazy loading → MissingGreenlet on an async session | Use WidgetResult(**widget.as_dict()) — Base.as_dict() iterates __dict__, which contains only already-loaded attributes; missing relationships fall back to the Pydantic field default (None) | | Mutating inside async with self.database.manager.session: | No session.begin() → no commit → silent data loss | Switch to async with self.database: | | Calling self.database.widget.create(...) outside any context | Session state undefined | Wrap in the appropriate context | | Expecting a relationship to appear in the Result when the caller did not pass it in loads | Base.as_dict() skips unloaded relations, so the field ends up None — if your use case depends on the relation, the response is silently wrong | Either forward *request.loads to the repo call and document that the relation is optional in the Result, or add the relation to loads unconditionally inside the use case (rare — usually it's the caller's choice) |
Rule of thumb: when in doubt, use async with self.database:. It works for both reads and writes — worst case you pay for a transaction you didn't need. The other direction (using manager.session for a write) silently loses data.
Mapping ORM → Result — use Base.as_dict()
Every ORM model inherits Base.as_dict() (defined in src/database/psql/models/base/core.py). This is the only way you should build a Result from an ORM row.
# Single
return WidgetResult(**widget.as_dict())
# List (inside select_many use cases)
data=[WidgetResult(**w.as_dict()) for w in widgets]
Why this works and model_validate doesn't
Base.as_dict() iterates self.__dict__ directly. SQLAlchemy puts only loaded attributes in __dict__:
- Columns (
uuid,name,owner_uuid,created_at, …) are populated atSELECTtime → always present. - Relationships (
owner,tags, …) are populated only if the caller eager-loaded them vialoads=.... Otherwise they're not in__dict__at all — they live on the class descriptor and would be fetched via lazy-load ongetattr.
So widget.as_dict() never triggers a lazy load — it doesn't do getattr, it reads __dict__ directly. Missing relationship → missing dict key → Pydantic falls back to the field default (owner: OwnerResult | None = None).
By contrast, WidgetResult.model_validate(widget) uses Pydantic's from_attributes=True mode, which walks the declared fields and calls getattr(widget, "owner") — that triggers SQLAlchemy's lazy load, which inside an async session crashes with MissingGreenlet. Never do this for ORM rows. (It's still fine for dicts, Results, and other non-ORM objects.)
The load contract
The caller decides which relations appear in the response via loads=...:
loads=()(default) → only columns; relationships come back asNoneand the HTTP serializer (exclude_none=Trueinpresentation/http/common/serializers/default.py) drops them from the JSON entirely.loads=("owner",)→as_dict()findsownerin__dict__as a nestedBase, recursively converts it to a dict, and Pydantic coerces the dict intoOwnerResult.
Don't defensively check inspect(orm).unloaded in the use case. The load contract is the opt-in — honour it and let as_dict() do its thing.
Extra ORM columns vs declared Result fields
Base.as_dict() returns every column in __dict__, including ones the Result doesn't declare (password, created_at, updated_at, role_uuid, …). Pydantic v2 defaults to extra="ignore", so these silently drop during WidgetResult(**widget.as_dict()). No config needed.
List (select_many) use cases — special shape
from src.application.common.pagination import OffsetPagination
from src.application.v1.results import OffsetResult, WidgetResult
class SelectManyWidgetRequest(Request):
loads: tuple[WidgetLoads, ...] = ()
name: str | None = None
pagination: OffsetPagination = OffsetPagination()
@dataclass(slots=True)
class SelectManyWidgetUseCase(
UseCase[SelectManyWidgetRequest, OffsetResult[WidgetResult]]
):
database: DBGateway
async def __call__(
self, request: SelectManyWidgetRequest
) -> OffsetResult[WidgetResult]:
async with self.database.manager.session:
total, widgets = (
await self.database.widget.select_many(
*request.loads,
name=request.name,
**request.pagination.model_dump(),
)
).result()
return OffsetResult[WidgetResult](
data=[WidgetResult(**w.as_dict()) for w in widgets],
offset=request.pagination.offset,
limit=request.pagination.limit,
total=total,
)
pagination: OffsetPagination = OffsetPagination()— the application version (src/application/common/pagination.py), which allowslimit: int | None = None(unbounded for internal callers).**request.pagination.model_dump()spreadsorder_by,offset,limitinto the repo call.WidgetResult(**w.as_dict())— see "Mapping ORM → Result" above.- Returns
OffsetResult[WidgetResult]— the generic envelope with.map()so the endpoint can retarget item types.
Anti-patterns (hard no)
- ❌ Importing anything from
src.infrastructure.*,src.presentation.*,src.consumers.*,src.tasks.*. The layer hook will block this, but be aware anyway. - ❌ Importing a
Contractclass frompresentation/and returning it. ReturnResulttypes only. - ❌ Calling
session.executedirectly — go through the repository. - ❌ Writing two separate
async withblocks onself.databasein one use case. - ❌ Adding business logic to a
Requestclass.Requestis a frozen Pydantic model — validation only. - ❌ Registering a use case in
setup_use_cases()but forgetting to re-export it fromusecases//__init__.py. - ❌ Using
@dataclasswithoutslots=True. The base class auto-applies it, but being explicit keeps mypy happy.
Source & license
This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.
- Author: ocbunknown
- Source: ocbunknown/fastapi-claude-template
- License: MIT
Install and usage instructions live in the source repository linked above.
Reviews
No reviews yet, be the first.
Write a review
Versions
- v0.1.0 Imported from the upstream source.