
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 Create
e 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
Inventory
for 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
Restore
que reproduz todos os eventos anteriores, classificados por versão, na instância atual do agregadoInventory
.
Essa é a implementação da ideia
Event Sourcing
dentro da unidade. Event Sourcing
Falaremos 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 é
Inventory
totalmente 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 Inventory
no 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
StartInventoryCommand
e 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 comando
FinishInventoryCommand
(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
FinishInventoryEvent
e 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 Inventory
define 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 unidade
Inventory
... 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
Events
por Inventories
e Measurements
.
Integração com contextos externos limitados
Este é o esquema de interação de um contexto limitado
Inventory
com 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
, Inventory
e 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
Inventory
interage com Auth
atravé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 Auth
a verificação. Auth
irá 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 Inventory
salva os carimbos nos cookies - ID do usuário, login, ID da pizzaria e define o tempo de vida do cookie.
Nota .
Auth
Descrevemos com mais detalhes como o serviço funciona no artigo " Sutilezas de autorização: uma visão geral da tecnologia OAuth 2.0 ".
Ele
Inventory
interage 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 -
Datacatalog
fornecerá Inventory
todas 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-queue
para 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
Inventory
para 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 publicqueue
e 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 Inventory
e 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_archive
armazena 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 .