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:
| operation | how it goes wrong | how bad |
|---|---|---|
| Lowering | Loses 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. |
| Derivation | Collides, 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. |
| Elaboration | Picks 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
| attribute | kind | source of the fact |
|---|---|---|
null, blank | lowered | 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_key | lowered | 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_length | lowered | The interesting one. It looks like a target detail and it is a domain constraint that already exists in your pydantic models. Section below. |
unique | lowered | 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 target | lowered | 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_name | derived | 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_delete | elaborated | 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_index | elaborated | 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
- 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.
max_lengthis read fromFieldInfo.metadataby scanning forannotated_types.MaxLen, never by attribute access. A string field with no length constraint emitsTextField(); with one,CharField(max_length=n).- Optional lowers to both
null=Trueandblank=True. One domain fact, two target attributes. on_delete=PROTECTreplacesDO_NOTHINGas the default, with the one-clause reason in the header.- 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).
- No sidecar. The trigger for building one is the first per-field
on_deleteoverride 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.