68  
dotnet
Поиск  
Always will be ready notify the world about expectations as easy as possible: job change page
May 10

Driving consistent behaviour in .NET using the Unit of Work pattern

Driving consistent behaviour in .NET using the Unit of Work pattern
Автор:
Источник:
Просмотров:
2259

A guide to implementing the Unit of Work pattern in an Entity Framework .NET application and using it to drive additional behaviours.

In this article we will explore how the Unit of Work pattern can be used to improve data consistency and drive additional cross-cutting behaviours in our .NET applications. We will implement Unit of Work in an Entity Framework ASP.NET API, and use it to decouple behaviours such as Event handling and Auditing of changes from our core Business Logic.

• • •

Unit of Work

In modern systems we generally need to read and write data across lots of different database tables when handling any given HTTP request. A Unit of Work can be used to group and commit all database write operations for a request into a single atomic transaction. This ensures that all grouped operations either pass or fail together.

Having a Unit of Work abstraction wrapped around all database changes provides a few benefits:

  • Data Consistency: Grouping all operations into a single transaction ensures that our data does not become out of sync. This can happen if some operations pass and some fail in a single request.
  • Performance: Reduced round trips to the database.
  • Single Integration Point: Database changes are committed from a single place. This simplifies our code and also provides a single point that we can use to integrate additional behaviours for our applications.

• • •

Implementing Unit of Work

Let’s take some example code for a Weather API and see how we can improve it using the Unit of Work pattern. The Weather API implements Entity Framework, CQRS and the Repository pattern. Our example focuses on a Creating a Weather Forecast using a Command within a POST endpoint.

  • Weather Forecast data is provided.
  • A WeatherForecast Entity is created and Inserted into the database.
  • If the Temperature is less than 0 or greater than 40, then a WeatherAlert Entity is Inserted into the database and an Alert Notification is sent.

Starting point

Our starting point uses a generic Repository implementation for saving database changes. Data is committed to the database through the InsertAsync method on the Repository.

namespace CleanArchitecture.Application.Weather.Commands
{
    public sealed record CreateWeatherForecastCommand(int Temperature, DateTime Date, string? Summary) : Command;

    public sealed class CreateWeatherForecastCommandHandler : CommandHandler<CreateWeatherForecastCommand>
    {
        private readonly IRepository<WeatherForecast> _repository;
        private readonly IRepository<WeatherAlert> _alertsRepository;
        private readonly INotificationsService _notificationsService;

        public CreateWeatherForecastCommandHandler(IRepository<WeatherForecast> repository,
            IRepository<WeatherAlert> alertsRepository,
            INotificationsService notificationsService)
        {
            _repository = repository;
            _alertsRepository = alertsRepository;
            _notificationsService = notificationsService;
        }

        protected override async Task<Guid> HandleAsync(CreateWeatherForecastCommand request)
        {
            var forecast = WeatherForecast.Create(request.Date,
                                                 Temperature.FromCelcius(request.Temperature),
                                                 request.Summary);
            await _repository.InsertAsync(forecast);
            
            if (IsExtremeTemperature(request.Temperature))
            {
                var alert = WeatherAlert.Create(request.Summary, request.Temperature, request.Date);
                await _alertsRepository.InsertAsync(alert);
                await _notificationsService.SendWeatherAlertAsync(request.Summary, request.Temperature, request.Date);
            }
            
            return forecast.Id;
        }
        
        private bool IsExtremeTemperature(int temperatureC)
        {
            return temperatureC < 0 || temperatureC > 40;
        }
    }
}

Our starting point is fairly clean, however there are a few important issues to look into:

  • It is possible for the WeatherForecast Insert to be successful, but the WeatherAlert Insert fails. This would cause the WeatherAlert to be lost and our data to become inconsistent.
  • Every time we create a WeatherAlert Entity in the application we must remember to also add code to Send an Alert Notification as well.
  • The WeatherAlert Insert might be successful but the Alert Notification could fail to Send.

Grouping database operations using Unit of Work

Our first version of Unit of Work will allow us to commit all database operations in a single atomic unit. Entity Framework makes this really easy for us by collecting all changes to our Entities in the DbContext ChangeTracker. Calling SaveChanges on the DbContext will commit all uncommitted changes from the ChangeTracker to the database in a single transaction.

