The authorization model in Virtual Blood Bank changed how I thought about roles. An admin is not automatically allowed to do everything. In this domain, admins manage users and facilities, but they do not perform clinical actions. Clinicians create, cancel, and receive requests for their facility. Supply staff manage inventory and fulfill incoming requests for their facility.

That is least privilege applied to the actual workflow, not just a generic “admin/user” split.

The project encodes this in two layers. DRF permissions protect the endpoint action. A domain authorizer protects the transition itself. For example, accept, reject, and ship require a supply user from the fulfilling facility. receive and cancel require a clinician from the requesting facility.

This double layer is intentional. View permissions give a clear HTTP-level response. Domain authorization keeps the business rule close to the lifecycle code, so it is not lost if the operation is reused outside one view.

The transition seam

The strongest architecture choice in VBB is the split between BloodRequestLifecycleService and BloodRequestTransitionService.

The lifecycle service mutates state: accept, ship, receive, cancel, reject. It knows when to use transactions, when to lock units, when to create or delete BloodUnit rows, and which statuses are valid from the current state.

The transition service orchestrates everything around that mutation. It checks authorization, calls the lifecycle service, records status history, writes audit entries, and creates notification events.

That separation kept the code understandable. If I need to inspect inventory correctness, I read lifecycle code. If I need to inspect side effects like audit and notifications, I read transition code. If I need to inspect who may perform an action, I read the authorizer.

The general lesson is that state machines get messy when every concern lives in one view method. Pulling the transition into a domain service gives the workflow a stable home.

Correctness at the point of action

When a clinician creates a request, the serializer checks that the fulfilling facility has enough units. That is good user feedback, but it is not the final guarantee. Inventory can change before supply staff accept the request.

So accept() checks stock again inside a transaction and locks matching blood units with select_for_update(). If there is not enough stock, the request is rejected with an insufficient-stock reason. If there is enough, the service removes the oldest-expiring units first and moves the request to ACCEPTED.

This pattern is the same lesson as checkout in the e-commerce API: early validation improves UX; transactional validation protects reality.

Testing the permission matrix

The tests are useful because they encode negative cases. Admins cannot create requests, accept requests, or add inventory. Supply staff cannot create requests. Clinicians cannot add inventory or accept requests. Cancelling an accepted request restores stock. Accept, ship, and receive create the expected status history.

Those tests are not just coverage. They document the permission matrix in executable form. For a workflow with multiple roles, that is more valuable than only testing happy paths.

The tradeoff is verbosity. Role-based workflow tests create a lot of setup: facilities, users, stock, requests, and authenticated clients. Factory helpers make that manageable. The alternative is under-testing the exact rules that make the product safe.

The project taught me that authorization should be written in the language of the domain. “Supply for fulfilling facility” is clearer than “has permission X.” “Clinician for requesting facility” is clearer than “is owner.” The code should preserve those phrases because they are the product rules.