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

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 platform
Lab Info
Level
Advanced
Last updated
Oct 06, 2026
Duration
45m

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 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 for new directly: 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 IOrderRepository and an OrderProcessor. If each class keeps building its own copies with new, 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 every new call site by hand. ## What a DI container actually buys you

    .NET ships a dependency injection container out of the box: IServiceCollection to declare what a type needs and how to build it, and IServiceProvider to 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 an IOrderRepository.

    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 new object should or shouldn't be reused. ## The five problems ahead

    You'll carry OrderPulse through this progression:

    1. Step 2 -- pull the repository and pricing service out of OrderProcessor and into constructor parameters, then build the first composition root that resolves the whole graph from an IServiceCollection.
    2. Step 3 -- add a Generic Host BackgroundService and 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.
    3. 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 IServiceScopeFactory and correct IDisposable handling.
    4. 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 working

    Open src/OrderProcessor.cs now, before Step 2 asks you to change anything. Notice the two new expressions inside it -- one building a repository, one building a pricing service -- and notice that nothing about OrderProcessor'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 solution folder.

    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.

  2. 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 InMemoryOrderRepository today, a fake one in a test tomorrow -- as long as it satisfies the interface. OrderProcessor's constructor already asks for an IOrderRepository and an IPricingService and stores both, yet src/OrderProcessor.cs still violates this twice: LoadOrderAsync builds its own InMemoryOrderRepository, and PriceOrder builds its own StandardPricingService. You'll remove the first one now. ## Now the second dependency

    With the repository injected, OrderProcessor still hides a second new inside PriceOrder. 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 graph

    OrderProcessor no longer builds anything itself, but something still has to decide that an IOrderRepository parameter should receive an InMemoryOrderRepository, and that an IPricingService parameter should receive a StandardPricingService. That's the job of a composition root: one place that registers every interface-to-implementation mapping in an IServiceCollection, builds an IServiceProvider from it, and resolves the top-level type it needs. src/CompositionRoot.cs is that place for OrderPulse. Once it can resolve a working OrderProcessor, every constructor parameter OrderProcessor declares is being satisfied by the container, not by you.

  3. 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 BackgroundService is how .NET runs long-lived background work -- a class that overrides ExecuteAsync and keeps running for the lifetime of the app. src/OrderPollingWorker.cs is OrderPulse's: it needs an IOrderRepository to find pending orders and an OrderProcessor to process each one, and it gets both exactly the way OrderProcessor gets 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 graph

    ASP.NET Core controllers get their dependencies through constructor injection too -- the framework resolves a controller instance per request the same way IServiceProvider.GetRequiredService resolves anything else. src/OrdersController.cs needs the same IOrderRepository the 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 hosts

    OrderPollingWorker and OrdersController must never end up resolving from two different IServiceCollection instances -- that would silently recreate the exact fragmentation problem manual wiring caused. src/CompositionRoot.cs gets extended, not duplicated: the same method that will back the worker's host also backs the controller's host. Once RegisterHostServices lists every type both classes depend on, resolving either one from the resulting provider proves they're reading from one consistent graph.

  4. Challenge

    Step 4: Choose correct lifetimes and eliminate captive dependencies

    The bug: a singleton holding a scoped dependency

    A BackgroundService is registered once and lives for the whole application -- effectively a singleton. OrderPollingWorker currently captures its IOrderRepository and OrderProcessor in 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 using IServiceScopeFactory.CreateScope(), then resolving from that scope's provider instead of the captured fields. ## Disposing exactly once

    Every scope you create needs to end cleanly, and anything scoped that implements IDisposable gets disposed when its scope ends. src/InMemoryOrderRepository.cs owns 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 second Dispose call is a safe no-op instead of a double-free.

  5. 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.cs currently hardcodes its discount rate; binding a PricingOptions type through IOptions<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 HttpClient

    Keyed 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.cs holds both: a plain email sender and an SMS sender that must call out over HTTP. The SMS sender takes its HttpClient through constructor injection too -- the typed-client pattern -- so the container manages the client's lifetime and you never call new HttpClient() yourself. ## Auditing without a service locator

    A 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 in OrderProcessor or the senders themselves ever calls IServiceProvider.GetService directly -- the anti-pattern a service locator would introduce. src/NotificationDecorator.cs is where that wrapping logic lives. ## Assembling the whole thing

    Everything from this lab converges in src/HostSetup.cs: PricingOptions bound 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. RunOrderPulseAsync is 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.

  6. 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.csproj
    

    The 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_1
    

    Swap 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

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