Skip to content

CRUD Template

Summary

Every resource follows the same four-file slice. Repetition is intentional: it makes the codebase predictable and generatable.


The slice

1
2
3
4
5
6
domain/<resource>/
    models.py               # pure Pydantic model (no DB fields)
    ports/
        db.py               # document model + repository factory
        service.py          # service class + factory
        converters.py       # (optional) transforms

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 models produces the HTTP contract (see the Build Recipe, step 3). Response schemas frequently declare a required id: str, so a generated class is not a domain model. If you write class ItemDocument(Item, BaseDocument) and Item re-exports a required id: str, that field shadows BaseDocument.id and no Mongo _id can ever be set. Fix it one of three ways:

  1. Preferred: keep the domain model id-free (hand-author it from the CRUD template), and keep the required id only on the wire model.
  2. Order the bases so the document wins: class ItemDocument(BaseDocument, Item).
  3. Keep a separate document type and translate with ports/converters.py.

models.py

A framework-free Pydantic model describing the business shape (no id).

1
2
3
class Item(BaseModel):
    name: str
    quantity: int = 0

ports/db.py

The persistence model extends the business model and adds database concerns. The repository factory selects the right repository flavour.

# Item is id-free; BaseDocument adds id/created_at/updated_at.
class ItemDocument(Item, BaseDocument):
    pass

def create_item_repo(*, owner_repo=None) -> ItemRepository:
    return repo_factory(
        collection="items",
        document=ItemDocument,
        populate_rules=[PopulateRule(field_name="owner", collection_name="users")],
        cache=None,               # or a CachedBaseRepository config
    )

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 the PopulationEngine.
  • 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/....

1
2
3
4
5
6
class ItemService(CrudsServiceAdaptor[Item, ItemDocument]):
    model = Item
    schema = ItemDocument

def get_item_service(repo) -> ItemService:
    return build(ItemService, instances={"repo": repo})

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).