The "D" in SOLID — and the capstone of everything Part I has been building toward. Every lesson in this module was, quietly, in service of this one idea.
Look back at where this module started. Lesson 073 showed composition wired to an interface instead of a concrete class. Lesson 077 showed OrderService depending on IInventoryRepository, IPaymentGateway, and IOrderNotifier — never on a concrete SQL, Stripe, or email class. Lesson 078 showed a class composing several focused capabilities. Lesson 079 showed why those capabilities should be small and honest. Lesson 080 showed how to keep a class's public face clean when contracts collide. Every single one of those lessons was quietly obeying one rule, without ever naming it out loud:
// This one line, repeated across five lessons, IS the Dependency Inversion Principle:
public sealed class OrderService(
IInventoryRepository inventory, // not SqlInventoryRepository
IPaymentGateway payments, // not StripeGateway
IOrderNotifier notifier) // not EmailNotifier
{
// OrderService — the important, high-level policy — never once mentions
// a concrete, low-level class. It only ever depends on abstractions.
}
That's not a coincidence, and it's not just "good practice" in some vague sense. It's the last, and arguably most important, of the five SOLID principles — the one that explains why depending on interfaces instead of concrete classes matters so much that an entire module was built around teaching you to do it instinctively.
In this lesson — the capstone of Part I — you'll learn the Dependency Inversion Principle precisely: what "high-level" and "low-level" actually mean, why the word "inversion" is in the name, exactly how it differs from Dependency Injection (a distinction that trips up even experienced developers), and how everything from lessons 073 through 080 was building toward this one idea.
The Dependency Inversion Principle (DIP) — the "D" in SOLID — has two parts, and both matter:
1. High-level modules should not depend on low-level modules. Both should depend on abstractions.
2. Abstractions should not depend on details. Details should depend on abstractions.
Unpacked into plain language: the important, policy-level code in your application (what should happen — "charge the customer, reserve the stock, send a confirmation") should never be written in terms of specific, swappable implementation details (how it happens — "using Stripe's SDK, a SQL Server table, and SMTP"). Instead, both sides — the policy and the detail — should be written against a shared abstraction (an interface) that neither one owns exclusively.
High-level module = code that expresses business policy — OrderService, deciding what an order-placement workflow consists of. Low-level module = code that does the mechanical, technology-specific work of one step — SqlInventoryRepository talking to a real database, StripeGateway talking to Stripe's API. DIP says the high-level module must never mention the low-level module's concrete type — only the abstraction standing between them.
Without DIP, the natural way to write software points the dependency arrow the "obvious" direction — important business logic ends up directly depending on the specific technology it happens to be using today:
// WITHOUT dependency inversion — the "obvious" but costly design
public sealed class OrderService
{
private readonly SqlInventoryRepository _inventory = new(); // a concrete SQL Server class
private readonly StripeGateway _payments = new(); // a concrete Stripe class
private readonly SmtpOrderNotifier _notifier = new(); // a concrete SMTP class
public async Task<OrderResult> PlaceOrderAsync(Order order)
{
// OrderService — the most important business logic in the entire application —
// is now permanently welded to SQL Server, Stripe, and SMTP.
if (!await _inventory.HasStockAsync(order.Items)) return OrderResult.OutOfStock();
var result = await _payments.ChargeAsync(order.Total);
// ...
return OrderResult.Success(order.Id);
}
}
// Switching payment providers means editing OrderService directly.
// Unit testing PlaceOrderAsync means standing up a real database, a real Stripe sandbox,
// and a real SMTP server — or not testing it at all.
This is what DIP exists to prevent: your most valuable code — the business rules that make your application your application — becomes hostage to whichever database driver, payment SDK, or email library you happened to pick on day one. Change vendors, and you're editing the policy code, not just swapping a component. Want to unit test the policy, and you're forced to drag along every real infrastructure dependency it touches.
The name comes from what happens to the dependency arrow. Without DIP, dependencies point downward, the "natural" direction — policy depends on detail:
Dependency inversion flips this. Both the high-level policy and the low-level detail now point at something in between — an abstraction that the high-level side defines and the low-level side must conform to:
OrderService actually needs — not around what SQL Server or Stripe happen to exposeNotice the arrow on the low-level side now points up, toward the abstraction — the opposite of where it pointed before. Both OrderService and SqlInventoryRepository depend on IInventoryRepository; neither depends on the other directly. That's the "inversion": the low-level detail is now the one that has to conform to a contract defined by, and for, the high-level policy — not the other way around.
OrderService needs "check stock," "charge a customer," "notify someone" — described in terms the policy cares about, not in terms of SQL, Stripe, or SMTPIInventoryRepository.HasStockAsync(items) — named and shaped the way OrderService thinks about the problem, not the way a SQL table happens to be structured (this is interface segregation, lesson 079, applied at the architectural level)OrderService's constructor takes IInventoryRepository, never SqlInventoryRepository — this is composition wired to an interface, exactly as lesson 073 taughtSqlInventoryRepository : IInventoryRepository — the detail now conforms to a contract it doesn't get to define on its own termsSqlInventoryRepository and hands it to OrderService as an IInventoryRepository; this wiring step is Dependency Injection, the technique covered next// Before — high-level LightSwitch depends directly on a low-level detail
public sealed class IncandescentBulb
{
public void PowerOn() => Console.WriteLine("Bulb glowing.");
}
public sealed class LightSwitch
{
private readonly IncandescentBulb _bulb = new(); // welded to ONE specific bulb type
public void Flip() => _bulb.PowerOn();
}
// Want an LED bulb instead? You're editing LightSwitch itself.
// After — both sides depend on a shared abstraction
public interface ISwitchable
{
void PowerOn();
}
public sealed class IncandescentBulb : ISwitchable
{
public void PowerOn() => Console.WriteLine("Bulb glowing.");
}
public sealed class LedBulb : ISwitchable
{
public void PowerOn() => Console.WriteLine("LED illuminated.");
}
public sealed class LightSwitch(ISwitchable device) // depends ONLY on the abstraction
{
public void Flip() => device.PowerOn();
}
var switchWithIncandescent = new LightSwitch(new IncandescentBulb());
var switchWithLed = new LightSwitch(new LedBulb());
// LightSwitch's own code never changed — the detail plugged in from outside
Code → Meaning → Result: Before, LightSwitch — the high-level "policy" of flipping something on — was permanently wired to one specific bulb technology. After, LightSwitch depends only on ISwitchable, an abstraction shaped around what a switch actually needs ("something I can power on"). Either bulb type can be handed in without LightSwitch changing a single line — this is DIP working at the smallest possible scale.
Bringing together composition (073), multiple interfaces (078), segregation (079), and explicit implementation (080) into one coherent, DIP-compliant order fulfillment workflow:
// ── Abstractions — shaped around what the HIGH-LEVEL policy needs (lesson 079: focused, not fat) ──
public interface IInventoryRepository
{
Task<bool> HasStockAsync(IReadOnlyList<OrderItem> items, CancellationToken ct = default);
Task ReserveAsync(IReadOnlyList<OrderItem> items, CancellationToken ct = default);
}
public interface IPaymentGateway
{
Task<PaymentResult> ChargeAsync(decimal amount, string customerToken, CancellationToken ct = default);
}
public interface IOrderNotifier
{
Task NotifyOrderPlacedAsync(Order order, CancellationToken ct = default);
}
// ── High-level module — the business policy. Depends ONLY on abstractions. ──
public sealed class OrderFulfillmentService(
IInventoryRepository inventory,
IPaymentGateway payments,
IOrderNotifier notifier)
{
public async Task<OrderResult> PlaceOrderAsync(Order order, CancellationToken ct = default)
{
if (!await inventory.HasStockAsync(order.Items, ct))
return OrderResult.OutOfStock();
var paymentResult = await payments.ChargeAsync(order.Total, order.CustomerToken, ct);
if (!paymentResult.Success)
return OrderResult.PaymentFailed(paymentResult.Reason);
await inventory.ReserveAsync(order.Items, ct);
await notifier.NotifyOrderPlacedAsync(order, ct);
return OrderResult.Success(order.Id);
}
// Not one line of this class mentions SQL, Stripe, or SMTP.
// This is the entire point: the POLICY is completely insulated from the DETAILS.
}
// ── Low-level modules — the details. They depend UPWARD on the abstractions. ──
public sealed class SqlInventoryRepository(DbConnection connection) : IInventoryRepository
{
public Task<bool> HasStockAsync(IReadOnlyList<OrderItem> items, CancellationToken ct = default) =>
/* real SQL query against connection */ Task.FromResult(true);
public Task ReserveAsync(IReadOnlyList<OrderItem> items, CancellationToken ct = default) =>
/* real SQL update against connection */ Task.CompletedTask;
}
public sealed class StripePaymentGateway(IStripeClient client) : IPaymentGateway
{
public async Task<PaymentResult> ChargeAsync(decimal amount, string customerToken, CancellationToken ct = default)
{
var response = await client.CreateChargeAsync(amount, customerToken, ct);
return response.Succeeded ? PaymentResult.Success(response.TransactionId) : PaymentResult.Failed(response.DeclineReason);
}
}
public sealed class EmailOrderNotifier(ISmtpClient smtp) : IOrderNotifier
{
public Task NotifyOrderPlacedAsync(Order order, CancellationToken ct = default) =>
smtp.SendAsync(order.CustomerEmail, "Order confirmed", $"Order {order.Id} is on its way.", ct);
}
// ── The composition root — the ONE place that knows about every concrete type ──
// Everywhere else in the application, only the abstractions are visible.
services.AddScoped<IInventoryRepository, SqlInventoryRepository>();
services.AddScoped<IPaymentGateway, StripePaymentGateway>();
services.AddScoped<IOrderNotifier, EmailOrderNotifier>();
services.AddScoped<OrderFulfillmentService>();
// ── A test never touches SQL Server, Stripe, or SMTP at all ──
var fulfillment = new OrderFulfillmentService(
new FakeInventoryRepository(alwaysInStock: true),
new FakePaymentGateway(alwaysSucceeds: true),
new FakeOrderNotifier());
var result = await fulfillment.PlaceOrderAsync(testOrder);
Assert.True(result.Success); // milliseconds, zero infrastructure, fully deterministic
Why this is DIP working end to end, not just "using interfaces":
OrderFulfillmentService — the high-level module — has zero compiled dependency on SqlInventoryRepository, StripePaymentGateway, or EmailOrderNotifier. You could delete all three low-level classes and OrderFulfillmentService would still compile.HasStockAsync, ChargeAsync, NotifyOrderPlacedAsync) — not around what a SQL table, Stripe's SDK, or SMTP happen to expose. That's the "abstractions should not depend on details" half of DIP.IPaymentGateway and changing one line in the composition root — OrderFulfillmentService is never touched.OrderFulfillmentService genuinely cannot tell the difference between a fake and the real thing.A wall socket is an abstraction owned by the electrical system of your house — the house's wiring (the "high-level" side, since it's what you actually built and care about) doesn't know or care whether a lamp, a phone charger, or a vacuum cleaner gets plugged in. Every appliance manufacturer (the "low-level" side) builds their plug to conform to the socket's shape — not the other way around; nobody rewires their house to match a new toaster. The socket's shape is the abstraction. The house depends on the socket shape; every appliance also depends on the socket shape; neither the house nor any specific appliance depends on the other directly. That's dependency inversion — and it's exactly why you can unplug a broken toaster and plug in a working one without touching a single wire in the wall.
OrderFulfillmentService.cs only references IInventoryRepository, IPaymentGateway, IOrderNotifier in its usings and signatures — the compiler never needs SqlInventoryRepository to exist to build this file; that's the literal, mechanical meaning of "does not depend on"SqlInventoryRepository and hand it over — DIP doesn't eliminate that need, it just relocates it to one narrow place (the composition root) instead of scattering new SqlInventoryRepository() throughout the policy codeOrderFulfillmentService's constructor, sees it wants an IInventoryRepository, looks up which concrete type was registered for that interface, and passes the resolved instance in — this reflection-driven resolution is what makes the wiring in Program.cs actually worknew OrderFulfillmentService(new FakeInventoryRepository(...), ...) test line manually constructs the object graph, no container involved — DIP is satisfied by the compile-time reference structure, regardless of how the runtime instances get assembledThese two terms get used almost interchangeably in casual conversation, and it causes real confusion. They are not the same thing — one is a principle, the other is a technique that helps you follow it.
OrderFulfillmentService(IInventoryRepository inventory, ...) — nothing about a container requirednew it up internallyOrderFulfillmentService(SqlInventoryRepository inventory) is still dependency injection, just injecting a concrete class instead of an abstractionThe two ideas combine constantly, which is exactly why they get blurred together: DI is the most common, practical technique for satisfying DIP — you inject an abstraction instead of newing up a concrete class, and now you're doing both at once. But they are separable, and seeing them separately is the whole point of this distinction:
public OrderFulfillmentService(SqlInventoryRepository inventory) — the dependency is injected from outside (that's DI), but it's still a concrete, low-level class (that violates DIP). You've made testing marginally easier (you can pass a different SqlInventoryRepository instance) but you still can't swap the technology or use a fake without a real database connection string.public OrderFulfillmentService(IInventoryRepository inventory) — an abstraction (DIP), supplied from outside (DI). This is what every example in this lesson, and most of Part I, has been building toward.// This uses a DI container, but it does NOT satisfy DIP
public sealed class OrderFulfillmentService(SqlInventoryRepository inventory) { ... }
services.AddScoped<SqlInventoryRepository>();
// OrderFulfillmentService still directly references SqlInventoryRepository.
// Swapping databases means editing this class's signature. No abstraction exists between them.
The dependency being injected must itself be an abstraction — IInventoryRepository, not SqlInventoryRepository — for DIP to actually be satisfied. Using a container doesn't automatically buy you inversion; the type in the constructor parameter is what matters.
Defining IInventoryRepository with methods like ExecuteQueryAsync(string sql) or GetRowAsync(string tableName, int id) — this "abstraction" is really just SQL Server wearing an interface costume. It leaks the low-level detail's shape straight through, so OrderFulfillmentService is still effectively coupled to how a relational database thinks, even though it technically depends on an interface.
Shape the interface around what the high-level policy actually needs to express — HasStockAsync(items), not ExecuteQueryAsync(sql). This is DIP's second rule in action: "abstractions should not depend on details" — the interface belongs conceptually to the high-level side, not to whichever low-level technology happens to implement it first.
Creating an IOrderTotalCalculator interface, with exactly one implementation that will ever exist, purely because "DIP says depend on abstractions" — for a small, pure calculation with no I/O, no external system, and no plausible alternative implementation, this just adds a file and an indirection with no real benefit.
DIP earns its cost at the boundary between your policy and something genuinely variable or external — a database, a payment provider, a notification channel, a filesystem, a third-party API. A pure, deterministic calculation with a single obvious implementation usually doesn't need an interface just to satisfy the letter of the principle.
And when it's overkill: for small, internal, pure-logic classes with exactly one plausible implementation and no I/O — as Mistake 3 covers, forcing an interface onto every class "because SOLID says so" adds files and indirection without adding real flexibility or testability. DIP is a tool for managing genuine points of variation and external dependency, not a mandate to abstract everything.
This is the capstone of Part I — let's confirm you can tell dependency inversion apart from dependency injection, and recognize the principle in real code.
1. According to the Dependency Inversion Principle, what should both high-level and low-level modules depend on?
Correct: B
Why B is correct: This is the precise statement of DIP — both sides depend on an abstraction, and that abstraction is shaped around what the high-level policy needs, not around what the low-level detail happens to expose.
Why A is incorrect: Depending on each other directly is exactly the problem DIP solves — it creates the tight coupling shown in the "Why Does It Exist?" section, where a vendor change forces edits to business logic.
Why C is incorrect: A container is one tool that can help wire dependencies at runtime, but DIP itself is a design principle about dependency direction — it doesn't require any specific container or tool, as the manually-constructed test in the real-world example demonstrated.
Why D is incorrect: This is precisely backwards — depending on implementation details is what DIP explicitly prohibits for the high-level side.
Reinforcement: The abstraction sits between both sides and is owned conceptually by the high-level policy — neither side depends on the other directly.
2. A developer writes public OrderFulfillmentService(SqlInventoryRepository inventory) and passes the dependency in through the constructor rather than constructing it internally. Is this Dependency Inversion, Dependency Injection, both, or neither?
Correct: B
Why B is correct: This is exactly Mistake 1 and the "DI without DIP" case from Common Confusion — supplying the dependency from outside is dependency injection (the technique), but since SqlInventoryRepository is a concrete, low-level class rather than an abstraction, the Dependency Inversion Principle is not being followed here.
Why A is incorrect: Using a constructor parameter alone does not guarantee DIP — the type of that parameter is what determines whether the dependency is inverted (an abstraction) or not (a concrete class), as this exact example shows.
Why C is incorrect: This gets it backwards — DIP is not satisfied here at all (concrete class, no abstraction), while DI clearly is (the dependency comes from outside).
Why D is incorrect: Constructor injection is one of the most common forms of dependency injection — supplying any dependency via a constructor parameter, concrete or abstract, is DI by definition.
Reinforcement: DI is about how a dependency is supplied; DIP is about what kind of thing — abstraction or detail — it is. This example does one but not the other.
3. Why is IInventoryRepository designed with a method like HasStockAsync(items) rather than something like ExecuteQueryAsync(string sql)?
Correct: B
Why B is correct: This is DIP's second rule — "abstractions should not depend on details" — covered directly in Mistake 2. An interface shaped like the low-level technology (raw SQL) is really just that technology wearing an interface costume; it still couples the high-level policy to how a relational database thinks, even though it technically compiles against an interface.
Why A is incorrect: This has nothing to do with performance — the concern is about coupling and abstraction leakage, not execution speed.
Why C is incorrect: ExecuteQueryAsync(string sql) is perfectly valid C# — the problem with it is a design one, not a syntax one.
Why D is incorrect: The two designs are not equivalent — one keeps the high-level policy genuinely decoupled from the database technology, the other only appears to while still leaking its shape through.
Reinforcement: A leaky abstraction that mirrors the low-level implementation's shape doesn't actually deliver DIP's benefit, even though it technically uses an interface.
4. In the real-world example, why can OrderFulfillmentService be unit tested with FakeInventoryRepository, FakePaymentGateway, and FakeOrderNotifier with zero real infrastructure?
Correct: B
Why B is correct: This is the direct payoff of DIP — since OrderFulfillmentService's constructor only knows about IInventoryRepository, IPaymentGateway, and IOrderNotifier, any class satisfying those contracts works identically from its perspective, fake or real. The test constructs the object graph manually, with no container needed at all.
Why A is incorrect: Nothing is automatic — this testability exists specifically because the class was designed to depend on abstractions in the first place; a version depending on concrete classes (Mistake 1) would not be nearly this easy to test.
Why C is incorrect: A properly-written fake holds no real connection at all — it's an in-memory stand-in, which is exactly why the test runs in milliseconds with no infrastructure.
Why D is incorrect: The "Under the Hood" section showed the test constructing OrderFulfillmentService directly with new — no container is involved or required; DIP is satisfied by the compile-time reference structure alone.
Reinforcement: Fast, infrastructure-free unit testing is one of the most concrete, everyday payoffs of following the Dependency Inversion Principle.
5. A developer creates an IOrderTotalCalculator interface with exactly one implementation, for a small, pure, deterministic calculation with no I/O and no plausible alternative implementation — purely because "SOLID says depend on abstractions." Is this a good application of DIP?
Correct: B
Why B is correct: This is precisely Mistake 3 and the "When Should I Use It?" guidance — DIP is a tool for managing genuine variation and external dependency (databases, payment providers, anything swappable or worth faking), not a mandate to abstract every class regardless of whether there's a real detail to invert.
Why A is incorrect: This treats DIP as a mechanical rule to apply everywhere rather than a principle for managing genuine architectural boundaries — exactly the over-application this question describes.
Why C is incorrect: Naming convention has no bearing on whether an interface is architecturally justified — the substance of whether there's a genuine point of variation is what matters.
Why D is incorrect: Interfaces are entirely appropriate for calculation logic when there's a genuine need — multiple real strategies, or a need to swap behavior (as lesson 073's IDiscountStrategy showed) — the issue here is specifically the lack of any genuine variation or external dependency.
Reinforcement: Apply DIP where it earns its cost — real boundaries with the outside world, or genuine points of variation — not reflexively on every single class.
Part I complete. From composition wired to an interface, through segregated contracts and explicit implementation, to the Dependency Inversion Principle tying it all together — you now design classes the way production C# systems are actually built: as policies depending on abstractions, with the details plugged in from outside.
dotnetmadeeasy.com — Learn C# and .NET, the right way.