Inverting Dependencies: Where Layered Architecture Gets Stuck
In a layered architecture compile-time dependencies flow downward, so business rules get locked to EF Core, SQLite and external APIs; the fix is to keep the runtime call direction but move the interfaces into the domain project that uses them, turning the reference arrow inward. Shown with Bookstore's "test without a database" and "switch carriers" scenarios, the "I added an interface, done" fallacy, and the idea shared by Hexagonal, Onion and Clean.
Version note: The code in this post was built and run with C# 14 and .NET 10.
Guess First
Bookstore's ordering side is a classic three-layer solution: Bookstore.Web handles HTTP (Hypertext Transfer Protocol) requests, Bookstore.Business holds the business rules, and Bookstore.Data talks to the database and external services. References flow top-down: Web → Business → Data. The Data project contains a BookstoreDbContext written with Entity Framework Core (EF Core) and a ShippingApiClient that calls the carrier's pricing API (Application Programming Interface). The ordering rule lives in Business:
using Bookstore.Data;
using Microsoft.EntityFrameworkCore;
namespace Bookstore.Business;
public sealed class OrderService(BookstoreDbContext db, ShippingApiClient shipping)
{
public async Task<int> PlaceOrderAsync(string customerEmail, string postalCode, int bookId, int quantity,
CancellationToken ct = default)
{
BookEntity book = await db.Books.SingleAsync(b => b.Id == bookId, ct);
if (book.Stock < quantity)
throw new InvalidOperationException($"Only {book.Stock} copies of '{book.Title}' left.");
decimal fee = await shipping.GetQuoteAsync(postalCode, quantity * 400, ct);
book.Stock -= quantity;
OrderEntity order = new() { CustomerEmail = customerEmail, PostalCode = postalCode, ShippingFee = fee,
Total = book.Price * quantity + fee };
order.Lines.Add(new OrderLineEntity { BookId = bookId, Quantity = quantity, UnitPrice = book.Price });
db.Orders.Add(order);
await db.SaveChangesAsync(ct);
return order.Id;
}
}
Someone on the team wants a unit test for the simplest rule: with 2 books in stock, ordering 5 must throw InvalidOperationException. No database, no carrier API; just the rule:
[Fact]
public async Task Rejects_order_when_stock_is_insufficient()
{
DbContextOptions<BookstoreDbContext> options = new DbContextOptionsBuilder<BookstoreDbContext>()
.UseSqlite("Data Source=bookstore.db")
.Options;
using BookstoreDbContext db = new(options);
ShippingApiClient shipping = new(new HttpClient { BaseAddress = new Uri("http://localhost:5199/") });
OrderService service = new(db, shipping);
await Assert.ThrowsAsync<InvalidOperationException>(
() => service.PlaceOrderAsync("ada@example.com", "06100", bookId: 1, quantity: 5));
}
Two questions. Does this test pass, and if not, which exception does it fail with? The second is more interesting: the test project references only Bookstore.Business; how many NuGet packages come along when it compiles?
In a Layered Architecture, Dependencies Flow Down
The test is red:
[xUnit.net 00:00:00.69] Bookstore.Business.Tests.OrderServiceTests.Rejects_order_when_stock_is_insufficient [FAIL]
Expected: typeof(System.InvalidOperationException)
Actual: typeof(Microsoft.Data.Sqlite.SqliteException)
---- Microsoft.Data.Sqlite.SqliteException : SQLite Error 1: 'no such table: Books'.
Failed! - Failed: 1, Passed: 0, Skipped: 0, Total: 1, Duration: 546 ms - Bookstore.Business.Tests.dll (net10.0)
We never reached the stock rule. The first line of OrderService goes to the database; SQLite opened an empty file and the query blew up because there is no Books table. To make the test pass you would first create the schema, insert a book row, and then stand up an HTTP server that imitates the carrier API. You are building two pieces of infrastructure to test one if.
The answer to the second question tells the same story. The transitive packages of the Business project:
dotnet list Bookstore.Business package --include-transitive
Project 'Bookstore.Business' has the following package references
[net10.0]:
Transitive Package Resolved
> Microsoft.Data.Sqlite.Core 10.0.14
> Microsoft.EntityFrameworkCore 10.0.14
> Microsoft.EntityFrameworkCore.Abstractions 10.0.14
> Microsoft.EntityFrameworkCore.Analyzers 10.0.14
> Microsoft.EntityFrameworkCore.Relational 10.0.14
> Microsoft.EntityFrameworkCore.Sqlite 10.0.14
> Microsoft.EntityFrameworkCore.Sqlite.Core 10.0.14
...
> SQLitePCLRaw.provider.e_sqlite3 2.1.13
There are 21 packages in the list. The project that holds the business rules asked for none of them, yet it carries everything down to SQLite's native library. The reason is a single line: the ProjectReference in Bookstore.Business.csproj. In a layered architecture each layer references the one below it; Business knows Data, and Data knows EF Core and SQLite. Dependencies are transitive: Business indirectly knows everything Data knows.
Layers themselves are not a bad idea; separating responsibilities and keeping UI (user interface) code away from database code is still right. The Introduction to Architecture series post "Layered Architecture: The Real Responsibilities of Presentation, Business, and Data Layers" covers the strengths of this style and anti-patterns like the architecture sinkhole. This post is about one thing only: the direction of the arrows. Microsoft's "Common web application architectures" guide puts the main drawback of traditional N-layer in the same place: because compile-time dependencies run from top to bottom, the Business Logic Layer, which holds the most important logic, depends on data access details and often on the existence of a database, so testing it needs a test database.
In Bookstore this has two concrete costs. The first you just saw: business rules cannot be tested without a database. The second has not happened yet but is on its way: the product team wants to switch carriers. The new carrier's API speaks differently, and that change will ripple all the way into the project where the rules live. We will see that shortly.
Source Code Dependency vs Runtime Call Direction
Architecture drawings very often mix two different things into one arrow. Who calls whom at runtime is one; who knows whose name at compile time is the other. The first is control flow: while OrderService.PlaceOrderAsync runs it calls BookstoreDbContext, because it must learn the book's stock. You cannot change that direction; if a business rule needs data, it will ask for it somewhere. The second is the source code dependency: OrderService.cs starts with using Bookstore.Data; and its csproj references the Data project. That direction is entirely a design decision.
In a layered architecture both arrows point the same way: Business both calls Data and depends on it at compile time. The tool for keeping the call direction while reversing the compile-time dependency is the Dependency Inversion Principle (DIP). In short: high-level policy depends not on low-level detail but on an abstraction, and that abstraction is owned by the side that uses it. We covered the principle itself, the difference between DI (Dependency Injection) and IoC (Inversion of Control), and the composition root at class level in the SOLID (the initials of Robert C. Martin's five class design principles) and Design Principles series post "Dependency Inversion Principle: DIP vs DI vs IoC and the Composition Root"; here we apply the same idea at the project and architecture level.

In the drawing on the left the red arrows are both the call direction and the reference direction. On the right the runtime call still goes from Ordering to Infrastructure (the dashed blue arrow), but the compile-time reference has flipped: Infrastructure implements the interfaces that Ordering defines. When people say "invert the dependency" in architecture, they always mean these solid arrows.
The "I Added an Interface, Done" Fallacy
The team's first reflex is usually: "If it can't be tested, let's add interfaces." Two interfaces go into the Data project, the implementations implement them, and OrderService now takes interfaces instead of concrete classes:
namespace Bookstore.Data;
public interface IOrderRepository
{
Task<BookEntity?> FindBookAsync(int bookId, CancellationToken ct);
void Add(OrderEntity order);
Task SaveChangesAsync(CancellationToken ct);
}
public interface IShippingApiClient
{
Task<decimal> GetQuoteAsync(string postalCode, int weightGrams, CancellationToken ct);
}
With small fakes for those two interfaces, the test really does turn green:
Passed! - Failed: 0, Passed: 1, Skipped: 0, Total: 1, Duration: 5 ms - Bookstore.Business.Tests.dll (net10.0)
But did the arrows change direction? The output of dotnet list Bookstore.Business.Tests package --include-transitive still contains Microsoft.EntityFrameworkCore.Sqlite; the test project now carries 34 packages (21 from Data, the rest from xUnit and the test SDK, Software Development Kit). The interfaces were added, but they live in the Data project and Business keeps referencing Data to reach them. On top of that, the signatures speak Data's language: BookEntity and OrderEntity are EF Core entities, and IShippingApiClient speaks in the carrier API's parameters.
Now the carrier changes. The new carrier asks for prices with POST instead of GET, in kilograms instead of grams, and answers in kuruş (hundredths of a lira) together with a service code. The Data team updates the interface to the new API:
// The new carrier's API: wants postal code and kilograms, returns the price in kurus
public sealed record ShipmentQuery(string PostalCode, decimal WeightKg);
public sealed record ShipmentQuote(long PriceInKurus, string ServiceCode);
public interface IShippingApiClient
{
Task<ShipmentQuote> GetQuoteAsync(ShipmentQuery query, CancellationToken ct);
}
The change started in Data; what breaks is Business:
OrderService.cs(15,38): error CS1501: No overload for method 'GetQuoteAsync' takes 3 arguments
The ordering rule did not change. The stock check is the same, the total formula is the same. Still, you have to open the file that holds the business rules and learn the carrier's units (kuruş, kilograms). What matters is not whether an interface exists but where it lives and whose language it speaks. An interface on the wrong side buys testability but does not change the direction.
Turning the Arrows: The Side That Uses the Interface Owns It
The fix is to put the business rule and the interfaces it needs into the same project. The new structure has three projects: Bookstore.Ordering, which references nothing; Bookstore.Infrastructure, which references it; and the test project, which also references only Ordering. The interfaces describe Ordering's needs in Ordering's language:
namespace Bookstore.Ordering;
// The interfaces live on the side that uses them, in Ordering's language
public interface IOrderStore
{
Task<Book?> FindBookAsync(int bookId, CancellationToken ct);
Task<int> SaveAsync(Order order, Book reservedBook, CancellationToken ct);
}
public sealed record ShippingQuoteRequest(string PostalCode, int WeightGrams);
public interface IShippingQuoteProvider
{
Task<decimal> QuoteAsync(ShippingQuoteRequest request, CancellationToken ct);
}
The stock rule also moved out of the EF Core entity into a Book class that belongs to the domain; its Reserve method enforces the rule itself. (As Bookstore grows, stock will move to the Inventory module's StockItem; in this series we keep it on the Book in Ordering to keep the flow simple.) The use case now only orchestrates the flow:
namespace Bookstore.Ordering;
public sealed class PlaceOrderService(IOrderStore store, IShippingQuoteProvider shipping)
{
private const int GramsPerBook = 400;
public async Task<int> PlaceAsync(string customerEmail, string postalCode, int bookId, int quantity,
CancellationToken ct = default)
{
Book book = await store.FindBookAsync(bookId, ct)
?? throw new InvalidOperationException($"Book {bookId} not found.");
book.Reserve(quantity);
decimal fee = await shipping.QuoteAsync(new ShippingQuoteRequest(postalCode, quantity * GramsPerBook), ct);
Order order = new(customerEmail, postalCode, book.Id, quantity, book.Price, fee);
return await store.SaveAsync(order, book, ct);
}
}
Asking again for the reference graph and Ordering's package list:
== Bookstore.Ordering
There are no Project to Project references in project Bookstore.Ordering.
== Bookstore.Infrastructure
Project reference(s)
--------------------
../Bookstore.Ordering/Bookstore.Ordering.csproj
== Bookstore.Ordering.Tests
Project reference(s)
--------------------
../Bookstore.Ordering/Bookstore.Ordering.csproj
Project 'Bookstore.Ordering' has the following package references
[net10.0]: No packages were found for this framework.
Ordering carries no packages. The number of lines mentioning EF Core in the test project's transitive list is zero. The two tests I wrote, one for the stock rule and one for "the total includes the shipping fee", pass in milliseconds with an in-memory IOrderStore and an IShippingQuoteProvider that returns a fixed fee:
Passed! - Failed: 0, Passed: 2, Skipped: 0, Total: 2, Duration: 8 ms - Bookstore.Ordering.Tests.dll (net10.0)
The carrier change now touches a single place. The new carrier's units stay inside the adapter in Infrastructure:
// New carrier: POST, kilograms, price in kurus
public sealed class NewCarrierQuoteProvider(HttpClient http) : IShippingQuoteProvider
{
public async Task<decimal> QuoteAsync(ShippingQuoteRequest request, CancellationToken ct)
{
HttpResponseMessage response = await http.PostAsJsonAsync("v2/quotes",
new ShipmentQuery(request.PostalCode, request.WeightGrams / 1000m), ct);
response.EnsureSuccessStatusCode();
ShipmentQuote quote = (await response.Content.ReadFromJsonAsync<ShipmentQuote>(ct))!;
return quote.PriceInKurus / 100m;
}
private sealed record ShipmentQuery(string PostalCode, decimal WeightKg);
private sealed record ShipmentQuote(long PriceInKurus, string ServiceCode);
}
The old carrier's adapter, OldCarrierQuoteProvider, implements the same interface and they compile side by side. When I plugged both into the same PlaceOrderService with an in-memory SQLite database and a fake HttpMessageHandler, the outgoing requests were GET quotes?postalCode=06100&weight=400 and a POST v2/quotes with the body {"postalCode":"06100","weightKg":0.4}; not a single line changed in Ordering. The composition root (Program.cs in ASP.NET Core) decides which adapter is used.
The Same Direction Inside .NET: EF Core Providers
You can see this structure in a library you use every day. The dependencies in the .nuspec of the Microsoft.EntityFrameworkCore package are Microsoft.EntityFrameworkCore.Abstractions, Microsoft.EntityFrameworkCore.Analyzers, Microsoft.Extensions.Caching.Memory and Microsoft.Extensions.Logging. No database such as SQLite or PostgreSQL is named. The arrow points the other way: Microsoft.EntityFrameworkCore.Sqlite.Core depends on Microsoft.EntityFrameworkCore.Relational, and Relational depends on Microsoft.EntityFrameworkCore. EF Core's core defines policies such as change tracking and the LINQ (Language Integrated Query) query pipeline; database-specific details plug in from provider packages on the outside by implementing abstractions the core defines. Adding support for a new database does not require changing EF Core's core, just as switching carriers in Bookstore does not change Ordering.
The Shared Idea of Three Styles: Domain in the Middle, Infrastructure at the Edge
The rest of this series reads three well-known tellings of the same idea from their original sources:
| Style | Author and year | What it emphasizes |
|---|---|---|
| Hexagonal Architecture (Ports and Adapters) | Alistair Cockburn, 2005 | Keeping the application equally distant from UI, tests and database; the inside/outside distinction |
| Onion Architecture | Jeffrey Palermo, 2008 | All coupling toward the center, the domain model in the middle, inner rings defining interfaces |
| Clean Architecture | Robert C. Martin, 2012 | The Dependency Rule: source code dependencies point only inward; the shape of data crossing a boundary |
The shared core of all three is what we did in this post: business rules and the abstractions they need sit at the center; database, HTTP, UI and frameworks sit at the edge; everything at the edge references the center, and the center does not know the names of anything at the edge. In his 2012 post Martin says the systems this family produces are independent of frameworks, UI, database and external agencies, and testable. Microsoft's guide also lists Hexagonal, Ports-and-Adapters, Onion and Clean as names the same architecture has had over the years. The differences are in the details: Hexagonal stays out of the inner structure, Onion describes rings, Clean sets rules for the data crossing a boundary. We will open them one by one in the next three posts.
It is worth saying up front that this has a cost. The structure on the right has two types, Book and BookRow, and an adapter that maps between them by hand. More projects, more interfaces, one more level of indirection when you follow a call. That cost is the price of protecting business rules from infrastructure that is likely to change. Paying it in an application whose infrastructure will never change and which has almost no business rules is waste.
Common Pitfalls
Putting the interface next to its implementation. As you saw above, as long as IShippingApiClient lives in Data, Business stays dependent on Data. The interface belongs in the project of the side that uses it.
Designing the interface in the outside world's language. A signature like GetQuoteAsync(ShipmentQuery) copies the carrier's API; when the carrier changes, the interface changes. IShippingQuoteProvider instead asks Ordering's question: "How much does this weight cost to this postal code?" Units and formats are the adapter's problem.
Carrying the EF Core entity inward as the domain model. If you put the interface in the right place but use BookEntity in its signature, Ordering still has to reference Data. The domain needs its own types; we will look at ways of keeping the EF Core mapping in Infrastructure in this series' "Persistence Ignorance and Infrastructure Adapters" post.
Trying to reverse the runtime direction too. Ordering still calls code in Infrastructure; the goal is not to prevent the call but to reverse the compile-time reference. Turning the flow inside out with events and callbacks because "the domain must not call anything" brings needless complexity.
Writing the rule down and not protecting it. Today the reference graph is clean; six months from now someone may add a NuGet package to Ordering for "a small shortcut". Architecture tests that catch this in CI (Continuous Integration) are the topic of the 8th post in this series.
When Not to Use It
If there are almost no business rules, if the application is little more than listing and editing a table on screen, inverting dependencies gains you little beyond new projects and mapping code. The same goes for a short-lived prototype or a small internal tool tied to one database and no external services. There, separation by folders in a single project and good naming are enough. The value shows up when there is real domain logic to protect and at least one piece of infrastructure that is likely to change. We will discuss in detail when it becomes too much in the last post of the series.
A Small Experiment
★☆☆ In a solution you work on, run dotnet list package --include-transitive for the project that holds the business rules. Is there a database provider, an HTTP client library or a messaging SDK in the list? If so, find which ProjectReference brings it in.
★★☆ Add notifications to the "after" structure in this post: after an order is placed, the customer should get an email. Decide which project the interface goes into, how to shape its name and signature in Ordering's language (not that of SMTP, the Simple Mail Transfer Protocol), and use a fake implementation in the test.
★★★ Start from the "I added an interface" version of OrderService and turn the arrows step by step: first move the interfaces into Business, then replace BookEntity in the signatures with a domain type, and finally delete the Data reference from Business's csproj. Note which error the compiler gives at each step.
In the next post we go to the first and plainest telling of this idea: the Hexagonal Architecture Alistair Cockburn wrote down in 2005. With the concepts of port and adapter we will drive the same PlaceOrderService equally from a Minimal API endpoint, a console command and an xUnit test.