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

When Object Design Starts Fighting Back in C#

A print studio runs its order pipeline through an OrderProcessor class that was only half refactored: its constructor accepts a pricing calculator and a validator, but its methods don't use them. You will finish that refactor so the processor depends on injected contracts, then work outward through the rest of the codebase: creational patterns to control how print jobs and a shared price catalog get built, structural patterns to reconcile a legacy pricing feed and a bundle of jobs that can nest inside each other, behavioral patterns that give each order status its own transition object and make the order discount an interchangeable rule, and finally EF Core repositories coordinated by a Unit of Work so an order and its customer record are saved together in one SaveChanges call.

Lab platform
Lab Info
Level
Advanced
Last updated
Nov 05, 2026
Duration
1h 15m

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: Meet the tangled order pipeline

    Step 1: Meet the tangled order pipeline

    The print studio prints business cards, posters, and banners for local shops, and every order flows through one class: OrderProcessor. It prices the order, decides whether the order is even valid, and—once you look closer—assumes it knows exactly how pricing, validation, and status transitions should work, because it builds every collaborator itself.

    Open src/OrderProcessor.cs. The constructor already accepts an IPricingCalculator and an IOrderValidator, but ProcessOrder doesn't trust either of them: it calls new PricingCalculator() directly, and IsOrderValid repeats validation logic that already lives on OrderValidator. That's the shape you'll spend this lab pulling apart. ## Why direct instantiation is the problem

    A class that constructs its own dependencies can only ever behave one way. Swapping in a test double, a legacy pricing feed, or a discounted strategy means editing OrderProcessor itself, and every edit risks the logic that already works. Constructor injection doesn't remove complexity—it moves the decision of which implementation to use to whoever builds an OrderProcessor, which is exactly where that decision belongs.

    The same question—who decides which behavior runs?—shows up further along the pipeline: what should happen when an order moves out of its current status, and which discount applies to its total. Both are easier to change once each answer is an object of its own. ## The patterns you'll apply, and where

    This lab moves through the codebase in the order a real refactor would:

    • Step 2 breaks OrderProcessor's hard-wired dependencies apart so it depends on contracts, not concrete classes.
    • Step 3 introduces Singleton, Simple Factory, and Prototype to control how shared catalogs and print jobs get built and duplicated.
    • Step 4 reconciles a legacy pricing feed and a tree of bundled jobs using Adapter, Composite, and Decorator.
    • Step 5 gives each order status its own State object, makes the bulk-order discount an interchangeable Strategy, and coordinates EF Core repositories through a Unit of Work.

    Each step leaves the codebase runnable—you're reshaping a working system, not building one from a blank file. ## What's already in src/

    Most of the supporting types you'll need—Order, IPricingCalculator, PrintJob and its subtypes, PrintStudioDbContext—are already written and compiling. Your work is concentrated in the methods that still take the shortcut: a new where an injected field belongs, or a fixed return value where a computed one belongs. Each task tells you exactly which method and which file.

  2. Challenge

    Step 2: Break OrderProcessor's hard-wired dependencies

    Step 2: Break OrderProcessor's hard-wired dependencies

    OrderProcessor already declares a constructor that accepts IPricingCalculator and IOrderValidator—someone started this refactor and stopped halfway. The fields are there, but the methods that should use them still reach for a new instance or repeat logic that belongs elsewhere.

    Constructor injection only pays off once every method actually uses the injected field instead of building its own. That's the gap you're closing first. ## Composing validation instead of inlining it

    OrderValidator already exists as a separate collaborator behind the IOrderValidator contract, with its own Validate method—and its rules are stricter than what OrderProcessor currently checks inline, since it also confirms the customer's email looks valid. Delegating to it isn't just tidier code; it's the only way OrderProcessor picks up that stricter rule without duplicating it.

  3. Challenge

    Step 3: Control construction with creational patterns

    Step 3: Control construction with creational patterns

    Three different construction problems show up once you look past OrderProcessor itself: every part of the system that needs pricing data should share one loaded catalog, not reload it; requests for a print job need to produce the right subtype without the caller knowing which class that is; and staff who customize a job template want to duplicate it without re-entering every option.

    These are three different patterns because they're three different problems—Singleton controls lifetime, Simple Factory controls which class, and Prototype controls how a copy is made. ## Singleton: one shared PriceCatalog

    src/PriceCatalog.cs defines a PriceCatalog with a private constructor—nothing outside the class can call new PriceCatalog()—and a static Instance property that's supposed to be the only way in. Right now that property doesn't actually enforce a single instance; it just hides the constructor call inside itself.

    The textbook fix is a null check: build the instance if _instance is null, then return it. That isn't thread-safe. Two threads can both see null and both run the constructor, so "exactly once" only holds when one thread is running. Lazy<T> does the same deferred construction with the locking built in, which is why you'll use it here.

    In an application with a dependency injection container, you'd usually skip the hand-rolled Singleton and register the class with a singleton lifetime instead (for example, services.AddSingleton<PriceCatalog>()). The container then owns the one instance and injects it, and tests can supply a different one. This lab has no container, so you'll build the pattern by hand to see how it works. ## Simple Factory: build the right PrintJob

    src/PrintJobFactory.cs already defines BusinessCardJob, PosterJob, and BannerJob, each overriding Describe(), plus a PrintJobFamily enum that names the three families. Rather than have every caller pick a constructor directly—and every caller need to know all three class names—PrintJobFactory.Create takes a PrintJobFamily and hands back the matching subtype. The enum means a typo fails to compile instead of producing the wrong job.

    This is a Simple Factory: one method chooses the class with a switch. The Gang of Four Factory Method pattern is different. It declares an overridable creator method on a base class, and each subclass overrides it to decide which product to build. A Simple Factory is the lighter choice when the set of families is small and fixed, as it is here. ## Prototype: clone a customized template

    A JobTemplate bundles a name with a list of chosen options. When staff duplicate a template to start a new job with the same options, they need an independent copy: editing the new job's options must never rewrite the template it came from. That's what Clone is supposed to guarantee, and right now it doesn't.

  4. Challenge

    Step 4: Reshape mismatched interfaces and job trees

    Step 4: Reshape mismatched interfaces and job trees

    The print studio's pricing history predates IPricingCalculator: a LegacyPricingFeed reports rates in cents through a method named FetchRateInCents, which no code that expects an IPricingCalculator can call directly. Meanwhile, staff increasingly bundle several print jobs into one customer order, and those bundles can themselves contain bundles. Both problems are about reconciling shapes that don't match—an old interface against a new one, and a single job against a tree of them. ## Adapter: wrap the legacy feed

    src/PricingFeedAdapter.cs implements IPricingCalculator and holds a LegacyPricingFeed, but its CalculateTotal never actually calls into the feed it wraps. An adapter's whole job is translating one call into another—here, cents-per-unit into a dollar total for the whole order. ## Composite: describe a tree of jobs

    PrintJobBundle is itself a PrintJob that holds a list of other PrintJobs, which is what lets a bundle contain another bundle. That only works if Describe() builds its answer from the children it holds, so a bundle's description automatically reflects whatever's been added to it, at any depth. ## Decorator: log without touching the calculator

    Audit logging needs to happen around every pricing call, but adding it inside PricingCalculator (or PricingFeedAdapter, or any future calculator) would mean repeating the same logging code in every implementation. AuditLoggingPricingDecorator wraps any IPricingCalculator and is supposed to record the call before handing off to the real one—the calculator underneath never knows it's being watched.

    An audit entry needs to say which order was priced, but not who placed it. Identify the order by its Id. A customer email is personal data, and logs tend to be retained longer and read more widely than the records they describe.

  5. Challenge

    Step 5: Model status changes and discounts, then commit as one unit

    Step 5: Model status changes and discounts, then commit as one unit

    Two behaviors in the pipeline still need a home of their own: what happens when an order moves on from its current status, and which discount applies to its total. State and Strategy each turn one of those into an object you can swap. Once those are in place, orders need somewhere durable to live—through EF Core repositories that a Unit of Work commits together, so an order and its customer record are saved at one explicit commit boundary. ## State: let each status own its transition

    src/OrderState.cs defines an abstract OrderState class with one method, Advance, and three implementations—PendingState, ShippedState, DeliveredState—one per status an order can be in. Instead of one method branching on order.Status as a string, each state class knows exactly what advancing from that status means. PendingState is supposed to move an order into "Shipped"; right now it just reads the status back unchanged. ## Strategy: make the order discount interchangeable

    src/DiscountStrategy.cs defines IDiscountStrategy with an Apply method that takes an order total and returns the discounted total. That makes a discount rule just an object that can be swapped for another one—no discount, a bulk discount, a future seasonal discount—without touching whatever code applies it. BulkOrderDiscountStrategy is supposed to cut ten percent off the total of any order over $100; right now it applies no discount at all. ## Repository: stage orders through EF Core

    src/PrintStudioDbContext.cs defines a PrintStudioDbContext with DbSet<OrderRecord> Orders and DbSet<CustomerRecord> Customers, backed in this lab by the EF Core in-memory provider. Two repositories wrap that context so the rest of the code deals in orders and customers, not in DbSet calls directly. CustomerRepository in src/CustomerRepository.cs is already finished: its Add stages a customer on the change tracker without saving. OrderRepository.Add is supposed to do the same for orders, but right now it does nothing at all. ## Unit of Work: commit at one boundary

    Staging an order through OrderRepository.Add, or a customer through CustomerRepository.Add, doesn't persist anything until something calls SaveChanges. UnitOfWork exists to be that one commit boundary. It exposes both repositories as Orders and Customers, built over the same PrintStudioDbContext, and Commit saves whatever they've staged in a single SaveChanges call. Right now Commit always returns 0 without saving anything.

    On a relational provider such as SQL Server, PostgreSQL, or SQLite, EF Core wraps each SaveChanges call in a database transaction, so the order and the customer are saved together or not at all. The in-memory provider this lab uses doesn't support transactions. It shows where the commit happens, but it can't roll back a half-finished save.

    In production code

    • DbContext is already a Unit of Work (its change tracker plus SaveChanges), and each DbSet<T> already behaves like a repository. Wrapping them pays off when you want domain-facing methods such as FindByEmail, or a seam that keeps EF Core out of the rest of the code. Many teams use the DbContext directly instead.
    • Prefer an async CommitAsync that awaits SaveChangesAsync, so a database round trip doesn't block a thread.
    • Put an IUnitOfWork interface in front of the class so consumers can be tested with a fake, and decide who disposes the context: either UnitOfWork implements IDisposable and disposes it, or a DI container creates both with a scoped lifetime and disposes them at the end of the request.

    This lab keeps Commit synchronous and concrete so the boundary itself stays in focus.

  6. Challenge

    Step 6: Watch one order travel the rebuilt pipeline

    Step 6: Watch one order travel the rebuilt pipeline

    Every task is done and graded. Now follow one order through everything you rebuilt. Two OrderProcessor instances price it with two different calculators, then the tour builds its print jobs, ships the order, applies the discount, and saves the order and its customer in a single commit.

    Run the tour

    In the Terminal tab, run:

    dotnet run --project src/Lab.csproj
    

    src/Program.cs follows one order for [email protected] (250 business cards, 6 posters, and 4 banners). Any task you haven't finished shows up as a not built yet line instead of a result. ## One processor class, two pricing calculators

    Look at the Pricing and validation section. The tour builds two OrderProcessor instances, each given a different calculator, and prices the same order with both:

    Flat-rate PricingCalculator total: $2600.00
    Legacy feed, adapted and audited, total: $115.00
      Audit log: Pricing requested for order 1042
    

    The totals differ because the two calculators price differently:

    • The flat-rate PricingCalculator charges $10.00 per unit, whatever the job: 260 units x $10.00 = $2600.00.
    • The legacy feed charges by family: 250 business cards x $0.10 + 6 posters x $5.00 + 4 banners x $15.00 = $25.00 + $30.00 + $60.00 = $115.00.

    Both totals come from OrderProcessor.ProcessOrder, and OrderProcessor needed no further change after Task 2.1, when it started using whichever calculator it's given. The legacy feed reaches it through your PricingFeedAdapter from Task 4.1, which turns the feed's cents into dollars, wrapped in your audit decorator from Task 4.3.

    The audit line above uses the message suggested in Task 4.3, so yours may be worded differently. Either way, it names order 1042 and leaves out [email protected].

    The next two lines show True for [email protected] and False for shop.example.com. The old inline checks in IsOrderValid never looked at the email. Now that the method delegates to OrderValidator (Task 2.2), the order with no @ is rejected. ## Jobs built, bundled, and copied

    In Building print jobs, PrintJobFactory returns Poster Job, Banner Job, Business Card Job: one job for each PrintJobFamily value, where it used to return a business card every time (Task 3.2). The launch bundle holds a business card and an event kit, and the event kit is itself a bundle holding the poster and the banner. Describe() lists the launch bundle's children in order, and when it reaches the event kit it lists that bundle's children, so the output reads Business Card Job, Poster Job, Banner Job. Your join from Task 4.2 handles the nested bundle with no special case.

    The template lines show your Prototype from Task 3.3. The clone gained Rounded corners, but the original still lists only Matte, because each copy owns its own Options list. Above them, PriceCatalog.Instance (Task 3.1) returns the same object every time, so every caller reads the same rates. ## Shipped, discounted, and committed together

    Fulfillment shows Status: Pending -> Shipped -> Delivered -> Delivered. Each arrow is one state object's Advance call, not a branch on the status string. Your PendingState from Task 5.1 starts the chain. DeliveredState is the last state, so it leaves the order where it is. Your bulk discount from Task 5.2 turns the $115.00 total into $103.50, and a $60.00 order passes through unchanged.

    The Unit of Work section shows the commit boundary you built in Tasks 5.3 and 5.4:

    Before Commit: orders saved = 0, customers saved = 0
    Commit wrote 2 records
    After Commit: orders saved = 1, customers saved = 1
    Saved order #1 for [email protected], total $103.50
    

    OrderRepository.Add and CustomerRepository.Add only staged their rows, so nothing was saved until Commit ran. Then one SaveChanges call wrote both rows. Against a relational database, EF Core would run that call in a transaction, so a failure on either row would save neither. The in-memory provider has no transactions, so the tour shows the single commit point but not a rollback.

    That's the lab. The class that used to build everything itself now receives its collaborators, and each piece you replaced can change without touching the others.

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