I’m reviving Munchausen, a C# NuGet package I started 9 years ago. This is part 6 of an 8-part series documenting both the development process and the engineering decisions behind bringing the project back to life.
This is the Dev Log: the practical work, cleanup, implementation steps, and day-to-day progress behind this part of the project.
For three milestones, Build() has been a method that throws
NotImplementedException. M5 is where it finally earns its name. This is the compiler: take everything the builder captured, plus everything inference decided, and assemble it into one frozen plan, or fail with a tidy list of every reason it couldn't.
Errors are first-class citizens
While designing Build(), I kept returning to one question: what would make a failed definition easy to fix? My answer was to avoid bare exceptions. Every failure gets a stable code, LIE001 through LIE005 for the v1.0 cases, and Build() collects all of them before throwing. If your definition has a bad expression, a rule conflict, and an empty name, you get one exception listing all
three instead of discovering them one at a time.
I built each diagnostic with a deliberately triggerable test:
-
LIE001:With(c => c.Owner.FirstName, ...)(nested path, illegal target) -
LIE002:With(...)andIgnore(...)on the same member (contradiction) -
LIE003: arequiredmember of an interface type (can't be resolved) -
LIE004: two equally-resolvable constructors, no tiebreaker -
LIE005:WithName(" ")(empty/whitespace)
There's something satisfying about writing tests whose job is to provoke
failure and then checking that the failure is well labeled. Good error messages
are a feature. Keeping the code registry and expected diagnostic table in sync
through a conformance test makes that feature difficult to erode accidentally.
Recursion, without the headache
The scary part, on paper, was the recursive-type graph. Employee has a Manager that's an Employee; compile that naively, and you recurse forever. My original architecture used a two-phase "allocate empty shells, then fill them" dance.
While implementing it, I found a simpler route: keep a visited set of types and a worklist, and have nested members reference their child by type rather than a direct plan pointer. The runtime looks the plan up in a dictionary later.
Compiling Employee now completes in a single pass and records a friendly LIE009 Info note. The implementation improved the architecture.
The deferral I had to make peace with
Here's the uncomfortable bit. The compiler wants to bind a generator to each inferred member, but the actual generators (the thing that produces "Anthony" for a FirstName) are datasets, and datasets are two milestones away in M7. Classic ordering tension.
The pragmatic answer was to emit a placeholder delegate that throws "bound in M7." Since generation itself is still stubbed, those placeholders are never called, while everything I can verify, the plan structure, constructor choices, diagnostics, and recursive termination, is real. It nagged at me to add deliberate throws, but this let me preserve the milestone boundary without pretending the leaves were finished. I left the IOUs explicit for future-me in M7.
A tooling mystery
This is also the milestone when my beloved dotnet format trick for generating the public API file suddenly stopped working; it complained about a "duplicate source file" and refused to populate anything. I hand-wrote the new exception-type entries instead and moved on, but it bugged me. (Spoiler: I finally diagnosed it in M7, and the fix was embarrassingly simple. Foreshadowing.)
Where it leaves things
Lie.Define<Car>().Build() now works end to end. It returns an immutable
LieDefinition<Car> holding a complete, frozen plan: a constructor choice, every
member's value source, derivations, the reachable child plans, retained
Info/Warning diagnostics. Generate() still throws, there's no runtime, but the
compile is real, validated, and recursion-safe.
What's next
M6: actually running the thing. The generation runtime, lifecycle, cancellation, exception wrapping, depth, and cycle handling. It also forces me to revisit the milestone plan: with the real generators still in M7, what can the runtime honestly produce?
Top comments (0)