namespace CleanArchitecture.Infrastructure.Repositories
{
    internal sealed class UnitOfWork : IUnitOfWork
    {
        private readonly WeatherContext _context;

        public UnitOfWork(WeatherContext context)
        {
            _context = context;
        }

        public async Task<bool> CommitAsync(CancellationToken cancellationToken = default)
        {
            // After executing this line all the changes (from any Command Handler and Domain Event Handlers)
            // performed through the DbContext will be committed
            await _context.SaveChangesAsync(cancellationToken);

            return true;
        }
    }
}

Simple Unit of Work Implementation

The UnitOfWork is injected into the application as a Scoped service so that it has the same lifetime as the DbContext and Repository instances.

Now both the WeatherForecast and WeatherAlert Entities are committed to the database at the same time when CommitAsync is called.

namespace CleanArchitecture.Application.Weather.Commands
{
    public sealed record CreateWeatherForecastCommand(int Temperature, DateTime Date, string? Summary) : Command;

    public sealed class CreateWeatherForecastCommandHandler : CommandHandler<CreateWeatherForecastCommand>
    {
        private readonly IRepository<WeatherForecast> _repository;
        private readonly IRepository<WeatherAlert> _alertsRepository;
        private readonly INotificationsService _notificationsService;
        private readonly IUnitOfWork _unitOfWork;

        public CreateWeatherForecastCommandHandler(IRepository<WeatherForecast> repository,
            IRepository<WeatherAlert> alertsRepository,
            INotificationsService notificationsService,
            IUnitOfWork unitOfWork)
        {
            _repository = repository;
            _alertsRepository = alertsRepository;
            _notificationsService = notificationsService;
            _unitOfWork = unitOfWork;
        }

        protected override async Task<Guid> HandleAsync(CreateWeatherForecastCommand request)
        {
            var forecast = WeatherForecast.Create(request.Date,
                                                 Temperature.FromCelcius(request.Temperature),
                                                 request.Summary);
            _repository.Insert(forecast);
            
            if (IsExtremeTemperature(request.Temperature))
            {
                var alert = WeatherAlert.Create(request.Summary, request.Temperature, request.Date);
                _alertsRepository.Insert(alert);
                await _notificationsService.SendWeatherAlertAsync(request.Summary, request.Temperature, request.Date);
            }
            
            // All database changes committed here!
            await _unitOfWork.CommitAsync();
            
            return forecast.Id;
        }
        
        private bool IsExtremeTemperature(int temperatureC)
        {
            return temperatureC < 0 || temperatureC > 40;
        }
    }
}

Repository implementation

The Repository implementation can Add and Remove Entities linked to the DbContext ChangeTracker. It doesn’t contain any logic for committing changes to the database, that is done by the UnitOfWork. There is also no Update method, the Entity Framework ChangeTracker keeps track of all changes so they can be committed by the UnitOfWork as well.

namespace CleanArchitecture.Infrastructure.Repositories
{
    internal class Repository<T> : IRepository<T> where T : AggregateRoot
    {
        private readonly WeatherContext _context;
        private readonly DbSet<T> _entitySet;

        public Repository(WeatherContext context)
        {
            _context = context;
            _entitySet = _context.Set<T>();
        }

        public IQueryable<T> GetAll(bool noTracking = true)
        {
            var set = _entitySet;
            if (noTracking)
            {
                return set.AsNoTracking();
            }
            return set;
        }

        public async Task<T?> GetByIdAsync(Guid id)
        {
            return await _entitySet.FindAsync(id);
        }

        public void Insert(T entity)
        {
            _entitySet.Add(entity);
        }

        public void Delete(T entity)
        {
            _entitySet.Remove(entity);
        }
    }
}

Driving additional behaviours from Unit of Work

Our new UnitOfWork class has already improved our application by allowing database changes to be committed in a single atomic transaction. Now we can take things further by using the UnitOfWork to drive additional cross-cutting behaviours for our application.

Decoupling through events

In our previous code, we Send a Weather Alert every time we Insert a new WeatherAlert Entity. If we end up creating additional WeatherAlert Entities from other areas in our application, we will need to remember to add code to Publish a Notification as well.

