Dev Blog

Отделение бизнес логики от способа хранения

Рассмотрим ситуацию когда бизнес логика зависит от фреймворка и базы данных. Все прекрасно, все работает, фичи добавляются. Но появляется требование сменить базу, либо устаревший фреймворк. 

В этом случае надо изменять код содержащий бизнес логику. Если есть зависимость на проект к доступам к данным, и это более менее инкапсулировано, и модели данных отделены от моделей для фреймворка, то возможно изменений будет не много. Но как показывает практика модели просачиваются как и сам фреймворк. Нередко появляется зависимость от конкретной базы данных в виде реляционных запросов.

В этом случае придется переписывать много, даже проще начать новый проект. Причем заметьте, что  бизнес логика не менялась, а переписывать ее придется.

Тут надо добавить что современные фреймворки позволяют абстрагироваться от базы данных, но такие соглашения очень легко нарушить и простой сменой провайдера дело не обойдется.

Итого, когда бизнес логика зависит от фреймворка и от конкретной базы данных возникают следующие проблемы: фреймворк не получится заменить без переписывания всего проекта. Например у вас был linq2db и руководство решило поменять его на Entity Framework. Или было ADO с запросами для базы данных Ms SQL и появилось желание использовать фреймворк. Может быть что руководство решило заменить базу данных Ms SQL на Postgres.

При такой архитектуре проект с бизнес логикой зависит от проекта с доступом к данным:

flowchart LR; UseCases[Бизнес логика] DataAccess[Доступ к данным] UseCases-->DataAccess

Изменения в слое к котором работают с данными влечет изменения в бизнес логике.

Например проект доступа к данным написан на net 4.5 и бизнес логика соответственно тоже должны быть на нем.

Как можно исправить положение дел? По идеи бизнес логика описывает поведение и то как данные хранятся не должно влиять на нее. Правилам бизнес процесса не важно какая база данных или фреймворк используется.

Если детали хранения спрятать за интерфейсами репозитория. И эти интерфейсы определить в проекте с бизнес логикой. При этом реализовать их в проекте с доступом к данным, то получиться такая схема зависимостей:

flowchart LR; subgraph LogicProj["Проект с бизнес логикой"] BL["Бизнес логика"] -->|использует|IRepository end subgraph DataAccess["Доступ к данным"] Repository -->|реализует|IRepository end

Причем такая бизнес логика могла бы быть на netstandart. Даже не зависеть от net 4.5 или net core. При таком подходе реализация способов хранения не влияет на бизнес правила.

Такой подход так же правильный логически: бизнес логика определяет контракты с которыми она работает, а то как они реализованы с точки зрения бизнес логики - неважно. Контракт репозитория определяет то что надо передать и потом это сохранить. Можно сказать был применен паттерн стратегия.

Теперь зависимость инвертировалась. Можно сказать что был применен принцип инверсии зависимостей и при этом пограничный интерфейс IRepository остается внутри проекта с бизнес логикой.

Независимо от способа хранения необходимо обеспечить параллелизм, идентификатор и бизнес транзакцию.

Параллелизм

Для параллелизма можно использовать оптимистичную блокировку в виде токена. Это специальное свойство которое будет изменяться при обновлении записи:

public byte[] Timestamp { get; set; }

Если при сохранении это свойство отлично от того значения когда оно было прочитано, то значит кто то уже успел его поменять. В этом случае надо просто повторить действие со всей сопутствующей логикой.

Идентификатор

Идентификатор можно определить числовым либо guid. Однако сам тип по сути нам не важен в коде бизнес логики, и нет гарантий что на каком то этапе его тип не решат поменять. Поэтому его можно спрятать:

public sealed class Identifier : IEquatable<Identifier>, IComparable<Identifier>
{
    public Identifier(int value) => Value = value;

    public int Value { get; }

    public int CompareTo(Identifier? other)
    {
        if (ReferenceEquals(this, other))
            return 0;

        if (ReferenceEquals(null, other))
            return 1;

        return Value.CompareTo(other.Value);
    }

    public bool Equals(Identifier? other)
    {
        if (ReferenceEquals(null, other))
            return false;

        if (ReferenceEquals(this, other))
            return true;

        return Value.Equals(other.Value);
    }

    public override bool Equals(object? obj)
    {
        if (ReferenceEquals(null, obj))
            return false;

        if (ReferenceEquals(this, obj))
            return true;

        if (obj.GetType() != GetType())
            return false;

        return ((Identifier)obj).Value.Equals(Value);
    }

    public override int GetHashCode() => Value;
}

