Cómo DDD nos ayudó a construir nuevas revisiones en pizzerías

En las pizzerías, es importante construir un sistema de contabilidad y gestión de inventarios. El sistema es necesario para no perder productos, no realizar castigos innecesarios y predecir correctamente las compras para el próximo mes. Un papel importante en la contabilidad de las revisiones. Le ayudan a verificar los balances de alimentos y verificar la cantidad real y lo que hay en el sistema.







La auditoría de Dodo no se basa en papel: el auditor tiene una tableta, donde el auditor toma nota de todos los productos y crea informes. Pero hasta 2020, la revisión en pizzerías se realizaba precisamente en trozos de papel, simplemente porque era más y más fácil de esa manera. Esto, por supuesto, dio lugar a datos inexactos, errores y pérdidas: la gente comete errores, se pierden trozos de papel y hay muchos más. Decidimos solucionar este problema y mejorar la forma de la tableta. La implementación decidió utilizar DDD. Cómo lo hicimos, te contamos más.



Primero, brevemente sobre los procesos comerciales para comprender el contexto. Consideremos el esquema del movimiento de productos y dónde están las revisiones en él, y luego pasemos a los detalles técnicos, de los cuales habrá muchos.



Esquema del movimiento de productos y por qué es necesaria una revisión



Hay más de 600 pizzerías en nuestra red (y este número seguirá creciendo). Todos los días hay un movimiento de materias primas en cada uno de ellos: desde la elaboración y venta de productos, saneamientos de ingredientes por fecha de vencimiento, hasta el movimiento de materias primas a otras pizzerías de la cadena. El saldo de la pizzería contiene constantemente alrededor de 120 elementos necesarios para la producción de productos y, además, una gran cantidad de consumibles, materiales domésticos y productos químicos para mantener limpia la pizzería. Todo esto requiere "contabilidad" para saber qué materias primas abundan y cuáles faltan. 



"Contabilidad" describe cualquier movimiento de materias primas en las pizzerías. La entrega es una ventaja en el balance y la cancelación es una desventaja. Por ejemplo, cuando pedimos una pizza, el cajero acepta el pedido y lo envía para su procesamiento. Luego, la masa se extiende y se rellena con ingredientes como queso, salsa de tomate y pepperoni. Todos estos productos entran en producción, se cancelan. Además, la cancelación puede ocurrir cuando finaliza la fecha de vencimiento.



Como resultado de las entregas y cancelaciones, se forman "saldos de almacén". Este es un informe que refleja cuántas materias primas hay en el balance general en función de las operaciones en el sistema de información. Todo esto es el "saldo de liquidación". Pero hay un "valor real": cuánta materia prima hay actualmente en stock.



Revisiones



Para calcular el valor real, se utilizan "revisiones" (también se les llama "inventarios"). 



Las auditorías ayudan a calcular con precisión la cantidad de materias primas para compras. Demasiadas compras congelarán el capital de trabajo y aumentará el riesgo de cancelar el exceso de productos, lo que también conduce a pérdidas. No solo un exceso de materias primas es peligroso, sino también su escasez, lo que puede provocar una interrupción en la producción de algunos productos, lo que conducirá a una disminución de los ingresos. Las auditorías ayudan a ver cuántas ganancias está obteniendo una empresa menos ganancias debido a pérdidas contables y no contabilizadas de materias primas, y trabajan para reducir costos.



Las revisiones comparten sus datos con la debida consideración para su posterior procesamiento, por ejemplo, informes de construcción.



Problemas durante el proceso de revisión o cómo funcionaban las revisiones antiguas



Las revisiones son un proceso laborioso. Lleva mucho tiempo y consta de varias etapas: contar y fijar los restos de materias primas, resumir los resultados de las materias primas por áreas de almacenamiento, ingresar los resultados en el sistema de información Dodo IS.



Anteriormente, las auditorías se realizaban con un formulario en papel y lápiz, en el que figuraba una lista de materias primas. Al resumir, conciliar y transferir resultados manualmente a Dodo IS, existe la posibilidad de cometer un error. En una auditoría completa, se cuentan más de 100 tipos de materias primas, y el cálculo en sí a menudo se lleva a cabo al final de la tarde o temprano en la mañana, lo que puede afectar la concentración.



Como resolver el problema



