Como o DDD nos ajudou a fazer novas revisões em pizzarias

Nas pizzarias, é importante construir um sistema de gestão contábil e de estoque. O sistema é necessário para não perder produtos, não realizar baixas desnecessárias e prever corretamente as compras para o próximo mês. Um papel importante na contabilização de revisões. Eles ajudam a verificar o equilíbrio alimentar e a verificar a quantidade real e o que há no sistema.







A auditoria na Dodo não é baseada em papel: o auditor tem um tablet onde o auditor anota todos os produtos e cria relatórios. Mas até 2020, a revisão nas pizzarias era feita justamente em pedaços de papel - simplesmente porque era cada vez mais fácil assim. Isso, é claro, levou a dados imprecisos, erros e perdas - as pessoas cometem erros, pedaços de papel se perdem e há muito mais. Decidimos resolver este problema e melhorar a forma de tablet. A implementação decidiu usar DDD. Como fizemos, contaremos mais a você.



Primeiro, faça um breve resumo sobre os processos de negócios para entender o contexto. Vamos considerar o esquema da movimentação de produtos, e onde estão as revisões nele, e então passar aos detalhes técnicos, que serão muitos.



Esquema da movimentação de produtos e por que uma revisão é necessária



São mais de 600 pizzarias em nossa rede (e esse número continuará crescendo). Todos os dias há uma movimentação de matéria-prima em cada uma delas: desde o preparo e venda dos produtos, baixa de ingredientes pelo prazo de validade, até a movimentação da matéria-prima para outras pizzarias da rede. O saldo da pizzaria contém constantemente cerca de 120 itens necessários à produção dos produtos, além de uma grande quantidade de insumos, materiais domésticos e produtos químicos para manter a pizzaria limpa. Tudo isso requer "contabilidade" para saber quais matérias-primas estão em abundância e quais faltam. 



"Contabilidade" descreve qualquer movimento de matéria-prima em pizzarias. A entrega é um ponto positivo no balanço patrimonial e a baixa contábil é um ponto negativo. Por exemplo, quando pedimos uma pizza, o caixa aceita o pedido e o envia para processamento. A massa é então estendida e recheada com ingredientes como queijo, molho de tomate e calabresa. Todos esses produtos entram em produção - são baixados. Além disso, a baixa pode ocorrer quando a data de vencimento termina.



Como resultado de entregas e baixas, são formados "saldos de depósito". Este é um relatório que reflete quantas matérias-primas estão no balanço patrimonial com base nas operações no sistema de informação. Tudo isso é o “saldo de liquidação”. Mas há um "valor real" - quanta matéria-prima está realmente em estoque agora.



Revisões



Para calcular o valor real, são usadas "revisões" (também chamadas de "estoques"). 



As auditorias ajudam a calcular com precisão a quantidade de matéria-prima para compras. Muitas compras congelam o capital de giro e aumenta o risco de dar baixa nos produtos excedentes, o que também leva a perdas. Não só o excesso de matéria-prima é perigoso, mas também a sua escassez - isso pode levar a uma paralisação na produção de alguns produtos, o que acarretará na diminuição da receita. As auditorias ajudam a ver quanto lucro uma empresa está perdendo devido a perdas registradas e não contabilizadas de matérias-primas e a trabalhar para reduzir custos.



As revisões compartilham seus dados levando em consideração o processamento posterior, por exemplo, a criação de relatórios.



Problemas no processo de revisão, ou como as revisões antigas funcionaram



As revisões são um processo trabalhoso. Demora muito e consiste em várias etapas: contagem e fixação dos restos de matéria-prima, síntese dos resultados das matérias-primas por áreas de armazenamento, introdução dos resultados no sistema de informação Dodo IS.



Anteriormente, as auditorias eram realizadas com um formulário de caneta e papel, no qual havia uma lista de matérias-primas. Ao resumir, reconciliar e transferir manualmente os resultados para o Dodo IS, existe a possibilidade de cometer um erro. Em uma auditoria completa, mais de 100 itens de matérias-primas são contados, e o cálculo em si é freqüentemente realizado no final da noite ou de manhã cedo, o que pode prejudicar a concentração.



Como resolver o problema



Nossa equipe de Game of Threads está desenvolvendo contabilidade em pizzarias. Decidimos lançar um projeto denominado “tablet do auditor”, que vai simplificar a auditoria das pizzarias. Decidimos fazer tudo em nosso próprio sistema de informação Dodo IS, no qual estão implementados os principais componentes de contabilidade, para que não necessitemos de integrações com sistemas de terceiros. Além disso, todos os países onde estivermos presentes poderão utilizar a ferramenta sem recorrer a integrações adicionais.



