Lesson 16 · Lowering vs elaboration

The fact was there, or it wasn't

You answered the override question by splitting it: max_length is inferrable from the domain model, on_delete and db_index get defaults. That is the right split, and it is worth knowing that it is a named split — those are two different operations with different failure modes, and the whole lesson is that mixing them up is how a constraint the user wrote silently disappears.


Two operations, one output

Lowering Translating a fact that is in the source into the target's way of saying it. CompanyId | None becomes null=True. The source said it; you re-expressed it.
Elaboration Supplying a fact that is not in the source, because the target requires one and the source never had an opinion. on_delete=PROTECT. Nothing in the domain model mentions cascade semantics; you decided.
Derivation The third category, and the one people collapse into elaboration: a fact computed from the source by a total function, where the source did not state it but also could not have disagreed. related_name="user_company" from lesson 14. No decision was made — the value is forced.

The reason to separate them is that their failure modes are not comparable:

operationhow it goes wronghow bad
LoweringLoses or distorts something the user wrote. Silent and serious. The user stated an intent, the output does not have it, and nothing anywhere reports a problem — the generated code is valid, just weaker than what was described.
DerivationCollides, or is not stable across runs. Loud. A collision is a startup error and a non-stable name is a spurious diff. Both are visible immediately.
ElaborationPicks a default the user would not have picked. Recoverable. Wrong, but it was never in the source, so no information was destroyed — the fix is an override, and the user can see the value in the generated file.

So the rule is: prefer lowering, and never elaborate a fact you could have lowered. Defaulting something the source actually stated is the only one of the three that loses information, and it is the easy mistake because the elaborated version works.


Your Django attributes, sorted

attributekindsource of the fact
null, blanklowered X | None in the annotation. Two Django attributes for one domain fact — null is the database, blank is form validation — so this is one-to-many lowering, and forgetting blank makes an optional field required in every Django form.
primary_keylowered The id: XId field. Your question-10 answer, and it is lowering rather than elaboration precisely because the domain declares the id — if it didn't, Django's implicit AutoField would be elaboration.
max_lengthlowered The interesting one. It looks like a target detail and it is a domain constraint that already exists in your pydantic models. Section below.
uniquelowered If the domain has a notion of uniqueness. If it doesn't, this is not elaboration either — it is absent, and defaulting it to True would be inventing a constraint, which is worse than inventing a permission.
ForeignKey targetlowered Lesson 14's resolution. The domain said CompanyId; the resolver worked out what that denotes. Note that resolution is lowering even though it required a whole symbol table — effort is not the discriminator, presence in the source is.
related_namederived Computed from (source model, owning field). The domain never mentions reverse accessors, but it also could not have disagreed about this value — there was no choice to make.
on_deleteelaborated Your default. Nothing in a pydantic model expresses cascade semantics, and Django refuses to construct a ForeignKey without it, so a value must be invented.
db_indexelaborated Your default. Purely a performance fact; the domain has no vocabulary for it at all. The most clearly elaborated attribute on the list, and the least consequential to get wrong.

Five of eight are lowered, which is the sanity check on the design: if most of a backend's output were elaborated, the domain model would not be the source of truth in any meaningful sense. It is also why the sidecar file you were weighing is not needed yet — there are two elaborated attributes and you have defaults for both.


max_length, and the fact you cannot decline to state

Start with the constraint from the target: Django's CharField requires max_length. It is check fields.E120“CharFields must define a max_length attribute” — and it is a hard error at startup, not a warning. So “we enforce it in pydantic, so Django doesn't need it” is not available as a position. Something must be emitted.

Which makes the real choice a two-way one, and it is a choice about where the constraint lives:

Lower it models.CharField(max_length=50). The constraint is in the database schema. Tightening it later is a migration.
Decline it models.TextField() — which has no max_length and so sidesteps E120 entirely. The constraint lives only in pydantic. Tightening it later is a code change.

Both are defensible and they are not the same decision as the rest of this lesson, because the pydantic constraint is a validation fact and the Django one is a schema fact. Lowering it couples your domain model to your migration history: every time someone tightens a string length in a pydantic model, they generate a migration, and every relaxation is a migration that rewrites a column. Declining it keeps validation in one place at the cost of a database that will accept data your domain would reject — which matters exactly as much as you have writers that bypass the domain layer.

The recommendation, given what you said: lower it. You already enforce it in pydantic, so the values exist and cost nothing to read; a CharField(max_length=n) is more introspectable for anything reading the schema; and the migration coupling is real but is the normal cost of having a schema at all. But state the decision in the generated file's provenance header, because it is the kind of thing that looks arbitrary in six months.

Where the constraint actually lives, in pydantic v2