Nuestro equipo de Game of Threads está desarrollando contabilidad en pizzerías. Decidimos lanzar un proyecto llamado “tableta de auditor”, que simplificará la auditoría de las pizzerías. Decidimos hacer todo en nuestro propio sistema de información Dodo IS, en el que se implementan los principales componentes para la contabilidad, por lo que no necesitamos integraciones con sistemas de terceros. Además, todos los países de nuestra presencia podrán utilizar la herramienta sin recurrir a integraciones adicionales.



Incluso antes de comenzar a trabajar en el proyecto, en el equipo discutimos el deseo de aplicar DDD en la práctica. Afortunadamente, uno de los proyectos ya ha aplicado con éxito este enfoque, por lo que teníamos un ejemplo que puede ver: este es el proyecto de “ caja ”.



En este artículo, hablaré sobre los patrones tácticos de DDD que usamos en el desarrollo: agregados, comandos, eventos de dominio, servicio de aplicaciones e integración de contextos acotados. No describiremos los patrones estratégicos y los fundamentos de DDD; de lo contrario, el artículo será muy extenso. Ya hablamos de esto en el artículo “ ¿Qué puedes aprender sobre el diseño basado en dominios en 10 minutos? "



Nueva versión de revisiones



Antes de comenzar la auditoría, debe saber qué contar exactamente. Para ello necesitamos plantillas de revisión . Están configurados por el rol de "administrador de oficina". La plantilla de revisión es una entidad InventoryTemplate. Contiene los siguientes campos:



  • identificador de plantilla;

  • ID de pizzería;

  • Nombre de la plantilla;

  • categoría de revisión: mensual, semanal, diaria;

  • unidades;

  • áreas de almacenamiento y materias primas en esta área de almacenamiento 



Para esta entidad, se ha implementado una funcionalidad CRUD y no nos detendremos en ella en detalle.



Una vez que el auditor tiene una lista de plantillas, puede iniciar la auditoría . Esto suele ocurrir cuando la pizzería está cerrada. En este momento, no hay pedidos y las materias primas no se mueven; puede obtener datos fiables sobre los saldos.



Al comenzar la auditoría, el auditor selecciona una zona, por ejemplo un refrigerador, y va a contar las materias primas allí. En el refrigerador ve 5 paquetes de queso, de 10 kg cada uno, ingresa 10 kg * 5 en la calculadora, presiona "Enter more". Luego, nota 2 paquetes más en el estante superior y hace clic en "Agregar". Como resultado, tiene 2 medidas: 50 y 20 kg cada una.



Medidallamamos la cantidad ingresada de materias primas por el inspector en un área determinada, pero no necesariamente el total. El auditor puede ingresar dos mediciones de un kilogramo o solo dos kilogramos en una medición; cualquier combinación puede ser. Lo principal es que el propio auditor debe ser claro.





Interfaz de calculadora.



Entonces, paso a paso, el auditor considera todas las materias primas en 1-2 horas y luego completa la auditoría.



El algoritmo de acciones es bastante simple:



  • el auditor puede iniciar la auditoría;

  • el auditor puede agregar mediciones en la revisión iniciada;

  • el auditor puede completar la auditoría.



A partir de este algoritmo, se forman los requisitos comerciales del sistema.



Implementación de la primera versión del agregado, comandos y eventos del dominio



Primero, definamos los términos que se incluyen en el conjunto de plantillas tácticas DDD. Nos referiremos a ellos en este artículo.



Plantillas tácticas DDD



Agregado es un grupo de objetos de valor y entidad. Los objetos de un clúster son una sola entidad en términos de modificación de datos. Cada agregado tiene un elemento raíz a través del cual se accede a las entidades y valores. Las unidades no deben diseñarse demasiado grandes. Consumirán mucha memoria y la probabilidad de que la transacción se complete con éxito disminuye.



El límite agregado es un conjunto de objetos que deben ser consistentes dentro de una sola transacción: se deben observar todos los invariantes dentro de este grupo.



Las invariantes son reglas comerciales que no pueden ser inconsistentes.



MandoEs algún tipo de acción en la unidad. Como resultado de esta acción, se puede cambiar el estado del agregado y se pueden generar uno o más eventos de dominio.



