ASP.NET Core Web API - How to hide DbContext transaction in the middleware pipeline?

Viewed 2148

I'm building 3-tier ASP.NET Core web API. It consist of Data, Business (Core) and WebAPI layers:

  1. Core layer is independent (has not knowledge of EFCore nor any other project)
  2. Data layer - has reference to Core and EFCore
  3. WebAPI layer - final layer, knows about Core, Data and EFCore

I was struggling making decision how to handle db transactions. I'm injecting DbContext (scoped) to my Data classes and well as to the Controller (I'm omitting Business project, since it doesn't know EFCore at all). Because there is only one instance of the DbContext per request, it's the same object in the Data as well as in the Controller.

So, the Business logic is doing, what's supposed to do, calling objects in the Data layer. Whenever Data layer needs to Save changes into DB, it does. Everything is wrapped around transaction per request. So if something goes wrong... all changes are rolledback.

This is sample controller's method that shows how I did it (simplified):

    [HttpPut("{id}")]
    public IActionResult UpdateMeeting(int id, [FromBody] MeetingDto meeting)
    {
        using (var transaction = _dbContext.Database.BeginTransaction())
        {
            if (meeting == null)
            {
                return BadRequest();
            }

            _meetingService.AddMeetingChanges(meeting);

            meeting.Id = id;
            _meetingService.UpdateMeeting(meeting);
        }
        return NoContent();
    }

Everything works great. So what's the issue? I need to repeat this:

    using (var transaction = _dbContext.Database.BeginTransaction())
    {


    }

... in every operation, that needs transaction.

So I was thinking, is it possible to start the transaction in the middleware / pipeline (I'm not sure about terminology). Simply speaking - I want to begin transaction explicitly per EVERY request. I want to hide in the middleware. So that whenever I inject DbContext to the Data classes, there is transaction started already

EDIT: Possible solution:

  1. Created a UnitOfWork class:

    public class UnitOfWork
    {
        private readonly RequestDelegate _next; 
    
        public UnitOfWork(RequestDelegate next)
        {
            _next = next;
        }   
    
        public async Task Invoke(HttpContext httpContext, MyContext ctx)
        {
            using (var transaction = ctx.Database.BeginTransaction())
            {
                await _next(httpContext);
                transaction.Commit();
            }
        }
    }   
    
  2. Injected UnitOfWork class as a middleware after UseHttpsRedirection and before UseMvc:

    public void Configure(IApplicationBuilder app, IHostingEnvironment env)
    {
        if (env.IsDevelopment())
        {
            app.UseDeveloperExceptionPage();
        }
        else
        {
            app.UseExceptionHandler(appBuilder =>
            {
                appBuilder.Run(async context =>
                {
                    context.Response.StatusCode = 500;
                    await context.Response.WriteAsync("An unexpected error happened. Please contact IT.");
                });
            });
        }   
    
        app.UseHttpsRedirection();
        app.UseMiddleware<UnitOfWork>();
        app.UseMvc();
    }
    
1 Answers

I had a similar problem and built a middleware on top of the suggestion and solution from @Ish Thomas. And just in case somebody finds this question I want to leave my middleware solution here.

But unfortunately, I also had to use the EF Connection Resiliency configuration EnableRetryOnFailure(). This configuration is not compatible with ctx.Database.BeginTransaction() and throws an InvalidOperationException.

InvalidOperationException: The configured execution strategy 'SqlServerRetryingExecutionStrategy' does not support user initiated transactions. Use the execution strategy returned by 'DbContext.Database.CreateExecutionStrategy()' to execute all the operations in the transaction as a retriable unit.

services.AddDbContext<DemoContext>(
        options => options.UseSqlServer(
            "<connection string>",
            providerOptions => providerOptions.EnableRetryOnFailure()));

The middleware creates a transaction when the HTTP verb is POST, PUT, or DELETE. Otherwise, it invokes the next middleware without the transaction. If an exception is thrown the transaction commit isn't executed and the changes made within this request are rolled back.

Middleware Code:

public class TransactionUnitMiddleware
{
    private readonly RequestDelegate next;

    public TransactionUnitMiddleware(RequestDelegate next)
    {
        this.next = next;
    }

    public async Task Invoke(HttpContext httpContext, DemoContext context)
    {
        string httpVerb = httpContext.Request.Method.ToUpper();

        if (httpVerb == "POST" || httpVerb == "PUT" || httpVerb == "DELETE")
        {
            var strategy = context.Database.CreateExecutionStrategy();
            await strategy.ExecuteAsync<object, object>(null!, operation: async (dbctx, state, cancel) =>
            {
                // start the transaction
                await using var transaction = await context.Database.BeginTransactionAsync();

                // invoke next middleware 
                await next(httpContext);

                // commit the transaction
                await transaction.CommitAsync();

                return null!;
            }, null);
        }
        else
        {
            await next(httpContext);
        }
    }
}

Hope this helps somebody ;)

Related