Конечно если его будут потом сравнивать или инициализировать, то тип просочится. Однако в подавляющем объеме кода его не будет.

Можно подумать, что достаточно определить интерфейс и реализовать его в проекте с доступом к данным и проекте представления (api, mvc). Но тогда появится 2 неявных момента работающих на рефлексии, потому что надо передать с представления id через бизнес логику и вытащить внутреннее значение в проекте с данными. Еще я считаю что логика сравнения идентификатора не связана с сохраняемостью и далеко ее удалять от бизнес логики не имеет смысла.

Пример с заказами

Попробуем теперь это на примере с заказами. Обычный заказ в каком ни будь интернет магазине. У него есть заказчик, список выбранных товаром, общая стоимость, дата создания, дата обновления и адрес доставки. 

Опишем логику создания черновика заказа, а потом переведем его в ожидание оплаты.

Шаги для создания черновика:

  1. открывается бизнес транзакция
  2. определяется заказчик
  3. создается черновик заказа
  4. заполняются позиции заказа, при этом проверяются товары на складе, высчитывается общая стоимость заказа
  5. заказ с позициями обновляется
  6. происходит запись в историю
  7. транзакция подтверждается

Для перевода в статус ожидание оплаты:

  1. открывается бизнес транзакция
  2. достается заказ
  3. выполняется проверка что это черновик
  4. заполняются позиции с проверкой и резервированием покупок, высчитывается общая стоимость заказа
  5. заказ обновляется
  6. происходит запись в историю
  7. транзакция подтверждается

 

public interface IOrders
{
    Task<Identifier> CreateDraft(CreateDraftModel model, CancellationToken cancellationToken);

    Task MoveToAwaitingPayment(MoveToAwaitingModel model, CancellationToken cancellationToken);
}

public class Orders : IOrders
{
    public Orders(
        ITransaction transaction,
        IOrdersRepository ordersRepository,
        IProductRepository productRepository,
        ICustomerRepository customerRepository)
    {
        _transaction = transaction;
        _ordersRepository = ordersRepository;
        _productRepository = productRepository;
        _customerRepository = customerRepository;
    }

    private readonly ICustomerRepository _customerRepository;
    private readonly IOrdersRepository _ordersRepository;
    private readonly IProductRepository _productRepository;
    private readonly ITransaction _transaction;

    public async Task<Identifier> CreateDraft(
        CreateDraftModel model,
        CancellationToken cancellationToken)
    {
        await using var _ = await _transaction.Begin(cancellationToken);

        var customer = await _customerRepository
            .Get(model.CustomerId)
            .ConfigureAwait(false);

        if (customer == CustomerModel.Null)
            throw new CustomerNotFoundException(customer.Id);

        var order = await _ordersRepository.Create(new CreateOrderModel
            {
                Status = Status.Draft,
                CustomerId = customer.Id,
                Address = model.Address,
                Cost = 0
            }, cancellationToken)
            .ConfigureAwait(false);

        decimal totalOrderCost = 0;

        foreach (var item in model.Items)
        {
            var product = await _productRepository
                .Get(item.ProductId, cancellationToken)
                .ConfigureAwait(false);

            if (product == ProductModel.Null)
                throw new ProductNotFoundException(product.Id);

            await _ordersRepository.CreateItem(new CreateItemModel
                {
                    OrderId = order.Id,
                    ProductId = product.Id,
                    Count = item.Count
                }, cancellationToken)
                .ConfigureAwait(false);

            totalOrderCost += product.Cost * item.Count;
        }

        await _ordersRepository.Update(new UpdateOrderModel
            {
                Id = order.Id,
                Status = Status.Draft,
                Address = model.Address,
                Cost = totalOrderCost,
                Timestamp = order.RowVersion
            }, cancellationToken)
            .ConfigureAwait(false);

        await _ordersRepository.WriteHistory(new HistoryModel
            {
                OrderId = order.Id,
                Status = Status.Draft,
            }, cancellationToken)
            .ConfigureAwait(false);

        await _transaction.Commit(cancellationToken)
            .ConfigureAwait(false);

        return order.Id;
    }