Mesmo antes de começar a trabalhar no projeto, nós, na equipe, discutimos o desejo de aplicar o DDD na prática. Felizmente, um dos projetos já aplicou essa abordagem com sucesso, então temos um exemplo que você pode ver - este é o projeto “ caixa ”.



Neste artigo, falarei sobre os padrões DDD táticos que usamos no desenvolvimento: agregados, comandos, eventos de domínio, serviço de aplicativo e integração de contextos limitados. Não descreveremos os padrões e fundamentos estratégicos do DDD, caso contrário, o artigo será muito longo. Já falamos sobre isso no artigo “ O que você pode aprender sobre Domain Driven Design em 10 minutos? "



Nova versão de revisões



Antes de iniciar a auditoria, você precisa saber exatamente o que contar. Para isso, precisamos de modelos de revisão . Eles são configurados pela função "gerente de escritório". O modelo de revisão é uma entidade InventoryTemplate. Ele contém os seguintes campos:



  • identificador de modelo;

  • ID da pizzaria;

  • nome do modelo;

  • categoria de revisão: mensal, semanal, diária;

  • unidades;

  • áreas de armazenamento e matérias-primas nesta área de armazenamento 



Para esta entidade, uma funcionalidade CRUD foi implementada e não iremos nos alongar sobre ela em detalhes.



Assim que o auditor tiver uma lista de modelos, ele pode iniciar a auditoria . Isso geralmente acontece quando a pizzaria está fechada. No momento, não há pedidos e as matérias-primas não estão se movendo - você pode obter dados sobre os saldos com segurança.



Iniciando a auditoria, o auditor seleciona uma zona, por exemplo uma geladeira, e vai contar as matérias-primas lá. Na geladeira, ele vê 5 pacotes de queijo, de 10 kg cada, entra 10 kg * 5 na calculadora, pressiona "Enter more". Então ele percebe mais 2 pacotes na prateleira superior e clica em "Adicionar". Como resultado, ele tem 2 medições - 50 e 20 kg cada.



Mediçãochamamos a quantidade inserida de matéria-prima pelo inspetor em uma determinada área, mas não necessariamente o total. O inspetor pode inserir duas medidas de um quilograma ou apenas dois quilogramas em uma medição - qualquer combinação pode ser. O principal é que o próprio auditor seja claro.





Interface da calculadora.



Assim, passo a passo, o auditor considera todas as matérias-primas em 1-2 horas e, em seguida, conclui a auditoria.



O algoritmo de ações é bastante simples:



  • o auditor pode iniciar a auditoria;

  • o auditor pode adicionar medições na revisão iniciada;

  • o auditor pode completar a auditoria.



A partir desse algoritmo, são formados os requisitos de negócios para o sistema.



Implementação da primeira versão do agregado, comandos e eventos do domínio



Primeiro, vamos definir os termos que estão incluídos no conjunto de modelos táticos DDD. Iremos nos referir a eles neste artigo.



Modelos DDD táticos



Aggregate é um cluster de objetos de entidade e valor. Os objetos em um cluster são uma entidade única em termos de modificação de dados. Cada agregado possui um elemento raiz por meio do qual entidades e valores são acessados. As unidades não devem ser projetadas muito grandes. Eles consomem muita memória e a probabilidade de conclusão bem-sucedida da transação diminui.



Limite agregado é um conjunto de objetos que devem ser consistentes em uma única transação: todas as invariáveis ​​dentro deste cluster devem ser observadas.



Invariantes são regras de negócios que não podem ser inconsistentes.



ComandoÉ algum tipo de ação na unidade. Como resultado dessa ação, o estado do agregado pode ser alterado e um ou mais eventos de domínio podem ser gerados.



Um evento de domínio é uma notificação de uma mudança no estado de um agregado, necessária para manter a consistência. O agregado garante consistência transacional: todos os dados devem ser alterados aqui e agora. A consistência resultante garante consistência a longo prazo - os dados mudarão, mas não aqui e agora, mas após um período indefinido de tempo. Esse intervalo depende de muitos fatores: o congestionamento das filas de mensagens, a prontidão dos serviços externos para processar essas mensagens, a rede.



Elemento raizÉ uma entidade com um identificador global exclusivo. Os elementos filhos só podem ter identidade local dentro de um agregado inteiro. Eles podem referir-se um ao outro e apenas ao seu elemento raiz.



Equipes e eventos



Vamos descrever os requisitos de negócios como uma equipe. Os comandos são apenas DTOs com campos descritivos.



