DEV Community

Cover image for Engineering Post: Executing the plan: lifecycle, cancellation, cycles, wrapped-once
Ernesto Herrera Salinas
Ernesto Herrera Salinas

Posted on

Engineering Post: Executing the plan: lifecycle, cancellation, cycles, wrapped-once

I’m reviving Munchausen, a C# NuGet package I started 9 years ago. This is part 7 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.

M6 makes Generate() real. A GenerationOperation exists per call; it owns the PRNG and reference time (resolved once), traverses the frozen plan, and produces objects. This milestone tests whether the lifecycle I designed is precise enough to execute: ordering, cancellation checkpoints, exception wrapping, and depth/cycle handling.

One operation, resolved once

The operation constructor settles the things that must not drift mid-call:

  • Seed: options.Seed ?? defaults.Seed ?? entropy. The entropy path is the single place in src/ allowed to touch System.Random, one draw from Random.Shared to seed an unseeded run.
  • Reference time: options.ReferenceTime ?? defaults.ReferenceTime ?? options.TimeProvider?.GetUtcNow() ?? DateTimeOffset.UtcNow, captured once. No method ever reads the clock again.

The lifecycle, in order

Per object: construct → populate members in MetadataToken order → run derivations in registration order. That ordering is part of reproducibility, so the traversal is rigid by design. Each phase boundary is a cancellation checkpoint: before each root, nested object, collection element, and derivation pass.

Wrapped exactly once

Every user-delegate site, a ConstructWith, a With generator, a Derive, is wrapped in exactly one try/catch that throws LieGenerationException carrying the model type, member path, index, and the GenerationPhase of the site, with the original exception preserved as InnerException. OperationCanceledException
passes through untouched (cancellation is never a "generation failure"). To keep
the real user exception as the inner, not a TargetInvocationException, typed
user delegates are adapted to the uniform shape with a compiled Expression.Invoke
rather than DynamicInvoke.

The exception hierarchy itself got corrected here: LieDefinitionException and
the new LieGenerationException both derive from a public abstract
LieException. I had designed that common base but missed it in M5; implementing
the runtime exposed the omission.

Path-based cycles, frame-based depth

Cycle detection is path-based, not type-based: a candidate nested type is a
cycle only if it already appears in the active ancestor chain. So two Address
siblings (billing and shipping) both generate; they're on different branches, while Employee.Manager terminates, because Employee is its own ancestor. Depth
is the object-frame count (the default max is 3); collections don't push a frame,
but their element objects do. On depth or cycle, Terminate nulls the reference /
empties the collection, and Throw raises a LieGenerationException.

Reflection-free collection materialization

Collections materialize through delegates compiled at build time, so generation
stays reflection-free. A small generic helper is bound to the element type once:

public static object ToList<T>(IReadOnlyList<object?> elements) { /* ... */ }
// bound via MakeGenericMethod(elementType).CreateDelegate at compile time
Enter fullscreen mode Exit fullscreen mode

Arrays and the six sequence interfaces materialize fully; dictionaries land as the
right empty type for now (their keys are usually strings, which arrive with the
datasets).

The deferral question, answered

M6 hit the ordering tension in my plan head-on: the real scalar generators arrive
in M7, so what can the runtime honestly generate today? I chose to bind only the
pure-PRNG type defaults, int, bool, Guid, double, decimal, enum,
byte[], and friends, and defer strings, dates, semantic generators, and rich
test-model goldens to M7. Every runtime mechanic is testable now with primitive
fixtures and user delegates; only dataset content waits.

What's next: M7, the Datasets

M7 binds the leaves. Eight public datasets, the English data tables, the internal
generators (phone, guid-string, short-code), and the wiring that connects every
semantic catalog name to a real implementation, so FirstName finally produces
"Anthony" and Email produce something @example.com. After M7, the placeholders from M5 are gone, and the canonical Car generates a full, believable graph.

Top comments (0)