A better approach is for our WeatherAlert Entity to publish an Event when it is created and for an Event Handler to Send the Weather Alert. This way it will not matter where the Entity is created, we will always Publish and Handle the Event in the same way.

public abstract class AggregateRoot : EntityBase
{
    protected AggregateRoot() : this(Guid.NewGuid())
    {

    }

    protected AggregateRoot(Guid id)
    {
        Id = id;
    }

    private readonly List<DomainEvent> _domainEvents = new List<DomainEvent>();
    public IReadOnlyCollection<DomainEvent> DomainEvents => _domainEvents.AsReadOnly();

    public void AddDomainEvent(DomainEvent eventItem)
    {
        _domainEvents.Add(eventItem);
    }

    public void ClearDomainEvents()
    {
        _domainEvents.Clear();
    }
}

public sealed class WeatherAlert : AggregateRoot
{
    private WeatherAlert(string summary, Temperature temperature, DateTime date)
    {
        Date = date;
        Temperature = temperature;
        Summary = summary;
    }

    public static WeatherAlert Create(string summary, Temperature temperature, DateTime date)
    {
        var alert = new WeatherAlert(summary, temperature, date);
        alert.PublishCreated();
        return forecast;
    }

    private void PublishCreated()
    {
        AddDomainEvent(new WeatherAlertCreatedDomainEvent(Id, Temperature.Celcius, Summary!, Date));
    }

    public DateTime Date { get; private set; }
    public Temperature Temperature { get; private set; }
    public string Summary { get; private set; }
}

Publishing Domain Events From an Entity

When Events are published in an application, generally we want to wait until the operation they were published from is complete before the Event is Handled. Events are defined as something that happened in the past, so our code should treat them that way.

We can use the ‘Delayed Dispatch’ technique to add any Domain Events to our Entities and Dispatch/Publish them later.

Our UnitOfWork is the ideal place to hook into for Publishing any Domain Events that our Entities have raised. Right before we Commit our database changes, we can extract any Domain Events that have been raised by all Entities in the Entity Framework DbContext ChangeTracker and Publish them.

namespace CleanArchitecture.Infrastructure.Repositories
{
    internal sealed class UnitOfWork : IUnitOfWork
    {
        private readonly WeatherContext _context;
        private readonly IMediator _mediator;

        public UnitOfWork(WeatherContext context,
            IMediator mediator)
        {
            _context = context;
            _mediator = mediator;
        }

        public async Task<bool> CommitAsync(CancellationToken cancellationToken = default)
        {
            // Dispatch Domain Events raised
            // by any Entities in the ChangeTracker
            await DispatchEventsAsync();

            // After executing this line all the changes (from any Command Handler and Domain Event Handlers)
            // performed through the DbContext will be committed
            await _context.SaveChangesAsync(cancellationToken);

            return true;
        }

        private async Task DispatchEventsAsync()
        {
            var processedDomainEvents = new List<DomainEvent>();
            var unprocessedDomainEvents = GetDomainEvents();
            // this is needed incase another DomainEvent is published from a DomainEventHandler
            while (unprocessedDomainEvents.Any())
            {
                await DispatchDomainEventsAsync(unprocessedDomainEvents);
                processedDomainEvents.AddRange(unprocessedDomainEvents);
                unprocessedDomainEvents = GetDomainEvents()
                                            .Where(e => !processedDomainEvents.Contains(e))
                                            .ToList();
            }

            ClearDomainEvents();
        }

        private List<DomainEvent> GetDomainEvents()
        {
            var aggregateRoots = GetTrackedAggregateRoots();
            return aggregateRoots
                .SelectMany(x => x.DomainEvents)
                .ToList();
        }

        private List<AggregateRoot> GetTrackedAggregateRoots()
        {
            return _context.ChangeTracker
                .Entries<AggregateRoot>()
                .Where(x => x.Entity.DomainEvents != null && x.Entity.DomainEvents.Any())
                .Select(e => e.Entity)
                .ToList();
        }

        private async Task DispatchDomainEventsAsync(List<DomainEvent> domainEvents)
        {
            foreach (var domainEvent in domainEvents)
            {
                await _mediator.Publish(domainEvent);
            }
        }

