RizTech Academy logo
RizTech Academy
Capstone: Nidaan, end to endLesson 2 of 545 min

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 raises ProtectedError).
  • 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 raises IntegrityError).
  • 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 in transaction.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

  1. Write the full model set (Doctor, Patient, Appointment, TestReport, Payment) with honest field types and __str__; run makemigrations/migrate.
  2. Justify each on_delete choice in one sentence as a clinic policy; then test CASCADE and PROTECT actually behave as chosen.
  3. Add the CheckConstraint and UniqueConstraint; migrate, then attempt a negative fee and a double booking and confirm both are rejected.
  4. Add the city index and confirm via EXPLAIN that a filter(city=...) uses it.
  5. Sketch the manager methods, model methods, and any services Nidaan will need — deciding where each piece of logic lives before you write it.
  6. 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

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