A practical guide to building maintainable, testable and resilient database-driven ASP.NET applications with C#, Entity Framework Core, dependency injection and SOLID design principles.
Most modern ASP.NET applications eventually need to persist and retrieve data from a relational database. Although it is possible to communicate directly with a database using ADO.NET, modern .NET applications commonly use an object-relational mapper (ORM) such as Entity Framework Core (EF Core) or a lightweight mapper such as Dapper.
EF Core allows developers to work with database records using strongly typed C# objects and LINQ rather than writing SQL for every operation. At the same time, a well-designed application should not allow database technology to dictate the structure of the entire application. SOLID principles, dependency injection (DI), separation of concerns and resilience policies are therefore just as important as the database technology itself.
This article demonstrates a typical architecture using ASP.NET Core, EF Core and SQL Server, while also explaining when Dapper may be a better choice.
A useful starting point is to separate the application into logical responsibilities:
The goal is not to create layers simply for the sake of creating layers. The important objective is to ensure that each component has a clear responsibility and that changes in one area do not unnecessarily affect the rest of the system.
For a SQL Server-backed ASP.NET Core application, the SQL Server EF Core provider can be added to the project. EF Core also provides command-line tooling for migrations and database development.
dotnet add package Microsoft.EntityFrameworkCore.SqlServer
dotnet add package Microsoft.EntityFrameworkCore.Design
EF Core supports multiple database providers. The provider used by the application determines how EF Core translates its queries and communicates with the underlying database.
Suppose the application manages customers. A simple entity might look like this:
public class Customer
{
public int Id { get; set; }
public string FirstName { get; set; } = string.Empty;
public string LastName { get; set; } = string.Empty;
public string Email { get; set; } = string.Empty;
public DateTime CreatedUtc { get; set; }
}
This class represents the application's model of a customer. EF Core maps the properties to columns in a database table.
For larger applications, explicit configuration can be placed in
OnModelCreating or separate IEntityTypeConfiguration<T> classes:
public class CustomerConfiguration : IEntityTypeConfiguration<Customer>
{
public void Configure( EntityTypeBuilder<Customer> builder)
{
builder.HasKey(x => x.Id);
builder.Property(x => x.FirstName)
.HasMaxLength(100)
.IsRequired();
builder.Property(x => x.LastName)
.HasMaxLength(100)
.IsRequired();
builder.Property(x => x.Email)
.HasMaxLength(320)
.IsRequired();
builder.HasIndex(x => x.Email)
.IsUnique();
}
}
The DbContext is EF Core's primary entry point for interacting with the database. It represents a unit of work and exposes DbSet<T> properties for entities that the application queries or modifies.
public class ApplicationDbContext : DbContext
{
public ApplicationDbContext( DbContextOptions<ApplicationDbContext> options) : base(options) { }
public DbSet<Customer> Customers => Set<Customer>();
protected override void OnModelCreating(
ModelBuilder modelBuilder)
{
modelBuilder.ApplyConfigurationsFromAssembly(
typeof(ApplicationDbContext).Assembly);
}
}
The connection string should normally be stored in ASP.NET Core configuration rather than hard-coded in application code.
For example, development configuration might contain:
{ "ConnectionStrings": { "DefaultConnection": "Server=localhost;Database=CustomerDb;Trusted_Connection=True;TrustServerCertificate=True" } }
Production applications should use an appropriate secret-management mechanism rather than committing credentials to source control.
Microsoft recommends using the most secure authentication mechanism available for deployed applications, particularly when connecting to managed cloud databases.
ASP.NET Core has dependency injection built into the framework. EF Core integrates directly with this DI system through AddDbContext.
var connectionString = builder.Configuration.GetConnectionString("DefaultConnection") ?? throw new InvalidOperationException( "DefaultConnection was not configured.");
builder.Services.AddDbContext(options =>
options.UseSqlServer(connectionString));
AddDbContext registers the context as a scoped service by default. This fits the common ASP.NET Core model in which one HTTP request represents a unit of work.
The context can then be injected into an application service:
public class CustomerService
{
private readonly ApplicationDbContext _db;
public CustomerService(ApplicationDbContext db)
{
_db = db;
}
}
Dependency injection is more than a convenient way of obtaining a DbContext. It is a fundamental architectural tool.
Instead of a service creating its own database implementation:
var db = new ApplicationDbContext(...);
the service receives what it needs from the DI container:
public CustomerService( ICustomerRepository customers) { _customers = customers; }
This reduces coupling and makes the service easier to test. A unit test can provide a fake or mock implementation of ICustomerRepository without requiring a real SQL Server connection.
SOLID principles provide a useful set of guidelines for structuring database-driven applications.
A class should have one primary responsibility. A controller should not contain SQL, database transaction management, business rules and HTTP formatting all in the same class.
Instead:
Controller
↓
Application Service
↓
Repository / Data Access
↓
EF Core
↓
Database
Components should be open to extension without requiring unnecessary modification. Interfaces such as ICustomerRepository can allow the underlying implementation to evolve without forcing every caller to change.
An implementation of an abstraction should be usable wherever that abstraction is expected. If a repository interface promises predictable customer retrieval semantics, replacing its implementation should not unexpectedly change those semantics.
Avoid giant interfaces containing unrelated database operations. Prefer focused abstractions where an application genuinely benefits from them.
Higher-level application logic should depend on abstractions rather than directly on infrastructure details. This is particularly valuable when database access is an infrastructure concern.
public interface ICustomerRepository
{
Task<Customer?> GetByIdAsync(int id, CancellationToken cancellationToken);
Task<IReadOnlyList<Customer>> GetAllAsync(
CancellationToken cancellationToken);
}
The application service depends on ICustomerRepository, while the infrastructure layer provides an EF Core implementation.
EF Core allows database queries to be expressed using LINQ.
public async Task<Customer?> GetByIdAsync(int id, CancellationToken cancellationToken)
{
return await _db.Customers.AsNoTracking().SingleOrDefaultAsync(customer => customer.Id == id, cancellationToken);
}
AsNoTracking() is useful for read-only queries because EF Core does not need to maintain change-tracking state for the returned entities.
Filtering can also be composed naturally:
var customers = await _db.Customers .AsNoTracking() .Where(x => x.LastName.StartsWith("Smith")) .OrderBy(x => x.LastName) .ThenBy(x => x.FirstName) .ToListAsync(cancellationToken);
Adding a new entity is straightforward:
var customer = new Customer { FirstName = "Jane", LastName = "Smith", Email = "[email protected]", CreatedUtc = DateTime.UtcNow };
_db.Customers.Add(customer);
await _db.SaveChangesAsync(cancellationToken);
EF Core tracks the entity and generates the appropriate INSERT statement when SaveChangesAsync() is called.
EF Core migrations allow the application's database schema to evolve alongside the application's model.
dotnet ef migrations add InitialCreate dotnet ef database update A migration records schema changes such as creating tables, adding columns or creating indexes.
In production, migrations should be treated as controlled deployment artifacts. Teams should consider their deployment strategy, database permissions, backward compatibility and rollback requirements rather than blindly updating production databases during application startup.
A controller can remain relatively thin when database and application responsibilities have been separated:
[ApiController] [Route("api/customers")]
public class CustomersController : ControllerBase
{
private readonly ICustomerService _service;
public CustomersController(ICustomerService service)
{
_service = service;
}
[HttpGet("{id:int}")]
public async Task<ActionResult<CustomerDto>> Get(
int id,
CancellationToken cancellationToken)
{
var customer = await _service.GetByIdAsync(
id,
cancellationToken);
if (customer is null)
return NotFound();
return Ok(customer);
}
}
The controller is concerned primarily with HTTP semantics. It does not need to know whether the data came from EF Core, Dapper or another persistence mechanism.
A database is an external dependency. Even a well-designed database can temporarily become unavailable because of network interruptions, transient infrastructure failures, failovers, throttling or other environmental conditions.
Resilience means designing the application so that appropriate transient failures do not immediately become user-visible application failures.
EF Core provides execution strategies for connection resiliency. For SQL Server, retry-on-failure can be enabled when configuring the context.
builder.Services.AddDbContext<ApplicationDbContext>(options => { options.UseSqlServer(connectionString, sqlOptions => { sqlOptions.EnableRetryOnFailure(maxRetryCount: 5, maxRetryDelay: TimeSpan.FromSeconds(10), errorNumbersToAdd: null);
});
});
The exact retry values should be based on the application's operational requirements. Retrying every failure is not automatically correct. Permanent errors, invalid SQL, constraint violations and authentication failures generally should not be treated as transient failures.
A retry policy should consider:
For explicit transactions, EF Core's execution strategy may need to execute the complete transactional operation as a retriable unit. Microsoft documents using CreateExecutionStrategy() for this scenario.
Retry policies should not be used to hide an unhealthy database. A request should still have a sensible upper time limit.
public async Task<Customer?> GetCustomerAsync(int id, CancellationToken cancellationToken)
{
return await _db.Customers.AsNoTracking().SingleOrDefaultAsync( x => x.Id == id, cancellationToken);
} Passing the ASP.NET Core request cancellation token through the application and data-access layers allows abandoned HTTP requests to stop unnecessary database work.
Repository abstractions can be useful, particularly when they represent meaningful application concepts. However, creating a generic repository that simply wraps every method of DbSet<T> can add an unnecessary abstraction over EF Core.
For example, an interface such as:
Task<T> GetAsync(...); Task AddAsync(...); Task UpdateAsync(...); Task DeleteAsync(...); IQueryable<T> Query();
may simply reproduce functionality that EF Core already provides.
A better question is whether the abstraction protects a meaningful architectural boundary. If the application genuinely benefits from hiding persistence details, a focused repository or query service can be valuable. Otherwise, injecting a carefully scoped DbContext into an application service may be simpler.
EF Core and Dapper are both popular approaches to database access, but they solve somewhat different problems.
Dapper describes itself as a simple micro-ORM that simplifies ADO.NET. Developers write SQL and Dapper maps query results to .NET objects.
| Area | Entity Framework Core | Dapper |
|---|---|---|
| Abstraction level | Full ORM | Lightweight micro-ORM/object mapper |
| SQL | Usually generated from LINQ | Normally written explicitly by the developer |
| Change tracking | Built-in | Not provided as an ORM change-tracking system |
| Migrations | Built-in EF Core migrations | Typically handled separately |
| LINQ | Core query mechanism | Not its primary query abstraction |
| Complex object graphs | Strong support through relationships and projections | Requires explicit SQL and mapping decisions |
| SQL control | Lower-level SQL control | High SQL control |
| Learning curve | Larger feature set to learn | Small and straightforward API |
| Performance | Strong general-purpose performance, but ORM overhead exists | Low abstraction overhead and often attractive for highly tuned SQL |
| Best fit | Domain-rich applications and general CRUD/data access | SQL-heavy applications, reporting and performance-sensitive queries |
Dapper's own published benchmarks demonstrate its focus on low overhead, although benchmark results should never be treated as universal application-performance guarantees. Real performance depends on query shape, indexes, network latency, database workload, result size and application architecture.
EF Core is often a strong choice when an application:
EF Core can also execute raw SQL when the ORM abstraction is not the right tool for a particular query, so choosing EF Core does not necessarily mean giving up all control over SQL.
Dapper can be particularly attractive when:
Dapper can also be useful alongside EF Core. For example, EF Core might handle normal transactional application operations while Dapper handles a small number of complex reporting queries.
EF Core and Dapper do not have to be mutually exclusive.
ASP.NET Core API | +-----------------------+ | | Application Services Reporting Services | | v v EF Core Dapper | | +-----------+-----------+ | SQL Server
The key is to keep the choice behind appropriate application boundaries. A controller should not need to know whether its data-access service uses EF Core or Dapper.
Database performance is usually more about the database operation than simply choosing one .NET data-access library.
Important considerations include:
AsNoTracking() for appropriate read-only queries.A poorly indexed query executed through Dapper can still be slow, while a well-designed EF Core query can perform very well. Performance decisions should therefore be based on profiling and representative workloads rather than ORM reputation alone.
A larger application might use a structure such as:
src/ MyApp.Api/ Controllers/ Program.cs
MyApp.Application/
Services/
Interfaces/
DTOs/
MyApp.Domain/
Entities/
ValueObjects/
MyApp.Infrastructure/
Persistence/
ApplicationDbContext.cs
Configurations/
Repositories/
tests/
MyApp.UnitTests/
MyApp.IntegrationTests/
This is only one possible architecture. The correct structure depends on the size and complexity of the application. The important principle is that dependencies should flow deliberately rather than allowing every component to reference every other component.
Database code benefits from integration testing against a real database provider. Unit tests should generally focus on business behaviour and should not attempt to prove that SQL Server itself works correctly.
For integration tests, consider using an isolated SQL Server instance, a containerised database or another environment that closely resembles production.
Avoid assuming that every relational database provider behaves identically. In particular, tests against an in-memory provider may fail to reproduce important relational behaviour present in SQL Server or another production database.
Resilience works best when combined with observability. A production application should make it possible to determine:
Logging, metrics and distributed tracing can reveal whether a problem is in the API, application layer, database connection, query or database infrastructure.
Database access should be designed with security in mind from the start.
One advantage of LINQ-based EF Core queries is that parameters are normally generated for values rather than requiring developers to concatenate user input into SQL strings.
For a conventional ASP.NET Core business application, a sensible default architecture is:
DbContext through ASP.NET Core dependency injection.Connecting an ASP.NET application to a database is technically simple; building database access that remains maintainable under real-world requirements is considerably more challenging.
Entity Framework Core provides a powerful abstraction for relational data, including LINQ querying, change tracking, migrations and provider-specific database functionality. ASP.NET Core's dependency-injection system makes it straightforward to configure and consume DbContext with a scoped lifetime appropriate for many web applications.
The larger architectural lesson is that EF Core should be part of an application's infrastructure rather than becoming the application's architecture. SOLID principles and dependency injection help keep business logic independent from infrastructure details, while resilience policies help applications tolerate appropriate transient database failures.
Dapper remains an excellent alternative when developers need direct SQL control and a lightweight mapping layer. The choice between EF Core and Dapper should therefore be based on the application's domain, query complexity, performance requirements, team expertise and operational constraints rather than on the assumption that one tool is universally better.
In many mature applications, the strongest solution is not necessarily choosing one technology exclusively. EF Core can handle the majority of domain-oriented persistence while Dapper can be introduced selectively for specialised SQL-heavy workloads. The architectural boundary is what allows either implementation to evolve without spreading database concerns throughout the application.
Also Read: Building and Deploying a Microservice
AI-generated fashion design is revolutionizing the industry, pushing the boundaries of creativity and innovation in style.
Browse most trending Journalist jobs in South Africa. Apply today
Browse most trending Photographer jobs in South Africa. Apply today
Browse most trending Fashion jobs in United Kingdom. Apply today
AI-Generated Fashion Design
SEP 10, 2026
Streetwear Luxury Collaborations
SEP 10, 2026 5,617 VIEWS
Get the latest creative news from FooBar about art, design and business.
Subscribe