A database can work perfectly for months and still fail when an application needs it most. A server restart, network interruption, storage problem, or database maintenance operation can make the primary database unavailable.
Applications that depend on a single database connection often struggle during these situations. Users may receive errors, transactions may fail, and background jobs may stop working.
Database failover helps an application continue working when its primary database becomes unavailable. However, configuring a secondary database is not enough. The application must also handle connection changes, retry failed operations safely, and recover from interrupted transactions.
The best way to find these problems is to test database failover before they happen in production.
This article explains how to plan and test database failover in an ASP.NET Core application using SQL Server as an example. The same testing principles apply to PostgreSQL, MySQL, and managed cloud databases.
What Is Database Failover?
Database failover is the process of moving database operations from an unavailable primary database to another database instance.
In a typical high-availability setup, the application connects to a primary database. A secondary instance is available to take over if the primary fails.
A simplified architecture looks like this:
ASP.NET Core Application
|
v
Database Endpoint
|
v
Primary Database
|
Replication
|
v
Secondary DatabaseWhen the primary database becomes unavailable, the database platform or cluster management system promotes an eligible secondary instance.
The application then reconnects through the database endpoint and continues its work.
The exact behavior depends on the database technology and deployment configuration. Some systems provide automatic failover, while others require an administrator or orchestration system to initiate it.
Failover does not guarantee that every operation will succeed. Requests already running during the transition may fail, and transactions that were not committed may need to be executed again.
Why Database Failover Testing Matters
A database cluster can appear healthy while still having problems that only become visible during a failure.
For example, an ASP.NET Core application may use a connection pool containing connections to the old primary database. After failover, some of those connections may no longer be usable.
Another problem occurs when the application retries a database operation without checking whether the previous attempt succeeded.
Consider a payment operation:
The application sends a payment record to the database.
The database commits the transaction.
The connection breaks before the application receives confirmation.
The application retries the operation.
The payment record may be inserted twice.
This is why failover testing must cover more than database availability. It must also verify application behavior and data consistency.
Prepare a Test Environment
Do not start failover testing by shutting down a production database.
Use a separate environment that resembles the production architecture as closely as possible.
The environment should include:
A primary database instance.
A secondary database instance.
Replication or availability configuration.
The same database driver and connection settings used by the application.
An ASP.NET Core application connected through the intended database endpoint.
Monitoring for database health and application errors.
For SQL Server, an Always On Availability Group can provide a primary and secondary replica. A managed SQL database service may provide a different failover mechanism.
The test should use the same connection strategy that production uses. Testing directly against individual database instances will not accurately represent an application that connects through a failover-aware endpoint.
Before starting, verify that replication is healthy and that the secondary is eligible for failover.
Configure the ASP.NET Core Database Connection
An ASP.NET Core application commonly uses Entity Framework Core to access SQL Server.
A typical registration looks like this:
builder.Services.AddDbContext<AppDbContext>(options =>
{
options.UseSqlServer(
builder.Configuration.GetConnectionString(
"DefaultConnection"));
});The connection string should point to the database endpoint designed for application connections.
For an SQL Server Availability Group, the connection may use a listener:
{
"ConnectionStrings": {
"DefaultConnection": "Server=tcp:sql-listener,1433;Database=OrderDb;Integrated Security=True;Encrypt=True;TrustServerCertificate=False;Connect Timeout=15"
}
}This is an illustrative configuration. Authentication and encryption settings must match the deployment environment.
The important point is that the application should not need to change its connection string manually every time the primary database changes.
Enable connection resiliency
Entity Framework Core supports connection resiliency through provider-specific execution strategies.
For SQL Server:
builder.Services.AddDbContext<AppDbContext>(options =>
{
options.UseSqlServer(
builder.Configuration.GetConnectionString(
"DefaultConnection"),
sqlOptions =>
{
sqlOptions.EnableRetryOnFailure(
maxRetryCount: 5,
maxRetryDelay: TimeSpan.FromSeconds(10),
errorNumbersToAdd: null);
});
});This configuration allows EF Core to retry certain transient database failures.
However, retries are not a replacement for failover configuration. They cannot make an unavailable database available, and they do not guarantee that an operation is safe to execute again.
For example, a failed read can often be retried safely. A write operation needs more care because the first attempt may have committed before the connection failed.
Create a Test Operation
Before testing failover, create an operation that the application can execute repeatedly.
For example, an order service can insert a new order using a unique operation identifier.
public class Order
{
public int Id { get; set; }
public Guid OperationId { get; set; }
public string CustomerName { get; set; } = string.Empty;
public decimal Amount { get; set; }
public DateTime CreatedAtUtc { get; set; }
}Create a unique index on OperationId:
protected override void OnModelCreating(
ModelBuilder modelBuilder)
{
modelBuilder.Entity<Order>()
.HasIndex(x => x.OperationId)
.IsUnique();
}The operation identifier provides a way to detect duplicate submissions.
The service can then save an order using a stable identifier:
public async Task<int> CreateOrderAsync(
Guid operationId,
string customerName,
decimal amount,
CancellationToken cancellationToken)
{
var existing = await _context.Orders
.FirstOrDefaultAsync(
x => x.OperationId == operationId,
cancellationToken);
if (existing != null)
{
return existing.Id;
}
var order = new Order
{
OperationId = operationId,
CustomerName = customerName,
Amount = amount,
CreatedAtUtc = DateTime.UtcNow
};
_context.Orders.Add(order);
await _context.SaveChangesAsync(cancellationToken);
return order.Id;
}This is a basic example of idempotent request handling. In a production implementation, the lookup and insert must also handle concurrent requests safely. The unique database constraint remains the final protection against duplicate operation IDs.
If a connection fails after a transaction commits, the client can retry using the same operation ID rather than creating a new logical operation.
Join the conversation! Your thoughts help the community grow.