Un evento de dominio es una notificación de un cambio en el estado de un agregado, necesario para mantener la coherencia. El agregado asegura la coherencia transaccional: todos los datos deben cambiarse aquí y ahora. La coherencia resultante garantiza la coherencia a largo plazo: los datos cambiarán, pero no aquí y ahora, sino después de un período de tiempo indefinido. Este intervalo depende de muchos factores: la congestión de las colas de mensajes, la disponibilidad de los servicios externos para procesar estos mensajes, la red.



Elemento raízEs una entidad con un identificador global único. Los elementos secundarios solo pueden tener identidad local dentro de un agregado completo. Pueden referirse entre sí y solo pueden hacer referencia a su elemento raíz.



Equipos y eventos



Describamos el requisito empresarial en equipo. Los comandos son solo DTO con campos descriptivos.



El comando "agregar medida" tiene los siguientes campos:



  • valor de medición: la cantidad de materias primas en una determinada unidad de medida, puede ser nula si se eliminó la medición;

  • versión: la medición se puede editar, por lo que se necesita una versión;

  • identificador de materia prima;

  • unidad de medida: kg / g, l / ml, piezas;

  • identificador del área de almacenamiento.



Medición agregando 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; }
}




También necesitamos un evento que resultará de la ejecución de estos comandos. Marcamos el evento con una interfaz IPublicInventoryEvent; la necesitaremos para la integración con consumidores externos en el futuro.



En el caso de "medición" los campos son los mismos que en el comando "Agregar medición", excepto que el evento también almacena el identificador de la unidad en la que ocurrió y su versión.



Código de 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; }
}




Cuando hemos descrito los comandos y eventos, podemos implementar el agregado Inventory.



Implementación del agregado de inventario





Inventario de diagrama agregado UML.



El enfoque es este: el inicio de la revisión inicia la creación del agregado Inventory, para esto usamos el método de fábrica Createy comenzamos la revisión con el comando StartInventoryCommand.



Cada comando muta el estado del agregado y guarda los eventos en la lista changes, que se enviarán al almacenamiento para su registro. Además, en base a estos cambios, se generarán eventos para el mundo exterior.



Cuando se Inventoryha creado el agregado , podemos restaurarlo para cada solicitud posterior para cambiar su estado.



  • Los cambios ( changes) se almacenan desde la última vez que se restauró la unidad.

  • El estado se restaura mediante un método Restoreque reproduce todos los eventos anteriores, ordenados por versión, en la instancia actual del agregado Inventory.



Esta es la implementación de la idea Event Sourcingdentro de la unidad. Hablaremos Event Sourcingsobre cómo implementar la idea dentro del marco del repositorio un poco más adelante. Hay una bonita ilustración del libro de Vaughn Vernon: El





estado de la unidad se restaura aplicando eventos en el orden en que ocurren.



Luego se realizan varias mediciones por parte del equipo AddMeasurementCommand. La auditoría termina con un comando FinishInventoryCommand. El agregado valida su estado en métodos mutantes para cumplir con sus invariantes.



Es importante tener en cuenta que la unidad está Inventorycompletamente versionada, al igual que cada medida. Las mediciones son más difíciles: debe resolver los conflictos en el método de manejo de eventos When(MeasurementEvent e). En el código, solo mostraré el procesamiento del comando AddMeasurementCommand.



Código de inventario 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
}




Cuando ocurre el evento “Medido”, se verifica la presencia de una medición existente con este identificador. Si este no es el caso, se agrega una nueva medición.



Si es así, se necesitan controles adicionales:



  • no puede editar una medida remota;

  • la versión entrante debe ser más grande que la anterior.



Si se cumplen las condiciones, podemos establecer un nuevo valor y una nueva versión para la medición existente. Si la versión es más pequeña, entonces esto es un conflicto. Para esto, lanzamos una excepción MeasurementConcurrencyException. Si la versión coincide y los valores difieren, también se trata de una situación de conflicto. Bueno, si tanto la versión como el valor coinciden, no se han producido cambios. Tales situaciones generalmente no surgen.



La entidad "medición" contiene exactamente los mismos campos que el comando "Agregar medición".



Código de entidad "congelado"
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; }
}




El uso de métodos agregados públicos está bien demostrado por las pruebas unitarias.



Código de prueba unitaria "agregar una medición después del inicio de la revisión"
[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
    });
}




