DEV Community

Dhana
Dhana

Posted on

Registering a Concrete Class Instead of an Interface: A Small DI Detail With a Big Testing Cost

Dependency Injection registration often gets reviewed for one thing: which lifetime was chosen — Singleton, Scoped, or Transient. A separate, easy-to-miss detail matters just as much for the long-term health of a codebase: whether the registration points to an interface or a concrete class. This distinction rarely causes a visible bug on its own, but it quietly determines whether a class can ever be properly unit tested.

Two Registrations That Look Similar But Aren't

Consider these two ways of registering the same underlying class:

csharp
// Registration A - concrete class only
services.AddTransient();

// Registration B - interface mapped to implementation
services.AddScoped();

Both successfully let clsWorkFlow be injected somewhere in the application. Both compile without error, and both work correctly at runtime for normal request handling. The difference only becomes visible when you try to do something with the class beyond its default, real-database behavior — specifically, when you try to test it.

What the Consuming Controller Looks Like in Each Case

Registration A typically pairs with a Controller like this:

csharp
public class WorkFlowController : ControllerBase
{
private readonly clsWorkFlow _workFlowDataAccess;

public WorkFlowController(clsWorkFlow workFlowDataAccess)
{
    _workFlowDataAccess = workFlowDataAccess;
}
Enter fullscreen mode Exit fullscreen mode

}

Registration B pairs with this instead:

csharp
public class WorkFlowController : ControllerBase
{
private readonly IclsWorkFlow _workFlowDataAccess;

public WorkFlowController(IclsWorkFlow workFlowDataAccess)
{
    _workFlowDataAccess = workFlowDataAccess;
}
Enter fullscreen mode Exit fullscreen mode

}

At a glance, these look almost identical — one word different in two places. But that one word changes what's possible during testing.

Why the Interface Version Can Be Tested and the Concrete Version Can't

To unit test a Controller in isolation — without hitting a real Oracle database — you need to supply a fake or mock version of whatever it depends on. This is only possible when the dependency is expressed as an interface:

csharp
public class FakeWorkFlow : IclsWorkFlow
{
public List GetAddWorkFlows(int employeeSlno, int createdBy)
{
return new List
{
new CtrlSlnoName { CtrlSlno = 1, Name = "Test Workflow" }
};
}
// remaining interface methods implemented with fake data
}

With the Controller depending on IclsWorkFlow, a test can substitute FakeWorkFlow in place of the real, database-backed clsWorkFlow:

csharp
var controller = new WorkFlowController(new FakeWorkFlow());
var result = controller.GetAddRoleMapping(employeeSlno: 5, createdBy: 1);
// assert on result, no real database touched

If the Controller instead depends directly on the concrete clsWorkFlow class, there is no interface to implement a fake version of. FakeWorkFlow can't be substituted in, because the Controller's constructor demands the actual clsWorkFlow type specifically, not anything that merely behaves like it. The only way to test this Controller is to run it against a real (or real-like) Oracle connection — slower, harder to set up reliably, and prone to failing for reasons unrelated to the actual logic being tested, like a database being temporarily unreachable.

Why This Detail Is Easy to Miss in Practice

Registering a concrete class directly is often the path of least resistance early in a project — it compiles, it works, and the missing interface doesn't cause any errors or warnings. The cost only becomes apparent later, when someone tries to add tests to a Controller and discovers there's no way to isolate it from the database. At that point, retrofitting an interface onto an already-widely-used concrete class means touching every registration and every constructor that depends on it — a much larger change than defining the interface correctly from the start would have been.

This is also why interface-based registration is considered a default good practice in ASP.NET Core projects, even when no tests exist yet at the time a class is written. The interface costs very little to add upfront — a few lines defining method signatures — but keeps the door open for testing later, without requiring a disruptive refactor across the codebase.

A Practical Habit for Reviewing Registrations

When reviewing or writing a new service registration, it's worth checking two things together rather than just one: not only which lifetime was chosen, but whether the registration maps an interface to an implementation, or registers a concrete type on its own. A registration like services.AddTransient() works today, but it's worth asking whether this class will ever need to be tested in isolation, mocked, or swapped for an alternative implementation later. If the answer is plausibly yes — which is true for most Data Access classes — defining and registering against an interface from the outset avoids a more expensive change down the line.

Takeaway

The difference between services.AddTransient() and services.AddScoped() looks trivial on the surface, but it determines whether a class can ever be properly unit tested without touching a real database. Interface-based registration doesn't change how the application behaves for a real user, but it directly shapes how maintainable and testable the codebase remains as it grows — a detail worth checking in every registration, not just the lifetime chosen alongside it.

Top comments (2)

Collapse
 
iqtechsolutions profile image
Ivan Rossouw •

I’d frame an interface as one possible testing seam rather than the condition for testability. A concrete class can be tested directly, and for a thin database adapter an integration test against a disposable database may provide more value than a mock-heavy unit test.

The expensive coupling usually appears when one dependency combines SQL access, business policy, and mapping. My rule is to introduce an application-owned interface around volatile or external behavior, keep trivial services concrete, and prove the seam with both a fake and a contract test against the real adapter.

A useful review question is: does this interface express a capability, or does it merely duplicate every method on one class?

Collapse
 
dhanagani_lakshmi_2487ad0 profile image
Dhana •

That's a fair and important nuance, thank you. I was focusing specifically on the unit-testing case where you want to isolate logic without touching a real database, but you're right that framing it as "interface = testable, concrete = not" oversimplifies things. A concrete class can absolutely be tested directly, and for a thin adapter, an integration test against a real (or disposable) database can genuinely be more valuable than mocking through an interface.

I really like your rule of thumb — introducing an interface around volatile/external behavior rather than doing it reflexively for every class. And the review question about whether an interface expresses a real capability versus just duplicating a single class's methods is a great practical check. Appreciate you adding this context!