The API Creation Contract¶
This is the contract every new endpoint in NimbusVault Backend must follow. It is non-negotiable: a PR that breaks the contract is not mergeable.
The flow, in one diagram¶
1. VaultManagement/urls/<Domain>.py (register the route)
│
│ ── 1a. NimbusVault/urls.py (only if new urls module)
▼
2. VaultManagement/views/<Domain>.py (class-based view; one method per verb)
│
▼
3. VaultManagement/serializers/<Domain>.py (validate the request)
│
▼
4. Orchestrators/<Domain>Orchestrators/ (business workflow + rollback)
│
│ ── may invoke other domain orchestrators (cross-domain composition)
▼
5. bll/<Domain>/HelperFunctions.py (pure helpers — no ORM)
│
▼
6. bll/<Domain>/GetApisbll.py OR (read-path business logic)
bll/<Domain>/PostApisbll.py (write-path business logic)
│
▼
7. bll/<Domain>/service.py ← ONLY place that touches the ORM
│ Exposes ONLY:
│ create, get, filter, update,
│ delete, bulk_create,
│ bulk_update, bulk_delete
▼
VaultModels/models/<Domain>.py (Django models)
The seven steps¶
To add a new endpoint you must touch — in order — the following files and only the following files. (Files marked new module are only created the first time a domain is introduced.)
1. URL — VaultManagement/urls/<Domain>.py¶
Add one path() entry pointing at a class-based view from VaultManagement/views/<Domain>.py.
# VaultManagement/urls/MyDomain.py
from django.urls import path
from VaultManagement.views.MyDomain import (
CreateMyThing,
GetMyThings,
)
urlpatterns = [
path("my-domain/create", CreateMyThing.as_view()),
path("my-domain/getAll", GetMyThings.as_view()),
]
If this is a brand-new domain (a new urls/<Domain>.py file), register it in NimbusVault/urls.py:
# NimbusVault/urls.py
urlpatterns += [
# ... existing entries ...
path(ModelName.url_vault, include("VaultManagement.urls.MyDomain")),
]
URL conventions:
- All paths are mounted under
/vault/(theModelName.url_vaultprefix is applied automatically by theinclude). - Path segments use lowerCamelCase (e.g.,
getAll,markAsRead) or kebab-case (e.g.,my-domain) — match the convention already used by the neighbouring URLs in the file. - One
path()per HTTP route, even if multiple verbs share a view (the view's class methods handle the verbs).
2. View — VaultManagement/views/<Domain>.py¶
Class-based, one method per HTTP verb. Each method:
- Constructs the serializer with
data=request.data(ordata=request.query_paramsfor GET filters). - Calls
serializer.is_valid(); on failure returns a 400 withflatten_serializer_errors(serializer.errors). - Resolves the orchestrator from
OrchestratorRegistry. - Calls the orchestrator's domain method with
serializer.validated_data(andrequest.userwhen needed). The method only stages steps — it does not return business values. - Calls
orch.mainorchestrator.execute(). - Reads
result/result_statusfrom the orchestrator context (or uses a hard-coded status if the workflow is simple) and returns a DRFResponse(...).
# VaultManagement/views/MyDomain.py
from NimbusVaultConstants.CommonConstants.CommonConstants import (
ModelAPIView,
flatten_serializer_errors,
)
from rest_framework.response import Response
from VaultManagement.serializers.MyDomain import (
CreateMyThingSerializer,
GetMyThingsQuerySerializer,
)
from Orchestrators.OrchestratorRegistry import OrchestratorRegistry
from Orchestrators.MyDomainOrchestrators.MyDomainOrchestrator import MyDomainOrchestrator
class CreateMyThing(ModelAPIView):
"""Create a new MyThing."""
def post(self, request):
serializer = CreateMyThingSerializer(data=request.data)
if not serializer.is_valid():
return Response(
status=400,
data={"msg": flatten_serializer_errors(serializer.errors)},
)
orch: MyDomainOrchestrator = OrchestratorRegistry.get("my_domain")
orch.create_thing(user=request.user, **serializer.validated_data) # stages steps
orch.mainorchestrator.execute() # runs them
# The step published its outcome into the context; the view reads it back.
result = orch.mainorchestrator.get_context_value("result", check_validation=False)
status = orch.mainorchestrator.get_context_value("result_status", check_validation=False) or 201
return Response(data=result or {"msg": "Created"}, status=status)
class GetMyThings(ModelAPIView):
"""List MyThings for the current user."""
def get(self, request):
serializer = GetMyThingsQuerySerializer(data=request.query_params)
if not serializer.is_valid():
return Response(
status=400,
data={"msg": flatten_serializer_errors(serializer.errors)},
)
# READ paths may skip the orchestrator and call BLL directly.
from bll.MyDomain.GetApisbll import list_things
results = list_things(user=request.user, **serializer.validated_data)
return Response(data=results, status=200)
View conventions:
- Inherit from
ModelAPIView(gets auth + perms wired automatically). - Use type hints on the orchestrator variable so IDEs can navigate.
- Never call
Model.objects.…from a view. Never callservice.pyfrom a view directly — go through the orchestrator (writes) orGetApisbll.py(reads). - Use
@SchemaMapping.<method_name>decorators where Swagger metadata is configured (seeSwagger/<Domain>/for the constants module).
3. Serializer — VaultManagement/serializers/<Domain>.py¶
One serializer per request shape. Validates required fields, types, ranges, enum values. No business logic — no DB lookups, no permission checks. If it can't be expressed declaratively in the serializer, it belongs in the orchestrator or bll/.
# VaultManagement/serializers/MyDomain.py
from rest_framework import serializers
class CreateMyThingSerializer(serializers.Serializer):
name = serializers.CharField(max_length=255)
description = serializers.CharField(required=False, allow_blank=True)
category = serializers.ChoiceField(choices=["A", "B", "C"])
class GetMyThingsQuerySerializer(serializers.Serializer):
category = serializers.ChoiceField(choices=["A", "B", "C"], required=False)
limit = serializers.IntegerField(min_value=1, max_value=200, default=50)
offset = serializers.IntegerField(min_value=0, default=0)
4. Orchestrator — Orchestrators/<Domain>Orchestrators/<Domain>Orchestrator.py¶
The orchestrator owns the workflow: which steps run, in what order, with what rollback. It composes calls into bll/<Domain>/ and (when needed) other domain orchestrators.
Three rules to internalise before writing one:
- Methods stage steps. They do not return business values. The view does not get a return back from the staging call.
- Forward and rollback are module-level functions in
bll/<Domain>/PostApisbll.py. Not lambdas, not closures, not methods on the orchestrator class. The orchestrator passes them by reference toadd_step(forward_fn, rollback_fn, *args, **kwargs). - Steps signal "exit the workflow" by writing
result_statusto the orchestrator context — not by returning a value. Settingresult_statusraisesStepExitExceptionand bails the rest of the steps (with or without rollback, depending on the value).
# Orchestrators/MyDomainOrchestrators/MyDomainOrchestrator.py
from Orchestrators.OrchestratorRegistry import OrchestratorRegistry
from Orchestrators.AbstractOrchestrators.BaseOrchestrator import BaseOrchestrator
from Orchestrators.ContextManager import context_manager
from VaultModels.ModelRegistry import MODEL_REGISTRY
from bll.MyDomain.PostApisbll import create_thing_bll, rollback_create_thing_bll
class MyDomainOrchestrator(metaclass=BaseOrchestrator):
"""
Description:
Orchestrates MyDomain write workflows (create / update / delete).
"""
def __init__(self, orchestrator_obj):
"""
Description: Bind to the shared MainOrchestrator and resolve siblings.
Input:
- orchestrator_obj: The MainOrchestrator instance bound to this run.
Output:
- None.
"""
self.mainorchestrator = orchestrator_obj or OrchestratorRegistry.get("main")
@context_manager(
get_contexts={"common": []},
set_contexts={"common": ["result", "result_status", "thing_obj"]},
)
def create_thing(self, user, name, description="", category="A"):
"""
Description: Stage the create-MyThing workflow.
Input:
- user: Acting user.
- name, description, category: Field values for the new MyThing.
Output:
- None. Result lands in the orchestrator context.
"""
self.mainorchestrator.set_context_schema({
"thing_obj": MODEL_REGISTRY["MyThing"],
})
self.mainorchestrator.add_step(
create_thing_bll,
rollback_create_thing_bll,
user=user,
name=name,
description=description,
category=category,
)
And the step functions live in bll/<Domain>/PostApisbll.py:
# bll/MyDomain/PostApisbll.py
from Orchestrators.ContextManager import context_manager
from VaultErrors.BackendErrors import errors
from bll.MyDomain.HelperFunctions import validate_thing_name, format_thing
from bll.MyDomain.service import my_thing_service
@context_manager(get_contexts={"common": []},
set_contexts={"common": ["result", "result_status", "thing_obj"]})
def create_thing_bll(orchestrator, user, name, description, category):
"""Forward step. Receives the MainOrchestrator as the first arg."""
validate_thing_name(name)
if my_thing_service.exists(name=name, owner=user):
orchestrator.set_context_value("result", {"msg": f"'{name}' already exists"})
orchestrator.set_context_value("result_status", 400) # raises StepExitException → rollback
return
thing = my_thing_service.create(name=name, description=description, category=category, owner=user)
orchestrator.set_context_value("thing_obj", thing)
orchestrator.set_context_value("result", format_thing(thing))
orchestrator.set_context_value("result_status", 201) # raises StepExitException → exit, no rollback
@context_manager(get_contexts={"common": ["thing_obj"]}, set_contexts={})
def rollback_create_thing_bll(orchestrator):
"""Rollback. Runs only if a later step in the workflow fails."""
thing = orchestrator.get_context_value("thing_obj")
my_thing_service.delete(thing)
Then register the orchestrator by adding one line to Orchestrators/RegisterOrchestrator.py:
# Orchestrators/RegisterOrchestrator.py
from Orchestrators.MyDomainOrchestrators.MyDomainOrchestrator import MyDomainOrchestrator
def register_orchestrator():
# ... existing entries ...
OrchestratorRegistry.register("my_domain", MyDomainOrchestrator)
Orchestrator conventions:
- No decorator. Registration is an explicit line in
Orchestrators/RegisterOrchestrator.py::register_orchestrator(). - The class uses
metaclass=BaseOrchestrator, which enforces Description/Input/Output docstrings on every method and the class itself. - Forward and rollback functions live in
bll/<Domain>/PostApisbll.py(or wherever the domain's write-side BLL lives) — they are module-level so they can be passed by reference. - Steps communicate via the orchestrator context (
get_context_value/set_context_value). Every method and step declares its access via@context_manager(get_contexts=..., set_contexts=...). - Compose other orchestrators by resolving them in
__init__viaself.mainorchestrator.get_other_orchestrator(self, "<name>")and calling their methods — their steps land on the samemainorchestratorand roll back as one. - A read-only endpoint does not need an orchestrator — call
bll/<Domain>/GetApisbll.pydirectly from the view.
See Architecture → Orchestrators for the full framework reference, including exit-signalling semantics, @context_manager rules, and the docstring metaclass requirements.
5. Helpers — bll/<Domain>/HelperFunctions.py¶
Pure functions used across GetApisbll.py and PostApisbll.py. No ORM access. Examples: input normalisation, name validation, formatting helpers, permission predicates that only need data already in the request, computations.
# bll/MyDomain/HelperFunctions.py
import re
from VaultErrors.BackendErrors import errors
def validate_thing_name(name: str) -> None:
if not re.match(r"^[A-Za-z][A-Za-z0-9 _-]{0,254}$", name):
raise errors.bad_request.ValidationError(
"Name must start with a letter and contain only letters, digits, space, _ or -."
)
def format_thing_payload(thing) -> dict:
return {
"id": str(thing.id),
"name": thing.name,
"category": thing.category,
}
6. Verb-split BLL — bll/<Domain>/GetApisbll.py, bll/<Domain>/PostApisbll.py¶
Business logic is split by HTTP verb intent:
| File | What goes here |
|---|---|
GetApisbll.py |
Read paths. List, retrieve, search, export, count, exists checks. |
PostApisbll.py |
Write paths. Create, update, delete, bulk operations, state transitions, side effects (audit log writes, notifications, webhooks). |
PATCH and DELETE business logic also go in PostApisbll.py (it's "non-GET", not literally "POST"). The split is about read vs. write, not the HTTP verb name.
Each function in these files:
- Receives Python primitives + the requesting
user. - Calls
service.pyfor all ORM access. - Calls
HelperFunctions.pyfor pure transformations. - Returns dicts / domain objects (not DRF Responses — those are the view's job).
- Raises
errors.*fromVaultErrors/BackendErrors.pyon business failures.
# bll/MyDomain/PostApisbll.py
from VaultErrors.BackendErrors import errors
from bll.MyDomain.HelperFunctions import format_thing_payload
from bll.MyDomain.service import my_thing_service
def create_thing(user, name, description, category):
existing = my_thing_service.get(name=name, owner=user)
if existing is not None:
raise errors.bad_request.ValidationError(f"Thing '{name}' already exists.")
thing = my_thing_service.create(
name=name, description=description, category=category, owner=user,
)
return thing
def undo_create_thing(thing):
my_thing_service.delete(thing)
# bll/MyDomain/GetApisbll.py
from bll.MyDomain.HelperFunctions import format_thing_payload
from bll.MyDomain.service import my_thing_service
def list_things(user, category=None, limit=50, offset=0):
filters = {"owner": user}
if category is not None:
filters["category"] = category
qs = my_thing_service.filter(**filters).order_by("-created_at")
rows = qs[offset : offset + limit]
return {
"results": [format_thing_payload(t) for t in rows],
"total": qs.count(),
}
7. Service — bll/<Domain>/service.py¶
The only file in the entire codebase allowed to import a Django model from this domain. It exposes exactly the data-access primitives listed in Data layer → What service.py is allowed to contain and nothing more.
# bll/MyDomain/service.py — minimal example
from typing import Optional, Iterable, Any
from django.db.models import QuerySet
from VaultModels.models.MyDomain import MyThing
class MyThingService:
@staticmethod
def create(**fields) -> MyThing:
return MyThing.objects.create(**fields)
@staticmethod
def get(**filters) -> Optional[MyThing]:
return MyThing.objects.filter(**filters).first()
@staticmethod
def filter(**filters) -> QuerySet:
return MyThing.objects.filter(**filters)
@staticmethod
def update(instance: MyThing, **fields) -> MyThing:
for k, v in fields.items():
setattr(instance, k, v)
instance.save(update_fields=list(fields.keys()))
return instance
@staticmethod
def delete(instance_or_id: Any) -> bool:
if isinstance(instance_or_id, MyThing):
instance_or_id.delete()
return True
deleted, _ = MyThing.objects.filter(pk=instance_or_id).delete()
return deleted > 0
@staticmethod
def bulk_create(rows: Iterable) -> list[MyThing]:
instances = [r if isinstance(r, MyThing) else MyThing(**r) for r in rows]
return MyThing.objects.bulk_create(instances)
@staticmethod
def bulk_update(rows: Iterable[MyThing], fields: list[str]) -> int:
return MyThing.objects.bulk_update(list(rows), fields)
@staticmethod
def bulk_delete(filters_or_ids) -> int:
if isinstance(filters_or_ids, dict):
deleted, _ = MyThing.objects.filter(**filters_or_ids).delete()
else:
deleted, _ = MyThing.objects.filter(pk__in=filters_or_ids).delete()
return deleted
my_thing_service = MyThingService()
Hard rules¶
These are not suggestions:
service.pyis the only file that touchesModel.objects. Every other module imports the service and calls its functions.service.pycontains onlycreate,get,filter,update,delete,bulk_create,bulk_update,bulk_delete. Nothing else.- Views never call
service.pydirectly. Writes go through orchestrators; reads go throughbll/<Domain>/GetApisbll.py. - Serializers contain no business logic. Validation only — no DB lookups, no permission checks.
- No business logic in URL files. A URL file imports views and maps paths. That's it.
- All errors use
VaultErrors/BackendErrors.errors.*. Never raise bareExceptionor DRF built-ins from the BLL. - Orchestrator steps have rollbacks. Either a real compensating action or an explicit no-op with a comment explaining why.
Anti-patterns (don't do this)¶
| Anti-pattern | Why it's wrong | Where it should live instead |
|---|---|---|
Model.objects.filter(...) inside a view |
Bypasses the service layer; N+1 fixes are then everyone's problem. | service.py.filter(...). |
from VaultModels.models import … inside HelperFunctions.py |
Helpers are pure — no ORM. | Move call into GetApisbll.py / PostApisbll.py, which calls service.py. |
service.py with a def publish_version(...) method that does permission check + audit log + status change |
Business logic crept into the data-access layer. | Split: status change → update() call; permission + audit → PostApisbll.py. |
Serializer's validate_x method calls Model.objects.filter(...).exists() |
Same problem as views — the service layer is bypassed. | Move the existence check into PostApisbll.py and raise from there. |
One giant Apisbll.py with everything |
Loses the GET/POST split that makes reads easy to optimise independently of writes. | Split into GetApisbll.py and PostApisbll.py. |
View directly returns Response(model_instance) |
Models aren't JSON-serialisable; this leaks ORM shape to the API. | Use a serializer or a format_* helper for response shaping. |
See the next chapter, Worked example, for a complete end-to-end build that follows the contract — and Good practices for the performance patterns (N+1 avoidance, bulk ops, caching, indexes) every implementation should use.