Poniéndolo todo junto: comandos, eventos, agregado de inventario





Ciclo de vida agregado de inventario cuando se ejecuta Finish Inventory.



El diagrama muestra el proceso de procesamiento de comandos FinishInventoryCommand. Antes de procesar, es necesario restaurar el estado de la unidad Inventoryen el momento de la ejecución del comando. Para hacer esto, cargamos todos los eventos que se realizaron en esta unidad en la memoria y los reproducimos (p. 1). 



En el momento de la finalización de la revisión, ya tenemos los siguientes eventos: el comienzo de la revisión y la adición de tres mediciones. Estos eventos aparecieron como resultado del procesamiento de comandos StartInventoryCommandy AddMeasurementCommand, en consecuencia. En la base de datos, cada fila de la tabla contiene el ID de revisión, la versión y el cuerpo del evento en sí.



En esta etapa, ejecutamos el comandoFinishInventoryCommand(pág. 2). Este comando primero verificará la validez del estado actual de la unidad - que la revisión está en un estado InProgress, y luego generará un nuevo cambio de estado agregando un evento FinishInventoryEventa la lista changes(elemento 3).



Cuando se complete el comando, todos los cambios se guardarán en la base de datos. Como resultado, aparecerá una nueva línea con el evento FinishInventoryEventy la última versión de la unidad en la base de datos (p. 4).



Tipo Inventory(revisión): elemento agregado y raíz en relación con sus entidades anidadas. Por tanto, el tipo Inventorydefine los límites de la unidad. Los límites agregados incluyen una lista de entidades de tipo Measurement(medición) y una lista de todos los eventos realizados en el agregado ( changes).



Implementación de toda la función.



Por características, nos referimos a la implementación de un requisito comercial específico. En nuestro ejemplo, consideraremos la función Agregar medida. Para implementar la función, necesitamos comprender el concepto de "servicio de aplicación" ( ApplicationService).



Un servicio de aplicación es un cliente directo del modelo de dominio. Los servicios de aplicación garantizan las transacciones cuando se utiliza la base de datos ACID, lo que garantiza que las transiciones de estado se conserven atómicas. Además, los servicios de aplicaciones también abordan cuestiones de seguridad.



Ya tenemos una unidadInventory... Para implementar la función completa, usaremos el servicio de la aplicación por completo. En él, debe verificar la presencia de todas las entidades conectadas, así como los derechos de acceso del usuario. Solo después de que se cumplan todas las condiciones, es posible guardar el estado actual de la unidad y enviar eventos al mundo exterior. Para implementar un servicio de aplicación, utilizamos MediatR.



Código de función "agregar medición"
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");
            }
    }
}




Abastecimiento de eventos



Durante la implementación, decidimos elegir el enfoque ES por varias razones:



  • Dodo tiene ejemplos del uso exitoso de este enfoque.

  • ES facilita la comprensión del problema durante un incidente: todas las acciones del usuario se almacenan.

  • Si adopta el enfoque tradicional, no podrá pasar a ES.



La idea de implementación es bastante simple: agregamos todos los eventos nuevos que aparecieron como resultado de los comandos a la base de datos. Para restaurar el agregado, obtenemos todos los eventos y los reproducimos en la instancia. Para no obtener un gran lote de eventos cada vez, eliminamos los estados cada N eventos y reproducimos el resto de esta instantánea.



ID de tienda global de inventario
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; }
}




Después de varios meses de funcionamiento, nos dimos cuenta de que no tenemos una gran necesidad de almacenar todas las acciones del usuario en la instancia de la unidad. La empresa no utiliza esta información de ninguna manera. Dicho esto, hay una sobrecarga para mantener este enfoque. Habiendo evaluado todos los pros y contras, planeamos pasar de ES al enfoque tradicional: reemplazar el letrero Eventscon Inventoriesy Measurements.



Integración con contextos delimitados externos



Este es el esquema de interacción de un contexto limitado Inventorycon el mundo exterior.





Interacción del contexto de revisión con otros contextos. El diagrama muestra contextos, servicios y su pertenencia entre sí.



En el caso de Auth, Inventoryy Datacatalog, hay un contexto limitado para cada servicio. El monolito realiza varias funciones, pero ahora solo nos interesa la funcionalidad contable en las pizzerías. Además de las revisiones, la contabilidad también incluye el movimiento de materias primas en las pizzerías: recibos, transferencias, cancelaciones.



