I’m reviving Munchausen, a C# NuGet package I started 9 years ago. This is part 3 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.
M2 is the first time Munchausen looks at your code instead of just shuffling bits. Point it at a class, and it has to figure out: what are the members, in what order, which are nullable, which are required, which can I actually set? It's the "reflection" milestone: the whole game does that work once, at Build() time, and caches it, so generating objects later never pays the reflection tax.
That sounded tidy when I put it in the design. Implementing it exposed how many runtime details hide behind the phrase "read a type."
The order obsession
The detail I keep coming back to is member order. Reflection won't guarantee a stable order, so I sort by MetadataToken, which aligns with how you wrote the properties in the source. Why care so much? Because order is part of the determinism promise. Each member draws from the random stream in sequence, so if the order wobbles, the output wobbles. There's a slightly spooky consequence I had to write down and test: if you reorder two properties in your model, your seeded fixtures change. That feels surprising until you remember the alternative is having no stable anchor at all. Determinism has to hang on something.
Nullability is a rabbit hole
I knew NRT detection would be annoying; I underestimated how much. Value-type nullability is a one-liner. Reference-type nullability is encoded in attributes the compiler sprinkles around, and you read it through NullabilityInfoContext, which, fun fact, isn't thread-safe, so I new one up per build call. The genuinely awkward part was testing the "oblivious" case (a reference type compiled with no NRT annotations). The whole project builds with nullable enabled, so I couldn't naturally produce an oblivious member; I had to drop a #nullable disable island into a test fixture just to manufacture one. Felt a little dirty, but it's the honest way to cover that branch.
The gotcha that ate twenty minutes
xUnit + internal types + me being careless. I wrote a [Theory] whose parameter was an internal enum (NullabilityKind), and the compiler slapped me with CS0051: a public test method can't expose a less-accessible type in its signature. Obvious in hindsight. The fix is to keep internal types out of the signature and only use them in the method body (which InternalsVisibleTo
allows), so the theory passes strings and compares .ToString(). I hit this exact wall again two milestones later, and at least recognized it instantly the second time. Scar tissue is a form of documentation.
The satisfying bit: init-only round-trips
Init-only properties ({ get; init; }) look like they should be unsettable after construction. But init is a compile-time fiction, at the IL level the setter is just a method, and you can absolutely call it later via a compiled accessor.
Proving that with a round-trip test (construct, set the init-only member through the accessor, read it back, watch it change) was a small "oh, neat" moment, and it matters: the runtime is going to rely on exactly this to populate records and init-only models.
Building for a future I won't write yet
The thing I like about this milestone's shape is the seams. I put discovery and accessor creation behind two interfaces so that, someday, a source generator can replace reflection with zero-cost, AOT-safe code and nothing downstream notices.
v1.0 doesn't promise AOT, but this was a cheap opportunity to avoid painting it into a corner.
Where it leaves things
The library can now take any of the test models and tell you its constructors, its members in deterministic order, each member's type, nullability, required-ness, and writability, and hand back compiled get/set delegates, with a reflection fallback for AOT. All cached, all immutable, all computed once. Still, nothing a user can call, but the engine now understands the raw material.
What's next
M3 is a milestone marker I've been looking forward to: the first public API.
Lie.Define<T>(), the fluent builder, and the little expression parser that decides x => x.Email is fine, but x => x.Owner.Email is a diagnostic. It's also the first time I've gotten to feed the PublicApiAnalyzer something real and find out how much fun it is to hand-maintain a locked surface file.













