Optimistic Concurrency Management in ASP.NET Core Application Using ETags and If-Match Header

4 minute read

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.

Source code