DEV Community

Cover image for Engineering Post: A builder that captures everything and validates nothing (yet)
Ernesto Herrera Salinas
Ernesto Herrera Salinas

Posted on

Engineering Post: A builder that captures everything and validates nothing (yet)

I’m reviving Munchausen, a C# NuGet package I started 9 years ago. This is part 4 of an 8-part series documenting both the development process and the engineering decisions behind bringing the project back to life.

This is the Engineering Post: the reasoning, trade-offs, API decisions, and technical choices behind this part of the project.

M3 is where Munchausen grows a public face: Lie.Define<T>(), the fluent
builder, the options records, and the little expression resolver that polices how
you target members. It tests an API choice I made during design: the builder
captures state and validates nothing; every failure is reported at Build(), in
one place, with a diagnostic code.

Capture now, fail later

LieDefinitionBuilder<T> wraps an internal BuilderState. Each fluent method
appends a record and returns this; nothing is parsed or checked:

internal sealed record MemberRuleRecord(
    LambdaExpression MemberExpression,
    MemberRuleKind Kind,        // WithValue, WithGenerator, Derive, Ignore, Preserve
    object? Payload,
    int RegistrationIndex);
Enter fullscreen mode Exit fullscreen mode

Keeping the builder dumb has three payoffs: it's cheap, it preserves registration
order (which Derive and last-write-wins resolution depend on), and it funnels
all error reporting into Build() so users get every problem at once instead of
one-at-a-time exceptions.

WithDefaults is the one method that merges immediately, non-null properties
overwrite, implementing per-property last-write-wins. WithSeed(42) is literally
WithDefaults(new GenerationDefaults { Seed = 42 }), no special case.

The single targeting rule

ExpressionMemberResolver enforces exactly one shape: x => x.Property. It
unwraps the Convert/ConvertChecked node the compiler inserts when a value-type
member is read through a generic lambda, then checks the body is a member access
rooted directly at the parameter:

while (body is UnaryExpression { NodeType: Convert or ConvertChecked } c)
    body = c.Operand;

if (body is MemberExpression { Member: PropertyInfo p } m
    && m.Expression is ParameterExpression param
    && ReferenceEquals(param, expression.Parameters[0]))
    return ExpressionResolution.Resolved(p);

return ExpressionResolution.Failed("LIE001", /* expression text */);
Enter fullscreen mode Exit fullscreen mode

Anything else, x => x.Owner.Name (nested), x => x.Make.ToUpper() (method),
x => x.Items[0] (indexer), a captured variable, a constant, produces LIE001
with the offending expression text. Single-level targeting is a deliberate v1.0
rule; nested behavior is the job of child definitions later. Putting that decision
in one class means there's exactly one place it's enforced.

The locked surface, for real this time

M3 is the first milestone that adds public API, so the PublicApiAnalyzer from M0
finally bites. Adding the builder forced a realization: its method signatures
reference types that don't fully exist yet. With(expr, Func<GenerationContext,
TProperty>)
needs GenerationContext; Build() returns LieDefinition<T>, whose
Explain() returns InferenceReport. So those types get introduced now as
public stubs, declared, documented, with NotImplementedException internals
that later milestones fill in. When I split the work into stages, I allowed those
placeholders until M6 so I could test the complete public type graph before all
of its behavior existed.

Two surface mechanics worth noting:

  • The builder's constructor is internal, you create builders through Lie.Define<T>(), never new, so the public listing has no builder ctor.
  • The two Generate overloads use optional parameters, which trips the analyzer's RS0026 ("don't overload with optional params"). I revisited the overloads, still preferred their call-site ergonomics, and suppressed RS0026 with the rationale recorded. The warning informed the decision without making it for me.

Generating the surface file

Hand-writing PublicAPI.Shipped.txt is error-prone (every default value,
nullability annotation, and generic arity has to be exact). The trick is
dotnet format analyzers --diagnostics RS0016, which applies the analyzer's
"add to public API" fix and writes the entries in the precise format, then I move
them from Unshipped to Shipped after reviewing them against the API design.
(This worked here, broke mysteriously in M5, and got properly diagnosed in M7.
More on that saga later.)

Verification

The resolver accept/reject corpus produces LIE001 with expression text on every
bad shape; WithDefaults merge tests cover per-property last-write, null
no-opinion, and WithSeed equivalence; an acceptance test fluently chains the
whole builder from an external assembly to prove the surface is usable.

What's next: M4, the Inference Engine

M3 captures what you said. M4 figures out what to do with everything you didn't, the inference engine. Structural classification (scalar / nested / collection /
unsupported), the semantic catalog that maps FirstName → a name and Email → an
email, the confidence model, and the plan types the compiler will later freeze.
It's the milestone with the most "taste" baked in, and the one where implementation exposes a contradiction in my design.

Top comments (0)