O comando "adicionar medição" possui os seguintes campos:



  • valor de medição - a quantidade de matérias-primas em uma determinada unidade de medição, pode ser nula se a medição foi excluída;

  • versão - a medição pode ser editada, portanto, é necessária uma versão;

  • identificador de matéria-prima;

  • unidade de medida: kg / g, l / ml, peças;

  • identificador da área de armazenamento.



Medição adicionando código de comando
public sealed class AddMeasurementCommand
{
    // ctor

    public double? Value { get; }
    public int Version { get; }
    public UUId MaterialTypeId { get; }
    public UUId MeasurementId { get; }
    public UnitOfMeasure UnitOfMeasure { get; }
    public UUId InventoryZoneId { get; }
}




Também precisamos de um evento que resultará da execução desses comandos. Marcamos o evento com uma interface IPublicInventoryEvent- vamos precisar dela para integração com consumidores externos no futuro.



No caso de “medição” os campos são iguais aos do comando “Adicionar medição”, exceto que o evento também armazena o identificador da unidade na qual ocorreu e sua versão.



Código do evento "congelado"
public class MeasurementEvent : IPublicInventoryEvent
{
    public UUId MaterialTypeId { get; set; }
    public double? Value { get; set; }
	
    public UUId MeasurementId { get; set; }
    public int MeasurementVersion { get; set; }
    public UUId AggregateId { get; set; }
    public int Version { get; set; }
    public UnitOfMeasure UnitOfMeasure { get; set; }
    public UUId InventoryZoneId { get; set; }
}




Quando tivermos descrito comandos e eventos, podemos implementar o agregado Inventory.



Implementando o Agregado de Estoque





Diagrama de agregação UML, Inventário.



A abordagem é a seguinte: o início da revisão inicia a criação do agregado Inventory, para isso usamos o método de fábrica Createe iniciamos a revisão com o comando StartInventoryCommand.



Cada comando altera o estado do agregado e salva os eventos da lista changes, que serão enviados ao armazenamento para gravação. Além disso, com base nessas mudanças, eventos para o mundo exterior serão gerados.



Quando o agregado Inventoryfor criado, podemos restaurá-lo para cada solicitação subsequente para alterar seu estado.



  • As alterações ( changes) são armazenadas desde a última vez que a unidade foi restaurada.

  • O estado é restaurado por um método Restoreque reproduz todos os eventos anteriores, classificados por versão, na instância atual do agregado Inventory.



Essa é a implementação da ideia Event Sourcingdentro da unidade. Event SourcingFalaremos sobre como implementar a ideia dentro da estrutura do repositório um pouco mais tarde. Há uma bela ilustração do livro de Vaughn Vernon: O





estado da unidade é restaurado aplicando os eventos na ordem em que ocorrem.



Em seguida, várias medições são feitas pela equipe AddMeasurementCommand. A auditoria termina com um comando FinishInventoryCommand. O agregado valida seu estado em métodos mutantes para cumprir seus invariantes.



É importante observar que a unidade é Inventorytotalmente versionada, assim como cada medição. Com medições é mais difícil - você deve resolver conflitos no método de tratamento de eventos When(MeasurementEvent e). No código, vou mostrar apenas o processamento do comando AddMeasurementCommand.



Código de estoque agregado
public sealed class Inventory : IEquatable<Inventory>
{
    private readonly List<IInventoryEvent> _changes = new List<IInventoryEvent>();

    private readonly List<InventoryMeasurement> _inventoryMeasurements = new List<InventoryMeasurement>();

    internal Inventory(UUId id, int version, UUId unitId, UUId inventoryTemplateId,
        UUId startedBy, InventoryState state, DateTime startedAtUtc, DateTime? finishedAtUtc)
	
        : this(id)
    {
        Version = version;
        UnitId = unitId;
        InventoryTemplateId = inventoryTemplateId;
        StartedBy = startedBy;
        State = state;
        StartedAtUtc = startedAtUtc;
        FinishedAtUtc = finishedAtUtc;
	
    }

    private Inventory(UUId id)
    {
        Id = id;
        Version = 0;
        State = InventoryState.Unknown;
    }
	
    public UUId Id { get; private set; }
    public int Version { get; private set; }
    public UUId UnitId { get; private set; }
    public UUId InventoryTemplateId { get; private set; }
    public UUId StartedBy { get; private set; }
    public InventoryState State { get; private set; }
    public DateTime StartedAtUtc { get; private set; }
    public DateTime? FinishedAtUtc { get; private set; }
    public ReadOnlyCollection<IInventoryEvent> Changes => _changes.AsReadOnly();
	
