Virtual Blood Bank is a Django REST API for a district-level healthcare workflow: facilities hold blood inventory, clinicians request units from other facilities, supply staff fulfill those requests, and the system keeps track of status, timestamps, notifications, and audit history.
The project taught me a simple lesson: some domains are not CRUD. A blood request is not just a row whose status can be edited. It has an allowed lifecycle, role-specific actions, inventory consequences, and clinical context.
The main models reflect that. Facility stores Ethiopian administrative location data, including woreda. User belongs to a facility and has a role: ADMIN, SUPPLY, or CLINICIAN. BloodUnit stores blood type, facility, donation time, and expiry date. BloodRequest connects a requesting facility to a fulfilling facility and tracks status, notes, rejection reason, and transition timestamps. BloodRequestStatusEvent stores append-only history for status changes.
That append-only history matters. If a request moved from pending to accepted to in transit to fulfilled, the API can show that path. In healthcare-adjacent systems, “what happened?” is often as important as “what is the current state?”
Nested resources fit the domain
Inventory is exposed as a nested facility resource:
/api/v1/facilities/{id}/inventory/
/api/v1/facilities/{id}/inventory-summary/
/api/v1/facilities/{id}/staff/
That URL shape matches how users think. Blood units are not floating global objects; they belong to a facility. The nested endpoint lets the API enforce facility scope naturally. Supply users can add inventory only to their own facility. Authenticated users can read facility inventory according to the product rules.
Django and DRF helped here with viewsets, serializers, nested routers, permissions, and queryset filtering. The code still has to express the domain rules, but the framework gives a clear place to put them.
State beats status edits
The request lifecycle is explicit:
PENDING -> ACCEPTED -> IN_TRANSIT -> FULFILLED
PENDING -> REJECTED
PENDING or ACCEPTED -> CANCELLED
Accepting a request checks inventory, removes units from the fulfilling facility, records an acceptance timestamp, and may emit a low-stock notification event. Shipping records dispatch. Receiving creates new blood units at the requesting facility and records fulfillment. Cancelling an accepted request restores stock to the fulfilling facility.
Those are domain operations, not generic updates. The API exposes them as action endpoints: accept, reject, ship, receive, and cancel. That makes invalid transitions harder to express from the client.
The tradeoff is more backend code. A generic PATCH /blood-requests/{id}/ would be less code, but it would let clients invent illegal state changes. In this domain, the extra code is worth it because the backend must protect the workflow.
What I learned
The big lesson was to model the verbs. In CRUD thinking, the nouns dominate: facilities, units, requests. In workflow systems, verbs often carry the real business rules: accept, ship, receive, cancel, reject.
Django’s strength was giving me enough structure to separate those verbs from HTTP details. The views translate requests into domain operations. The domain layer owns transitions and inventory mutations. The models keep durable state and history.
That separation made the project easier to reason about because I could ask specific questions: What states are allowed? Who may perform this action? What inventory changes? What history is recorded? What notification event is emitted?
Those are the questions a backend should make answerable.