Designing the domain model
In a database-backed application, the data model is the foundation everything else rests on — get it right and the features follow, get it wrong and you fight it forever. So the capstone starts, as every real Django project should, by designing the domain in models and relationships before writing feature code. This lesson designs Nidaan's complete data layer, and — crucially — explains the reasoning behind each choice, so you are designing deliberately, not copying.
Start from the entities
A clinic's domain has a handful of core entities. Name them and what each is about:
- Doctor — a clinician (name, specialisation).
- Patient — a person the clinic treats (name, contact, city, date of birth).
- Appointment — a patient seeing a doctor at a time (status, fee).
- TestReport — a diagnostic result belonging to a patient (test name, result, an uploaded scan).
- Payment — money received against an appointment or report.
Designing means deciding each entity's fields with honest types and the relationships between them. We take the two in turn, applying what the models and relationships lessons taught.
The models, with deliberate field choices
class Doctor(models.Model):
name = models.CharField(max_length=100)
specialisation = models.CharField(max_length=100)
def __str__(self):
return f"Dr {self.name}"
class Patient(models.Model):
name = models.CharField(max_length=100)
phone = models.CharField(max_length=15) # CharField, NOT integer — leading zeros, +91
city = models.CharField(max_length=60)
date_of_birth = models.DateField(null=True, blank=True) # optional: null (DB) + blank (forms)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
indexes = [models.Index(fields=["city"])] # staff filter by city constantly
def __str__(self):
return self.name
Every choice is a decision from the models lessons: phone is a CharField (phone numbers are not
quantities), date_of_birth is optional with null=True, blank=True (the two-layer optionality), a
created_at audit timestamp, and an index on city because it is filtered constantly (verified earlier
to be used via EXPLAIN). Money, when it appears, is a DecimalField (exact), never FloatField.
The relationships, and the on_delete decisions
Relationships are where a clinic model is won or lost, and on_delete is a real data-policy choice, not
boilerplate:
class Appointment(models.Model):
class Status(models.TextChoices):
BOOKED = "booked", "Booked"
DONE = "done", "Done"
CANCELLED = "cancelled", "Cancelled"
patient = models.ForeignKey(Patient, on_delete=models.CASCADE, related_name="appointments")
doctor = models.ForeignKey(Doctor, on_delete=models.PROTECT, related_name="appointments")
scheduled_for = models.DateTimeField()
status = models.CharField(max_length=10, choices=Status.choices, default=Status.BOOKED)
fee = models.DecimalField(max_digits=8, decimal_places=2, default=0)
class TestReport(models.Model):
patient = models.ForeignKey(Patient, on_delete=models.CASCADE, related_name="reports")
appointment = models.ForeignKey(Appointment, on_delete=models.SET_NULL, null=True, blank=True,
related_name="reports")
test_name = models.CharField(max_length=120)
result = models.TextField(blank=True)
scan = models.FileField(upload_to="reports/%Y/%m/", blank=True)
reported_at = models.DateTimeField(auto_now_add=True)
The on_delete reasoning, each a clinic policy:
- Appointment → Patient is
CASCADE: an appointment has no meaning without its patient, so deleting a patient removes their appointments (verified: CASCADE removes children). - Appointment → Doctor is
PROTECT: you must not be able to delete a doctor who has appointments and silently destroy that history — the deletion is blocked (verified: PROTECT raisesProtectedError). - TestReport → Appointment is
SET_NULL: a report can outlive the appointment it came from (kept for the patient's history even if the appointment record is removed), so the link is nulled, not cascaded.
These three different choices on three relationships are the model thinking about what should happen to real medical data — exactly the judgement the relationships lesson demanded.
Constraints: make illegal states impossible
The data model should make bad data impossible, not merely discouraged (the transactions lesson). Two invariants a clinic needs, enforced by the database:
class Appointment(models.Model):
# ... fields ...
class Meta:
constraints = [
models.CheckConstraint(condition=models.Q(fee__gte=0), name="fee_non_negative"),
models.UniqueConstraint(fields=["doctor", "scheduled_for"], name="no_double_booking"),
]
CheckConstraint(fee >= 0)— a fee can never be negative, on any write path (verified: a negative fee raisesIntegrityError).UniqueConstraint(doctor, scheduled_for)— a doctor cannot be booked twice for the same slot, enforced by the database regardless of concurrency (the "created twice" race the concurrency module covered). Application checks alone would not be race-safe; the constraint is.
Modelling invariants as constraints means the clinic's rules are properties of the data, not conventions code must remember.
Where behaviour and queries will live
Finally, design where the logic goes before writing it (the architecture module):
- Query logic on a manager —
Appointment.objects.booked(),.for_city(city),.visible_to(user)— named once, chainable (verified). Views ask the manager. - Single-object behaviour on the model —
appointment.complete()sets status, computes fee, records payment (wrapped intransaction.atomic). - Multi-model/external processes in a service — when completing an appointment grows to charge a card
and send an SMS, that becomes
services.complete_appointment(). - Reporting via aggregation — "revenue per doctor", "appointments by status" as
annotate/aggregate(verified), never Python loops.
Designing this up front means the feature lessons have a home for every piece of logic, and the model does not become a dumping ground. The whole point of designing first: the data model and the decisions around it are the architecture — the next lesson's build is largely filling in behaviour the design already has a place for.
Check your work
Design first, and why. In a database-backed app the data model is the foundation; design entities, fields, relationships and constraints before feature code, because everything rests on it.
Honest field types. CharField for phone (not integer), DecimalField for money (not float), optional
fields with null+blank, audit timestamps, and an index on a filtered column (city).
Relationship on_delete as policy. Appointment→Patient CASCADE (no meaning without patient),
Appointment→Doctor PROTECT (never destroy history), TestReport→Appointment SET_NULL (report outlives the
appointment) — verified behaviours.
Constraints for invariants. CheckConstraint(fee >= 0) and UniqueConstraint(doctor, scheduled_for)
(no double-booking) enforced by the database on every path and race-safely — verified.
Where logic will live. Queries on managers, single-object behaviour on models, multi-model/external processes in services, reporting via aggregation — designed before building.
Practice
- Write the full model set (Doctor, Patient, Appointment, TestReport, Payment) with honest field types and
__str__; runmakemigrations/migrate. - Justify each
on_deletechoice in one sentence as a clinic policy; then test CASCADE and PROTECT actually behave as chosen. - Add the
CheckConstraintandUniqueConstraint; migrate, then attempt a negative fee and a double booking and confirm both are rejected. - Add the
cityindex and confirm viaEXPLAINthat afilter(city=...)uses it. - Sketch the manager methods, model methods, and any services Nidaan will need — deciding where each piece of logic lives before you write it.
- Compare this design to your paper sketch from the brief: where did you reach for a weaker type, miss a
constraint, or choose the wrong
on_delete?
Official documentation
- Django — Models — Fields, relationships,
Meta. - Django — Constraints —
CheckConstraint,UniqueConstraint. - Django — Model field reference — Choosing honest field types.
Next: building the core — appointments and test reports.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship