Optimistic Concurrency Management in ASP.NET Core Application Using ETags and If-Match Header
In my earlier article here, I talked about different ways to handle concurrency. As you know, optimistic concurrency is a common method for dealing with such situations. In this article, I’ll demonstrate a practical way to manage concurrency in ASP.NET using ETags and If-Match.
Tools and Technology Used
- ASP.net core Web API
- Visual C#
- Entity Framework
- SQL Server
Step 1: Create a asp.net core web api project name ConcurrencyHandling.API
Step 2: Install the following nuget packages in the project.
Microsoft.EntityFrameworkCore
Microsoft.EntityFrameworkCore.Design
Microsoft.EntityFrameworkCore.SqlServer
Microsoft.EntityFrameworkCore.Tools
Step 3: Create a Model class name Product in Models folder
- Product class to store Product Information
- In the following class RecordVersion property is used to track the updated version of the record.
Account.cs
namespace ConcurrencyHandling.API.Models
{
public class Product
{
public int ProductId { get; set; }
public string? Name { get; set; }
public decimal Price { get; set; }
public int StockQuantity { get; set; }
// Concurrency control properties
public Guid RecordVersion { get; set; }
}
}
Step 4: Create a Context class name ApplicationDbContext in Data folder.
ApplicationDbContext.cs
using ConcurrencyHandling.API.Models;
using ConcurrencyHandling.API.Models;
using Microsoft.EntityFrameworkCore;
namespace ConcurrencyHandling.API.Data
{
public class ApplicationDbContext : DbContext
{
public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options) : base(options)
{
}
public DbSet<Product>? Product { get; set; }
}
}
Step 5: Add connection string in appsettings.json file
appsettings.json
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"ConnectionStrings": {
"DefaultConnection": "Server=localhost;Database=ConcurrencyEtagsDB;User Id=sa;Password=MyPassword;TrustServerCertificate=True;"
},
"AllowedHosts": "*"
}
Step 6: Configure Program class as follows.
Program.cs
using ConcurrencyHandling.API.Data;
using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.Services.AddControllers();
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.AddDbContext<ApplicationDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));
var app = builder.Build();
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();
app.Run();
Step 7: Create a Controller class ProductsController in Controllers folder.
- In GetProduct action Etag header is added with the response header like below.
Response.Headers.Add(ETAG_HEADER, product.RecordVersion.ToString());
- In the PutProduct and DeleteProduct actions, if the existing record’s version matches the current Etag and the requested Etag, then the update and delete operations will occur; otherwise, they will not take place.
public async Task<IActionResult> PutProduct(int id, Product product)
{
if (id != product.ProductId)
{
return BadRequest();
}
var existingProduct = await _context.Product.FindAsync(id);
if (existingProduct == null)
{
return NotFound();
}
// Check ETag header
var existingProductETag = existingProduct.RecordVersion.ToString();
var requestProductETag = Request.Headers[IF_MATCH_HEADER].FirstOrDefault();
if (existingProductETag != requestProductETag)
{
// StatusCodes.Status412PreconditionFailed means "Precondition Failed"
// meaning the ETag header value does not match the current ETag value
return StatusCode(StatusCodes.Status412PreconditionFailed);
}
// Update the existing product with the new values
_context.Entry(existingProduct).CurrentValues.SetValues(product);
existingProduct.RecordVersion = Guid.NewGuid();
_context.Entry(existingProduct).State = EntityState.Modified;
try
{
await _context.SaveChangesAsync();
}
catch (DbUpdateConcurrencyException)
{
throw;
}
return NoContent();
}
ProductsController.cs
using ConcurrencyHandling.API.Data;
using ConcurrencyHandling.API.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace ConcurrencyHandling.API.Controllers
{
[Route("api/[controller]")]
[ApiController]
// Concurrency check using ETags
public class ProductsController : ControllerBase
{
private const string ETAG_HEADER = "ETag";
private const string IF_MATCH_HEADER = "If-Match";
private readonly ApplicationDbContext _context;
public ProductsController(ApplicationDbContext context)
{
_context = context;
}
// GET: api/Products
[HttpGet]
public async Task<ActionResult<IEnumerable<Product>>> GetProduct()
{
if (_context.Product == null)
{
return NotFound();
}
return await _context.Product.ToListAsync();
}
// GET: api/Products/5
[HttpGet("{id}")]
public async Task<ActionResult<Product>> GetProduct(int id)
{
if (_context.Product == null)
{
return NotFound();
}
var product = await _context.Product.FindAsync(id);
if (product == null)
{
return NotFound();
}
// Add ETag header
Response.Headers.Add(ETAG_HEADER, product.RecordVersion.ToString());
return product;
}
// PUT: api/Products/5
// To protect from overposting attacks, see https://go.microsoft.com/fwlink/?linkid=2123754
[HttpPut("{id}")]
public async Task<IActionResult> PutProduct(int id, Product product)
{
if (id != product.ProductId)
{
return BadRequest();
}
var existingProduct = await _context.Product.FindAsync(id);
if (existingProduct == null)
{
return NotFound();
}
// Check ETag header
var existingProductETag = existingProduct.RecordVersion.ToString();
var requestProductETag = Request.Headers[IF_MATCH_HEADER].FirstOrDefault();
if (existingProductETag != requestProductETag)
{
// StatusCodes.Status412PreconditionFailed means "Precondition Failed"
// meaning the ETag header value does not match the current ETag value
return StatusCode(StatusCodes.Status412PreconditionFailed);
}
// Update the existing product with the new values
_context.Entry(existingProduct).CurrentValues.SetValues(product);
existingProduct.RecordVersion = Guid.NewGuid();
_context.Entry(existingProduct).State = EntityState.Modified;
try
{
await _context.SaveChangesAsync();
}
catch (DbUpdateConcurrencyException)
{
throw;
}
return NoContent();
}
// POST: api/Products
// To protect from overposting attacks, see https://go.microsoft.com/fwlink/?linkid=2123754
[HttpPost]
public async Task<ActionResult<Product>> PostProduct(Product product)
{
if (_context.Product == null)
{
return Problem("Entity set 'ApplicationDbContext.Product' is null.");
}
product.RecordVersion = Guid.NewGuid();
_context.Product.Add(product);
await _context.SaveChangesAsync();
return CreatedAtAction("GetProduct", new { id = product.ProductId }, product);
}
// DELETE: api/Products/5
[HttpDelete("{id}")]
public async Task<IActionResult> DeleteProduct(int id)
{
//if (_context.Product == null)
//{
// return NotFound();
//}
var product = await _context.Product.FindAsync(id);
if(product == null)
{
return NotFound();
}
var existingProductETag = product.RecordVersion.ToString();
var requestProductETag = Request.Headers[IF_MATCH_HEADER].FirstOrDefault();
if (existingProductETag != requestProductETag)
{
// StatusCodes.Status412PreconditionFailed means "Precondition Failed"
// meaning the ETag header value does not match the current ETag value
return StatusCode(StatusCodes.Status412PreconditionFailed);
}
_context.Product.Remove(product);
await _context.SaveChangesAsync();
return NoContent();
}
//private bool ProductExists(int id)
//{
// return (_context.Product?.Any(e => e.ProductId == id)).GetValueOrDefault();
//}
}
}
Step 8: Create migration and update database
- Open Package Manager Console (PMC).
- Select the project name ConcurrencyHandling.API
- Run the following command in PMC
Add-Migration InitialCreate
This will create a new migration file named “InitialCreate” under the Migrations folder of your
- Run the following command in PMC
Update-Database
Step 9: Run the application and test concurrency
- Create a new record by PostProduct action using swagger. Sample example for post data.
{
"productId": 0,
"name": "Dell Laptop",
"price": 1200,
"stockQuantity": 50
}
- Now, let’s modify the record using the PUT method. Keep in mind to include the current ETag value in the If-Match request header.
-
Do same for the delete method.
-
You can only update and delete record only when if-match header is same as row version otherwise not.