Build a FastAPI permit service for reviewer 47 and reviewer 81. Only reviewer 47 may read or approve case 62. The detail and export metadata routes resolve the case through a permission-scoped dependency. Approval accepts an operation ID, reason, and expected revision under a strict input model, while its public response returns only case ID, status, and revision. The command service uses a request-scoped async database session but owns its transaction explicitly. It records the case change, unique operation result, and notification outbox row together. A response lost after commit can be replayed under the same operation ID. The notification dispatcher has its own session and keeps working after an API process restart. A lifespan context owns one outbound client per process and closes it during shutdown.
Project: FastAPI permit service boundaries
Build contract
- Resolve an authenticated reviewer, then query the requested case under that reviewer's current assignment.
- Reject unknown or out-of-range command fields and build a deliberate public response projection.
- Give each request a session; commit one approval transaction before returning success.
- Use a unique operation key and outbox event so a lost response or process restart is recoverable.
- Close shared outbound resources during application shutdown and keep worker sessions separate from request sessions.
Implementation checkpoint
def may_claim_notification(event_id: str, delivered: set[str], leased: set[str]) -> bool:
return event_id not in delivered and event_id not in leased
delivered_events = {"permit-62-approved-47"}
leased_events = {"permit-81-reviewed-29"}
print(may_claim_notification("permit-62-approved-47", delivered_events, leased_events))
# Output: FalseCost and boundaries
The scoped object lookup adds database work to every private route, which must happen before protected data leaves the service. Input and output model validation cost CPU proportional to the accepted payload and response; bound the queue to 47 rows before serialization. A request session may borrow a pooled connection while SQL runs, so measure checkout waits and open transaction age. Case row locks can queue concurrent approvals. The operation table and outbox grow with retained commands and events, and need a policy for retry, inspection, and expiry. One outbound client per worker saves connection setup but multiplies total remote connections by worker count. A response background task is only a wake-up; the durable row is what survives a restart.
Failure drill
Guess case 62 as reviewer 81 through both detail and export routes. Send an unknown approved_by field, an overlong reason, and a stale revision; none may become an authorized approval. Race two requests with one operation ID and inspect the case, operation, and outbox tables. Commit once, discard the HTTP response, and replay the same command to recover the prior result. Force a transaction failure after updating the case and confirm the outbox row also disappears. Kill the API process after a successful response but before the optional wake-up; restart the dispatcher and deliver the pending event. Verify its lease expiry and provider retry behavior under an ambiguous timeout.
Acceptance checks
- No read or write crosses a reviewer's current case boundary.
- Unknown input is rejected and no internal audit field appears in the response.
- Concurrent replay commits one logical approval and one outbox event.
- A worker can recover pending delivery after the API process stops.
Common Mistakes
- Confusing a valid token with case permission.
- Returning database objects without a public projection.
- Committing after a success response has already left.
- Passing a request session to a response background task.
Related lessons
FastAPI Contract and Resource Boundaries; FastAPI Dependencies and Object Authorization; FastAPI Input, Output, and Error Contracts; FastAPI Async Sessions and Transaction Ownership; FastAPI Lifespan, Background Work, and Delivery State.
