CRUD Template
Summary
Every resource follows the same four-file slice. Repetition is intentional: it makes the codebase predictable and generatable.
The slice
The id rule (model vs. document)
Two different objects, two different jobs:
| Object | Lives in | Has id? |
Purpose |
|---|---|---|---|
| Domain model | domain/<resource>/models.py |
No | business shape, framework-free |
| Document | domain/<resource>/ports/db.py |
Yes (from BaseDocument) |
Mongo persistence shape |
BaseDocument (mongo-ops) supplies id: Optional[PyObjectId] (aliased to
_id) plus created_at / updated_at. The domain model must not declare
id; Mongo owns it.
Trap — generated wire models.
openapi-first modelsproduces the HTTP contract (see the Build Recipe, step 3). Response schemas frequently declare a requiredid: str, so a generated class is not a domain model. If you writeclass ItemDocument(Item, BaseDocument)andItemre-exports a requiredid: str, that field shadowsBaseDocument.idand no Mongo_idcan ever be set. Fix it one of three ways:
- Preferred: keep the domain model id-free (hand-author it from the CRUD template), and keep the required
idonly on the wire model.- Order the bases so the document wins:
class ItemDocument(BaseDocument, Item).- Keep a separate document type and translate with
ports/converters.py.
models.py
A framework-free Pydantic model describing the business shape (no id).
ports/db.py
The persistence model extends the business model and adds database concerns. The repository factory selects the right repository flavour.
Nested references
The wire contract often declares a reference as an expanded object
(expense.entity: Entity), while the domain model stays id-free. Pick one
convention and apply it consistently:
- Embed (default for small, stable refs) — store the object as a
dict/list[dict]on the document; the API returns it verbatim. No populate wiring. - Populate — store an id and declare a
PopulateRule; mongo-ops expands it on read. The referenced repo must be registered with thePopulationEngine. - Id only — store the id and let the client resolve it.
For populate-backed repositories change a reference with update, not patch:
patch rejects changes to populated FK fields by design.
ports/service.py
The service is built through the DI library, injecting the repository. The
generic CRUD base provides create/read/update/patch/delete/search/....
Naming conventions
| Concept | Convention |
|---|---|
| collection | plural snake case (items) |
| document | <Resource>Document |
| repository factory | create_<resource>_repo |
| service | <Resource>Service |
| service factory | get_<resource>_service |
| resource key | <resource>_svc |
| operationId | list_items, create_item, ... |
Optional files
converters.py— pure functions mapping between domain types.ports/enrichment.py— adapts a pipeline into an async callable the service can invoke (used when a resource is populated by a pipeline).