Featured resource
2026 Tech Forecast
2026 Tech Forecast

1,500+ tech insiders, business leaders, and Pluralsight Authors share their predictions on what’s shifting fastest and how to stay ahead.

Download the forecast
  • Lab
    • Libraries: If you want this lab, consider one of these libraries.
    • Core Tech
Labs

C# Callbacks to Generated Code

ShopFlow is a lightweight order-processing engine. Orders move through a pipeline of validators, price calculators, status broadcasters, discount classifiers, and a JSON reporter. Each step of the lab adds one layer to that pipeline: first the raw delegate plumbing, then lambda refactors, then event-driven status updates, then pattern-match-based discount rules, and finally compile-time JSON serialization metadata so the reporter never reflects at runtime.

Lab platform
Lab Info
Level
Advanced
Last updated
Oct 08, 2026
Duration
45m

Contact sales

By clicking submit, you agree to our Privacy Policy and Terms of Use, and consent to receive marketing emails from Pluralsight.
Table of Contents
  1. Challenge

    Step 1: Explore the ShopFlow starter project

    Welcome to ShopFlow

    ShopFlow is a lightweight order-processing engine for a fictional e-commerce platform. Orders flow through five distinct pipeline stages — validation, price adjustment, status broadcasting, discount classification, and JSON reporting — and each stage of this lab adds one of those layers.

    By the time you finish, you will be able to trace a single Order from raw delegate invocation all the way through to a source-generated JSON payload, touching every major C# callback and pattern-matching feature along the way. Open src/Order.cs. The file contains the two key types you will use throughout the lab:

    public record Order(string Id = "", decimal TotalAmount = 0m, List<string> Items = null, OrderStatus Status = OrderStatus.Pending);
    public enum OrderStatus { Pending, Processing, Shipped, Delivered, Cancelled }
    

    Order is an immutable record. Its Items list (each entry a simple item name) is what the list-pattern arms in Step 5 will inspect, and TotalAmount is what every price-adjustment function threads through the pipeline. ## Skeleton source files

    The src/ directory contains these stubs — one per pipeline stage:

    | File | Stage | |---|---| | Validators.cs | Named-delegate validation (Step 2) | | PriceCalculator.cs | Named-delegate price chain (Step 2) | | FuncValidators.cs | Action/Func refactor and closures (Step 3) | | OrderProcessor.cs | Event publisher and leak prevention (Step 4) | | DiscountClassifier.cs | Switch-expression pattern matching (Step 5) | | OrderReporter.cs | Source-generated JSON serialization (Step 5) |

    Each file has // TODO comments marking the exact lines you will fill in. The test suite in tests/ imports all of them, so a compilation error in one file surfaces immediately across every step. ## How the five stages connect

    Order ──► Validators ──► PriceCalculator ──► OrderProcessor
                                                        │
                                                 StatusChanged event
                                                        │
                                              DiscountClassifier
                                                        │
                                               OrderReporter (JSON)
    

    Validation gates the order. Price adjustment produces the billable total. The processor broadcasts status transitions to any number of subscribers. The classifier maps the final order shape to a discount tier. The reporter turns the summary into a JSON string ready for an external API — without reflection.

    You will not need to change Order.cs or any test file. Everything happens in the six source files above.

    If you get stuck, you can refer to the provided solution code for each task, available in the solution folder.

    info> This lab experience was developed by the Pluralsight team using an internally developed AI tool. All sections were verified by human experts for accuracy prior to publications. However, content may still contain errors or inaccuracies, and we recommend independent verification.

    To report a problem or provide feedback, click here. Feedback may be used to improve accuracy in accordance with our Privacy Policy.

  2. Challenge

    Step 2: Build type-safe delegate validators and a multicast price chain

    Step 2: Build type-safe delegate validators and a multicast price chain

    Before Func and Action existed as built-in types, every callback in C# required a named delegate declaration. Understanding named delegates is still essential because multicast invocation lists, event types, and interop scenarios all expose this mechanism directly.

    In this step you will declare two named delegate types, wire them up to concrete method bodies, and learn why iterating GetInvocationList() is the only reliable way to collect return values from a multicast chain. ## Named delegates

    A named delegate declaration looks like a method signature prefixed with the delegate keyword:

    public delegate bool OrderValidator(Order order);
    

    This creates a first-class type. Variables of that type hold a reference to any method that matches the signature, and you invoke them exactly like a method call:

    OrderValidator check = o => o.TotalAmount > 0;
    bool ok = check(myOrder); // delegates are callable
    

    The key property to remember: when you combine two delegates with +, only the last return value survives a direct invocation. To aggregate all return values you must iterate GetInvocationList() yourself. ## Multicast delegates and invocation lists

    Combining delegates with + (or +=) produces a multicast delegate whose invocation list contains both originals. Calling the combined delegate fires them in order, but the compiler discards intermediate return values — only the last one is visible at the call site.

    To aggregate results you must reach into the list explicitly:

    var combined = first + second;
    foreach (var d in combined.GetInvocationList())
    {
        bool result = ((OrderValidator)d)(order);
        // collect result here
    }
    

    This pattern appears everywhere real validation pipelines need an "all must pass" semantic. ## Chaining price adjusters

    The same delegate mechanism powers the price pipeline. A PriceAdjuster delegate takes a decimal and returns a decimal, making it trivially composable: pass the output of one invocation as the input to the next. This explicit hand-chaining makes the data flow visible in the source and is the foundation you will later refactor to a List<Func<decimal,decimal>>.

  3. Challenge

    Step 3: Refactor callbacks to Action and Func with controlled closures

    Step 3: Refactor callbacks to Action and Func with controlled closures

    .NET has shipped generic Action and Func delegate families since .NET 3.5, eliminating the need for a separate delegate declaration in most scenarios. The trade-off is purely syntactic: Func<Order, bool> is structurally identical to the OrderValidator you declared in Step 2, but it requires no extra type definition.

    This step also introduces closures — one of the most powerful and most misunderstood features of lambda expressions. ## Action and Func

    Func<T, TResult> is a pre-declared generic delegate for functions that return a value. Action<T> is the equivalent for void callbacks. The type parameters encode the whole signature:

    Func<Order, bool> isValid = o => o.TotalAmount > 0;
    Action<Order>     log     = o => Console.WriteLine(o.Id);
    

    Because these are just delegates, they participate in multicast chains and GetInvocationList() exactly as named delegates do. ## Lambda closures

    A lambda can reference variables from its enclosing scope. The compiler promotes those variables to a heap-allocated closure object, and the lambda holds a reference to that object — not a copy of the value at the moment of definition.

    decimal rate = 0.1m;
    Func<decimal, decimal> addTax = p => p * (1 + rate);
    rate = 0.2m;         // lambda sees 0.2, not 0.1
    decimal total = addTax(100m); // 120, not 110
    

    This capture-by-reference semantics is intentional and powerful, but it can produce surprising results if you mutate the captured variable after building the lambda list. ## Capture by reference in practice

    The closure demonstration task is the classic interview gotcha made concrete: define a lambda that references a local variable, then mutate that variable before the lambda runs. The pipeline total will reflect the updated value, not the value at definition time. Understanding this prevents subtle pricing bugs whenever a discount rate or configuration value is recalculated between lambda construction and lambda invocation.

  4. Challenge

    Step 4: Wire an order-status event publisher and prevent subscriber leaks

    Step 4: Wire an order-status event publisher and prevent subscriber leaks

    Delegates power events. The event keyword restricts access to a delegate field so that external code can only subscribe (+=) or unsubscribe (-=); it cannot invoke or replace the delegate directly. This encapsulation is the publisher–subscriber pattern built into the language.

    This step adds an OrderProcessor class that broadcasts state transitions, and it makes the leak-prevention rule observable: you will watch a handler's counter stop incrementing the moment it is unsubscribed. ## Custom EventArgs

    The .NET event convention passes two arguments to every handler: the sender (object) and an EventArgs-derived payload. Creating a custom subclass lets subscribers read strongly-typed data without casting:

    public class OrderStatusChangedEventArgs : EventArgs
    {
        public string OrderId { get; }
        public OrderStatus NewStatus { get; }
        public OrderStatusChangedEventArgs(string id, OrderStatus s)
            => (OrderId, NewStatus) = (id, s);
    }
    

    The event declaration on the publisher then becomes:

    public event EventHandler<OrderStatusChangedEventArgs>? StatusChanged;
    ``` ## Subscribing to events
    
    Subscribing with `+=` appends a handler to the invocation list; unsubscribing with `-=` removes it. Both operations are thread-safe on the `event` field because the CLR swaps the underlying delegate atomically.
    
    A common mistake is to subscribe an anonymous lambda and then try to unsubscribe it: the compiler creates a new delegate instance for each lambda literal, so `-=` finds nothing to remove. Always hold a reference to the same delegate instance you passed to `+=`. ## Preventing event leaks
    
    An event leak occurs when a subscriber outlives its useful lifetime because it was never removed from the invocation list. The publisher holds a reference to the handler, which in turn may hold a reference to a large object graph — preventing garbage collection entirely.
    
    The fix is always the same: call `-=` with the original delegate instance when the subscriber is done. The next task makes this observable by asserting that the removed handler's counter does not increase after teardown.
  5. Challenge

    Step 5: Classify orders with switch expressions and emit JSON via source generation

    Step 5: Classify orders with switch expressions and emit JSON via source generation

    The final step wires together two modern C# features: exhaustive switch expressions with property and list patterns, and System.Text.Json source generation via JsonSerializerContext. Together they close the pipeline — the classifier determines the discount, and the reporter serialises the outcome to JSON without touching the reflection stack at runtime. ## Exhaustive switch expressions

    A switch expression must cover every possible input or the compiler emits an error. Property patterns let you match on the value of one or more properties simultaneously:

    DiscountTier tier = order switch
    {
        { TotalAmount: >= 500, Items.Count: >= 5 } => DiscountTier.Premium,
        { TotalAmount: >= 200 }                    => DiscountTier.Standard,
        { Items.Count: >= 1 }                      => DiscountTier.Basic,
        _                                          => DiscountTier.None,
    };
    

    Arms are tested top-to-bottom and the first match wins, so order the most specific guards first. The discard arm _ is the exhaustive fallback the compiler requires. ## Source-generated JSON serialization

    System.Text.Json can generate all serialization metadata at compile time so your application never needs to scan types at startup. You opt in by:

    1. Creating a partial class that inherits JsonSerializerContext.
    2. Annotating it with one [JsonSerializable(typeof(T))] attribute per type you want to serialize.
    [JsonSerializable(typeof(OrderSummary))]
    internal partial class ShopFlowJsonContext : JsonSerializerContext { }
    

    The source generator emits a Default singleton with a strongly-typed JsonTypeInfo<OrderSummary> property. Passing that property to JsonSerializer.Serialize routes the call entirely through the generated code path. ## Using the generated context

    Once the context is annotated, consuming it is a single method call:

    string json = JsonSerializer.Serialize(summary, ShopFlowJsonContext.Default.OrderSummary);
    

    The second argument is a JsonTypeInfo<OrderSummary> — not a JsonSerializerOptions instance. This overload is what bypasses reflection. If you pass JsonSerializerOptions instead, the runtime silently falls back to reflection and the source-generation benefit is lost.

  6. Challenge

    Step 6: Run the application

    Every task is done, and each one was graded the moment you completed it. What you have not done yet is watch the whole application run at once -- which is the only place the pieces you wrote separately become a single working program.

    Start the application

    Open the Terminal tab and run:

    dotnet run --project src/Lab.csproj
    

    The program runs in the terminal and prints its output there. Every behaviour it shows is code you wrote in the previous steps, so read the output against what each task asked for -- that is the whole lab, working as one program. ### Run the checks yourself

    The lab ran each task's test for you as you went, but they are ordinary tests and you can run them whenever you like. The whole suite:

    dotnet test tests/Lab.Tests.csproj --nologo --verbosity quiet
    

    Or one task at a time:

    bash runTest.sh Task_2_1
    

    Each failure names the task it belongs to, so the output tells you which step to go back to rather than which line to stare at. Two things worth trying before you finish: break something on purpose and watch the matching check fail, then put it back. Knowing what a failure looks like is worth as much as knowing what a pass looks like, and it costs you nothing here.

    When you are done, everything you wrote is still in the editor tabs -- the lab is yours to keep reading.

About the author

Pluralsight’s AI authoring technology is designed to accelerate the creation of hands-on, technical learning experiences. Serving as a first-pass content generator, it produces structured lab drafts aligned to learning objectives defined by Pluralsight’s Curriculum team. Each lab is then enhanced by our Content team, who configure the environments, refine instructions, and conduct rigorous technical and quality reviews. The result is a collaboration between artificial intelligence and human expertise, where AI supports scale and efficiency, and Pluralsight experts ensure accuracy, relevance, and instructional quality, helping learners build practical skills with confidence.

Real skill practice before real-world application

Hands-on Labs are real environments created by industry experts to help you learn. These environments help you gain knowledge and experience, practice without compromising your system, test without risk, destroy without fear, and let you learn from your mistakes. Hands-on Labs: practice your skills before delivering in the real world.

Learn by doing

Engage hands-on with the tools and technologies you’re learning. You pick the skill, we provide the credentials and environment.

Follow your guide

All labs have detailed instructions and objectives, guiding you through the learning process and ensuring you understand every step.

Turn time into mastery

On average, you retain 75% more of your learning if you take time to practice. Hands-on labs set you up for success to make those skills stick.

Get started with Pluralsight