    public async Task MoveToAwaitingPayment(
        MoveToAwaitingModel model,
        CancellationToken cancellationToken)
    {
        await using var _ = await _transaction.Begin(cancellationToken)
            .ConfigureAwait(false);

        var order = await _ordersRepository
            .Get(model.Id, cancellationToken)
            .ConfigureAwait(false);

        if (order == OrderModel.Null)
            throw new OrdeNotFoundException(order.Id);

        if (order.Status != Status.Draft)
            throw new InvalidOrderStatusException(order.Id);

        decimal totalOrderCost = 0;

        await _ordersRepository.ClearItems(order.Id, cancellationToken)
            .ConfigureAwait(false);

        foreach (var item in model.Items)
        {
            var productId = item.ProductId;

            var product = await _productRepository
                .Get(productId, cancellationToken)
                .ConfigureAwait(false);

            if (product == ProductModel.Null)
                throw new ProductNotFoundException(product.Id);

            var reserved = await _productRepository
                .Reserve(product.Id, item.Count, cancellationToken)
                .ConfigureAwait(false);

            if (!reserved)
                throw new NotEnouthProductException(product.Id);

            await _ordersRepository.CreateItem(new CreateItemModel
                {
                    OrderId = order.Id,
                    ProductId = product.Id,
                    Count = item.Count
                }, cancellationToken)
                .ConfigureAwait(false);

            totalOrderCost += product.Cost * item.Count;
        }

        await _ordersRepository.Update(new UpdateOrderModel
            {
                Id = order.Id,
                Status = Status.AwaitingPayment,
                Cost = totalOrderCost,
                Address = model.Address,
                Timestamp = model.Timestamp
            }, cancellationToken)
            .ConfigureAwait(false);

        await _ordersRepository.WriteHistory(new HistoryModel
            {
                OrderId = order.Id
            }, cancellationToken)
            .ConfigureAwait(false);

        await _transaction.Commit(cancellationToken)
            .ConfigureAwait(false);
    }
}
    
public class CreateDraftModel
{
    public Identifier CustomerId { get; set; }
    public string Address { get; set; }
    
    public IReadOnlyCollection<ItemModel> Items { get; set; }
}

public class ItemModel
{
    public Identifier ProductId { get; set; }
    public int Count { get; set; }
}

public class MoveToAwaitingModel
{
    public Identifier Id { get; set; }
    public IReadOnlyCollection<ItemModel> Items { get; set; }
    public string Address { get; set; }
    public byte[] Timestamp { get; set; }
}

В коде выше была определена вся бизнес логика и интерфейсы под репозитории и бизнес транзакцию. Свойство Timestamp отвечает за оптимистичную блокировку. Так же параллелизм должен работать для метода Reserve резервирования количества товаров. Иначе говоря чтобы одновременно не было зарезервировано больше чем есть на складе. Еще можно обратить внимание что вместо null используется паттерн Null Object.

Так же нет никаких подробностей о сущностях в базе данных, такой код легче понять не подымая спецификацию или постановку задачи. Еще смело можно что то переименовать не опасаясь за столбцы и таблицы в базе данных.

Используемые контракты для репозиториев и транзакции выглядят так:

public interface ICustomerRepository
{
    Task<CustomerModel> Get(Identifier id);
}

public class CustomerModel
{
    public static CustomerModel Null { get; set; } = new();
    public Identifier Id { get; set; }
    public string Name { get; set; }
    public string Address { get; set; }
    public byte[] Timestamp { get; set; }
}

public interface IOrdersRepository
{
    Task<OrderModel> Get(Identifier id, CancellationToken cancellationToken);
    Task<OrderModel> Create(CreateOrderModel model, CancellationToken cancellationToken);
    Task Update(UpdateOrderModel model, CancellationToken cancellationToken);
    public Task<Identifier> CreateItem(CreateItemModel model, CancellationToken cancellationToken);
    public Task ClearItems(Identifier orderId, CancellationToken cancellationToken);
    Task WriteHistory(HistoryModel model, CancellationToken cancellationToken);
}

public class OrderModel
{
    public Identifier Id { get; set; }
    public Identifier CustomerId { get; set; }
    public byte[] RowVersion { get; set; }
    public Status Status { get; set; }
    public static OrderModel Null { get; set; } = new();
    public decimal Cost { get; set; }
}

public class CreateOrderModel
{
    public Status Status { get; set; }
    public Identifier CustomerId { get; set; }
    public string Address { get; set; }
    public decimal Cost { get; set; }
}

public class UpdateOrderModel
{
    public Identifier Id { get; set; }
    public decimal Cost { get; set; }
    public string Address { get; set; }
    public Status Status { get; set; }
    public byte[] Timestamp { get; set; }
}

public class CreateItemModel
{
    public Identifier ProductId { get; set; }
    public Identifier OrderId { get; set; }
    public int Count { get; set; }
}

public class HistoryModel
{
    public Identifier OrderId { get; set; }
    public Status Status { get; set; }
}

