- Lab
-
Libraries: If you want this lab, consider one of these libraries.
- Core Tech
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 Info
Table of Contents
-
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.InventoryManagementdomain 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
Productresponsible for its own stock. Reserving stock means lowering a product'sStockQuantityby the quantity an order requests, and releasing stock means adding that quantity back. The shop's rule is thatStockQuantitycan never drop below zero. By the end of this step,Productenforces that rule itself, and checkout rejects any order that asks for more pies than the shop has.The
application/folder already contains thesrc/BethanysPieShop.InventoryManagementsolution with a first version ofProduct, a workingReleaseStockmethod, 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
StockQuantityhas 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. Aprivate setkeeps the property readable from outside while reserving writes forProductitself, 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 throwArgumentExceptionlike the constructor's existing guards do. ### Guarded mutation and two kinds of failureWith the setter closed, the only way stock can go down is through a method that
Productcontrols.ReserveStockhas two distinct reasons to refuse: a quantity that is zero or negative is a caller mistake, which is whatArgumentExceptionis 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 withInvalidOperationException(the exception the guardrail inProgram.cscatches). Requesting exactly the remaining stock is allowed and leaves the product at zero.info> The commented-out block in
AddLinesis 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 untilReserveStockexists, so add the method before you uncomment it. -
Challenge
Step 2: Add a new discount policy
With
Productguarding its own stock, every checkout now respects what is on the shelf. Checkout already accepts anyIDiscountPolicyfrom its caller, so a new discount can take effect without any change to howCheckoutApplicationServicechooses a policy. Here, you'll add aMinimumOrderDiscountPolicythat discounts only orders at or above a minimum total, then remove the one conditional left in checkout's discount path by lettingNoDiscountPolicystand 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
IDiscountPolicyandIDiscountDescriberare capability interfaces:ApplyDiscountturns oneMoneytotal into another, andDescribeDiscountexplains the rule in words. Because checkout only knows those interfaces, a new discount is a new class rather than a new branch.LoyaltyDiscountPolicyis the closest template, with a sealed class, a constructor that validates its percentage, andMoney's*and-operators doing the arithmetic. The amountApplyDiscountreceives is the order's subtotal plus shipping, and the threshold is inclusive, so a total exactly equal to the minimum is discounted.info>
Program.cshas commented code that constructs this policy asnew MinimumOrderDiscountPolicy(new Money(60m), 10m), which fixes the constructor's parameter order: the minimum first, then the percentage. ### Replacing a branch with an objectBuildSummarystill contains one conditional: when no policy is supplied it callsCalculateTotal(), and otherwiseCalculateTotal(discountPolicy). The codebase already has an object that means "no discount", becauseNoDiscountPolicyreturns whatever total it is given. If the null case becomes that object, the branch disappears andBuildSummarymakes a single polymorphic call regardless of which policy it received, which is polymorphism doing the job anifused to do. C#'s null-coalescing operator expresses the fallback without a branch, for examplevar handler = suppliedHandler ?? new DefaultHandler();, which keepssuppliedHandlerwhen it is not null and otherwise uses the default.info> Make the change inside
BuildSummaryitself rather than moving the decision into a helper method. If you prefer not to allocate a newNoDiscountPolicyon every call, a class-levelstatic readonlyfield holding one instance is equally acceptable. -
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
DeliveryWindowvalue object for the scheduled time slot, a sealedExpressDeliverysubclass that receives its collaborators through its constructor, and a factory method onDeliverythat the providedOrderand checkout code build on. The window stores a start time and a duration instead of a start and an end, so awithexpression 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
withA 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#
recordgives you: value equality,withexpressions for copies, andinitaccessors that run during construction and duringwith. That last point is why the validation must live in theinitaccessor rather than only in the constructor, sincewith { Duration = ... }skips the constructor entirely but still runs the accessor.ShippingAddressin this codebase shows the pattern: a private backing field behind a property whoseinitaccessor 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. -
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
withA
withexpression copies a record while changing selected properties, for examplevar moved = original with { Start = newStart };, and becauseDeliveryWindowvalidates in itsinitaccessor, the copy is guaranteed valid. That single line is what the scaffold leaves to you.Program.csholds 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.WriteLinelines and the variable namesexpressWindowandrescheduledWindowexactly as they are, and declarerescheduledWindowtogether with itswithexpression in one statement. The block will not compile until that declaration exists. ### See the checkout scenarios runIn the Terminal, confirm you're in the
applicationfolder, then run:dotnet run --project src/BethanysPieShop.InventoryManagement.ConsoleAfter 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 for2026-12-24 10:00-12:00showing48.00both before and after the discount, then aGuardrail:line naming Blueberry Pie, and finallyCherry 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
Productowns its invariant, you added behavior as a newIDiscountPolicyclass and replaced a conditional with aNoDiscountPolicyobject, you composedExpressDeliveryfrom collaborators passed through its constructor, and you shapedDeliveryWindowso that even awithcopy 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
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.