The mechanical trap: max_length is not an attribute on FieldInfo. Pydantic v2 stores constraints as annotated_types objects in FieldInfo.metadata, whether the author wrote Field(max_length=3) or Annotated[str, StringConstraints(max_length=3)] — the two spellings converge on the same metadata list:

field = Model.model_fields["name"]
field.metadata          # [MaxLen(max_length=50)]
field.max_length        # AttributeError

So reading it means scanning the metadata list for the right annotated_types class, and a field can carry several. If you reach for getattr(field, "max_length", None) you get None on every model, forever, and the symptom is not an error — it is TextField() everywhere, which works. That is the silent-loss failure mode from the table above, arriving through a mundane wrong attribute name.

Worth a test that would catch it, and it is a metamorphic one in lesson 9's sense: T = add a max_length constraint to a domain field, R = the generated Django field changes. A golden-file test would pass with the bug baked in; this one cannot.


What an elaborator owes its user

Two obligations, and they are the whole reason to have named the category.

1. Inserted choices must be inspectable. The user has to be able to see what you decided for them, without reading your source. For a source-to-source generator this is nearly free — the value is right there in the emitted text — but the reason is not, and the reason is what makes a surprising default forgivable. Lesson 11's provenance header is where it goes:

# Generated by pydantic-codegen from core.domain.user.User
# Elaborated (not stated by the domain model):
#   on_delete=PROTECT   default: hard deletes are prohibited; the domain soft-deletes
#   db_index=True       default: applied to every foreign key

2. An elaborated default must be defensible in one clause. If you cannot say why in a comment, the honest move is to make it an error and require the user to state it. on_delete passes: hard deletes are prohibited, so raise rather than cascade. That is a real argument, and it is why PROTECT is the right default and DO_NOTHING was not — as lesson 14 noted, DO_NOTHING leaves the constraint in place and converts a prohibition into a driver-level IntegrityError. Same prohibition; worse diagnostic. It fails the one-clause test because the clause would have to be “we have written the SQL ourselves”, and you haven't.

When the sidecar becomes necessary

You do not need a per-field override file yet, and building one now would be speculative. Here is the trigger to watch for, so the decision is made by evidence: the moment one elaborated default is wrong for a specific field and the user cannot express that in the domain model. Concretely, the first time someone wants on_delete=CASCADE on one relation — a genuinely owned child row that should die with its parent — the default is no longer a default, it is a policy, and it needs an escape hatch.

When that happens, the answer is the sidecar rather than the domain model, and the argument is one you have already used: the domain model should not know Django exists. Putting on_delete on a pydantic field makes the domain layer import a web framework's enum, and it makes the FastAPI backend read a field that means nothing to it. A django.py beside the domain package, keyed by (model, field), keeps the waist intact. Its cost is drift — a key naming a field that no longer exists — which is a diagnostic, and a cheap one, since you already resolve every reference.


Exercise A · Which operation is it

For each generated attribute, decide whether the fact was in the source (lowered), computed from it without a choice (derived), or invented (elaborated).


Exercise B · Silent loss

Each pairs a domain field with what the backend emitted. Click the emitted line that loses or invents something, or say clean. Two are clean.


Exercise C · From memory


The diff

  1. Every emitted Django attribute is tagged in the backend with its kind — lowered, derived, or elaborated. Not documentation: it is what the provenance header is generated from, so it cannot go stale.
  2. max_length is read from FieldInfo.metadata by scanning for annotated_types.MaxLen, never by attribute access. A string field with no length constraint emits TextField(); with one, CharField(max_length=n).
  3. Optional lowers to both null=True and blank=True. One domain fact, two target attributes.
  4. on_delete=PROTECT replaces DO_NOTHING as the default, with the one-clause reason in the header.
  5. Two metamorphic properties, from lesson 9: adding any constraint to a domain field must change the generated Django field (catches silent loss), and changing an elaborated default must change nothing that a domain-derived test observes (catches elaboration leaking into semantics).
  6. No sidecar. The trigger for building one is the first per-field on_delete override request; when it comes, it goes beside the domain package, not on the model.

What this closes

The Django backend cluster now has all four load-bearing decisions made — resolution and edge identity (14), satisfiability and build order (15), and the lowering/elaboration line (16) — and each cashes out as a specific diff rather than a direction. What is left on the fringe is older than this cluster and not about Django at all: the nanopass question of whether these passes deserve separate IRs, and the literature session that is now overdue three times. That one has an easier on-ramp than it did: five of the eight rows in this lesson's table are patterns with published names, and mapping your own answers onto them is the same exercise as mapping a paper onto your code, run in the easy direction first.


status · resources · mission