public interface IProductRepository
{
    Task<ProductModel> Get(Identifier id, CancellationToken cancellationToken);
    Task<bool> Reserve(Identifier productId, int itemCount, CancellationToken cancellationToken);
}

public class ProductModel
{
    public Identifier Id { get; set; }
    public decimal Cost { get; set; }
    public int Total { get; set; }
    public static ProductModel Null { get; set; } = new();
}

public interface ITransaction
{
    Task<IAsyncDisposable> Begin(CancellationToken cancellationToken);
    Task Commit(CancellationToken cancellationToken);
}

Реализация

Начав писать код логики не вдаваясь в детали хранения мы оставили себе возможность свободно выбирать способ как будем хранить данные. Теперь остается реализовать все наши контракты на стороне проекта с доступом к данным.

Пусть фреймворк будет EF Core, а база Ms SQL.

Модели для работы с фреймворком будут такие:

public class Customer
{
    public Identifier Id { get; } = new(default);
    public string Name { get; set; }
    public string Address { get; set; }
    public ICollection<Order> Orders { get; set; }
    public byte[] Timestamp { get; set; }
}

public class History
{
    public Identifier Id { get; } = new(default);
    public Order Order { get; set; }
    public Identifier OrderId { get; set; }
    public DateTimeOffset Created { get; set; }
}

public class Item
{
    public Identifier Id { get; } = new(default);
    public Order Order { get; set; }
    public Identifier OrderId { get; set; }
    public Product Product { get; set; }
    public Identifier ProductId { get; set; }
    public byte[] Timestamp { get; set; }
    public int Count { get; set; }
}

public class Order
{
    public Identifier Id { get; } = new(default);
    public Identifier CustomerId { get; set; }
    public Customer Customer { get; set; }
    public Status Status { get; set; }
    public decimal Cost { get; set; }
    public string Address { get; set; }
    public DateTimeOffset Created { get; set; }
    public ICollection<History> History { get; set; }
    public ICollection<Item> Items { get; set; }
    public byte[] Timestamp { get; set; }
}

public class Product
{
    public Identifier Id { get; } = new(default);
    public string Name { get; set; }
    public int Count { get; set; }
    public decimal Cost { get; set; }
    public ICollection<Item> Items { get; set; }
    public byte[] Timestamp { get; set; }
}

Замечу что ef core позволяет делать модели чистыми без атрибутов с помощью fluent api, такой код гораздо приятнее воспринимать.

Сами настройки таблиц, связей, ключей и идентификатора определяются в классе:

public class StoreContext : DbContext
{
    public StoreContext()
    {
    }

    public StoreContext(DbContextOptions<StoreContext> options)
        : base(options)
    {
    }

    public DbSet<Customer> Customers { get; set; }
    public DbSet<History> History { get; set; }
    public DbSet<Item> Items { get; set; }
    public DbSet<Order> Orders { get; set; }
    public DbSet<Product> Products { get; set; }

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        if (!optionsBuilder.IsConfigured)
            optionsBuilder.UseSqlServer(@"Data Source=.;Initial Catalog=StoreDb;Integrated Security=True");

        base.OnConfiguring(optionsBuilder);
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        ConfigureCustomer(modelBuilder);
        ConfigureHistory(modelBuilder);
        ConfigureItems(modelBuilder);
        ConfigureOrders(modelBuilder);
        ConfigureProducts(modelBuilder);

