Sharing Business Logic between Services while maintaining Atomicity

Recently I was comfronted with an interesting case.
How do you call multiple Services (stateless) from 1 controller while maintaning atomicity of the transactions?

Overview

In most of my projects, I emply the Services and Repository through DI pattern. A good read on what it is and how it can be implemented can be found at Matthew Jones' Blogpost - The Repository-Service Pattern with DI and ASP.NET Core

So we follow generally the bellow pattern.
Controller calls Service, Service (depending on scope) can call 0 to many repositories.

Untitled-Diagram

This is the happy case, in which your scopes are simple and sadly this is the only one that most examples out there cover.

The tough case

In our case, the entities or scope in general are a bit more complex than Book-Bookstore examples.

Let's assume we have 3 entites, which involve a lot of Business Logic when working with.
A and B always require C, but C can live also on its own.

That leaves us with the requirements of a creation endpoint for all 3 entities.

Untitled-Diagram--2-

Remember all 3 classes have business logic within their Services. So if we follow the normal structure of the Repository + Service Pattern we would have the following:

private async Task<HeavyBLDTOA> Create(HeavyBLDTOA dto)
{
    // business logic here on the dto

    HeavyBLEntityA _entity = await _repository.CreateAsync(dto.ToEntity());
    _repository.Save();

    return _entity.ToDto();
}

In a quick glance we can see that we only call 1 repository and save the HeavyBLEntityA which is ok, as EF Core would handle to also create the HeavyBLEntityC.

However,

this results in us, bypassing the HeavyBLServiceC which includes some complex logic that is now lost and we are left with 2-3 choices:

  • πŸ‘Ž If the logic does not require external input (another DB read etc) then we can create a "smart" entity which on construction applies such logic.
    Unfortunately we are not always this lucky.
  • πŸ‘Ž Include the same Business Logic, both in ServiceA and ServiceC. The issue here being maintanability. In large codebases, having such a coupling without help from the language will result in pain or bugs.
  • πŸ‘ Inject ServiceC within ServiceA. Now this seems promising!

⭐Let's apply the last choice

private async Task<HeavyBLDtoA> CreateAsync(HeavyBLDtoA dto)
{
    // business logic here on the dto
    HeavyBLDtoC _heavyBLDtoC = await _heavyBLCService.CreateAsync(dto.HeavyBLC);
    dto.HeavyBLCId = _heavyBLDtoC.Id;

    HeavyBLEntity _entity = await _repository.CreateAsync(dto.ToEntity());
    _repository.Save();

    return _entity.ToDto();
}

What we managed to achieve with this approach is to keep the Business Logic of HeavyBLC within it's own service and always in one place.

Updating that logic now, of course would require consideration as it is being called by another service, but now we have a link between the BL and who calls it.

Don't forget Atomicity!

Unforunately, one more requirement is atomicity.
One action should be fullfiled either in full or none at all.

Imagine the following artificial case:

private async Task<HeavyBLDtoA> CreateAsync(HeavyBLDtoA dto)
{
    // business logic here on the dto
    HeavyBLDtoC _heavyBLDtoC = await _heavyBLCService.CreateAsync(dto.HeavyBLC);
    dto.HeavyBLCId = _heavyBLDtoC.Id;

    if (new Random().Next(1, 5) <= 4)
    {
        throw new Exception();
    }

    HeavyBLEntity _entity = await _repository.CreateAsync(dto.ToEntity());
    _repository.Save();

    return _entity.ToDto();
}

We added in the middle, a code that should give us a 3 ouf 4 chance of throwing an exception. If this is the case, that means that HeavyBLC will complete it's action but HeavyBLA will not, thus partially completing the required flow.
This would either create an orphan HeavyBLC entity which could potentially cause further system bugs.

The only way I found as an effective solution is using database transactions.

DB Transactions

I am no DB transaction exprert, however via EF's exposed functions this is an easy manner, as long as you keep an eye on the nesting...

However as we hide the actual EF functionality behind the Repository and maintaning transactions via injection of the different repos would be more bad than good, the solution is to move the transaction scope on a higher level.

Here comes the Transaction Scope. This actually came to my attention by another co-worker and I did not know we can handle transactions on such a high level, however as it appears this is the case!

⭐Let's apply it!

public async Task<HeavyBLDtoA> CreateAsync(HeavyBLDtoA dto)
{
    using (var t = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled))
    {
        HeavyBLDtoC _heavyBLDtoC = await _heavyBLCService.CreateAsync(dto.HeavyBLC);

        dto.HeavyBLCId = _heavyBLDtoC.Id;
        
        if (new Random().Next(1, 5) <= 4)
        {
            throw new Exception();
        }

        HeavyBLEntity _entity = await CreateAsync(dto.ToEntity());
                
        t.Complete();
        return _entity.ToContract();
    }
}

What we have achieved now is to encapsulate the whole flow within a TransactionScope. Each Service should include this, as long as you wish to guarantee atomicity.

In order to test this, I added the random exception generator in the middle of the code. When the exception is thrown, the flow is interupted and the TransactionScope should automatically rollback the changes, which is exactly how it worked!

Conclusion

The TransactionScope is new to me however it seems to allow us to keep our Service + Repository Pattern and adding Atomicity to the mix.

In a following post, I will go deeper into how the following case can be replicated in Microservices.