Open any enterprise .NET solution and you'll find interfaces everywhere. IOrderService, IOrderRepository, IProductValidator — each with exactly one implementation. When you ask why, the answer is always the same: "testability."
This is the most misunderstood "-ability" in software design.
The conventional wisdom says anything that talks to infrastructure should be mocked. Your service depends on a repository? Mock it. Your handler touches the database? Mock that too. The result is a codebase where every class has a corresponding interface, every test is a choreographed dance of mock setups, and nobody is actually testing whether the system works.
There's a better way to think about this.
Two Kinds of Dependencies
Vladimir Khorikov's article When to Mock draws a distinction that changed how I approach testing. He separates out-of-process dependencies into two categories:
Managed dependencies are infrastructure you own and control — your database, your cache, your message queue, your search index. These are only accessible through your application. No external system observes them directly.
Unmanaged dependencies are external systems where the interaction itself is observable by others — a payment gateway, an SMTP server, a message bus that other applications consume from. When you interact with these, you're producing behavior that external systems depend on.
The testing strategy follows from this distinction.
For managed dependencies, test with real instances. Spin up Postgres in a Docker container. Use the actual cache. This isn't a fake or a substitute. It's the real database, running in a controlled environment. The point is to verify that your code works with the infrastructure it actually uses. Nobody outside your application cares how you talk to your database, so there's no contract to verify. You only care about the outcome: is the data correct?
For unmanaged dependencies, use mocks. You can't hit Stripe's API in your test suite. But more importantly, the interaction itself is what matters. You need to verify that when your code decides to charge a card, it sends the right request with the right parameters. The external system's contract is observable to others, so your tests should assert that you're honoring it.
The key insight: mocks verify how your code interacts with a dependency. Real instances verify what your code produces. For managed dependencies, you care about outcomes. For unmanaged dependencies, you care about the interaction.
This has a practical consequence for refactoring. When you mock your repository, you're asserting "this query was called with these parameters." Refactor the query to be more efficient, or split it into two queries, and your test breaks. The behavior is identical, but the contract changed. That's test fragility. When you test against a real database, you assert on the outcome: is the order in the database with the correct values? Refactor however you like. If the outcome is correct, the test passes.
The Architectural Implication
This distinction has a structural consequence: push managed dependencies to the edges and keep your domain logic pure.
In practice, this means your handlers (or controllers, or whatever you call your orchestration layer) are the only code that touches the database. They load data, pass it to domain types, and persist the results. The domain types themselves receive data and return decisions.
This is where the interface-per-class ceremony falls away. You don't need IOrderRepository when your handler uses the DbContext directly and gets tested against a real database. You don't need IOrderService as a pass-through layer that exists only to be mocked. You don't need IProductValidator when validation logic lives in the domain type itself, which is pure and trivially testable without any test doubles. The abstractions that existed solely to enable mocking become unnecessary when you stop mocking managed dependencies.
And with those abstractions gone, the cognitive load drops. No more Ctrl+Click through five files across three projects to understand a single feature. No more holding a mental map of which interface lives in which layer. The handler is the feature — open one file, see the complete picture.
// Handler: owns the database interaction
public async Task<Guid> Handle(PlaceOrderRequest request, CancellationToken ct)
{
var product = await _db.Products.FindAsync(request.ProductId);
var order = Order.Create(product, request.Quantity); // Domain type: pure
_db.Orders.Add(order);
await _db.SaveChangesAsync(ct);
return order.Id;
}
// Domain type: no interfaces, no dependencies
public class Order
{
public static Order Create(Product product, int quantity)
{
if (quantity > product.MaxPerOrder)
throw new DomainException("Exceeds maximum quantity");
return new Order { /* ... */ };
}
}
The handler gets integration tested with a real database. If the handler also calls an unmanaged dependency like a payment gateway, that gets mocked. The domain type gets unit tested with no test doubles at all. You pass data in, assert on what comes out.
This is What Rails Got Right
If you've spent time in the Rails world, this might sound familiar. ActiveRecord models touch the database directly. Tests run against a real database with transactions that roll back. Nobody writes IProductRepository interfaces.
The Rails philosophy treats the database as a core part of your application, not an implementation detail you should abstract away. Pretending you might swap Postgres for MongoDB someday is architectural fantasy. The approach I'm describing shares that conviction. The handler owns the database and gets tested against a real one. Domain logic lives in separate pure types, because pure code is easier to reason about and test.
You get Rails-style integration tests for the code that touches infrastructure. You get trivial unit tests for domain logic. No repository interfaces. No service interfaces. No mocks for managed dependencies.
A Note on Message Buses
Khorikov classifies message buses as unmanaged dependencies, and in a distributed system, that's correct. If you publish a message and another application consumes it, the message schema is a contract. Change the schema, break the consumer. That's externally observable behavior.
But in a Majestic Monolith, the situation is often different. If you're using a queue for async processing within your own application, you own both the producer and consumer. The message format is an internal implementation detail. That's a managed dependency. Test it with the real queue.
The principle isn't "mock all message buses." It's "mock dependencies where the interaction is observable externally." In a monolith, you often control both ends.
The Folder Structure That Emerges
When you adopt this testing philosophy, a natural code organization follows. Your handlers become the unit of work. Each handler owns a complete feature: load data, execute domain logic, persist results. The request, response, handler, and any feature-specific domain types live together.
/Features
/Ordering
/PlaceOrder
PlaceOrderHandler.cs
PlaceOrderRequest.cs
PlaceOrderResponse.cs
/CancelOrder
CancelOrderHandler.cs
CancelOrderRequest.cs
Jimmy Bogard calls this Vertical Slice Architecture. The idea is simple: code that changes together should live together. When you need to understand how "Place Order" works, you open one folder. Everything you need is right there.
This structure emerges as a side effect of taking testability seriously. When your handlers are the integration boundary and your domain types are pure, grouping them by feature is the obvious choice.
What You Gain
The test for whether you've got this right: can you test your handlers against real managed dependencies, only mocking unmanaged ones? If yes, you're in good shape.
You end up with integration tests that verify real behavior against real infrastructure, unit tests for domain logic that are trivial to write and fast to run, no interface-per-class ceremony, no mock setup boilerplate, and code organized by what it does rather than what technical layer it belongs to.
The interfaces you do keep are the ones for unmanaged dependencies — the payment gateway, the external API, the email service. These abstractions earn their place because they represent a real contract boundary, not a testing convenience.
Most teams don't have a scale problem. They have a complexity problem. And that complexity often comes from abstractions that exist to satisfy a misunderstood idea of what testability means.