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.
Several v1 models existed only because data needed somewhere to go — not because they needed the flexibility of a full model:
TagChoices directly on Project. No table, no foreign key, no admin entry needed.CategoryChoices inline on Journal.save(). In v2, Project has a single ImageField. A portfolio project has one representative image; a separate one-to-many model was over-engineered.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.User. Moved to accounts.Profile as a proper OneToOneField.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.
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.
Two smaller but meaningful changes across models:
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.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.