    public ReadOnlyCollection<InventoryMeasurement> Measurements => _inventoryMeasurements.AsReadOnly();

    public static Inventory Restore(UUId inventoryId, IInventoryEvent[] events)
    {
        var inventory = new Inventory(inventoryId);
        inventory.ReplayEvents(events);
        return inventory;
    }

    public static Inventory Restore(UUId id, int version, UUId unitId, UUId inventoryTemplateId,
        UUId startedBy, InventoryState state, DateTime startedAtUtc, DateTime? finishedAtUtc,
        InventoryMeasurement[] measurements)
    {
        var inventory = new Inventory(id, version, unitId, inventoryTemplateId,
            startedBy, state, startedAtUtc, finishedAtUtc);

        inventory._inventoryMeasurements.AddRange(measurements);

        return inventory;
    }

    public static Inventory Create(UUId inventoryId)
    {
        if (inventoryId == null)
        {
            throw new ArgumentNullException(nameof(inventoryId));
        }

        return new Inventory(inventoryId);
    }

    public void ReplayEvents(params IInventoryEvent[] events)
    {
        if (events == null)
        {
            throw new ArgumentNullException(nameof(events));
        }

        foreach (var @event in events.OrderBy(e => e.Version))
        {
            Mutate(@event);
        }
    }

    public void AddMeasurement(AddMeasurementCommand command)
    {
        if (command == null)
        {
            throw new ArgumentNullException(nameof(command));
        }

        Apply(new MeasurementEvent
        {
            AggregateId = Id,
            Version = Version + 1,
            UnitId = UnitId,
            Value = command.Value,
            MeasurementVersion = command.Version,
            MaterialTypeId = command.MaterialTypeId,
            MeasurementId = command.MeasurementId,
            UnitOfMeasure = command.UnitOfMeasure,
            InventoryZoneId = command.InventoryZoneId
        });
    }

    private void Apply(IInventoryEvent @event)
    {
        Mutate(@event);
        _changes.Add(@event);
    }

    private void Mutate(IInventoryEvent @event)
    {
        When((dynamic) @event);
        Version = @event.Version;
    }

    private void When(MeasurementEvent e)
    {
        var existMeasurement = _inventoryMeasurements.SingleOrDefault(x => x.MeasurementId == e.MeasurementId);
        if (existMeasurement is null)
    {
        _inventoryMeasurements.Add(new InventoryMeasurement
        {
            Value = e.Value,
            MeasurementId = e.MeasurementId,
            MeasurementVersion = e.MeasurementVersion,
            PreviousValue = e.PreviousValue,
            MaterialTypeId = e.MaterialTypeId,
            UserId = e.By,
            UnitOfMeasure = e.UnitOfMeasure,
            InventoryZoneId = e.InventoryZoneId
        });
    }
    else
    {
        if (!existMeasurement.Value.HasValue)
        {
            throw new InventoryInvalidStateException("Change removed measurement");
        }

        if (existMeasurement.MeasurementVersion == e.MeasurementVersion - 1)
        {
            existMeasurement.Value = e.Value;
            existMeasurement.MeasurementVersion = e.MeasurementVersion;
            existMeasurement.UnitOfMeasure = e.UnitOfMeasure;
            existMeasurement.InventoryZoneId = e.InventoryZoneId;
        }
        else if (existMeasurement.MeasurementVersion < e.MeasurementVersion)
        {
            throw new MeasurementConcurrencyException(Id, e.MeasurementId, e.Value);
        }
        else if (existMeasurement.MeasurementVersion == e.MeasurementVersion &&
            existMeasurement.Value != e.Value)
        {
            throw new MeasurementConcurrencyException(Id, e.MeasurementId, e.Value);
        }
        else
        {
            throw new NotChangeException();
        }
    }
}

// Equals
// GetHashCode
}




Quando ocorre o evento “Medido”, é verificada a presença de uma medição existente com este identificador. Se este não for o caso, uma nova medição é adicionada.



Nesse caso, verificações adicionais são necessárias:



  • você não pode editar uma medição remota;

  • a versão de entrada deve ser maior do que a anterior.



Se as condições forem atendidas, podemos definir um novo valor e uma nova versão para a medição existente. Se a versão for menor, isso é um conflito. Para isso, lançamos uma exceção MeasurementConcurrencyException. Se a versão corresponder e os valores forem diferentes, essa também é uma situação de conflito. Bem, se a versão e o valor corresponderem, nenhuma alteração ocorreu. Essas situações geralmente não acontecem.



A entidade "medição" contém exatamente os mesmos campos do comando "Adicionar medição".



