- Lab
-
Libraries: If you want this lab, consider one of these libraries.
- Core Tech
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 Info
Table of Contents
-
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 anIPricingCalculatorand anIOrderValidator, butProcessOrderdoesn't trust either of them: it callsnew PricingCalculator()directly, andIsOrderValidrepeats validation logic that already lives onOrderValidator. That's the shape you'll spend this lab pulling apart. ## Why direct instantiation is the problemA 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
OrderProcessoritself, 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 anOrderProcessor, 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,PrintJoband its subtypes,PrintStudioDbContext—are already written and compiling. Your work is concentrated in the methods that still take the shortcut: anewwhere an injected field belongs, or a fixed return value where a computed one belongs. Each task tells you exactly which method and which file. - Step 2 breaks
-
Challenge
Step 2: Break OrderProcessor's hard-wired dependencies
Step 2: Break OrderProcessor's hard-wired dependencies
OrderProcessoralready declares a constructor that acceptsIPricingCalculatorandIOrderValidator—someone started this refactor and stopped halfway. The fields are there, but the methods that should use them still reach for anewinstance 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
OrderValidatoralready exists as a separate collaborator behind theIOrderValidatorcontract, with its ownValidatemethod—and its rules are stricter than whatOrderProcessorcurrently checks inline, since it also confirms the customer's email looks valid. Delegating to it isn't just tidier code; it's the only wayOrderProcessorpicks up that stricter rule without duplicating it. -
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
OrderProcessoritself: 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.csdefines aPriceCatalogwith a private constructor—nothing outside the class can callnew PriceCatalog()—and a staticInstanceproperty 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
_instanceis 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 PrintJobsrc/PrintJobFactory.csalready definesBusinessCardJob,PosterJob, andBannerJob, each overridingDescribe(), plus aPrintJobFamilyenum 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.Createtakes aPrintJobFamilyand 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 templateA
JobTemplatebundles 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 whatCloneis supposed to guarantee, and right now it doesn't. -
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: aLegacyPricingFeedreports rates in cents through a method namedFetchRateInCents, which no code that expects anIPricingCalculatorcan 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 feedsrc/PricingFeedAdapter.csimplementsIPricingCalculatorand holds aLegacyPricingFeed, but itsCalculateTotalnever 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 jobsPrintJobBundleis itself aPrintJobthat holds a list of otherPrintJobs, which is what lets a bundle contain another bundle. That only works ifDescribe()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 calculatorAudit logging needs to happen around every pricing call, but adding it inside
PricingCalculator(orPricingFeedAdapter, or any future calculator) would mean repeating the same logging code in every implementation.AuditLoggingPricingDecoratorwraps anyIPricingCalculatorand 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. -
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.csdefines an abstractOrderStateclass with one method,Advance, and three implementations—PendingState,ShippedState,DeliveredState—one per status an order can be in. Instead of one method branching onorder.Statusas a string, each state class knows exactly what advancing from that status means.PendingStateis supposed to move an order into"Shipped"; right now it just reads the status back unchanged. ## Strategy: make the order discount interchangeablesrc/DiscountStrategy.csdefinesIDiscountStrategywith anApplymethod 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.BulkOrderDiscountStrategyis 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 Coresrc/PrintStudioDbContext.csdefines aPrintStudioDbContextwithDbSet<OrderRecord> OrdersandDbSet<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 inDbSetcalls directly.CustomerRepositoryinsrc/CustomerRepository.csis already finished: itsAddstages a customer on the change tracker without saving.OrderRepository.Addis supposed to do the same for orders, but right now it does nothing at all. ## Unit of Work: commit at one boundaryStaging an order through
OrderRepository.Add, or a customer throughCustomerRepository.Add, doesn't persist anything until something callsSaveChanges.UnitOfWorkexists to be that one commit boundary. It exposes both repositories asOrdersandCustomers, built over the samePrintStudioDbContext, andCommitsaves whatever they've staged in a singleSaveChangescall. Right nowCommitalways returns0without saving anything.On a relational provider such as SQL Server, PostgreSQL, or SQLite, EF Core wraps each
SaveChangescall 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
DbContextis already a Unit of Work (its change tracker plusSaveChanges), and eachDbSet<T>already behaves like a repository. Wrapping them pays off when you want domain-facing methods such asFindByEmail, or a seam that keeps EF Core out of the rest of the code. Many teams use theDbContextdirectly instead.- Prefer an async
CommitAsyncthat awaitsSaveChangesAsync, so a database round trip doesn't block a thread. - Put an
IUnitOfWorkinterface in front of the class so consumers can be tested with a fake, and decide who disposes the context: eitherUnitOfWorkimplementsIDisposableand disposes it, or a DI container creates both with a scoped lifetime and disposes them at the end of the request.
This lab keeps
Commitsynchronous and concrete so the boundary itself stays in focus. -
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
OrderProcessorinstances 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.csprojsrc/Program.csfollows one order for[email protected](250 business cards, 6 posters, and 4 banners). Any task you haven't finished shows up as anot built yetline instead of a result. ## One processor class, two pricing calculatorsLook at the Pricing and validation section. The tour builds two
OrderProcessorinstances, 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 1042The totals differ because the two calculators price differently:
- The flat-rate
PricingCalculatorcharges $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, andOrderProcessorneeded no further change after Task 2.1, when it started using whichever calculator it's given. The legacy feed reaches it through yourPricingFeedAdapterfrom 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
1042and leaves out[email protected].The next two lines show
Truefor[email protected]andFalseforshop.example.com. The old inline checks inIsOrderValidnever looked at the email. Now that the method delegates toOrderValidator(Task 2.2), the order with no@is rejected. ## Jobs built, bundled, and copiedIn Building print jobs,
PrintJobFactoryreturnsPoster Job, Banner Job, Business Card Job: one job for eachPrintJobFamilyvalue, 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 readsBusiness 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 onlyMatte, because each copy owns its ownOptionslist. Above them,PriceCatalog.Instance(Task 3.1) returns the same object every time, so every caller reads the same rates. ## Shipped, discounted, and committed togetherFulfillment shows
Status: Pending -> Shipped -> Delivered -> Delivered. Each arrow is one state object'sAdvancecall, not a branch on the status string. YourPendingStatefrom Task 5.1 starts the chain.DeliveredStateis 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.50OrderRepository.AddandCustomerRepository.Addonly staged their rows, so nothing was saved untilCommitran. Then oneSaveChangescall 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.
- The flat-rate
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.