# Dev Log: The milestone where Build() stops lying

> Source: <https://dev.to/ernestohs/dev-log-the-milestone-where-build-stops-lying-1b2o>
> Published: 2026-06-20 20:35:00+00:00

I’m reviving Munchausen, a C# NuGet package I started 9 years ago. This is

part 6of 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.

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(...)`

and `Ignore(...)`

on the same member (contradiction)`LIE003`

: a `required`

member 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.

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.

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 `throw`

s, but this let me preserve the milestone boundary without pretending the leaves were finished. I left the IOUs explicit for future-me in M7.

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.)

`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.

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?