Código de entidade "congelou"
public class InventoryMeasurement
{
    public UUId MeasurementId { get; set; }
    public UUId MaterialTypeId { get; set; }
    public UUId UserId { get; set; }
    public double? Value { get; set; }

    public int MeasurementVersion { get; set; }

    public UnitOfMeasure UnitOfMeasure { get; set; }

    public UUId InventoryZoneId { get; set; }
}




O uso de métodos de agregação pública é bem demonstrado por testes de unidade.



Código de teste de unidade "adicionando uma medição após o início da revisão"
[Fact]
public void WhenAddMeasurementAfterStartInventory_ThenInventoryHaveOneMeasurement()
{
    var inventoryId = UUId.NewUUId();
    var inventory = Domain.Inventories.Entities.Inventory.Create(inventoryId);
    var unitId = UUId.NewUUId();
    inventory.StartInventory(Create.StartInventoryCommand()
        .WithUnitId(unitId)
        .Please());

    var materialTypeId = UUId.NewUUId();
    var measurementId = UUId.NewUUId();
    var measurementVersion = 1;
    var value = 500;
    var cmd = Create.AddMeasurementCommand()
        .WithMaterialTypeId(materialTypeId)
        .WithMeasurement(measurementId, measurementVersion)
        .WithValue(value)
        .Please();
    inventory.AddMeasurement(cmd);

    inventory.Measurements.Should().BeEquivalentTo(new InventoryMeasurement
    {
        MaterialTypeId = materialTypeId,
        MeasurementId = measurementId,
        MeasurementVersion = measurementVersion,
        Value = value,
        UnitOfMeasure = UnitOfMeasure.Quantity
    });
}




Juntando tudo: comandos, eventos, agregação de inventário





Ciclo de vida agregado do inventário ao executar Terminar inventário.



O diagrama mostra o processo de processamento do comando FinishInventoryCommand. Antes do processamento, é necessário restaurar o estado da unidade Inventoryno momento da execução do comando. Para fazer isso, carregamos todos os eventos que foram executados nesta unidade na memória e os reproduzimos (p. 1). 



No momento da conclusão da revisão, já temos os seguintes eventos - o início da revisão e a adição de três medições. Esses eventos apareceram como resultado do processamento do comando StartInventoryCommande AddMeasurementCommand, consequentemente. No banco de dados, cada linha da tabela contém o ID de revisão, a versão e o corpo do próprio evento.



Nesta fase, executamos o comandoFinishInventoryCommand(p. 2). Este comando verificará primeiro a validade do estado atual da unidade - se a revisão está em um estado InProgress, e então gerará uma nova mudança de estado adicionando um evento FinishInventoryEventà lista changes(item 3).



Quando o comando for concluído, todas as alterações serão salvas no banco de dados. Como resultado, uma nova linha com o evento FinishInventoryEvente a versão mais recente da unidade aparecerá no banco de dados (p. 4).



Tipo Inventory(revisão) - elemento agregado e raiz em relação às suas entidades aninhadas. Assim, o tipo Inventorydefine os limites da unidade. Os limites agregados incluem uma lista de entidades do tipo Measurement(medição) e uma lista de todos os eventos realizados no agregado ( changes).



Implementação de todo o recurso



Por recursos, queremos dizer a implementação de um requisito comercial específico. Em nosso exemplo, consideraremos o recurso Adicionar Medida. Para implementar o recurso, precisamos entender o conceito de "serviço de aplicativo" ( ApplicationService).



Um serviço de aplicativo é um cliente direto do modelo de domínio. Os serviços de aplicativos garantem as transações ao usar o banco de dados ACID, garantindo que as transições de estado sejam preservadas atômicas. Além disso, os serviços de aplicativo também tratam de questões de segurança.



Já temos uma unidadeInventory... Para implementar todo o recurso, usaremos o serviço de aplicativo inteiramente. Nele, é necessário verificar a presença de todas as entidades conectadas, bem como os direitos de acesso do usuário. Somente depois que todas as condições forem atendidas, é possível salvar o estado atual da unidade e enviar eventos para o mundo externo. Para implementar um serviço de aplicativo, usamos MediatR.



