Architecture recipes¶
ninja-devx supports layered architectures without imposing one. The
examples/recipes
project implements the same order API (place, list, cancel, with stock) three ways, over
the same models. One set of HTTP contract tests runs against all three.
| Recipe | Writes | Reads | Wiring |
|---|---|---|---|
| HackSoft | service functions (order_place(*, user, ...)) |
selector functions | none |
| Cosmic-lite | use case handlers over Repository[...] ports, RequestContext |
queryset in the controller | Container |
| Interactors | interactors over a gateway protocol | an interactor in __init__ |
dishka |
HackSoft: services and selectors¶
class OrderController(Controller):
options = ControllerOptions(permissions=[IsAuthenticated()])
@post("/", response={201: OrderOut})
def place(self, request: HttpRequest, payload: OrderIn) -> Status[Order]:
user = current_user(request, User)
return Status(
201, order_place(user=user, product_id=payload.product_id, quantity=payload.quantity)
)
Services raise OutOfStock(Conflict), and the controller never catches it
(Errors).
Cosmic-lite: use cases and repositories¶
class PlaceOrderHandler:
def __init__(
self, products: Repository[Product], orders: Repository[Order], context: Context
) -> None: ...
def __call__(self, command: PlaceOrder) -> Order: ...
class OrderController(Controller):
place = use_case(
post("/", response={201: OrderOut}), PlaceOrderHandler, command=to_place_order, status=201
)
@post("/{pk}/cancel", response=OrderOut)
def cancel(self, request: HttpRequest, pk: int, handler: Inject[CancelOrderHandler]) -> Order:
return handler(CancelOrder(order_id=pk))
Handlers are unit-tested with InMemoryRepository and make_context, without a
database.
Interactors with dishka¶
provider = Provider(scope=Scope.REQUEST)
provider.from_context(provides=HttpRequest, scope=Scope.REQUEST)
provider.provide(acting_user)
provider.provide(DjangoOrderGateway, provides=OrderGateway, scope=Scope.APP)
provider.provide_all(PlaceOrderInteractor, CancelOrderInteractor, ListOrdersInteractor)
provide_controllers(provider, [OrderController])
mount(api, {"/orders": OrderController}, container=DishkaResolver(make_container(provider)))
DishkaResolver checks at startup that dishka provides the controller and every
Inject[...] dependency.
Which one?¶
Start with plain controllers and CRUD. Move logic into services when a second caller appears (a task, a command, another endpoint). Add ports and use cases when you need to test business rules without the database, or to swap implementations. These are per-feature decisions, and the recipes show they can live side by side in one API.