ASP.NET Core makes dependency injection so easy that creating abstractions can sometimes become automatic.
Create an interface.
Create an implementation.
Register both with the DI container.
Inject the interface into the controller or service.
For example:builder.Services.AddScoped<IOrderService, OrderService>();
Then:public class OrdersController : ControllerBase { private readonly IOrderService _orderService; public OrdersController(IOrderService orderService) { _orderService = orderService; } }
This is a perfectly reasonable pattern.
The problem starts when we assume that every dependency in an ASP.NET Core application needs an interface.
We end up with interfaces that have one implementation, services that simply forward calls to another service, unnecessary layers, and a dependency graph that is harder to understand than the business problem it is supposed to solve.
The real purpose of an abstraction is not to make dependency injection possible.
The purpose of an abstraction is to create a useful boundary around something that may need to change independently.
Dependency Injection Is Not the Same as Abstraction
ASP.NET Core has built-in dependency injection, which allows us to register concrete classes directly:builder.Services.AddScoped<OrderService>();
We can then inject the concrete type:public class OrdersController : ControllerBase { private readonly OrderService _orderService; public OrdersController(OrderService orderService) { _orderService = orderService; } }
There is nothing inherently wrong with this.
We don’t need:IOrderService
simply because ASP.NET Core has a dependency injection container.
An interface becomes valuable when it represents a boundary that we actually care about.
For example:builder.Services.AddScoped<IPaymentGateway, StripePaymentGateway>();
Here, the abstraction makes sense because the application shouldn’t need to know that the payment implementation happens to use a particular provider.
The controller or application service depends on:IPaymentGateway
rather than:StripePaymentGateway
That is a meaningful dependency boundary.
Don’t Create Interfaces Automatically
A common ASP.NET Core pattern looks like this:public interface IProductService { Task<Product?> GetAsync(int id); } public class ProductService : IProductService { public async Task<Product?> GetAsync(int id) { // Implementation } }
And then:builder.Services.AddScoped<IProductService, ProductService>();
This may be completely appropriate.
But suppose ProductService is an internal application service with one implementation and no meaningful alternative implementation.
Then we should ask:
Why does
IProductServiceexist?
If the answer is:
“Because we use dependency injection.”
that’s not a particularly strong reason.
We could simply write:builder.Services.AddScoped<ProductService>();
and:public class ProductsController : ControllerBase { private readonly ProductService _productService; public ProductsController(ProductService productService) { _productService = productService; } }
The application is still using dependency injection.
We’ve simply avoided an abstraction that doesn’t currently provide meaningful value.
Where Abstractions Are Particularly Useful in ASP.NET Core
ASP.NET Core applications frequently interact with infrastructure and external systems.
These are strong candidates for abstraction.
Examples include:
- Payment providers
- Email providers
- SMS providers
- Cloud storage
- Message queues
- External REST APIs
- Third-party SDKs
- Authentication providers
- Search engines
- File systems
- Time providers
- Infrastructure-specific services
Consider an application that sends emails.
Instead of allowing application code to depend directly on a specific email provider:public class OrderService { private readonly SendGridClient _client; public OrderService(SendGridClient client) { _client = client; } }
we can introduce a business-oriented abstraction:public interface IEmailSender { Task SendAsync( string recipient, string subject, string body); }
The infrastructure implementation can depend on the external provider:public class EmailSender : IEmailSender { private readonly SendGridClient _client; public EmailSender(SendGridClient client) { _client = client; } public async Task SendAsync( string recipient, string subject, string body) { // Send using the provider } }
And register it:builder.Services.AddScoped<IEmailSender, EmailSender>();
Now the rest of the application depends on:IEmailSender
rather than the provider’s SDK.
If the email provider changes later, the change is largely isolated to the infrastructure implementation.
This is the kind of future-proofing that is worth paying for.
Don’t Abstract ASP.NET Core Itself
Another common mistake is wrapping framework functionality without a clear reason.
For example, developers sometimes create abstractions such as:public interface IControllerBase { }
or wrappers around framework services simply because they believe every dependency should be hidden.
But ASP.NET Core already provides well-defined abstractions.
For example:ILogger<T> IConfiguration IHttpClientFactory IHostEnvironment IOptions<T>
These are already abstractions.
We generally don’t need to create:IMyLogger IMyConfiguration IMyHttpClientFactory
unless our application has a specific domain reason for doing so.
Wrapping a framework abstraction with another abstraction can simply add another layer without protecting us from anything meaningful.
Avoid the “Interface for Every Service” Rule
Imagine an ASP.NET Core application containing:IUserService UserService IOrderService OrderService IProductService ProductService ICustomerService CustomerService IAddressService AddressService IReportService ReportService
And every interface has exactly one implementation.
This isn’t automatically bad.
But it is worth questioning.
If every class is abstracted, developers now have to navigate between interfaces and implementations constantly.
For example:OrdersController ↓ IOrderService ↓ OrderService ↓ IOrderRepository ↓ OrderRepository ↓ ApplicationDbContext
A simple request may now cross five architectural boundaries.
The important question becomes:
Does each boundary represent something meaningful?
If not, we may have created architecture for the sake of architecture.
Controllers Should Depend on Application Behavior
One useful abstraction boundary in ASP.NET Core is the boundary between the HTTP layer and application logic.
A controller should generally not contain complicated business logic.
Instead of:[HttpPost] public async Task<IActionResult> Create(CreateOrderRequest request) { // Validate everything // Calculate totals // Apply discounts // Save order // Send email // Publish event return Ok(); }
we can delegate the application behavior:[HttpPost] public async Task<IActionResult> Create(CreateOrderRequest request) { var order = await _orderService.CreateAsync(request); return Ok(order); }
Whether _orderService is represented by an interface depends on the application.
The important abstraction here is not necessarily:IOrderService
The important boundary is:HTTP concerns ↓ Application behavior
That distinction matters.
We shouldn’t confuse architectural boundaries with interfaces.
An abstraction can be useful without necessarily requiring an interface, and an interface can exist without representing a useful architectural boundary.
Dependency Injection Should Describe Your Architecture
Your DI registrations are effectively a map of your application’s architecture.
For example:builder.Services.AddScoped<OrderService>(); builder.Services.AddScoped<IEmailSender, EmailSender>(); builder.Services.AddScoped<IPaymentGateway, StripePaymentGateway>();
These registrations tell us something meaningful:
OrderServiceis an application service.IEmailSenderrepresents an infrastructure boundary.IPaymentGatewayrepresents a replaceable external dependency.
Compare that with:builder.Services.AddScoped<IOrderService, OrderService>(); builder.Services.AddScoped<IProductService, ProductService>(); builder.Services.AddScoped<ICustomerService, CustomerService>(); builder.Services.AddScoped<IAddressService, AddressService>(); builder.Services.AddScoped<IReportService, ReportService>();
If all of these interfaces simply exist because “that’s our standard pattern,” the DI configuration isn’t communicating much about the architecture.
It’s just registering layers.
Good dependency injection configuration should help us understand the system.
Use Abstractions at the Point of Instability
A useful way to think about dependency injection is:
Abstract things that are likely to vary.
Suppose your application uses a payment provider.CheckoutService | v IPaymentGateway | v StripePaymentGateway
The payment provider is outside your control.
It may change.
Therefore, an abstraction is valuable.
Now consider a simple domain calculation:public class OrderTotalCalculator { public decimal Calculate(Order order) { return order.Items.Sum(x => x.Price); } }
There may be no reason to create:public interface IOrderTotalCalculator { decimal Calculate(Order order); }
unless there is an actual reason for multiple implementations or another meaningful boundary.
This gives us a useful principle:
Put abstractions around volatility, not around everything.
HttpClient Is a Good Example of a Boundary
ASP.NET Core provides IHttpClientFactory, which already solves many concerns associated with managing HttpClient.
We can register a typed client:builder.Services.AddHttpClient<PaymentClient>(client => { client.BaseAddress = new Uri("https://payments.example.com"); });
Then:public class PaymentClient { private readonly HttpClient _httpClient; public PaymentClient(HttpClient httpClient) { _httpClient = httpClient; } public async Task<PaymentResponse> ChargeAsync( PaymentRequest request) { // HTTP communication } }
We don’t necessarily need to introduce multiple abstractions around this.
But if the rest of the application shouldn’t know anything about HTTP or the external payment API, we might introduce:public interface IPaymentGateway { Task<PaymentResult> ChargeAsync( decimal amount, string customerId); }
The implementation can then use the typed HTTP client.Application | v IPaymentGateway | v PaymentGateway | v PaymentClient | v External API
Now each boundary has a reason.
The application understands payments.
The infrastructure understands HTTP.
The external API remains an implementation detail.
Don’t Use Interfaces Just for Unit Testing
One of the most common arguments for interfaces is:
“We need an interface so we can mock it.”
For example:public interface IProductService { Task<Product?> GetAsync(int id); }
The interface exists primarily so a test can create:var mock = new Mock<IProductService>();
Testing considerations are important, but this shouldn’t automatically determine the architecture.
If the interface doesn’t represent a meaningful boundary, we may be creating production complexity to make a particular testing technique easier.
A better question is:
What should actually be isolated in this test?
External systems are excellent candidates.
For example:public interface IPaymentGateway { Task<PaymentResult> ChargeAsync(...); }
A checkout test can replace the external payment dependency with a test double.
That is valuable because payment processing is an external boundary.
By contrast, creating interfaces for every internal class simply to mock every interaction can lead to tests that describe implementation structure rather than business behavior.
Configuration Is Another Important Abstraction
ASP.NET Core’s options pattern provides another example of a useful abstraction.
Instead of injecting IConfiguration everywhere:public class PaymentService { private readonly IConfiguration _configuration; public PaymentService(IConfiguration configuration) { _configuration = configuration; } }
we can model configuration explicitly:public class PaymentOptions { public string ApiKey { get; set; } = string.Empty; public string BaseUrl { get; set; } = string.Empty; }
Register it:builder.Services.Configure<PaymentOptions>( builder.Configuration.GetSection("Payment"));
Then consume it:public class PaymentGateway { private readonly PaymentOptions _options; public PaymentGateway( IOptions<PaymentOptions> options) { _options = options.Value; } }
This is a useful abstraction because the application is expressing a meaningful configuration concept:PaymentOptions
rather than spreading configuration keys throughout the codebase.
Abstractions Can Protect You From Framework Changes Too
There are situations where an abstraction can protect application code from infrastructure or framework-specific details.
For example, suppose your application needs the current time.
You could write:var now = DateTime.UtcNow;
throughout the application.
But if business logic needs deterministic time during testing or a consistent time source, an abstraction may be useful.
Modern .NET also provides the TimeProvider abstraction.
You can inject it:public class SubscriptionService { private readonly TimeProvider _timeProvider; public SubscriptionService(TimeProvider timeProvider) { _timeProvider = timeProvider; } public bool HasExpired(DateTimeOffset expiresAt) { return _timeProvider.GetUtcNow() >= expiresAt; } }
Register:builder.Services.AddSingleton(TimeProvider.System);
This is a good example of using an abstraction because the dependency represents something that can vary in a meaningful way, especially during testing.
There is no need to invent:IMyTimeProvider
when the framework already gives us an appropriate abstraction.
Don’t Create an Abstraction for a Future You Cannot Describe
Consider this:public interface ICustomerProvider { Task<Customer> GetCustomerAsync(Guid id); }
Someone might say:
“We may eventually get customers from another system.”
That’s possible.
But what does the alternative system look like?
A database?
A CRM?
A remote API?
A cache?
An event stream?
We don’t know.
If we don’t know what the variation actually looks like, we may not know what the correct abstraction should be.
This is one of the biggest problems with speculative abstraction.
We can easily create an abstraction today that prevents us from designing the right abstraction tomorrow.
Sometimes the best decision is to wait.
Refactoring an Abstraction Later Is Often Cheaper Than Maintaining a Bad One
Developers sometimes fear removing an abstraction because they believe that once an interface exists, it must remain forever.
It doesn’t.
Suppose we start with:public class CustomerService { }
Later we discover that there are two implementations:DatabaseCustomerService CrmCustomerService
At that point we can introduce:public interface ICustomerService { }
and update the DI registration:builder.Services.AddScoped<ICustomerService, DatabaseCustomerService>();
The important thing is that we introduced the abstraction when we understood the problem.
This is often preferable to maintaining an unnecessary interface for years because we were afraid that we might eventually need it.
A Practical Decision Framework
Before adding an interface to an ASP.NET Core application, ask:
Does this represent a meaningful boundary?
If it represents an external system, infrastructure dependency, or important business concept, that’s a strong signal.
Is the dependency likely to change independently?
If a third-party provider can change without the business logic changing, abstraction is often valuable.
Would changing it later be expensive?
If replacing the dependency would require changes throughout the application, isolate it now.
Is there already meaningful variation?
Two implementations are stronger evidence than one hypothetical implementation.
Does ASP.NET Core already provide the abstraction?
Before creating your own interface, check whether the framework already provides an appropriate abstraction such as:ILogger<T> IHttpClientFactory IOptions<T> TimeProvider IHostEnvironment
Does the abstraction make the code easier to understand?
If adding an interface makes the architecture harder to understand without protecting anything meaningful, reconsider it.
A Simple ASP.NET Core Architecture
A pragmatic ASP.NET Core application might look like this: ASP.NET Core | Controller | v OrderService | +----------+----------+ | | v v IPaymentGateway IEmailSender | | v v StripePaymentGateway EmailSender | | v v External API Email Provider
Notice that not everything is an interface.
OrderService can remain concrete if there is no reason to abstract it.
The interfaces exist where they provide valuable boundaries.
This keeps the dependency graph relatively simple while still protecting the application from important external changes.
The Goal Isn’t Maximum Flexibility
It is tempting to think that good architecture means maximum flexibility.
But maximum flexibility often means maximum complexity.
An application that supports ten hypothetical implementations of every component isn’t necessarily better designed than an application that supports the one implementation it actually needs.
The goal should be appropriate flexibility.
We want to make important changes easier without making ordinary development unnecessarily difficult.
In an ASP.NET Core application, that often means:Concrete by default ↓ Identify real boundaries ↓ Abstract meaningful variation ↓ Register abstractions with DI ↓ Keep infrastructure behind those boundaries
Conclusion
Dependency injection makes it easy to introduce abstractions in ASP.NET Core.
That convenience can be both a strength and a weakness.
It is easy to fall into a pattern where every service gets an interface, every interface gets an implementation, and every implementation gets registered with the DI container.
But dependency injection is a mechanism—not an architectural goal.
The more important question is:
What are we protecting ourselves from?
If an application depends on a payment provider, email service, external API, message broker, or other infrastructure that may change independently, an abstraction can be extremely valuable.
If a class is simple, internal, stable, and has one obvious implementation, a concrete dependency may be the better design.
The best ASP.NET Core applications aren’t necessarily the ones with the most interfaces.
They’re the ones where the interfaces that do exist have a clear reason for being there.
Don’t abstract because you can. Abstract because the boundary matters.
That is how dependency injection becomes an architectural tool rather than just a registration mechanism.