- Lab
-
Libraries: If you want this lab, consider one of these libraries.
- Core Tech
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 Info
Table of Contents
-
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
Orderfrom 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. Opensrc/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 }Orderis an immutable record. ItsItemslist (each entry a simple item name) is what the list-pattern arms in Step 5 will inspect, andTotalAmountis what every price-adjustment function threads through the pipeline. ## Skeleton source filesThe
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/Funcrefactor 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
// TODOcomments marking the exact lines you will fill in. The test suite intests/imports all of them, so a compilation error in one file surfaces immediately across every step. ## How the five stages connectOrder ──► 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.csor 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
solutionfolder.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. -
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
FuncandActionexisted as built-in types, every callback in C# required a nameddelegatedeclaration. 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 delegatesA named delegate declaration looks like a method signature prefixed with the
delegatekeyword: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 callableThe 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 iterateGetInvocationList()yourself. ## Multicast delegates and invocation listsCombining 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
PriceAdjusterdelegate 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 aList<Func<decimal,decimal>>. -
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
ActionandFuncdelegate families since .NET 3.5, eliminating the need for a separatedelegatedeclaration in most scenarios. The trade-off is purely syntactic:Func<Order, bool>is structurally identical to theOrderValidatoryou 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 closuresA 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 110This 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.
-
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
eventkeyword 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
OrderProcessorclass 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 EventArgsThe .NET event convention passes two arguments to every handler: the sender (
object) and anEventArgs-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. -
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.Jsonsource generation viaJsonSerializerContext. 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 expressionsA
switchexpression 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 serializationSystem.Text.Jsoncan generate all serialization metadata at compile time so your application never needs to scan types at startup. You opt in by:- Creating a
partial classthat inheritsJsonSerializerContext. - 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
Defaultsingleton with a strongly-typedJsonTypeInfo<OrderSummary>property. Passing that property toJsonSerializer.Serializeroutes the call entirely through the generated code path. ## Using the generated contextOnce 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 aJsonSerializerOptionsinstance. This overload is what bypasses reflection. If you passJsonSerializerOptionsinstead, the runtime silently falls back to reflection and the source-generation benefit is lost. - Creating a
-
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.csprojThe 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 quietOr one task at a time:
bash runTest.sh Task_2_1Each 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
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.