Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Transaction Scope for different repository classes

I'm trying to wrap a transaction around 2 or more database operations which occur in different repository classes. Each repository class uses a DbContext instance, using Dependency Injection. I'm using Entity Framework Core 2.1.

public PizzaService(IPizzaRepo pizzaRepo, IPizzaIngredientsRepo ingredientRepo)
{
    _pizzaRepo = pizzaRepo;
    _ingredientRepo = ingredientRepo;
}

public async Task SavePizza(PizzaViewModel pizza)
{
    using (var scope = new TransactionScope(TransactionScopeOption.Required, new TransactionOptions { IsolationLevel = IsolationLevel.ReadCommitted }))
    {
        int pizzaRows = await _pizzaRepo.AddEntityAsync(pizza.Pizza);
        int ingredientRows = await _ingredientRepo.PutIngredientsOnPizza(
            pizza.Pizza.PizzaId,
            pizza.Ingredients.Select(x => x.IngredientId).ToArray());

        scope.Complete();
    }
}

}

Obviously, if one of the operations fails, I want to rollback the entire thing. Will this transaction scope be enough to rollback or should the repository classes have transactions on their own?

Even if above methods works, are there better ways to implement transactions?

like image 936
Michiel Wouters Avatar asked Oct 26 '25 20:10

Michiel Wouters


1 Answers

Repository patterns are great for enabling testing, but do not have a repository new up a DbContext, share the context across repositories.

As a bare-bones example (assuming you are using DI/IoC)

The DbContext is registered with your IoC container with a lifetime scope of Per Request. So at the onset of the service call:

public PizzaService(PizzaDbContext context, IPizzaRepo pizzaRepo, IPizzaIngredientsRepo ingredientRepo)
{
  _context = pizzaContext;
  _pizzaRepo = pizzaRepo;
  _ingredientRepo = ingredientRepo;
}

public async Task SavePizza(PizzaViewModel pizza)
{
  int pizzaRows = await _pizzaRepo.AddEntityAsync(pizza.Pizza);
  int ingredientRows = await _ingredientRepo.PutIngredientsOnPizza(
    pizza.Pizza.PizzaId,
    pizza.Ingredients.Select(x => x.IngredientId).ToArray());

  _context.SaveChanges();
} 

Then in the repositories:

public class PizzaRepository : IPizzaRepository
{
  private readonly PizzaDbContext _pizzaDbContext = null;

  public PizzaRepository(PizzaDbContext pizzaDbContext)
  {
    _pizzaDbContext = pizzaDbContext;
  }

  public async Task<int> AddEntityAsync( /* params */ )
  {
     PizzaContext.Pizzas.Add( /* pizza */)
     // ...
   }
}

The trouble I have with this pattern is that it restricts the unit of work to the request, and only the request. You have to be aware of when and where the context save changes occurs. You don't want repositories for example to call SaveChanges as that could have side effects depending on what was changed as far as the context goes prior to that being called.

As a result I use a Unit of Work pattern to manage the lifetime scope of the DbContext(s) where repositories no longer get injected with a DbContext, they instead get a locator, and the services get a context scope factory. (Unit of work) The implementation I use for EF(6) is Mehdime's DbContextScope. (https://github.com/mehdime/DbContextScope) There are forks available for EFCore. (https://www.nuget.org/packages/DbContextScope.EfCore/) With the DBContextScope the service call looks more like:

public PizzaService(IDbContextScopeFactory contextScopeFactory, IPizzaRepo pizzaRepo, IPizzaIngredientsRepo ingredientRepo)
{
  _contextScopeFactory = contextScopeFactory;
  _pizzaRepo = pizzaRepo;
  _ingredientRepo = ingredientRepo;
}

public async Task SavePizza(PizzaViewModel pizza)
{
  using (var contextScope = _contextScopeFactory.Create())
  {
    int pizzaRows = await _pizzaRepo.AddEntityAsync(pizza.Pizza);
    int ingredientRows = await _ingredientRepo.PutIngredientsOnPizza(
      pizza.Pizza.PizzaId,
      pizza.Ingredients.Select(x => x.IngredientId).ToArray());

    contextScope.SaveChanges();
  }
}  

Then in the repositories:

public class PizzaRepository : IPizzaRepository
{
  private readonly IAmbientDbContextLocator _contextLocator = null;

  private PizzaContext PizzaContext
  {
    get { return _contextLocator.Get<PizzaContext>(); }
  }

  public PizzaRepository(IDbContextScopeLocator contextLocator)
  {
    _contextLocator = contextLocator;
  }

  public async Task<int> AddEntityAsync( /* params */ )
  {
     PizzaContext.Pizzas.Add( /* pizza */)
     // ...
   }
}

This gives you a couple benefits:

  1. The control of the unit of work scope remains clearly in the service. You can call any number of repositories and the changes will be committed, or rolled back based on the determination of the service. (inspecting results, catching exceptions, etc.)
  2. This model works extremely well with bounded contexts. In larger systems you may split different concerns across multiple DbContexts. The context locator serves as one dependency for a repository and can access any/all DbContexts. (Think logging, auditing, etc.)
  3. There is also a slight performance/safety option for Read-based operations using the CreateReadOnly() scope creation in the factory. This creates a context scope that cannot be saved so it guarantees no write operations get committed to the database.
  4. The IDbContextScopeFactory and IDbContextScope are easily mock-able so that your service unit tests can validate if a transaction is committed or not. (Mock an IDbContextScope to assert SaveChanges, and mock an IDbContextScopeFactory to expect a Create and return the DbContextScope mock.) Between that and the Repository pattern, No messy mocking DbContexts.

One caution that I see in your example is that it appears that your View Model is serving as a wrapper for your entity. (PizzaViewModel.Pizza) I'd advise against ever passing an entity to the client, rather let the view model represent just the data that is needed for the view. I outline the reasons for this here.

like image 73
Steve Py Avatar answered Oct 28 '25 10:10

Steve Py