        private void ClearDomainEvents()
        {
            var aggregateRoots = GetTrackedAggregateRoots();
            aggregateRoots.ForEach(aggregate => aggregate.ClearDomainEvents());
        }

        public void Dispose()
        {
            _context.Dispose();
        }
    }
}

Publishing Events From Unit of Work

DispatchEventsAsync uses a while loop to keep checking for Domain Events in any tracked Entities as the Events are Handled. This is needed in case any additional Domain Events are raised from the Handlers.

MediatR is used to Publish and Handle the Domain Events in the same process.

namespace CleanArchitecture.Application.Weather.DomainEventHandlers
{
    public sealed class WeatherAlertCreatedDomainEventHandler : DomainEventHandler<WeatherAlertCreatedDomainEvent>
    {
        private readonly INotificationsService _notificationsService;

        public WeatherAlertCreatedDomainEventHandler(ILogger<DomainEventHandler<WeatherAlertCreatedDomainEvent>> logger,
            INotificationsService notificationsService) : base(logger)
        {
            _notificationsService = notificationsService;
        }

        protected override async Task OnHandleAsync(WeatherAlertCreatedDomainEvent @event)
        {
            if (IsExtremeTemperature(@event.Temperature))
            {
                await _notificationsService.SendWeatherAlertAsync(@event.Summary, @event.Temperature, @event.Date);
            }
        }

        private bool IsExtremeTemperature(int temperatureC)
        {
            return temperatureC < 0 || temperatureC > 40;
        }
    }
}

Handling Domain Events Using MediatR

Outbox pattern

Our application is much more decoupled and flexible now that we have added Event Handling through our UnitOfWork. We could still run into problems if:

  • Sending Alerts via our NotificationService fails. This will throw an Exception before our database changes can be Committed
  • Sending an Alert is successful but Committing our database changes fails

Luckily we can use Integration Events and the Outbox pattern to handle both of these cases!

When using the Outbox pattern, we treat Integration Events as Entities in our database and persist them when the parent transaction that produced them is Committed (1). This way we ensure that the Integration Events are always persisted if the transaction succeeds and not persisted if it fails.

A separate service is created to read queued Integration Events from the database (2) and Publish them via a Message Broker (3). When the Integration Event is successfully published, it is removed from the database. If the Message Broker is down then the service will keep trying to publish the events until it is back online.

Transactional Outbox Pattern
Transactional Outbox Pattern

An additional Background Service process would need to be run to Subscribe to Integration Events from the Message Broker.

I have written a previous article about handling Integration Events using the Outbox pattern, no surprises it uses a UnitOfWork to do so:
https://betterprogramming.pub/domain-driven-design-domain-events-and-integration-events-in-net-5a2a58884aaa

Other behaviours

There are lots of other behaviours that could also be driven from your UnitOfWork. The UnitOfWork creates a single point that we can use to implement some of those behaviours, without needing to make lots of changes to your application.

Here’s a few additional behaviours that I have seen implemented through Unit of Work:

• • •

Unit of Work is a simple but really effective pattern. If you’re using Entity Framework in your application, it wraps around it really nicely.

A full application which using Unit of Work with Event Publishing can be found on my GitHub below.

Похожее
Aug 1
Author: Sasha Marfut
Clean Architecture in practice. Clean Architecture is a popular approach to building software applications. A Clean Architecture defines clear boundaries between different application layers, resulting in improved maintainability. In addition, Clean Architecture approach aims to keep the business logic independent...
Oct 27, 2023
Author: Charles Chen
A friend reached out recently regarding the usage of Task Parallel Library (TPL) and ConcurrentBag in some .NET code. I inquired what the code was doing and it turns out that for each entry in some workload, it was performing...
Nov 18, 2020
Microsoft’s second release candidate of .NET 5 arrived October 13, bringing the merger of .NET Framework and .NET Core one step closer to completion. The new unified .NET platform is due for general availability November 10, 2020. Microsoft describes Release...
Jun 13
Author: Dayanand Thombare
Creating background services in .NET Core is a powerful way to perform long-running, background tasks that are independent of user interaction. These tasks can range from data processing, sending batch emails, to file I/O operations — all critical for today’s...
Написать сообщение
Тип
Почта
Имя
*Сообщение