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# Checkout Domain Design

You'll extend a pie-shop checkout system to handle three real-world requirements: stopping out-of-stock sales, applying a new discount based on order size, and adding a faster delivery option. In addition, you will be responsible for protecting an object's invariants through encapsulation, composing collaborators through constructors, swapping in new behavior through interfaces instead of conditionals, and extending a class hierarchy with an immutable value object.

Lab platform
Lab Info
Level
Beginner
Last updated
Sep 30, 2026
Duration
30m

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: Protect the stock invariant

    Welcome to the lab. This lab will expand upon the Bethany's Pie Shop application from the Object-oriented Programming in C# 14 course, but you do not need to have taken that course to complete this lab.


    Bethany's Pie Shop's checkout can already price orders, apply loyalty and seasonal discounts, and ship, deliver, or hold pies for pickup, but it has no reliable idea how many pies are actually on the shelf. You'll extend the BethanysPieShop.InventoryManagement domain with stock that protects itself, a minimum-order discount that plugs into checkout through the existing discount interfaces, and an express delivery option with its own scheduled delivery window, then watch all three work together in the same checkouts.

    Here, you'll make Product responsible for its own stock. Reserving stock means lowering a product's StockQuantity by the quantity an order requests, and releasing stock means adding that quantity back. The shop's rule is that StockQuantity can never drop below zero. By the end of this step, Product enforces that rule itself, and checkout rejects any order that asks for more pies than the shop has.

    The application/ folder already contains the src/BethanysPieShop.InventoryManagement solution with a first version of Product, a working ReleaseStock method, and commented-out checkout code that later tasks ask you to enable.

    info> The first time you open this lab, VS Code will prompt you to trust the workspace. Check the box, then click the button to trust all authors.

    info> Feeling stuck? Check out the matching solution/stepN/ folder for the step you're on to see a working implementation. Give it a try on your own first. ---

    Encapsulation starts with the setter

    Right now StockQuantity has a public setter, so any code anywhere can assign it a negative number and nothing stops it. An object should own its own state: expose what callers need to read, and change state only through members that can enforce the rules. A private set keeps the property readable from outside while reserving writes for Product itself, and a guard clause in the constructor rejects bad input at the one moment a value first enters the object. Zero is a legitimate stock level, because a sold-out pie is still a product, so the guard must reject only negative values, and it should throw ArgumentException like the constructor's existing guards do. ### Guarded mutation and two kinds of failure

    With the setter closed, the only way stock can go down is through a method that Product controls. ReserveStock has two distinct reasons to refuse: a quantity that is zero or negative is a caller mistake, which is what ArgumentException is for, while a quantity larger than what is on the shelf is a legitimate request that the business rule forbids, which this codebase already signals with InvalidOperationException (the exception the guardrail in Program.cs catches). Requesting exactly the remaining stock is allowed and leaves the product at zero.

    info> The commented-out block in AddLines is the checkout side of this rule. After every requested line is on the order, it reserves stock for each line and, if a later line cannot be reserved, releases the lines it already reserved before rethrowing, so a rejected checkout leaves every product's stock untouched. That block cannot compile until ReserveStock exists, so add the method before you uncomment it.

  2. Challenge

    Step 2: Add a new discount policy

    With Product guarding its own stock, every checkout now respects what is on the shelf. Checkout already accepts any IDiscountPolicy from its caller, so a new discount can take effect without any change to how CheckoutApplicationService chooses a policy. Here, you'll add a MinimumOrderDiscountPolicy that discounts only orders at or above a minimum total, then remove the one conditional left in checkout's discount path by letting NoDiscountPolicy stand in when no discount applies. By the end of this step, every checkout computes its final total through a single polymorphic call. ---

    A new strategy behind the same interfaces

    IDiscountPolicy and IDiscountDescriber are capability interfaces: ApplyDiscount turns one Money total into another, and DescribeDiscount explains the rule in words. Because checkout only knows those interfaces, a new discount is a new class rather than a new branch. LoyaltyDiscountPolicy is the closest template, with a sealed class, a constructor that validates its percentage, and Money's * and - operators doing the arithmetic. The amount ApplyDiscount receives is the order's subtotal plus shipping, and the threshold is inclusive, so a total exactly equal to the minimum is discounted.

    info> Program.cs has commented code that constructs this policy as new MinimumOrderDiscountPolicy(new Money(60m), 10m), which fixes the constructor's parameter order: the minimum first, then the percentage. ### Replacing a branch with an object

    BuildSummary still contains one conditional: when no policy is supplied it calls CalculateTotal(), and otherwise CalculateTotal(discountPolicy). The codebase already has an object that means "no discount", because NoDiscountPolicy returns whatever total it is given. If the null case becomes that object, the branch disappears and BuildSummary makes a single polymorphic call regardless of which policy it received, which is polymorphism doing the job an if used to do. C#'s null-coalescing operator expresses the fallback without a branch, for example var handler = suppliedHandler ?? new DefaultHandler();, which keeps suppliedHandler when it is not null and otherwise uses the default.

    info> Make the change inside BuildSummary itself rather than moving the decision into a helper method. If you prefer not to allocate a new NoDiscountPolicy on every call, a class-level static readonly field holding one instance is equally acceptable.

  3. Challenge

    Step 3: Add express delivery

    With stock and discounts handled, checkout still offers only pickup, shipping, and local delivery. Here, you'll add express delivery the same way the existing options were built: an immutable DeliveryWindow value object for the scheduled time slot, a sealed ExpressDelivery subclass that receives its collaborators through its constructor, and a factory method on Delivery that the provided Order and checkout code build on. The window stores a start time and a duration instead of a start and an end, so a with expression can never produce a window that ends before it starts. By the end of this step, a checkout can request express delivery and report both its window and its flat shipping cost. ---

    Value objects that stay valid under with

    A delivery window has no identity of its own, because two windows with the same start and duration are the same window, and that is exactly what a C# record gives you: value equality, with expressions for copies, and init accessors that run during construction and during with. That last point is why the validation must live in the init accessor rather than only in the constructor, since with { Duration = ... } skips the constructor entirely but still runs the accessor. ShippingAddress in this codebase shows the pattern: a private backing field behind a property whose init accessor validates before storing, plus a constructor that assigns through the properties. Here is an example:

    private int _count;
    
    public int Count
    {
        get => _count;
        init
        {
            if (value < 0)
            {
                throw new ArgumentException("Count cannot be negative.", nameof(value));
            }
    
            _count = value;
        }
    }
    ``` ### Inheritance for the contract, composition for the data
    
    `Delivery` is an abstract class with two obligations, `DisplayName` and `CalculateShippingCost`, and `PickupDelivery`, `ShippingDelivery`, and `LocalDelivery` each fulfill them with their own rule. `ExpressDelivery` joins that hierarchy the same way, and the data it needs, where to deliver and when, arrives through its constructor as two collaborators it holds onto, which is composition: passing required collaborators through the constructor. `LocalDelivery` is the template for the shape: a sealed class, constructor null guards, and a get-only property per collaborator. Express shipping is a flat 12.00 regardless of subtotal, with no free-shipping threshold.
    
    warning> `DisplayName` carries the window into the checkout summary, so it must follow the exact template `Express delivery ({start:yyyy-MM-dd HH:mm}-{end:HH:mm})`, and it must format with `CultureInfo.InvariantCulture` from `System.Globalization`. The `:` in a custom date format is otherwise replaced by the current culture's time separator, which is not always a colon. The shape is `someDate.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture)`. ### Factory methods keep creation in one place
    
    Every other `Delivery` subtype is created through a static factory on the abstract class (`ForPickup`, `ForShipping`, `ForLocalDelivery`) rather than with `new` at the call site, and `Order` builds on those factories from behind its private constructor. Adding `ForExpress` slots express delivery into that same controlled path, so callers never construct an `ExpressDelivery` directly.
    
    info> Two pieces of scaffold are waiting on `ForExpress`. `Order.CreateExpressDeliveryOrder` builds an order through `Order`'s private constructor using `Delivery.ForExpress`, exactly as `CreateLocalDeliveryOrder` does, and `CheckoutApplicationService.CheckoutExpressDelivery` creates that order and reuses the existing `AddLines` and `BuildSummary` methods, exactly as `CheckoutLocalDelivery` does. Neither compiles until `ForExpress` exists, so add the factory first.
  4. Challenge

    Step 4: Verify the checkout scenarios

    Stock reservation, the minimum-order discount, and express delivery now each exist as working pieces of the domain. Here, you'll bring them together into real checkouts and watch them interact: an order that qualifies for the new discount, one that doesn't, and one the stock guard turns away. By the end of this step, you'll see all three enhancements working together in the same checkout flow. ---

    Copying a value object with with

    A with expression copies a record while changing selected properties, for example var moved = original with { Start = newStart };, and because DeliveryWindow validates in its init accessor, the copy is guaranteed valid. That single line is what the scaffold leaves to you. Program.cs holds three commented-out checkout scenarios that use everything you built: the first checks out four Cherry Pies by express delivery with the minimum-order discount and qualifies for it, the second checks out two Blueberry Pies on a rescheduled window with the same policy and does not qualify, so the same object correctly leaves it undiscounted, and the third asks for more Blueberry Pies than remain, so the stock guard rejects it and the Cherry Pie reservation from that order is released.

    warning> Keep the scaffolded Console.WriteLine lines and the variable names expressWindow and rescheduledWindow exactly as they are, and declare rescheduledWindow together with its with expression in one statement. The block will not compile until that declaration exists. ### See the checkout scenarios run

    In the Terminal, confirm you're in the application folder, then run:

    dotnet run --project src/BethanysPieShop.InventoryManagement.Console
    

    After the existing scenarios, you'll see Express delivery (2026-12-23 14:00-16:00) | shipping: 12.00 | before discount: 72.00 | after discount: 64.80, then a rescheduled line for 2026-12-24 10:00-12:00 showing 48.00 both before and after the discount, then a Guardrail: line naming Blueberry Pie, and finally Cherry Pie stock after rejected checkout: 8. Well done. You extended Bethany's Pie Shop's checkout with three real business rules and watched them run together in the console: a product that refuses to be oversold, a discount that applies itself only when an order earns it, and an express delivery option with its own scheduled window.

    Along the way, you practiced these design moves on code you had not seen before: you closed a setter and guarded a constructor so Product owns its invariant, you added behavior as a new IDiscountPolicy class and replaced a conditional with a NoDiscountPolicy object, you composed ExpressDelivery from collaborators passed through its constructor, and you shaped DeliveryWindow so that even a with copy cannot break its rule.

    Those same moves transfer to any domain model you evolve: protect state at the boundary, add behavior through abstractions rather than branches, and make invalid values unrepresentable.

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