        base.OnModelCreating(modelBuilder);
    }

    private static void ConfigureCustomer(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Customer>()
            .HasKey(a => a.Id);

        modelBuilder.Entity<Customer>()
            .Property(a => a.Id)
            .HasConversion(id => id.Value,
                value => new Identifier(value))
            .UseIdentityColumn()
            .Metadata.SetBeforeSaveBehavior(PropertySaveBehavior.Ignore);

        modelBuilder.Entity<Customer>()
            .Property(a => a.Timestamp)
            .IsRowVersion()
            .IsConcurrencyToken();

        modelBuilder.Entity<Customer>()
            .Property(a => a.Name)
            .IsRequired();

        modelBuilder.Entity<Customer>()
            .Property(a => a.Address)
            .IsRequired();

        modelBuilder.Entity<Customer>()
            .HasMany(a => a.Orders)
            .WithOne(a => a.Customer)
            .HasForeignKey(a => a.CustomerId);
    }

    private static void ConfigureHistory(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<History>()
            .HasKey(a => a.Id);

        modelBuilder.Entity<History>()
            .Property(a => a.Id)
            .HasConversion(id => id.Value,
                value => new Identifier(value))
            .UseIdentityColumn()
            .Metadata.SetBeforeSaveBehavior(PropertySaveBehavior.Ignore);

        modelBuilder.Entity<History>()
            .Property(a => a.Created)
            .IsRequired();

        modelBuilder.Entity<History>()
            .Property(a => a.OrderId)
            .HasConversion(id => id.Value,
                value => new Identifier(value));

        modelBuilder.Entity<History>()
            .HasOne(a => a.Order)
            .WithMany(a => a.History)
            .HasForeignKey(a => a.OrderId)
            .IsRequired();
    }

    private static void ConfigureItems(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Item>()
            .HasKey(a => a.Id);

        modelBuilder.Entity<Item>()
            .Property(a => a.Id)
            .HasConversion(id => id.Value,
                value => new Identifier(value))
            .UseIdentityColumn()
            .Metadata.SetBeforeSaveBehavior(PropertySaveBehavior.Ignore);

        modelBuilder.Entity<Item>()
            .Property(a => a.Timestamp)
            .IsRowVersion()
            .IsConcurrencyToken();

        modelBuilder.Entity<Item>()
            .Property(a => a.Count)
            .IsRequired();

        modelBuilder.Entity<Item>()
            .Property(a => a.OrderId)
            .HasConversion(id => id.Value,
                value => new Identifier(value));

        modelBuilder.Entity<Item>()
            .Property(a => a.ProductId)
            .HasConversion(id => id.Value,
                value => new Identifier(value));

        modelBuilder.Entity<Item>()
            .HasOne(a => a.Order)
            .WithMany(a => a.Items)
            .HasForeignKey(a => a.OrderId)
            .IsRequired();

        modelBuilder.Entity<Item>()
            .HasOne(a => a.Product)
            .WithMany(a => a.Items)
            .HasForeignKey(a => a.ProductId)
            .IsRequired();
    }

    private static void ConfigureOrders(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Order>()
            .HasKey(a => a.Id);

        modelBuilder.Entity<Order>()
            .Property(a => a.Id)
            .HasConversion(id => id.Value,
                value => new Identifier(value))
            .UseIdentityColumn()
            .Metadata.SetBeforeSaveBehavior(PropertySaveBehavior.Ignore);

        modelBuilder.Entity<Order>()
            .Property(a => a.Timestamp)
            .IsRowVersion()
            .IsConcurrencyToken();

        modelBuilder.Entity<Order>()
            .Property(a => a.Created)
            .IsRequired();

        modelBuilder.Entity<Order>()
            .Property(a => a.Status)
            .IsRequired();

        modelBuilder.Entity<Order>()
            .Property(a => a.Cost)
            .HasPrecision(19, 4)
            .IsRequired();

        modelBuilder.Entity<Order>()
            .HasMany(a => a.History)
            .WithOne(a => a.Order);

        modelBuilder.Entity<Order>()
            .HasMany(a => a.Items)
            .WithOne(a => a.Order)
            .HasForeignKey(a => a.OrderId);

        modelBuilder.Entity<Order>()
            .HasOne(a => a.Customer)
            .WithMany(a => a.Orders)
            .HasForeignKey(a => a.CustomerId)
            .IsRequired();
    }

    private static void ConfigureProducts(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Product>()
            .HasKey(a => a.Id);

        modelBuilder.Entity<Product>()
            .Property(a => a.Id)
            .HasConversion(id => id.Value,
                value => new Identifier(value))
            .UseIdentityColumn()
            .Metadata.SetBeforeSaveBehavior(PropertySaveBehavior.Ignore);

        modelBuilder.Entity<Product>()
            .Property(a => a.Timestamp)
            .IsRowVersion()
            .IsConcurrencyToken();

        modelBuilder.Entity<Product>()
            .Property(a => a.Name)
            .IsRequired();

        modelBuilder.Entity<Product>()
            .Property(a => a.Count)
            .IsRequired();

        modelBuilder.Entity<Product>()
            .Property(a => a.Cost)
            .HasPrecision(19, 4)
            .IsRequired();

        modelBuilder.Entity<Product>()
            .HasMany(a => a.Items)
            .WithOne(a => a.Product);
    }
}

Тут появляется зависимость на конкретную базу в виде UseSqlServer и строки подключения для миграции, но при желании это можно вынести в другой проект и строить миграции через него.

Метод HasConversion настраивает соответствия для идентификатора. Методы IsRowVersion IsConcurrencyToken задают токен для оптимистичной блокировки. Настройка Metadata.SetBeforeSaveBehavior(PropertySaveBehavior.Ignore) нужна для сохранения еще не вычисленного идентификатора. Остальные методы настройки связей тривиальны.

