- Lab
-
Libraries: If you want this lab, consider one of these libraries.
- Core Tech
Dependency Graphs Outgrow Manual Wiring in C#
Trailhead Outfitters' order backend, OrderPulse, started as a console prototype where every class built its own dependencies with `new`. It now needs to grow into a background worker that drains pending orders and a small web API that reports order status -- both reading from the same services. Along the way the team hits a stale-data bug caused by a singleton holding onto a scoped repository, needs per-customer pricing discounts driven by configuration, must send confirmations by email or SMS depending on customer preference, and wants audit logging on every notification without smearing container lookups through the business logic. You will carry OrderPulse through each of these problems using nothing but the DI container that ships with .NET.
Lab Info
Table of Contents
-
Challenge
Step 1: Meet OrderPulse's tangled dependency graph
OrderPulse: built fast, wired by hand
Trailhead Outfitters' order backend, OrderPulse, started life as a console prototype. Its core class,
OrderProcessor, looks up an order and prices it, and today it does both by reaching fornewdirectly:new InMemoryOrderRepository()to fetch the order,new StandardPricingService()to price it. That was fine for a prototype nobody else touched. It stops being fine the moment a second entry point needs the same objects.And a second entry point is exactly what's coming. OrderPulse is about to grow a background worker that drains pending orders on a timer, and a small ASP.NET Core API that reports order status on demand. Both need an
IOrderRepositoryand anOrderProcessor. If each class keeps building its own copies withnew, you get two divergent object graphs instead of one consistent system -- and every future swap (a different repository, a test double, a decorator) means hunting down everynewcall site by hand. ## What a DI container actually buys you.NET ships a dependency injection container out of the box:
IServiceCollectionto declare what a type needs and how to build it, andIServiceProviderto resolve fully-built object graphs from those declarations. The shift you're making across this lab is small in mechanics but large in consequence: classes stop constructing their dependencies and start declaring them as constructor parameters. Something else -- the container -- decides what concrete type satisfies each parameter, and it decides once, in one place, instead of scattered across every class that happens to need anIOrderRepository.This is what lets a background worker and a web API share one consistent graph, lets you swap a real repository for a test double without touching business logic, and lets a container-managed lifetime (singleton, scoped, transient) replace ad-hoc rules about when a
newobject should or shouldn't be reused. ## The five problems aheadYou'll carry OrderPulse through this progression:
- Step 2 -- pull the repository and pricing service out of
OrderProcessorand into constructor parameters, then build the first composition root that resolves the whole graph from anIServiceCollection. - Step 3 -- add a Generic Host
BackgroundServiceand an ASP.NET Core controller that both consume that same graph, proving a worker and a web API can share one wiring instead of each inventing its own. - Step 4 -- catch a real bug: a long-lived singleton worker that captured a scoped repository and now serves stale data, and fix it with
IServiceScopeFactoryand correctIDisposablehandling. - Step 5 -- bind pricing configuration through
IOptions, register keyed HttpClient-backed notification senders chosen per customer, wrap them in a logging decorator, and assemble one final host that runs the whole submit-to-notify flow.
Each step edits real files under
src/and each change is checked by running the code, not by reading it -- so by the end you will have a working, container-driven OrderPulse, not just a description of one. ## Where you'll be workingOpen
src/OrderProcessor.csnow, before Step 2 asks you to change anything. Notice the twonewexpressions inside it -- one building a repository, one building a pricing service -- and notice that nothing aboutOrderProcessor's public methods needs to know which repository or pricing service it gets, only that it gets one that implements the right interface. That observation is the entire premise of constructor injection, and it's what Step 2 puts into practice first.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. - Step 2 -- pull the repository and pricing service out of
-
Challenge
Step 2: Replace manual wiring with constructor injection and the built-in container
Constructor injection, one dependency at a time
Constructor injection means a class declares what it needs as a constructor parameter and stores it, instead of building it internally. The class becomes agnostic about which implementation it receives -- a real
InMemoryOrderRepositorytoday, a fake one in a test tomorrow -- as long as it satisfies the interface.OrderProcessor's constructor already asks for anIOrderRepositoryand anIPricingServiceand stores both, yetsrc/OrderProcessor.csstill violates this twice:LoadOrderAsyncbuilds its ownInMemoryOrderRepository, andPriceOrderbuilds its ownStandardPricingService. You'll remove the first one now. ## Now the second dependencyWith the repository injected,
OrderProcessorstill hides a secondnewinsidePriceOrder. The fix is the same shape: call the field the constructor already set instead of constructing a fresh instance. Doing this one dependency at a time (rather than rewriting the whole class at once) is deliberate -- it's the habit you'll rely on when a class has five dependencies instead of two. ## Somebody still has to build the graphOrderProcessorno longer builds anything itself, but something still has to decide that anIOrderRepositoryparameter should receive anInMemoryOrderRepository, and that anIPricingServiceparameter should receive aStandardPricingService. That's the job of a composition root: one place that registers every interface-to-implementation mapping in anIServiceCollection, builds anIServiceProviderfrom it, and resolves the top-level type it needs.src/CompositionRoot.csis that place for OrderPulse. Once it can resolve a workingOrderProcessor, every constructor parameterOrderProcessordeclares is being satisfied by the container, not by you. -
Challenge
Step 3: Share injected services across a Generic Host worker and an ASP.NET controller
A background worker that shares the graph
The Generic Host's
BackgroundServiceis how .NET runs long-lived background work -- a class that overridesExecuteAsyncand keeps running for the lifetime of the app.src/OrderPollingWorker.csis OrderPulse's: it needs anIOrderRepositoryto find pending orders and anOrderProcessorto process each one, and it gets both exactly the wayOrderProcessorgets its dependencies -- through the constructor. The loop itself calls a smaller, directly testable method so you (and the grader) can drive one polling pass without spinning up the whole host. ## A controller that shares the same graphASP.NET Core controllers get their dependencies through constructor injection too -- the framework resolves a controller instance per request the same way
IServiceProvider.GetRequiredServiceresolves anything else.src/OrdersController.csneeds the sameIOrderRepositorythe worker uses, so a request for an order's status reflects whatever the worker most recently wrote. That shared visibility is only possible because both classes declare their dependencies instead of constructing private copies. ## One registration list, two hostsOrderPollingWorkerandOrdersControllermust never end up resolving from two differentIServiceCollectioninstances -- that would silently recreate the exact fragmentation problem manual wiring caused.src/CompositionRoot.csgets extended, not duplicated: the same method that will back the worker's host also backs the controller's host. OnceRegisterHostServiceslists every type both classes depend on, resolving either one from the resulting provider proves they're reading from one consistent graph. -
Challenge
Step 4: Choose correct lifetimes and eliminate captive dependencies
The bug: a singleton holding a scoped dependency
A
BackgroundServiceis registered once and lives for the whole application -- effectively a singleton.OrderPollingWorkercurrently captures itsIOrderRepositoryandOrderProcessorin the constructor and reuses those exact instances for every batch it drains, forever. If the repository is meant to be scoped -- fresh per unit of work -- then capturing it once at startup turns it into an accidental long-lived singleton: a captive dependency. The fix isn't a new lifetime annotation, it's asking the container for a fresh instance per batch usingIServiceScopeFactory.CreateScope(), then resolving from that scope's provider instead of the captured fields. ## Disposing exactly onceEvery scope you create needs to end cleanly, and anything scoped that implements
IDisposablegets disposed when its scope ends.src/InMemoryOrderRepository.csowns an audit resource that needs exactly that guarantee: released when the repository's scope closes, but never released twice if something disposes it more than once (which happens easily once scopes are created and torn down in a loop, as Task 4.1 just introduced). Guard the release with a flag so a secondDisposecall is a safe no-op instead of a double-free. -
Challenge
Step 5: Configure options, keyed HttpClient senders, and decorators to assemble OrderPulse
Pricing rules that come from configuration
IOptions<T>lets a class receive strongly-typed configuration through the same constructor-injection mechanism as any other dependency, instead of reading raw config strings itself.src/StandardPricingService.cscurrently hardcodes its discount rate; binding aPricingOptionstype throughIOptions<PricingOptions>means that rate now comes from configuration, so Trailhead Outfitters can change per-customer discounts without a recompile. ## Picking a sender by key, sending over a real HttpClientKeyed services let the container hold multiple registrations of the same interface, distinguished by a key you choose at resolution time -- exactly what's needed when a customer's notification channel (email or SMS) is decided per request, not fixed at startup.
src/NotificationSenders.csholds both: a plain email sender and an SMS sender that must call out over HTTP. The SMS sender takes itsHttpClientthrough constructor injection too -- the typed-client pattern -- so the container manages the client's lifetime and you never callnew HttpClient()yourself. ## Auditing without a service locatorA decorator wraps an existing implementation of an interface and adds behavior around it -- here, an audit log entry around every notification send -- without the wrapped sender or the calling code knowing logging happens at all. Registering a decorator through a factory delegate (
services.AddSingleton<INotificationSender>(sp => ...)) means the container builds the inner keyed sender and wraps it in one step, so nothing inOrderProcessoror the senders themselves ever callsIServiceProvider.GetServicedirectly -- the anti-pattern a service locator would introduce.src/NotificationDecorator.csis where that wrapping logic lives. ## Assembling the whole thingEverything from this lab converges in
src/HostSetup.cs:PricingOptionsbound from configuration, email and SMS senders registered under distinct keys and wrapped in the audit decorator, and the repository/processor/worker lifetimes from Steps 2 through 4 registered with the scoping rules that keep them correct.RunOrderPulseAsyncis the payoff -- it builds the provider, processes an order, picks a notification channel for the customer, and sends the confirmation through the fully decorated, keyed, container-resolved chain. -
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 rerun any of them whenever you like, one task at a time:
bash runTest.sh Task_2_1Swap in the number of the task you want to check. Each 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.