The audit found that my two binding documents disagreed about the v1.1 composition API. Closing that gap was not paperwork. A public method is a promise you can almost never take back, so reconciling the surface meant making real choices and living with them.
Some quick context on what composition is. It lets a definition control how the nested objects inside a generated graph are produced: "every Address in this order graph uses this address definition," or "this specific member uses that child definition." The surface in flux covered type-scoped, member-scoped, and element-scoped binding, plus substituting a concrete type for an abstract member.
Three forks had to be settled.
Fork one: the name
The element-scoped method was UseEach in the binding doc and UseElements in the design doc. Tiny, but public, so it gets a real decision. I kept UseEach. It was the name in the top-authority document already, and it reads cleanly next to
its siblings: Use for a type, UseEach for the elements of a collection. Not every fork is deep. Some are just "pick one on purpose and stop relitigating it."
Fork two: the shape
This one mattered. The binding doc had a single-generic member binding plus a separate UseAs method for substituting a concrete type for an abstraction. The design doc had folded both into one two-generic method.
// before: two verbs
Use<TChild>(expr, def);
UseAs<TAbstraction, TImplementation>(def);
UseAs<TAbstraction, TImplementation>(expr, def);
// after: one verb, abstract-capable
Use<TMember, TConcrete>(expr, def) where TConcrete : TMember;
I took the folded shape. The generic constraint carries abstract substitution for free: for a concrete member, TMember and TConcrete infer to the same type, so existing calls are unchanged, and for an interface-typed member the second generic
supplies the concrete definition. One verb instead of three, and the type system does the work that a separate method used to. UseAs disappeared.
Fork three: how much to ship
The folded Use handles abstract members one at a time. The binding doc also promised a type-level version: "every IParty in this graph is a Person," one registration, graph-wide. I deferred it.
The reasons were concrete. The compiler cannot infer both type arguments for the type-level form, so every call would spell them out. It needs a conflict rule for two concretes registered against one abstraction. And it quietly nudges users toward expecting random polymorphic selection, which is a different feature
entirely. It is cheap to add later and expensive to walk back, so it waits. In v1.1, abstract substitution is member-scoped and element-scoped only.
I also added one new diagnostic, LIE012, for an unsupported composition target (binding to a collection shape the library cannot materialize), since the existing diagnostic registry had no fitting code.
Writing the why into the contract
The part I am most glad I did: I recorded all of this as a "Revision 2.2 amendment" inside API_DESIGN.md itself. Not in a side note, not in a commit message, in the binding document, next to the surface it changed. The amendment says what folded into what, what was deferred and why, and that the type-level form is an additive follow-up.
The contract and the design doc finally describe the same API, and the next person to read either one will find the reasoning attached to the decision instead of having to reconstruct it, or worse, rediscover the conflict I just closed.
Takeaway
A public API is a promise, and drift between your contract and your design is a quiet way to break it. Reconcile deliberately, prefer the change that is additive and lets the type system carry the load, defer the expensive-to-reverse pieces, and write the why into the contract so it does not drift again.
What's next
With the surface honest, the deeper composition decisions were waiting, the ones that were not about taste at all. They were decided by the brief: solo, no deadline, quality over speed. Next post, paying for the future on purpose.
Top comments (0)