Реализация транзакции

Транзакцию можно реализовать так:

public class Transaction : ITransaction, IAsyncDisposable
{
    public Transaction(StoreContext storeContext) => _storeContext = storeContext;

    private readonly StoreContext _storeContext;
    private IDbContextTransaction? _dbContextTransaction;
    private bool _transactionOpen;

    public async ValueTask DisposeAsync()
    {
        if (!_transactionOpen)
            return;

        if (_dbContextTransaction != null)
            await _dbContextTransaction
                .RollbackAsync()
                .ConfigureAwait(false);

        _transactionOpen = false;
    }

    public async Task<IAsyncDisposable> Begin(CancellationToken cancellationToken)
    {
        if (_transactionOpen)
            throw new TransactionException("Нельзя открыть транзакцию если она уже открыта");

        _dbContextTransaction = await _storeContext.Database
            .BeginTransactionAsync(cancellationToken)
            .ConfigureAwait(false);

        _transactionOpen = true;

        return new TransactionScope(this);
    }

    public async Task Commit(CancellationToken cancellationToken)
    {
        if (!_transactionOpen)
            throw new TransactionException("Нельзя закомитить транзакцию если она не была открыта");

        if (_dbContextTransaction is null)
            throw new TransactionException("Нельзя закомитить транзакцию: транзакция не создана");

        await _dbContextTransaction
            .CommitAsync(cancellationToken)
            .ConfigureAwait(false);

        _transactionOpen = false;
    }

    private sealed class TransactionScope : IAsyncDisposable
    {
        public TransactionScope(Transaction transaction) => _transaction = transaction;

        private readonly Transaction _transaction;

        public async ValueTask DisposeAsync()
        {
            if (!_transaction._transactionOpen)
                return;

            if (_transaction._dbContextTransaction != null)
                await _transaction._dbContextTransaction
                    .RollbackAsync()
                    .ConfigureAwait(false);

            _transaction._transactionOpen = false;
        }
    }
}

Тут все просто, открывается транзакция если она не была открыта, при этом возвращается TransactionScope для using, это нужно в случае выхода из метода до того как транзакция была закоммичена и последующего ее отката. Далее если выхода не было происходит коммит и данные уже окончательно сохраняются в базе данных.

Причем сама транзакция тоже реализует IAsyncDisposable чтобы гарантировано освободить ресурсы не дожидаясь финализатора в случае если не был использован TransactionScope и произошел выход из метода без коммита.

Реализация репозиториев

Репозиторий заказчика содержит только метод получения:

public class CustomerRepository : ICustomerRepository
{
    public CustomerRepository(StoreContext storeContext) => _storeContext = storeContext;

    private readonly StoreContext _storeContext;

    public async Task<CustomerModel> Get(Identifier id)
    {
        var customer = await _storeContext.Customers.FirstOrDefaultAsync(a => a.Id == id);

        if (customer is null)
            return CustomerModel.Null;

        return new CustomerModel
        {
            Id = customer.Id,
            Name = customer.Name,
            Address = customer.Address,
            Timestamp = customer.Timestamp
        };
    }
}

Репозиторий заказа:

public class OrdersRepository : IOrdersRepository
{
    public OrdersRepository(StoreContext storeContext) => _storeContext = storeContext;
    private readonly StoreContext _storeContext;

    public async Task<OrderModel> Get(
        Identifier id,
        CancellationToken cancellationToken)
    {
        var order = await _storeContext.Orders
            .FirstOrDefaultAsync(a => a.Id == id, cancellationToken)
            .ConfigureAwait(false);

        if (order is null)
            return OrderModel.Null;

        return new OrderModel
        {
            Id = order.Id,
            RowVersion = order.Timestamp,
            Status = order.Status,
            CustomerId = order.CustomerId,
            Cost = order.Cost
        };
    }

    public async Task<OrderModel> Create(
        CreateOrderModel model,
        CancellationToken cancellationToken)
    {
        var order = new Order
        {
            Created = DateTimeOffset.UtcNow,
            Status = Status.Draft,
            Address = model.Address,
            Cost = model.Cost,
            CustomerId = model.CustomerId
        };

        _storeContext.Orders.Add(order);

        await _storeContext.SaveChangesAsync(cancellationToken)
            .ConfigureAwait(false);

        return new OrderModel
        {
            Id = order.Id,
            Status = order.Status,
            CustomerId = order.CustomerId,
            RowVersion = order.Timestamp
        };
    }