Código de recurso "adicionando medição"
public class AddMeasurementChangeHandler 
    : IRequestHandler<AddMeasurementChangeRequest, AddMeasurementChangeResponse>
{
    // dependencies
    // ctor

    public async Task<AddMeasurementChangeResponse> Handle(
        AddMeasurementChangeRequest request,
        CancellationToken ct)
    {
        var inventory =
            await _inventoryRepository.GetAsync(request.AddMeasurementChange.InventoryId, ct);
        if (inventory == null)
        {
            throw new NotFoundException($"Inventory {request.AddMeasurementChange.InventoryId} is not found");
        }

        var user = await _usersRepository.GetAsync(request.UserId, ct);
        if (user == null)
        {
            throw new SecurityException();
        }

        var hasPermissions =
        await _authPermissionService.HasPermissionsAsync(request.CountryId, request.Token, inventory.UnitId, ct);
        if (!hasPermissions)
        {
            throw new SecurityException();
        }

        var unit = await _unitRepository.GetAsync(inventory.UnitId, ct);
        if (unit == null)
        {
            throw new InvalidRequestDataException($"Unit {inventory.UnitId} is not found");
        }

        var unitOfMeasure =

Enum.Parse<UnitOfMeasure>(request.AddMeasurementChange.MaterialTypeUnitOfMeasure);


        var addMeasurementCommand = new AddMeasurementCommand(	
            request.AddMeasurementChange.Value,
            request.AddMeasurementChange.Version,
            request.AddMeasurementChange.MaterialTypeId,
            request.AddMeasurementChange.Id,
            unitOfMeasure,
            request.AddMeasurementChange.InventoryZoneId);

        inventory.AddMeasurement(addMeasurementCommand);

        await HandleAsync(inventory, ct);

        return new AddMeasurementChangeResponse(request.AddMeasurementChange.Id, user.Id, user.GetName());
    }

    private async Task HandleAsync(Domain.Inventories.Entities.Inventory inventory, CancellationToken ct)
    {
            await _inventoryRepository.AppendEventsAsync(inventory.Changes, ct);
 
            try
            {
                await _localQueueDataService.Publish(inventory.Changes, ct);
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "error occured while handling action");
            }
    }
}




Fonte de eventos



Durante a implementação, decidimos escolher a abordagem ES por vários motivos:



  • Dodo tem exemplos de uso bem-sucedido dessa abordagem.

  • O ES torna mais fácil entender o problema durante um incidente - todas as ações do usuário são armazenadas.

  • Se você adotar a abordagem tradicional, não será capaz de mudar para o ES.



A ideia de implementação é bastante simples - adicionamos todos os novos eventos que surgiram como resultado de comandos ao banco de dados. Para restaurar o agregado, recebemos todos os eventos e os reproduzimos na instância. Para não obter um grande lote de eventos todas as vezes, removemos os estados a cada N eventos e reproduzimos o restante deste instantâneo.



ID de armazenamento agregado de inventário
internal sealed class InventoryRepository : IInventoryRepository
{
    // dependencies
    // ctor

    static InventoryRepository()
    {
        EventTypes = typeof(IEvent)
            .Assembly.GetTypes().Where(x => typeof(IEvent).IsAssignableFrom(x))
            .ToDictionary(t => t.FullName, x => x);
    }

    public async Task AppendAsync(IReadOnlyCollection<IEvent> events, CancellationToken ct)
    {
        using (var session = await _dbSessionFactory.OpenAsync())
        {
            if (events.Count == 0) return;

            try
            {
                foreach (var @event in events)
                {
                    await session.ExecuteAsync(Sql.AppendEvent,
                        new
                        {
                            @event.AggregateId,
                            @event.Version,
                            @event.UnitId,
                            Type = @event.GetType().FullName,
                            Data = JsonConvert.SerializeObject(@event),
                            CreatedDateTimeUtc = DateTime.UtcNow
                        }, cancellationToken: ct);
                }
            }
            catch (MySqlException e)
                when (e.Number == (int) MySqlErrorCode.DuplicateKeyEntry)
            {
                throw new OptimisticConcurrencyException(events.First().AggregateId, "");
            }
        }
    }

    public async Task<Domain.Models.Inventory> GetInventoryAsync(
        UUId inventoryId,
        CancellationToken ct)
    {
        var events = await GetEventsAsync(inventoryId, 0, ct);

        if (events.Any()) return Domain.Models.Inventory.Restore(inventoryId, events);

        return null;
    }
    
    private async Task<IEvent[]> GetEventsAsync(
        UUId id,
        int snapshotVersion,
        CancellationToken ct)
    {
        using (var session = await _dbSessionFactory.OpenAsync())
    {
            var snapshot = await GetInventorySnapshotAsync(session, inventoryId, ct);
            var version = snapshot?.Version ?? 0;
        
            var events = await GetEventsAsync(session, inventoryId, version, ct);
            if (snapshot != null)
            {
                snapshot.ReplayEvents(events);
                return snapshot;
            }

            if (events.Any())
            {
                return Domain.Inventories.Entities.Inventory.Restore(inventoryId, events);
            }

            return null;
        }
    }

