cd /news/developer-tools/dev-log-the-milestone-where-build-st… · home topics developer-tools article
[ARTICLE · art-35108] src=dev.to ↗ pub= topic=developer-tools verified=true sentiment=· neutral

Dev Log: The milestone where Build() stops lying

A developer revived Munchausen, a C# NuGet package, after 9 years. In milestone M5, the Build() method was implemented to compile a frozen plan from captured builder data and inference results, collecting all errors (LIE001-LIE005) before throwing. The implementation handles recursive types like Employee with a visited set and worklist, and emits placeholder delegates for generators that will be bound in M7.

read3 min views1 publishedJun 20, 2026

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 tiebreakerLIE005

: 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?

── more in #developer-tools 4 stories · sorted by recency
── more on @munchausen 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/dev-log-the-mileston…] indexed:0 read:3min 2026-06-20 ·