    public async Task Update(
        UpdateOrderModel model,
        CancellationToken cancellationToken)
    {
        try
        {
            var order = await _storeContext.Orders
                .FirstOrDefaultAsync(a => a.Id == model.Id, cancellationToken)
                .ConfigureAwait(false);

            if (order is null)
                throw new EntityNotFoundException(nameof(Order), model.Id);

            order.Status = model.Status;
            order.Cost = model.Cost;
            order.Address = model.Address;
            order.Timestamp = model.Timestamp;

            await _storeContext.SaveChangesAsync(cancellationToken)
                .ConfigureAwait(false);
        }
        catch (DbUpdateConcurrencyException)
        {
            throw new OrderUpdateConcurrencyException(model.Id);
        }
    }

    public async Task ClearItems(Identifier orderId,
        CancellationToken cancellationToken)
    {
        var order = await _storeContext.Orders
            .Include(a => a.Items)
            .FirstOrDefaultAsync(a => a.Id == orderId, cancellationToken)
            .ConfigureAwait(false);

        if (order is null)
            throw new EntityNotFoundException(nameof(Order), orderId);

        _storeContext.Items.RemoveRange(order.Items);

        await _storeContext.SaveChangesAsync(cancellationToken)
            .ConfigureAwait(false);
    }


    public async Task<Identifier> CreateItem(CreateItemModel model,
        CancellationToken cancellationToken)
    {
        var item = new Item
        {
            OrderId = model.OrderId,
            ProductId = model.ProductId,
            Count = model.Count
        };

        _storeContext.Items.Add(item);

        await _storeContext.SaveChangesAsync(cancellationToken)
            .ConfigureAwait(false);

        return item.Id;
    }

    public async Task WriteHistory(
        HistoryModel model,
        CancellationToken cancellationToken)
    {
        _storeContext.History.Add(new History
        {
            Created = DateTimeOffset.UtcNow,
            OrderId = model.OrderId
        });

        await _storeContext.SaveChangesAsync(cancellationToken)
            .ConfigureAwait(false);
    }
}

Пока что репозитории были довольно просты, получение данных, простые проверки и возврат модели. Но с методом резервирования для товара будет немного сложнее. Сперва надо проверить, что на складе есть необходимое число товара, потом попытаться сохранить и если токен для оптимистичной блокировки изменился кем то другим, то повторить операцию.

public class ProductRepository : IProductRepository
{
    public ProductRepository(StoreContext storeContext) => _storeContext = storeContext;
    private readonly StoreContext _storeContext;

    public async Task<ProductModel> Get(
        Identifier id,
        CancellationToken cancellationToken)
    {
        var product = await _storeContext.Products
            .FirstOrDefaultAsync(a => a.Id == id, cancellationToken)
            .ConfigureAwait(false);

        if (product is null)
            return ProductModel.Null;

        return new ProductModel
        {
            Id = product.Id,
            Cost = product.Cost,
            Total = product.Count
        };
    }

    public async Task<bool> Reserve(
        Identifier productId,
        int itemCount,
        CancellationToken cancellationToken)
    {
        var tryCount = 0;

        var result = await TryReserve(productId, itemCount, cancellationToken)
            .ConfigureAwait(false);

        while (!result.concurrencySuccess &&
               !cancellationToken.IsCancellationRequested)
        {
            if (tryCount > 10)
                throw new MaxReserveTryCountLimitError(productId, itemCount);

            result = await TryReserve(productId, itemCount, cancellationToken)
                .ConfigureAwait(false);

            tryCount++;
        }

        return result.reserveSuccess;
    }

    private async Task<(bool concurrencySuccess, bool reserveSuccess)>
        TryReserve(Identifier productId, int itemCount, CancellationToken cancellationToken)
    {
        try
        {
            var product = await _storeContext.Products
                .FirstOrDefaultAsync(a => a.Id == productId &&
                                          a.Count - itemCount >= 0,
                    cancellationToken)
                .ConfigureAwait(false);

            if (product is null)
                return (true, false);

            product.Count -= itemCount;

            await _storeContext.SaveChangesAsync(cancellationToken)
                .ConfigureAwait(false);

            return (true, true);
        }
        catch (DbUpdateConcurrencyException)
        {
            return (false, false);
        }
    }
}

Метод TryReserve достает товар с условием что остаток больше либо равен нулю, если такого нет, то значит необходимого товара в нужном количестве нет на складе. Если же эта проверка пройдена, но при сохранении выбрасывается DbUpdateConcurrencyException, то метод возвращает в concurrencySuccess false и надо повторить попытку 10 раз. Если же параллелизм соблюден, то возвращаем результат успеха резервирования. Который зависит от остатка товара, если меньше нуля то зарезервировать не получилось.