    private async Task<Inventory> GetInventorySnapshotAsync(
        IDbSession session,
        UUId id,
        CancellationToken ct)
    {
        var record =
            await session.QueryFirstOrDefaultAsync<InventoryRecord>(Sql.GetSnapshot, new {AggregateId = id},
                cancellationToken: ct);
        return record == null ? null : Map(record);
    }

    private async Task<IInventoryEvent[]> GetEventsAsync(
        IDbSession session,
        UUId id,
        int snapshotVersion,
        CancellationToken ct)
    {
        var rows = await session.QueryAsync<EventRecord>(Sql.GetEvents,
            new
            {
                AggregateId = id,
                Version = snapshotVersion
            }, cancellationToken: ct);
        return rows.Select(Map).ToArray();
    }

    private static IEvent Map(EventRecord e)
    {
        var type = EventTypes[e.Type];
        return (IEvent) JsonConvert.DeserializeObject(e.Data, type);
    }
}

internal class EventRecord
{
    public string Type { get; set; }
    public string Data { get; set; }
}




Após vários meses de operação, percebemos que não temos uma grande necessidade de armazenar todas as ações do usuário na instância da unidade. A empresa não usa essas informações de forma alguma. Dito isso, há uma sobrecarga em manter essa abordagem. Tendo avaliado todos os prós e contras, planejamos mudar do ES para a abordagem tradicional - substituir o sinal Eventspor Inventoriese Measurements.



Integração com contextos externos limitados



Este é o esquema de interação de um contexto limitado Inventorycom o mundo exterior.





Interação do contexto de revisão com outros contextos. O diagrama mostra contextos, serviços e sua pertença uns aos outros.



No caso de Auth, Inventorye Datacatalog, há um contexto limitado para cada serviço. O monólito executa várias funções, mas agora estamos interessados ​​apenas na funcionalidade de contabilidade em pizzarias. Além das revisões, a contabilidade também inclui a movimentação de matérias-primas nas pizzarias: recebimentos, transferências, baixas.



HTTP



O serviço Inventoryinterage com Authatravés de HTTP. Em primeiro lugar, o usuário se depara com Auth, o que o leva a escolher uma das funções disponíveis para ele.



  • O sistema possui uma função "auditor", que o usuário escolhe durante a auditoria.

  • .

  • .



No último estágio, o usuário tem um token de Auth. O serviço de revisão precisa verificar este token, então ele pede Autha verificação. Authirá verificar se o tempo de vida do token expirou, se ele pertence ao proprietário ou se possui os direitos de acesso necessários. Se tudo estiver bem, ele Inventorysalva os carimbos nos cookies - ID do usuário, login, ID da pizzaria e define o tempo de vida do cookie.



Nota . AuthDescrevemos com mais detalhes como o serviço funciona no artigo " Sutilezas de autorização: uma visão geral da tecnologia OAuth 2.0 ".



Ele Inventoryinterage com outros serviços por meio de filas de mensagens. A empresa usa RabbitMQ como um corretor de mensagens, bem como a ligação acima dele - MassTransit.



RMQ: eventos de consumo



Serviço de diretório - Datacatalogfornecerá Inventorytodas as entidades necessárias: matérias-primas para contabilidade, países, divisões e pizzarias.



Sem entrar em detalhes da infraestrutura, descreverei a ideia básica de consumir eventos. Do lado do serviço de diretório, tudo já está pronto para publicação de eventos, vejamos o exemplo da entidade de matéria-prima.



Código de contrato de evento de datacatalog
namespace Dodo.DataCatalog.Contracts.Products.v1
{
    public class MaterialType
    {
        public UUId Id { get; set; }
        public int Version { get; set; }
        public int CountryId { get; set; }
        public UUId DepartmentId { get; set; }

        public string Name { get; set; }
        public MaterialCategory Category { get; set; }
        public UnitOfMeasure BasicUnitOfMeasure { get; set; }
        public bool IsRemoved { get; set; }
    }

    public enum UnitOfMeasure
    {
        Quantity = 1,
        Gram = 5,
        Milliliter = 7,
        Meter = 8,
    }

    public enum MaterialCategory
    {
        Ingredient = 1,
        SemiFinishedProduct = 2,
        FinishedProduct = 3,
        Inventory = 4,
        Packaging = 5,
        Consumables = 6
    }
}




Esta postagem foi publicada em exchange. Cada serviço pode criar seu próprio pacote exchange-queuepara consumir eventos.