HTTP



El servicio Inventoryinteractúa con AuthHTTP. En primer lugar, se enfrenta al usuario Auth, lo que le pide que elija uno de los roles disponibles para él.



  • El sistema tiene un rol de "auditor", que el usuario elige durante la auditoría.

  • .

  • .



En la última etapa, el usuario tiene un token de Auth. El servicio de revisión debe validar este token, por lo que solicita la Authvalidación. Authcomprobará si la vida útil del token ha expirado, si pertenece al propietario o si tiene los derechos de acceso necesarios. Si todo está bien, Inventoryguarda los sellos en las cookies: ID de usuario, inicio de sesión, ID de pizzería y establece la duración de la cookie.



Nota . AuthDescribimos con más detalle cómo funciona el servicio en el artículo " Sutilezas de la autorización: una descripción general de la tecnología OAuth 2.0 ". Interactúa con



otros servicios a Inventorytravés de colas de mensajes. La empresa utiliza RabbitMQ como intermediario de mensajes, así como el enlace anterior: MassTransit.



RMQ: Eventos de consumo



El servicio de directorio - Datacatalog- proporcionará Inventorytodas las entidades necesarias: materias primas para la contabilidad, países, divisiones y pizzerías.



Sin entrar en detalles de la infraestructura, describiré la idea básica de consumir eventos. Por el lado del servicio de directorio, todo ya está listo para publicar eventos, veamos el ejemplo de la entidad materia prima.



Código de contrato de evento de catálogo de datos
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 publicación está publicada en exchange. Cada servicio puede crear su propio paquete exchange-queuepara consumir eventos.





Esquema de publicación de un evento y su consumo a través de las primitivas RMQ.



En última instancia, existe una cola para cada entidad a la que se puede suscribir el servicio. Todo lo que queda es guardar la nueva versión en la base de datos.



Código de consumidor de eventos de 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: publicación de eventos



La parte de contabilidad del monolito consume datos Inventorypara respaldar el resto de la funcionalidad, donde se requieren datos de revisión. Todos los eventos sobre los que queremos notificar a otros servicios, los marcamos con la interfaz IPublicInventoryEvent. Cuando ocurre un evento de este tipo, los aislamos del changelog ( changes) y los enviamos a la cola de despacho. Para ello, se utilizan dos tablas publicqueuey publicqueue_archive.



Para garantizar la entrega de mensajes, utilizamos un patrón que solemos llamar "cola local", lo que implica Transactional outbox pattern. Guardar el estado del agregado Inventoryy enviar eventos a la cola local ocurre en una transacción. Tan pronto como se confirma la transacción, intentamos enviar mensajes al corredor de inmediato.



Si el mensaje se envió, se elimina de la cola publicqueue. De lo contrario, se intentará enviar el mensaje más tarde. Los suscriptores del monolito y las canalizaciones de datos luego consumen los mensajes. La tabla publicqueue_archivealmacena datos para siempre para un reenvío conveniente de eventos si es necesario en algún momento.



Código para publicar eventos en el intermediario de mensajes
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 al monolito para informes. El informe de pérdidas y excedentes le permite comparar dos revisiones entre sí. Además, hay un informe importante "saldos de almacén", que ya se mencionó anteriormente. 



¿Por qué enviar eventos a la canalización de datos? De todos modos, para informes, pero solo en rieles nuevos. Anteriormente, todos los informes vivían en un monolito, pero ahora se eliminan. Esto comparte dos responsabilidades: almacenamiento y procesamiento de datos analíticos y de producción: OLTP y OLAP. Esto es importante tanto en términos de infraestructura como de desarrollo.



Conclusión



Siguiendo los principios y prácticas del diseño basado en dominios, hemos podido construir un sistema confiable y flexible que satisface las necesidades comerciales de los usuarios. No solo obtuvimos un producto decente, sino también un buen código que es fácil de modificar. Esperamos que en sus proyectos haya un lugar para utilizar el diseño basado en dominios.



Puede encontrar más información sobre DDD en nuestra comunidad DDDevotion y en el canal de Youtube de DDDevotion . Puede discutir el artículo en Telegram en el chat de Dodo Engineering .



All Articles