Django Portfolio Journal

Version 2: Refactoring Django models: fewer tables, stronger constraints


When rebuilding version 2, the model architecture was the first thing I questioned. Version 1 had grown organically — each new piece of data got its own model and foreign key relationship. By the time I started the rebuild I had models that were essentially lookup tables with one or two fields, and relationships that added JOIN complexity without adding real value.

Models I removed

Several v1 models existed only because data needed somewhere to go — not because they needed the flexibility of a full model:

  • projects.Tag — two string choices. Replaced with TagChoices directly on Project. No table, no foreign key, no admin entry needed.
  • journal.Category — same situation. Replaced with CategoryChoices inline on Journal.
  • projects.Picture — v1 had a separate model with a cover flag and image resizing in save(). In v2, Project has a single ImageField. A portfolio project has one representative image; a separate one-to-many model was over-engineered.
  • projects.Link — v1 had separate Link models in both projects and journal. In v2 there is one Link model in journal with a panel field (JOURNAL or PROJECT) that scopes it to the right context.
  • doridoro.DoriDoro — personal profile data that was tightly coupled to User. Moved to accounts.Profile as a proper OneToOneField.
  • doridoro.Fact, Hobby, Reference — removed. The content these held either lives elsewhere or was no longer needed in version 2.

The _normalize_fields() pattern

In v1, field normalization (.strip(), .lower()) happened inside clean(). This created a subtle problem: calling save() with validation skipped — which management commands do when seeding data — also skipped normalization.

In v2, normalization is extracted into its own method, called unconditionally in save() before full_clean():

def _normalize_fields(self):
    if self.name:
        self.name = self.name.strip()

def save(self, *args, **kwargs):
    clean = kwargs.pop("clean", True)
    self._normalize_fields()
    if clean:
        self.full_clean()
    super().save(*args, **kwargs)

The clean=True default preserves the standard behavior. Passing clean=False skips validation but still normalizes the data. No more whitespace-padded entries from bulk imports.

Stronger constraints at the database level

v1 relied heavily on application-level checks inside clean(). v2 pushes more of this to the database via CheckConstraint, which enforces rules even if Django is bypassed entirely.

The Job model is a good example. v1 had one constraint. v2 has five — including one that enforces that company_name is required for certain job types, which previously only existed as a clean() check:

models.CheckConstraint(
    condition=~Q(job_type__in=SELECTED_JOB_TYPE_CHOICES) | ~Q(company_name=""),
    name="ck_job_selected_type_company",
    violation_error_message="With this job_type a company_name has to be filled out.",
),

All constraints in v2 include violation_error_code and violation_error_message — available since Django 5.0 — so Django raises a meaningful ValidationError when a constraint is violated at the application layer, rather than a raw database error.

Field type improvements

Two smaller but meaningful changes across models:

  • Job.description changed from TextField to JSONField. In v1 job descriptions were unstructured text. In v2 they are a list of bullet points — structured data stored correctly as JSON.
  • Nullable string fields replaced by blank=True, default="". Nullable strings create two representations of empty in the database (NULL and ""). Using default="" removes that ambiguity and simplifies queries.

Taken together, the v2 model layer is smaller — fewer models, fewer tables, fewer joins — but more precisely specified, with more of the data rules living at the database level rather than only in Python.


© 2026 Dorothea Reher  ·  Impressum  ·  Privacy Policy
Designed by BootstrapMade and modified by Dorothea Reher