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 Engineering Post: the reasoning, trade-offs, API decisions, and technical choices behind this part of the project.
Munchausen's performance story is "eager Build, cheap Generate." All the expensive discovery, reflection, expression compilation, and inference happen once when you call Build(). Generating a million objects after that touches no reflection at all. M2 is the first half of that bargain: the metadata layer that inspects a type, exactly once, and caches the result. Implementation is where I discovered how much complexity lay hidden within that simple promise.
Two seams
Everything is built behind two internal interfaces, so a source-generated, AOT-safe implementation can replace the reflection one later without touching anything downstream:
internal interface IModelMetadataProvider
{
ModelMetadata GetMetadata(Type type);
}
internal interface IMemberAccessorFactory
{
MemberAccessor CreateAccessor(MemberMetadata member);
}
v1.0 ships one implementation of each: ReflectionModelMetadataProvider and CompiledExpressionAccessorFactory. I kept them as interfaces, despite having a single implementation today, because they preserve the option to replace reflection with generated, AOT-safe metadata later.
Order is a feature
ModelMetadata.Members comes back sorted by MemberInfo.MetadataToken.
Reflection doesn't guarantee member order, and order is part of the determinism contract; members are populated in MetadataToken order, which matches source declaration order in practice. The consequence, documented and tested:
Reordering properties in your model changes seeded output. That's by design;
Determinism has to be anchored to something stable.
The nullability matrix
Reading nullable-reference-type annotations is fiddlier than it looks.
Nullable<T> is easy (Nullable.GetUnderlyingType), but reference-type
nullability lives in attributes that the compiler emits, surfaced through
System.Reflection.NullabilityInfoContext. The layer classifies each member as
NonNullable, Nullable, or Oblivious (pre-NRT / #nullable disable), and the
test matrix covers all of them plus required:
- non-nullable ref / nullable ref / oblivious ref
- value type /
Nullable<T> - the C#
requiredmodifier (viaRequiredMemberAttribute) - DataAnnotations
[Required]
IsRequired is the union of the modifier and the attribute, matching the API's
definition of "required."
Init-only is just a modreq
Writability is Writable, InitOnly, or ReadOnly. The interesting case is
init-only: a property with a setter whose return parameter carries the
IsExternalInit required-custom-modifier. That init restriction is a
compile-time C# rule, not a runtime one, which means the accessor can still
invoke the setter after construction. That fact is exactly why the accessor
round-trip tests cover init-only members: the runtime needs to populate them, and
it can.
Accessors: compiled, with a fallback
CompiledExpressionAccessorFactory builds get/set delegates with
Expression.Compile:
// getter: instance => (object)((TModel)instance).Property
// setter: (instance, value) => ((TModel)instance).Property = (TProperty)value
Value-type members box/unbox through Expression.Convert. When
RuntimeFeature.IsDynamicCodeSupported is false (NativeAOT), it degrades to plain PropertyInfo.GetValue/SetValue instead of crashing. The tests exercise both strategies over the same members, including forcing the reflection fallback, so the degraded path is proven, not assumed.
Cached, immutable, shared
ModelMetadata instances are immutable and cached process-wide in a ConcurrentDictionary<Type, ModelMetadata>, shared by every definition and the automatic path. I normally avoid process-global mutable state, but this cache stores only immutable facts derived from types. That made it a useful exception rather than a hidden source of behavior.
What's next: M3, the builder and expression resolver
M2 is internal plumbing; M3 is where Munchausen finally grows a public face.
Lie.Define<T>(), the fluent builder, the options records, and the
ExpressionMemberResolver that turns x => x.Property into a member reference
while rejecting anything fancier (x => x.Owner.Name, method calls, indexers)
with a diagnostic. It's also the first milestone that adds to the locked public
surface, which means the PublicApiAnalyzer from M0 finally has something to
track.














