Search code examples
c#.net-coredependency-injectionentity-framework-coreblazor-server-side

Handle EFCore Lifetimes in Blazor Server where Backend is used for REST API as well


I am using Blazor Server but want to have as little business logic as possible on my pages, especially to have the option to create REST endpoints that can handle the same use cases as my Blazor Server app does, so my load and save methods in my UI look pretty much like that:

private async Task HandleValidSubmit()
{
    if (_myDto != default)
    {
        _myDto.Id = await MyEntityService.Save(_myDto);
    }
}

In MyEntityService (which is in a separate class library, so I can easily use it in a future REST API), I then inject MyContext over DI:

public class MyEntityService : IMyEntityService
{
    private readonly ILogger<MyEntityService> _logger;
    private readonly IMyContext _context;

    public MyEntityService(ILogger<MyEntityService> logger, IMyContext context)
    {
        _logger = logger;
        _context = context;
    }

    public async Task<int> Save(DtoMyEntity dtoToSave)
    {
        _logger.LogTrace("{method}({dtoToSave})", nameof(Save), dtoToSave);
        //Some magic to convert my DTO to an EF Core entity
        await _context.SaveChangesAsync().ConfigureAwait(false);
        return entity.Id;
    }

}

Here is my DI configuration for the DB Context:

public static class ServiceCollectionExtensions
{
    public static IServiceCollection AddDatabase(this IServiceCollection services)
        => services
            .AddDbContext<IMyContext, MyContext>((provider, options) =>
            {
                options.UseNpgsql(provider.GetRequiredService<IDbConnectionStringHelper>()
                    .GetDbConnectionString("ServiceDatabase"));
                var environment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT");
                if (environment == "Development")
                {
                    options.EnableSensitiveDataLogging();
                }
                options.EnableDetailedErrors();
            }, ServiceLifetime.Transient, ServiceLifetime.Transient)
            .AddTransient<IDbConnectionStringHelper, DbConnectionStringHelper>()
    ;
}

When I now click Save two times without reloading the page/going to another page, and coming back, I get the following error:

The instance of entity type 'MyEntity' cannot be tracked because another instance with the key value '{Id: 1}' is already being tracked. When attaching existing entities, ensure that only one entity instance with a given key value is attached.

How can I resolve this issue? I know I should use IDbContextFactory as explained on the Microsoft docs here, but this seems to be very Blazor server specific. I also want my backend class library to be used in a REST API, where using just the context with Transient lifetime is absolutely fine.


Solution

  • Since Blazor Server do not create scope for each request, you have to maintain it by yourself.

    public class MyUIHandler
    {
        private readonly IServiceScopeFactory _scopeFactory;
    
        public MyUIHandler(IServiceScopeFactory scopeFactory)
        {
            _scopeFactory = scopeFactory;
        }
    
        private async Task HandleValidSubmit()
        {
            if (_myDto != default)
            {
                using var scope = _scopeFactory.CreateScope();
                var myEntityService = scope.ServiceProvider.GetRequiredService<IMyEntityService>();
                _myDto.Id = await myEntityService.Save(_myDto);
            }
        }
    }