На этом модели и репозитории созданы. Остается создать миграцию выполнив команду:

dotnet ef migrations add FirstMigration --project DataAccess

И для того что бы применить ее к базе:

dotnet ef database update  --project DataAccess

Если команды не выполнились, то надо установить инструмент:

dotnet tool install --global dotnet-ef

 

Как можно проверить что все работает? Либо написать клиентскую часть, либо просто интеграционный тест. Ограничимся тестом.

Вспомогательная обертка для создания зависимостей и выполнения логики:

public class TestDatabaseFixture
{
    private const string ConnectionString =
        @"Data Source=.;Initial Catalog=TestStoreDb;Integrated Security=True";

    public async Task Execute(Func<
        Orders,
        StoreContext,
        OrdersRepository,
        Task> action)
    {
        await using var context = CreateContext();
        await context.Database.EnsureDeletedAsync();
        await context.Database.EnsureCreatedAsync();

        await using var transaction = new Transaction(context);
        var ordersRepository = new OrdersRepository(context);
        var productRepository = new ProductRepository(context);
        var customerRepository = new CustomerRepository(context);

        var orderCases = new Orders(
            transaction,
            ordersRepository,
            productRepository,
            customerRepository);

        await action(orderCases, context, ordersRepository);
    }

    private StoreContext CreateContext()
        => new(new DbContextOptionsBuilder<StoreContext>()
            .UseSqlServer(ConnectionString)
            .Options);
}

Сам интеграционный тест:

public class OrdersIntegrationTests
{
    [Fact]
    public async Task CreateAndUpdate_ShouldSuccess()
    {
        var fixture = new TestDatabaseFixture();

        await fixture.Execute(async (
            orders,
            context,
            ordersRepository) =>
        {
            var customer = new Customer
            {
                Name = "Tom",
                Address = "New York"
            };

            var snikers = new Product
            {
                Cost = 10,
                Count = 100,
                Name = "Snikers"
            };

            var cola = new Product
            {
                Cost = 20,
                Count = 50,
                Name = "Cola"
            };

            context.Customers.Add(customer);
            context.Products.Add(snikers);
            await context.SaveChangesAsync();
            context.Products.Add(cola);
            await context.SaveChangesAsync();

            var orderId = await orders.CreateDraft(new CreateDraftModel
            {
                CustomerId = customer.Id,
                Address = customer.Address,
                Items = new[]
                {
                    new ItemModel
                    {
                        Count = 2,
                        ProductId = snikers.Id
                    },
                    new ItemModel
                    {
                        Count = 1,
                        ProductId = cola.Id
                    }
                }
            }, CancellationToken.None);

            var foundOrder = await ordersRepository.Get(orderId, CancellationToken.None);

            Assert.Equal(Status.Draft, foundOrder.Status);
            Assert.Equal(customer.Id, foundOrder.CustomerId);
            Assert.Equal(2 * 10 + 1 * 20, foundOrder.Cost);

            await orders.MoveToAwaitingPayment(new MoveToAwaitingModel
            {
                Address = "new address",
                Id = orderId,
                Items = new[]
                {
                    new ItemModel
                    {
                        Count = 3,
                        ProductId = snikers.Id
                    },
                    new ItemModel
                    {
                        Count = 4,
                        ProductId = cola.Id
                    }
                },
                Timestamp = foundOrder.RowVersion
            }, CancellationToken.None);

            foundOrder = await ordersRepository.Get(orderId, CancellationToken.None);

            Assert.Equal(Status.AwaitingPayment, foundOrder.Status);
            Assert.Equal(customer.Id, foundOrder.CustomerId);
            Assert.Equal(3 * 10 + 4 * 20, foundOrder.Cost);

            var order = await context.Orders
                .Include(a => a.Items)
                .ThenInclude(a => a.Product)
                .Where(a => a.Id == foundOrder.Id)
                .FirstAsync();

            var cost = order.Items.Sum(a => a.Product.Cost * a.Count);

            Assert.Equal(cost, foundOrder.Cost);
        });
    }
}

Этот тест создает начальные данные заказчика и товара. Потом через выполнение бизнес логики создается черновик и далее заказ переводиться в ожидание оплаты. При этом проверяется статус, заказчик и сумма заказа. Тест успешно выполняется и на этом реализацию можно завершить.

 

Данную реализацию не составит труда заменить на ADO, l2db или любой другой фреймворк. Но единственное это то что база должна уметь создавать транзакцию. Что касается параллелизма, то токен для оптимистичной блокировки можно реализовать где угодно.