Lesson 14 · Edges and reverse names
When the IR stops being a tree
You got the whole-program compilation unit, the owning side, the embedded-value flatten and identity ownership without help. What came back blank was both halves of the mechanism: what in the pipeline guarantees one foreign key, and what exactly collides when two models point at each other. Those are the same lesson, because they are both consequences of one broken invariant.
The invariant that broke
Every lesson up to here could assume two things, and relationships kill both.
| Compilation unit | Was one model. Is now the whole domain package, as you said — and the cost you did not name is that
your unit of recompilation grew with it. Nothing can be generated from a model in isolation any
more, because whether CompanyId resolves is a fact about a different file. |
| Shape of the IR | Was a tree: a model owns fields, a field owns a type. Is now a graph — two models can point at each other, and a reference can point forward to a model not yet loaded. Every tree-shaped assumption in the pipeline is now a latent bug: recursion without a visited set diverges, and “process each model independently” silently produces the wrong output. |
Both blanks in the diagnostic follow from the second row. Name the vocabulary first.
| Edge / relation | The thing the domain actually declares: a User belongs to a Company.
Note that it is not a field, and not a pair of fields. It is one fact that surfaces as one or two
fields. |
| Owning side | The side that physically stores the reference. In a relational database it is always the many side, because a column holds one value. You got this; the term is worth having because it is what makes “which side” a derived fact rather than a choice. |
| Reverse accessor | The attribute Django adds to the target model for you: Company.user_set. You did
not write it; it is not in your IR; it can still collide. |
| Reverse query name | A second name Django adds, used in lookups: Company.objects.filter(user__active=True).
Defaults to the lowercased model name with no _set. Two names per foreign key, not one — this
is the fact behind your blank on question 4. |
| Foreign Key Mapping | Fowler's name for the whole activity: map an association between objects to a foreign key between tables. Worth knowing because it puts your other two correct answers in the same catalogue — Identity Field is what you chose in question 10, and Embedded Value is the flatten you chose in question 9. Three of your answers are named patterns; that is the literature gap closing. |
Act I · One edge, two surface forms
Here is the backend from the diagnostic again, and the answer to what it produces:
const emit = (model: Model) =>
model.fields.map(f =>
isIdRef(f.type) ? fk(f, resolve(f.type)) :
isIdList(f.type) ? fk(f, resolve(f.type.item)) :
column(f))
Given User.company: CompanyId and Company.users: list[UserId] it emits
two foreign keys — one on User, one on Company — and the second
is wrong in two independent ways at once. It is on the one side, so the schema now claims a company has
exactly one user; and it duplicates an edge that was already represented, so the two can disagree.
The reason is visible in the type signature: emit takes a Model and maps over
fields. A function of that shape cannot decide not to emit, because the fact it would
need — that another model already carries this edge — is not in its argument. This is the same diagnosis as
lesson 3: a missing IR node showing up as a decision nothing is in a position to make.
Where the fix goes, and why not the other two places
Not the backend. Corrected after your note: the first version of this paragraph claimed the FastAPI backend wants both directions, and you were right that it doesn't — a payload should show one direction too. Which makes the argument simpler and stronger rather than weaker. Every backend wants one edge, so a rule placed in one of them is a rule the others have to reimplement identically: N backends, one fact, N copies. That is the narrow waist failing in the direction it always fails, and the fix is the one you named in session 1 — put the fact in the middle once. Deduplication is a fact about the domain, not about Django.
Worth keeping the corrected version in mind as a general check on placement arguments: “this backend needs it and that one doesn't” is a much weaker reason than “all backends need it, so it should exist once”, and the first is easy to assert without testing. If you cannot name a consumer that genuinely wants the other behaviour, you are not looking at a target-specific decision.
Not the loader. The loader sees one class at a time and by construction cannot know
whether UserId resolves; that is the whole reason the compilation unit grew.
A pass in the middle, and it earns its keep by adding a node. The IR grows a
Relation, the pass builds the set of them, and the backend maps over relations plus
scalar fields rather than over fields alone:
type Relation = {
readonly owner: ModelName // the many side; holds the column
readonly ownerField: FieldName // what the domain author called it
readonly target: ModelName
readonly optional: boolean
}
Two passes, in this order, and the order is forced:
// pass 1 — collect. Every model that declares an identity field claims its id type.
// Company: id: CompanyId => { CompanyId -> Company }
const table = declarations(models)
// pass 2 — resolve. Now every reference has something to resolve against.
const relations = referencesOf(models).map(r => lookup(table, r))
You cannot merge them. User may mention CompanyId before Company
is loaded, so any single traversal that resolves as it goes will fail on roughly half the possible file
orders — which is a lovely bug, because it passes your tests until someone renames a file. Collect
everything, then resolve: the two-phase structure is not a style preference, it is what a
forward reference costs.
The part that is easy to get wrong
Deduplication needs an identity for a relation, and the obvious one
is wrong. (source, target) does not work: an Invoice may have
payer: PartyId and payee: PartyId, which are two genuinely different
edges between the same pair of models. So identity has to include the owning field.
And that is where the asymmetry appears. User.company: CompanyId
names the owning field. Company.users: list[UserId] does not — it says there is an
edge to User, but not which User field carries it. If User had both
company and billing_company, no rule could tell you which one
Company.users is the reverse of.
The collection side carries strictly less information than the scalar side, so it cannot be the source of truth. The rule that follows:
- The scalar reference is the declaration. It creates the
Relation. - The collection is a view. It creates nothing, and lowers to a reverse accessor.
- A collection with no matching scalar reference is a diagnostic, not a foreign key —
Company.users: list[UserId]with noUserId-typed field onUseris almost certainly a typo, and the alternative is silently generating a schema the author did not describe. - A collection that matches two scalar references is also a diagnostic — ambiguous, and the fix is for the author to say which.
That last pair is the payoff of doing resolution yourself rather than deferring to Django: neither of those errors is expressible as a Django check, because by the time Django sees your output the collection has already vanished.
Both surface forms now normalise onto one node, which is lesson 12 in a new setting — and worth noticing, because in lesson 12 the two spellings were of a type and the canonical form was a rewrite. Here the two spellings are of an edge, the canonical form is a node that neither spelling mentions, and one of the two spellings is not even allowed to produce it. Canonicalisation sometimes means adding a representation rather than choosing between the ones you have.
Act II · Two names you did not write
Question 4, precisely: a bidirectional reference collides in Django's inferred methods is the right instinct and one term short. The thing that collides is a reverse accessor or a reverse query name, and the namespace it collides in belongs to the other model.
One ForeignKey injects two names into its target:
class User(models.Model):
company = models.ForeignKey("core.Company", on_delete=models.PROTECT)
# Django adds to Company, without being asked:
# Company.user_set <- reverse accessor (related_name)
# Company.objects.filter(user__...) <- reverse query name (related_query_name)
Both default to a function of the source model's name. That is the whole problem, because a generator's naming is systematic: if you name a foreign key field after its target model — the obvious choice, and the one a derived generator makes — then the reverse query name Django derives from the source model is on a collision course with the field name on the other side.
class User(models.Model):
company = models.ForeignKey("core.Company", on_delete=models.PROTECT)
class Company(models.Model):
owner = models.ForeignKey("core.User", on_delete=models.PROTECT)
# reverse query name for Company.owner, on User, is "company"
# User already has a field called "company"
# => fields.E303, and Django refuses to start
Django's system checks name all four shapes this takes, and they are worth reading as a taxonomy rather than as error codes — the two axes are which name (accessor or query name) and collides with what (a field, or another reverse name):
| check | collision | the generator input that causes it |
|---|---|---|
fields.E302 | reverse accessor vs a field name | Target model has a field named user_set. Rare, but a domain author who writes
user_set as a field name will hit it. |
fields.E303 | reverse query name vs a field name | The mutual-reference case above. Systematic in a generator, because the field name is derived from the model name on one side and the reverse name from the model name on the other. |
fields.E304 | reverse accessor vs another reverse accessor | Two references from one model to the same target — payer: PartyId and
payee: PartyId. Both reverse accessors default to invoice_set. This is the same
input that broke relation identity in Act I, failing a second time. |
fields.E305 | reverse query name vs another reverse query name | Same input as E304, on the other name. |
Three fixes, and you have seen them before
These are lesson 8's families, unchanged, applied to a namespace you do not own:
| Detect and reject | What happens if you do nothing: Django's checks catch all four. The catch is where — at application startup, in generated code your user did not write, naming a field they did not choose. That is the worst diagnostic locus available to you, and it is the default. |
| Rename | Always emit an explicit related_name. This is lesson 8's gensym, except it must be
stable rather than fresh — the name goes into a repository and into user queries, so a counter is
unacceptable. Derive it from (source model, owning field), which is unique by construction
because a model cannot have two fields with one name. related_name="user_company". Every one
of the four checks becomes unreachable. |
| Avoid | related_name="+" tells Django to create no reverse relation at all. Collisions are
impossible because the names do not exist. Correct when the domain never declared a collection on the
other side — and note what it costs: the reverse traversal, which is often the query the application
actually wants. |
The recommendation is rename, unconditionally. Not “rename when a collision is detected” — detection means you have written a global uniqueness check over a namespace Django populates by rules you are reimplementing, and you will get it wrong. Emit the derived name every time, including for the single-relation case where the default would have been fine. A generator's advantage over a human is that it can afford to be uniform.
Which leaves one honest cost: related_name="user_company" is uglier than
user_set, and it appears in application code the user writes by hand. That is exactly MLIR's
rule from lesson 12 — a canonical form is chosen for the machinery and is allowed to be uglier than what a
human would have written — except this time the ugliness is not confined to the IR, it is in the public
surface of the generated model. If you want it prettier, the escape hatch is per-field override, which is
the next lesson's subject.
While you are here: DO_NOTHING
You chose on_delete=DO_NOTHING because soft deletes are handled
in the domain. The Django docs are explicit about what that means: “Take no action. If your database
backend enforces referential integrity, this will cause an IntegrityError unless you manually
add an SQL ON DELETE constraint to the database field.” The foreign key constraint still
exists; you have only declined to tell Django what to do about it. So a hard delete() raises
IntegrityError from the driver — no model name, no field name, no useful message.
If hard deletes are supposed to be impossible in your domain, say that:
PROTECT raises ProtectedError naming the model and the relation. Same
prohibition, a diagnostic instead of a database error. DO_NOTHING is the option that means
I have written the SQL myself.
Exercise A · Generated models, some of them fine
Each is output from a Django backend. Click the line that breaks, or say clean. Two of the six are clean.
Exercise B · Which phase owns it
Six obligations. Place each in the loader (front end), a transformer (middle), or the Django backend. The discriminator is the one from lesson 1: could a second backend need this, and does the decision require facts the phase cannot see.
Exercise C · From memory
The diff
Relationjoins the IR —owner,ownerField,target,optional. The backend maps over relations and scalar fields, never over raw fields alone.- Two passes, in order: collect declarations into a symbol table, then resolve references. Resolution failure is a diagnostic with a span, accumulated rather than raised (lesson 6).
- A collection field never creates a relation. Unmatched or ambiguous collections are diagnostics — the two errors Django structurally cannot report for you.
- Every emitted
ForeignKeycarries an explicitrelated_namederived from(source model, owning field). No defaults, no collision detection. - The property to assert, in the language of lesson 9: for any domain package, the number of
ForeignKeys emitted equals the number of scalar id references — invariant under permuting file order, and invariant under adding a collection field.
What this unlocks
Two things, both blank in the diagnostic. Cycles: you now have an edge set, so you can
ask whether it has one — and question 11 was the shape of that problem, two non-nullable foreign keys
pointing at each other, where no order of construction works and the fix has to come from somewhere other
than ordering. Elaboration: on_delete, null,
related_name and db_index are all facts the backend invents because the target
demands them and the domain never said. That is the name for it, and it is the one gap in your
RESOURCES.md that this cluster finally makes worth closing.