In post 10 I closed the composition-versus-coherence question with one paragraph: I
kept strict object-scoping, reserved an Inline operator for later, and rejected
automatic reach-down. That was true, and it was too fast. A reader told me the
reserved operator was not clear from a sentence, which is fair. A design record that
asserts a decision without showing it is not really a record. So here is the seam,
worked out.
First, the good news that made this only one seam and not ten: composition and
coherence mostly do not collide. Composition substitutes complex types; coherence
binds scalar facets; those are disjoint kinds of member. A collection gives one
persona per element. Draw order falls out of the eager construction rule from post 7.
And the resolver pipeline I pre-paid for back in post 4 turned out to be coherence’s
host. The two threads layer cleanly almost everywhere. Almost.
The discontinuity
Coherence is object-scoped (post 5). That one rule has a consequence that only shows
up once you have composition encouraging you to split a type across nested objects:
moving a facet into a child changes whether it coheres.
// Flat: Email is a Person facet, so it coheres with the name.
Customer { FirstName, LastName, Email }
// -> "Maria", "Gonzalez", "maria.gonzalez@..."
// Decomposed: Contact is its own scope. A lone Email there does not activate a
// persona (one corroborating member, no name anchor), so it is a plain, unrelated email.
Customer { FirstName, LastName, Contact : ContactInfo { Email } }
// -> "Maria", "Gonzalez", "rwilson@..."
Enter fullscreen mode Exit fullscreen mode
Same three fields, same intent, different result, decided entirely by which object
they live on. That is the discontinuity.
The options
A, strict: object-scoping stays. The decomposed email does not cohere.
Maximally predictable. The gap is the already-deferred cross-entity work.
B, reach-down: a child with no entity of its own is absorbed into the parent scope.
The email coheres. But "absorbed or not" now depends on hidden conditions.
C, Inline: an explicit operator that declares a child shares the parent's scope.
Coheres when you say so, stays strict otherwise.
Enter fullscreen mode Exit fullscreen mode
I kept A as the v1.1 default and reserved C. The rest of this post is C, shown,
because a reserved operator you cannot picture is not a real reservation.
Inline, in three scenarios
Inline declares that a nested child shares its parent’s scope: its scalar facets
lift into the parent’s bundle. The child is still a real object; only its coherence
scoping changes.
Scenario one, the plain case. It pulls a value-object’s fields into the persona:
Lie.Define<Customer>()
.Inline(c => c.Contact) // Contact.Email/Phone join Customer's persona
.Build();
// FirstName="Maria" LastName="Gonzalez"
// Contact.Email="maria.gonzalez@..." Contact.Phone= the Person's phone
Enter fullscreen mode Exit fullscreen mode
Scenario two, where it changes activation, not just correlation. A parent with only
an Email and a nested { First, Last } activate nothing on their own; inlined, they
clear the gate together and become one person:
// Account { Email, Name : NameParts { First, Last } }
.Inline(a => a.Name)
// Name.First="David" Name.Last="Okafor"
// Email="david.okafor@..." <- now the same Person; without Inline it was unrelated
Enter fullscreen mode Exit fullscreen mode
Scenario three, the one that turns a silent wrong-look into a fix. Decompose an
address across two blocks and each block coheres into a different real place:
// Office { City, State, Postal : PostalBlock { PostalCode, Country } }
// default: City/State = Austin, Texas and PostalCode/Country = a German ZIP + Germany
.Inline(o => o.Postal)
// one real row projects across both: Austin, Texas, 78701, United States
Enter fullscreen mode Exit fullscreen mode
Each block was internally consistent and the object as a whole was nonsense. Inline
makes the two blocks one row.
What Inline does not do
A reserved feature is only honest if its limits are stated with its powers. Inline
does not do cross-entity correlation: an inlined person and an inlined address are
still independent bundles, so the person does not “live at” the address. It does not
split a collapsed same-type scope (two people flat on one object is still the deferred
case). And it loses to explicit rules, like every automatic-tier facet does (post 8).
Why reach-down lost
The deciding argument is the same spine the whole series runs on. Inline is a line
in your definition, so Explain can name it:
Office.Postal.PostalCode -> Address.PostalCode [coherent entity] (inlined from Postal)
Enter fullscreen mode Exit fullscreen mode
Reach-down would produce the same coherent values with no declaration anywhere to
point at. The report could only say “absorbed,” and you would have nothing in your
own code that explains why. A coherent value you cannot trace is exactly the kind of
magic the rigor lane exists to avoid.
Takeaway
Show, do not assert: a reserved operator you cannot picture is not a reservation, it
is a promissory note. And when two features meet, prefer the seam you can explain over
the convenience you cannot. Inline ships later, alongside the cross-entity work it
belongs with, but the default it sits on top of, strict and legible, is the v1.1
decision.
What’s next
This second pass through the design added exactly one decision and a lot of
consolidation. The last post is about that: the ledger of everything I have promised
but not shipped.
답글 남기기