Esquema de publicação de um evento e seu consumo através das primitivas RMQ.



Em última análise, há uma fila para cada entidade que o serviço pode assinar. Resta salvar a nova versão no banco de dados.



Código do consumidor do evento do Datacatalog
public class MaterialTypeConsumer : IConsumer<Dodo.DataCatalog.Contracts.Products.v1.MaterialType>
{
    private readonly IMaterialTypeRepository _materialTypeRepository;

    public MaterialTypeConsumer(IMaterialTypeRepository materialTypeRepository)
    {
         _materialTypeRepository = materialTypeRepository;
    }
 
    public async Task Consume(ConsumeContext<Dodo.DataCatalog.Contracts.Products.v1.MaterialType> context)
    {
        var materialType = new AddMaterialType(context.Message.Id,
            context.Message.Name,
            (int)context.Message.Category,
            (int)context.Message.BasicUnitOfMeasure,
            context.Message.CountryId,
            context.Message.DepartmentId,
            context.Message.IsRemoved,
            context.Message.Version);
    
        await _materialTypeRepository.SaveAsync(materialType, context.CancellationToken);
    }
}




RMQ: Publicação de eventos



A parte de contabilidade do monólito consome dados Inventorypara oferecer suporte ao restante da funcionalidade que requer dados de revisão. Todos os eventos sobre os quais queremos notificar outros serviços, marcamos com a interface IPublicInventoryEvent. Quando um evento desse tipo ocorre, nós os isolamos do changelog ( changes) e os enviamos para a fila de despacho. Para isso, são utilizadas duas tabelas publicqueuee publicqueue_archive.



Para garantir a entrega das mensagens, usamos um padrão que costumamos chamar de "fila local", ou seja Transactional outbox pattern. Salvar o estado do agregado Inventorye enviar eventos para a fila local ocorre em uma transação. Assim que a transação é confirmada, tentamos imediatamente enviar mensagens ao corretor.



Se a mensagem foi enviada, ela é removida da fila publicqueue. Caso contrário, será feita uma tentativa de enviar a mensagem mais tarde. Os assinantes dos pipelines de monólito e de dados consomem as mensagens. A tabela publicqueue_archivearmazena para sempre os dados para um re-envio conveniente de eventos se for necessário em algum ponto.



Código para publicar eventos para o corretor de mensagens
internal sealed class BusDataService : IBusDataService
{
    private readonly IPublisherControl _publisherControl;
    private readonly IPublicQueueRepository _repository;
    private readonly EventMapper _eventMapper;

    public BusDataService(
        IPublicQueueRepository repository,
        IPublisherControl publisherControl,
        EventMapper eventMapper)
    {
        _repository = repository;
        _publisherControl = publisherControl;
        _eventMapper = eventMapper;
    }

    public async Task ConsumePublicQueueAsync(int batchEventSize, CancellationToken cancellationToken)
    {
        var events = await _repository.GetAsync(batchEventSize, cancellationToken);
        await Publish(events, cancellationToken);
    }

    public async Task Publish(IEnumerable<IPublicInventoryEvent> events, CancellationToken ct)
    {
        foreach (var @event in events)
        {
            var publicQueueEvent = _eventMapper.Map((dynamic) @event);
            await _publisherControl.Publish(publicQueueEvent, ct);
            await _repository.DeleteAsync(@event, ct);
       }
    }
}




Enviamos eventos ao monólito para relatórios. O relatório de perdas e excedentes permite que você compare quaisquer duas revisões entre si. Além disso, existe um importante relatório "saldos de armazém", que já foi mencionado anteriormente. 



Por que enviar eventos para o pipeline de dados? Ao mesmo tempo - para relatórios, mas apenas em novos trilhos. Anteriormente, todos os relatórios viviam em um monólito, mas agora eles foram retirados. Isso compartilha duas responsabilidades - armazenamento e processamento de dados de produção e analíticos: OLTP e OLAP. Isso é importante tanto em termos de infraestrutura quanto de desenvolvimento.



Conclusão



Seguindo os princípios e práticas do Domain-Driven Design, fomos capazes de construir um sistema confiável e flexível que atende às necessidades de negócios dos usuários. Nós temos não apenas um produto decente, mas também um bom código que é fácil de modificar. Esperamos que em seus projetos haja um lugar para usar o Domain-Driven Design.



Você pode encontrar mais informações sobre DDD em nossa comunidade DDDevotion e no canal DDDevotion no Youtube . Você pode discutir o artigo no Telegram no bate-papo da Dodo Engineering .



All Articles