This content originally appeared on DEV Community and was authored by Isaac Ojeda
Introducción
En esta entrada veremos otro tema muy interesante dentro del mundo del Domain Driven Design, los Domain Events.
En este pequeño resumen de DDD y Domain Events, veremos para que nos sirven, cómo debemos implementarlos y sus diferencias con los integration events.
La implementación se hará como siempre, en asp.net core y puedes encontrar el código completo aquí.
Existen muchos conceptos de DDD que para mi siguen siendo un poco ajenos, ya que algunos en proyectos reales aun no los he implementado, aunque DDD tiene bastante tiempo, suele ser un tema complicado y avanzado, pero muchos de sus conceptos son necesarios y podría evaluarse su uso si el proyecto lo requiere.
Te recomiendo que revises proyectos de Clean Architecture como de Ardalis o Jason Taylor, ya que ellos emplean estos conceptos y este post está basado en sos proyectos (junto con el libro de Microsoft).
Domain Events: Diseño e implementación
Haremos uso de Domain Events siempre que nuestro dominio ocasione efectos secundarios al actualizar su estado. Estos efectos secundarios (AKA Side Effects) ocurren en muchas de las funcionalidades que los sistemas necesitan, pero a veces optamos por implementarlos de formas más "lineales".
Lo que quiero decir con esto, es el siguiente ejemplo:
Digamos que nuestro dominio es un Product que tiene n propiedades, pero las reglas de negocio son, que si un nuevo producto es creado en el sistema, se le tiene que avisar a alguien en particular.
Se puede programar de manera muy sencilla la creación del producto e inmediatamente programar la notificación, pero hay varios inconvenientes si siempre se programa así.
Los problemas surgen cuando quieres cambiar esa regla y tal vez agregar otra notificación según otros parámetros, lo que tendrías que hacer es modificar la misma rutina que crea un producto y agregar el nuevo requerimiento. Ocasionando que estén fuertemente acopladas las distintas funcionalidades.
Esto rompe con el principio Single Responsibility. Yo pensaría que lo correcto sería tener una función/clase que hiciera la creación de un producto, pero solo eso. Cualquier side effect o reglas de negocio ocasionadas por la actualización del dominio debería de encapsularse en otro lugar.
Esto tiene sus beneficios, como definir las reglas de negocio de forma explícita y la delegación de responsabilidades. Ya que actualizar un producto es una tarea, mandar notificaciones u otras acciones después de eso, son tareas diferentes.
Tener las responsabilidades delegadas hace el código más seguro de modificar, ya que siempre se cambia código en partes pequeñas, que en teoría, es probado independientemente.
¿Qué es un Domain Event?
Un domain event es algo que ha pasado en el domain y queremos que otras partes en el mismo domain (o sea, in-process) estén involucradas y enteradas de lo que acaba de suceder.
Los Domain Events son expresados de forma explícita. Es decir, se tiene que programar el evento que desencadena el domain event.
Dicho de otra forma, al usar domain events es un concepto explícito, porque siempre existirá un DomainEvent
y al menos un DomainEventHandler
. Uno expresa la definición del evento en sí (o sea, lo que acaba de suceder) y otro realiza la tarea según las reglas de negocio lo dicten. Pueden existir varios Handlers para un evento, para esto mismo es este diseño.
Por ejemplo, si tenemos un Producto y las reglas de negocio según el domain expert, son, que cada vez que un producto es dado de alta, se le tiene que avisar al de almacén (por decir un ejemplo) y podría también tener que avisarse al departamento de compras, o registrar una orden con algún proveedor, guardar un registro en la bitácora, etc.
En resumen, los domain events te ayudan a expresar, de forma explícita, las reglas de negocio (domain rules / enterprise rules) tal como es expresado por el domain expert. También, permite una mejor separación de responsabilidades del mismo dominio.
Es importante mencionar que, así como sucede en las transacciones de las bases de datos, todos los eventos desencadenados deben de terminar exitosamente o ninguno debería terminar.
Los Domain Events y sus side effects (Los Event Handlers ) deben de ocurrir casi inmediatamente después del evento, de preferencia in-process, y en el mismo domain model.
A diferencia de los Integration Events que siempre son asíncronos, los Domain Events pueden ser síncronos o asíncronos.
Domain Events vs Integration Events
En términos prácticos, los eventos de integración y de dominio son lo mismo: notificaciones de algo que acaba de suceder. Sin embargo, su implementación es diferente. Domain events son solo mensajes que se mandan a un “event dispatcher”, el cual puede ser implementado in-memory justo como lo hace MediatR (y lo haremos en el código más abajo) o de cualquier forma usando de preferencia un IoC container.
En cambio, el propósito de los integration events, es propagar una acción que se realizó exitosamente a otros subsistemas, ya sean otros microservicios, bounded contexts o incluso, sistemas externos. Pero estos solo suceden cuando se llevó a cabo la transacción exitosamente, es decir, cualquier update del dominio o side effect finalizaron con éxito y es necesario que otros sistemas se enteren de lo sucedido.
Los integration events suelen ser más complicados de implementar, ya que se necesita de infraestructura que sea resiliente y que permita esa comunicación inter-process por medio de la red. Se pueden usar message-brokers, queues, o hasta una base de datos compartida que funcione como mailbox.
Los integration events es un tema muy interesante, que tengo a futuro hablar a detalle de ellos en otro post.
¿Cómo se deben lanzar los domain events?
Existen varias técnicas para determinar como ejecutar estos handlers ocasionados por los domain events. Desde tener clases/métodos estáticos que manden a llamar el evento y ejecutarlo inmediatamente, o guardar una serie de eventos y ejecutarlos antes o después de que la transacción de la base de datos termine.
El approach que seguiremos en este post (y por simplicidad) será una ejecución deferred, es decir, al terminar la transacción de la BD, se ejecutarán todos los eventos registrados (in-process).
Lanzar los eventos antes o después de la transacción de la base de datos es una decisión importante a tomar. Existe la posibilidad que los handlers aun necesiten de la transacción en proceso para realizar alteraciones en el dominio. Si esto falla, todo falla, ya que la consistencia de la información se podría ver comprometida si algo se queda a medias.
Si estos eventos no modifican nada, simplemente realizan otro tipo de acciones, estas pueden llevarse acabo sin ninguna transacción involucrada.
Nota 💡: Puedes revisar este repositorio https://github.com/isaacOjeda/MinimalApiArchitecture que hice hace un par de meses donde se emplean Domain Events dentro de la misma transacción principal.
En la imagen vemos como el mismo Domain Model (o bounded context) tiene dos aggregate roots que se ven relacionados por un evento al crear una Orden.
CQRS - Command Query Responsibility Segregation
Aunque CQRS está fuera del scope principal de este post, quiero mencionar por que es bueno implementarlo a la par con Domain Events y0 deberías de empezar usar librerías como MediatR.
¿Por qué usar CQRS?
CQRS nos permite romper con Data Models gigantescos que usualmente se hacían en aplicaciones N - Capas tradicionales.
Modificar un entity puede involucrar validaciones y business rules que pueden llegar a complicar el acceso a datos. Y lo peor, tener una clase (data model) gigantesca con todas las operaciones que puede llegar a tener un entity. CQRS llega a separar estas responsabilidades en 2 conceptos principales: Queries y Comandos.
Partir el Data Model en Requests permite segregar las responsabilidades, las reglas de negocio y dividir el código según la funcionalidad y no según la cuestión técnica.
Un comando es aquel que altera el estado del dominio (crea o actualiza entities / aggregate roots) y un Queries es aquel que consulta el estado actual de un domain (generando así DTO’s) sin side effects.
Separar en comandos y queries, simplifica el diseño de tu aplicación, ya que un comando es el que puede llegar a tener muchas reglas de negocio y se pueden emplear aquí muchos conceptos de Domain Driven Design.
Se puede usar el Repository Pattern para actualizar el estado del dominio por medio del Aggregate Root, generar domain events y hasta integration events. Pero al consultar información, podemos hacer uso del DbContext
directamente sin ninguna complicación, ya que solo necesitamos ciertas partes de un Entity y no todo el Aggregate Root. Incluso, para consultar información podríamos usar Dapper o Store Procedures, todo depende de lo que se necesite.
En esta imagen vemos que los Queries y Comandos apuntan a una misma base de datos, ya que estamos empleando CQRS como un concepto lógico, pero sin problema (y si se requiere) podemos tener bases de datos replicadas que sean read-only para los Queries y la base de datos principal para los comandos. Esto en sistemas distribuidos y redundantes ayudarían a mejorar el rendimiento al consultar la información. El esquema lógico ya lo tendríamos, solo que emplear las consultas a bases de datos replicadas lo podríamos dejar en otro tema.
Otra razón más por lo que usaremos CQRS es porque para generar eventos y ejecutarlos, usaremos un mediador (MediatR) y este es perfecto también, para ejecutar Requests (sean comandos o queries) o para ejecutar notificaciones (los domain events).
Esto es una pequeño resumen de Domain Events y por que usaremos CQRS, pero realmente es un tema muy extenso. Te recomiendo este libro (este post está totalmente basado en ese libro) para conocer más sobre el tema (todo el libro es útil aunque no hagas microservicios, los conceptos pueden aplicarse a monolíticos sin problema).
Implementación en ASP.NET Core
Para iniciar con el demo, crearemos un proyecto web vacío de la forma que desees (CLI o Visual Studio). Básicamente haremos una versión resumida y más simple del ejemplo que les he compartido más arriba llamado MinimalApiArchitecture. Ese ejemplo está enfocado en emplear un DDD simple utilizando Vertical Slice Architecture (el cual tengo un post sobre eso).
Nota 💡: El código de este ejemplo lo puedes encontrar aquí. Y aquí el template de Vertical Slice Architecture. Dale ⭐ si te agrada el contenido.
La solución tendrá solo un proyecto web con la siguiente estructura de carpetas:
DomainEventsExample/
├─ Domain/
├─ Features/
├─ Persistence/
├─ Services/
Pero antes de comenzar, utilizaremos las siguientes librerías:
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="6.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="6.0.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.2.3" />
<PackageReference Include="Carter" Version="6.0.0" />
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="10.3.4" />
<PackageReference Include="MediatR.Extensions.Microsoft.DependencyInjection" Version="9.0.0" />
- Entity Framework Core: El ORM de siempre, usamos la implementación In Memory por simplicidad
- Swashbuckle: Usaremos Swagger para probar el endpoint
- Carter: Ayuda a registrar los endpoints de Minimal APIs en clases separadas de una forma cool
- FluentValidation: Parte de ella incluida en Carter, la usamos para validar requests
- MediatR: Nos ayuda con la implementación del patrón mediador, eventos, decorador, etc.
Domain
Aquí es donde va lo que ya sabemos según lo hemos aprendido en DDD y Clean Architecture. En este ejemplo solo tendremos Entities y Eventos:
Domain/
├─ Entities/
├─ Events/
├─ DomainEvent.cs
DomainEvent
es una clase base que nos ayuda a predefinir una notificación que MediatR puede manejar. También agregamos una interfaz que funciona como marcador y poder buscar Entities con eventos.
using MediatR;
namespace DomainEventsExample.Domain;
/// <summary>
/// Marker
/// </summary>
public interface IHasDomainEvent
{
public List<DomainEvent> DomainEvents { get; set; }
}
/// <summary>
/// Base event
/// </summary>
public abstract class DomainEvent : INotification
{
protected DomainEvent()
{
DateOccurred = DateTimeOffset.UtcNow;
}
public bool IsPublished { get; set; }
public DateTimeOffset DateOccurred { get; protected set; } = DateTime.UtcNow;
}
Esta interfaz la usaremos en nuestros entities, generalmente en los aggregate roots pero is up to you.
El evento que haremos como ejemplo, será muy sencillo, lo único que hará es notificar “algo” cuando un producto nuevo se haya registrado.
using DomainEventsExample.Domain.Entities;
namespace DomainEventsExample.Domain.Events;
public class ProductCreatedEvent : DomainEvent
{
public ProductCreatedEvent(Product product)
{
Product = product;
}
public Product Product { get; }
}
ProductCreatedEvent
siendo ahora del tipo INotification
, puede ser usado por el IPublisher
de MediatR para lanzar eventos (que haremos más adelante).
El Entity queda definido de esta manera:
using DomainEventsExample.Domain.Events;
namespace DomainEventsExample.Domain.Entities;
public class Product : IHasDomainEvent
{
public Product(int productId, string name, string description, double price)
{
ProductId = productId;
Name = name;
Description = description;
Price = price;
DomainEvents.Add(new ProductCreatedEvent(this));
}
public int ProductId { get; set; }
public string Name { get; private set; }
public string Description { get; private set; }
public double Price { get; private set; }
public List<DomainEvent> DomainEvents { get; set; } = new List<DomainEvent>();
}
Aquí se está obligando ese “contrato” o “business rules” especificando que cada vez que se crea un entity Product, un evento va a existir, sí o sí. Si es regla de negocio, no es opcional.
Como comenté antes, el evento no se manda a llamar inmediatamente, solamente al crear una instancia de Product se está definiendo la regla a seguir. Eventualmente todos los eventos registrados en los tipos IHasDomainEvent
serán ejecutados, de esto el DbContext
se encargará mas adelante.
Features: Products
Basándonos en el template de MinimalApiArchitecture, crearemos endpoints agrupados por funcionalidad. En este caso, solo tendremos un Query y un Comando del Entity Product.
La estructura y archivos quedarán de la siguiente forma:
Features/
├─ Products/
│ ├─ Commands/
│ │ ├─ CreateProduct.cs
│ ├─ EventHandlers/
│ │ ├─ ProductCreatedNotificationEventHandler.cs
│ ├─ Queries/
│ │ ├─ GetProducts.cs
Como te pudiste dar cuenta, tenemos algo adicional aquí, que son los Event Handlers. Como lo hemos estado hablando, aquí se implementan los Side Effects provocados por el Domain Event.
CreateProduct
Sin abordar a profundidad el tema en el post de Vertical Slice Architecture, la idea general es tener un Request → Handler → Response para ejecutar un endpoint del API:
using Carter;
using Carter.ModelBinding;
using DomainEventsExample.Domain.Entities;
using DomainEventsExample.Persistence;
using FluentValidation;
using MediatR;
namespace DomainEventsExample.Features.Products.Commands;
public class CreateProduct : ICarterModule
{
public void AddRoutes(IEndpointRouteBuilder app)
{
app.MapPost("api/products", async (IMediator mediator, CreateProductCommand command) =>
{
return await mediator.Send(command);
})
.WithName(nameof(CreateProduct))
.WithTags(nameof(Product))
.ProducesValidationProblem()
.Produces(StatusCodes.Status201Created);
}
public class CreateProductCommand : IRequest<IResult>
{
public string Name { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public double Price { get; set; }
}
public class CreateProductHandler : IRequestHandler<CreateProductCommand, IResult>
{
private readonly MyDbContext _context;
private readonly IValidator<CreateProductCommand> _validator;
public CreateProductHandler(MyDbContext context, IValidator<CreateProductCommand> validator)
{
_context = context;
_validator = validator;
}
public async Task<IResult> Handle(CreateProductCommand request, CancellationToken cancellationToken)
{
var result = _validator.Validate(request);
if (!result.IsValid)
{
return Results.ValidationProblem(result.GetValidationProblems());
}
var newProduct = new Product(0, request.Name, request.Description, request.Price);
_context.Products.Add(newProduct);
await _context.SaveChangesAsync();
return Results.Created($"api/products/{newProduct.ProductId}", null);
}
}
public class CreateProductValidator : AbstractValidator<CreateProductCommand>
{
public CreateProductValidator()
{
RuleFor(r => r.Name).NotEmpty();
RuleFor(r => r.Description).NotEmpty();
RuleFor(r => r.Price).NotEmpty();
}
}
}
Este endpoint valida y crea un producto nuevo, todo esto aun no involucra los Side Effects del domain event, lo único que sucede es la creación de un producto y el registro de un nuevo evento (aunque aquí eso no se ve, sucede en el constructor del Producto)
De igual forma, aquí el resumen:
-
AddRoutes
: Este método viene en la interfaz de CarterICarterModule
. Básicamente nos expone elIEndpointRouterBuild
(usualmente usado en Program.cs) para registrar todos los endpoints que se deseen. En este caso lo hacemos por archivo (solo el endpoint Create).- Contiene metadatos para que Swagger interprete bien el módulo de Carter, podemos ver la API bien documentada si usamos estos metoditos para Minimal APIs.
-
CreateProductCommand
: Este es el Request de mi endpoint, tiene la información necesaria para poder crear un producto. -
CreateProductHandler
: El handler de la solicitud, valida y ejecuta la creación del producto -
CreateProductValidator
: FluentValidation nos ayuda a validar las propiedades del request en una forma desacoplada (sin usar Data Annotations).
ProductCreatedNotificationEventHandler
El Event Handler es el que por fin ejecutará lo que la regla de negocio dice que debe de hacer. En este sencillo ejemplo, será notificar a “alguien que debe de saber” que se creó un nuevo producto:
using DomainEventsExample.Domain.Events;
using DomainEventsExample.Services;
using MediatR;
namespace DomainEventsExample.Features.Products.EventHandlers;
/// <summary>
/// "Notifica" por correo avisando del nuevo producto
/// </summary>
public class ProductCreatedNotificationEventHandler : INotificationHandler<ProductCreatedEvent>
{
private readonly ILogger<ProductCreatedNotificationEventHandler> _logger;
private readonly IEmailSender _emailSender;
public ProductCreatedNotificationEventHandler(
ILogger<ProductCreatedNotificationEventHandler> logger,
IEmailSender emailSender)
{
_logger = logger;
_emailSender = emailSender;
}
public async Task Handle(ProductCreatedEvent notification, CancellationToken cancellationToken)
{
_logger.LogInformation("Nueva notificación: Nuevo producto {Product}", notification.Product);
await _emailSender.SendNotification("random@email.com", "Nuevo Producto", $"Producto {notification.Product.Name}");
}
}
Utiliza INotificationHandler<INotification>
para que MediatR sepa que esta clase es el Handler del evento INotification
. Así cuando MediatR publique el evento, efectivamente relacionará el evento con su handler y lo ejecutará. Puedes leer más en el repositorio de MediatR para aprender más de esta librería.
Realmente aquí no estamos haciendo nada real, solo un ejemplo de que estamos mandando un correo.
Nota💡: Para poder correr este código, visita el repositorio para que veas la implementación de
IEmailSender
(Spoiler alert, no manda correos).
Persistence
La parte de la persistencia es proceso clave para la ejecución de los event handlers, ya que la idea es ejecutarlos una vez que la transacción del DbContext
haya terminado (aunque como se menciona arriba, podría necesitarse lo contrario).
En este caso, una vez que sepamos que los cambios a la base de datos se ejecutaron con éxito, lanzaremos todos los eventos registrados en el dominio. Todo esto dentro del DbContext
:
using DomainEventsExample.Domain;
using DomainEventsExample.Domain.Entities;
using MediatR;
using Microsoft.EntityFrameworkCore;
namespace DomainEventsExample.Persistence;
public class MyDbContext : DbContext
{
private readonly IPublisher _publisher;
private readonly ILogger<MyDbContext> _logger;
public MyDbContext(
DbContextOptions<MyDbContext> options,
IPublisher publisher,
ILogger<MyDbContext> logger) : base(options)
{
_publisher = publisher;
_logger = logger;
}
public DbSet<Product> Products => Set<Product>();
public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
var result = await base.SaveChangesAsync(cancellationToken);
var events = ChangeTracker.Entries<IHasDomainEvent>()
.Select(x => x.Entity.DomainEvents)
.SelectMany(x => x)
.Where(domainEvent => !domainEvent.IsPublished)
.ToArray();
foreach (var @event in events)
{
@event.IsPublished = true;
_logger.LogInformation("New domain event {Event}", @event.GetType().Name);
await _publisher.Publish(@event);
}
return result;
}
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);
builder.Entity<Product>()
.Ignore(x => x.DomainEvents);
}
}
Aquí el resumen:
-
IPublisher
: Es el publicador de eventos que MediatR proporciona -
SaveChangesAsync
: Sobre escribimos este método para agregar esta parte de disparos de eventos previamente registrados.-
ChangeTracker
es una herramienta muy útil, ya que EF Core lleva un registro de todos los entities consultados y modificados. Gracias a esto de forma muy sencilla consultamos todos los entities modificados en el Request actual y buscamos cuales de ellos tienen eventos sin publicar. - Posteriormente, en la posibilidad de que sean uno o más eventos, cada uno se ejecuta individualmente
-
-
OnModelCreating
: Configuramos los entities para que ignoren la propiedad DomainEvents, ya que no queremos que EF Core lo interprete como una tabla relacionada con el entity.
De esta forma, tenemos las siguientes responsabilidades delegadas:
- Creación del Producto y validación de datos de entrada
- Envío de correos a partir de un Domain Event
- Ejecución de los handlers al terminar la transacción
Si queremos más eventos, simplemente agregamos más Handlers y la creación del producto queda intacta.
Wrapping up
Para conectar todo lo que hemos hecho, vamos a configurar todos los servicios en el Program.cs de la siguiente manera:
using Carter;
using DomainEventsExample.Persistence;
using DomainEventsExample.Services;
using FluentValidation;
using MediatR;
using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddCarter();
builder.Services.AddDbContext<MyDbContext>(options => options
.UseInMemoryDatabase(nameof(MyDbContext)));
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.AddMediatR(typeof(Program));
builder.Services.AddValidatorsFromAssemblyContaining(typeof(Program));
builder.Services.AddScoped<IEmailSender, FakeEmailSender>();
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseHttpsRedirection();
app.MapCarter();
app.Run();
Configurando las dependencias de MediatR, Carter, FluentValidation, EF Core y entre otras, ya podremos correr la aplicación y ver como se comportan los Domain Events al crear un Producto.
Nota 💡: No mostré como funciona
FakeEmailSender
, pero es una implementación dummy deIEmailSender
.
Al mandar la siguiente solicitud usando Swagger:
curl -X 'POST' \
'https://localhost:7146/api/products' \
-H 'accept: */*' \
-H 'Content-Type: application/json' \
-d '{
"name": "Producto 01",
"description": "Descripción 01",
"price": 1
}'
Se creará el producto exitosamente, por lo tanto se generará un evento que podemos ver en consola:
info: Microsoft.EntityFrameworkCore.Update[30100]
Saved 1 entities to in-memory store.
info: DomainEventsExample.Persistence.MyDbContext[0]
New domain event ProductCreatedEvent
info: DomainEventsExample.Features.Products.EventHandlers.ProductCreatedNotificationEventHandler[0]
Nueva notificación: Nuevo producto DomainEventsExample.Domain.Entities.Product
info: DomainEventsExample.Services.FakeEmailSender[0]
Mandando correo a random@email.com
info: DomainEventsExample.Services.FakeEmailSender[0]
Body: Producto Producto 01
Conclusión
Los domain events son muy útiles para definir reglas de negocio de una forma explícita y obligatoria. Cuando algo sucede, el domain emite eventos y estos se ejecutan, sea donde sea que se mande a llamar el dominio, se ejecutarán eventos.
Estas reglas no tienes que estarlas cuidando viendo donde hay que ejecutarlas, el dominio las define y el dominio las ejecuta.
Este es solo un simple ejemplo, el libro de ***.NET Microservices: Architecture for Containerized .NET Applications* habla a profundidad de este tema y además cuenta con una aplicación demo en el repo eShopOnContainers. Este repo lo mantienen muy actualizado y es una muy buena referencia para implementar distintos patrones en nuestros servicios web.
Referencias
- Domain events. design and implementation | Microsoft Docs
- Strengthening your domain: Domain Events · Los Techies
- A better domain events pattern · Los Techies
- CQRS pattern - Azure Architecture Center | Microsoft Docs
- Applying simplified CQRS and DDD patterns in a microservice | Microsoft Docs
This content originally appeared on DEV Community and was authored by Isaac Ojeda
Isaac Ojeda | Sciencx (2022-02-05T18:21:54+00:00) DDD & CQRS: Aplicando Domain Events en ASP.NET Core.. Retrieved from https://www.scien.cx/2022/02/05/ddd-cqrs-aplicando-domain-events-en-asp-net-core/
Please log in to upload a file.
There are no updates yet.
Click the Upload button above to add an update.