Releases: oskardudycz/EventSourcing.NetCore
Fixed serialisation issue in samples using Newtonsoft Json.NET
It seems that during the .NET upgrade, I broke Newtonsoft Json.NET serialisation. I removed the usage of JsonConstructor
and NuGet that extended Json.NET with private constructors. Unfortunately, the test suite was not strong enough. That was strengthened up in this release.
I added ContractResolver
which enables private non-default constructors.
Details
- Added
JsonObjectContractProvider
that resolves Json object constructor, to support non-default, private constructor by default. - Moved JSONserialisationn to dedicated project to be able to not have a dependency on Core in the Test project,
- Added default
JsonSerializerSettings
and used in Newtonsoft related projects, - Added
NonDefaultConstructorContractResolver
andNonDefaultConstructorMartenJsonNetContractResolver
that useJsonObjectContractProvider
internally - Added missing tests for Cart Confirmation and Adding Product in Marten ECommerce Sample.
- Updated Requests classes to records in Marten ECommerce Sample.
Note: JsonObjectContractProvider
will be added to Marten in the follow-up PR.
Event Pipelines samples
Event Pipelines
Shows how to compose event handlers in the processing pipelines to:
- filter events,
- transform them,
- NOT requiring marker interfaces for events,
- NOT requiring marker interfaces for handlers,
- enables composition through regular functions,
- allows using interfaces and classes if you want to,
- can be used with Dependency Injection, but also without through builder,
- integrates with MediatR if you want to.
Overview
Having UserAdded event:
public record UserAdded(
string FirstName,
string LastName,
bool IsAdmin
);
We may want to create a pipeline, that will at first filter admin users:
public static bool IsAdmin(UserAdded @event) =>
@event.IsAdmin;
Then map events to a dedicated AdminAdded
event:
public record AdminAdded(
string FirstName,
string LastName
);
public static AdminAdded ToAdminAdded(UserAdded @event) =>
new(@event.FirstName, @event.LastName);
Then handle mapped events storing information about new admins:
public static void Handle(AdminAdded @event) =>
GlobalAdmins.Add(@event);
And distribute global admins to all tenants:
public static List<AdminGrantedInTenant> SendToTenants(UserAdded @event) =>
TenantNames
.Select(tenantName =>
new AdminGrantedInTenant(@event.FirstName, @event.LastName, tenantName)
)
.ToList();
public record AdminGrantedInTenant(
string FirstName,
string LastName,
string TenantName
);
public static void Handle(AdminGrantedInTenant @event) =>
AdminsInTenants.Add(@event);
}
MediatR is great, but it doesn't enable such advanced pipelines. This sample shows how to construct event pipelines seamlessly. See EventBus implementation.
You can use it with Dependency Injection
serviceCollection
.AddEventBus()
.Filter<UserAdded>(AdminPipeline.IsAdmin)
.Transform<UserAdded, AdminAdded>(AdminPipeline.ToAdminAdded)
.Handle<AdminAdded>(AdminPipeline.Handle)
.Transform<UserAdded, List<AdminGrantedInTenant>>(AdminPipeline.SendToTenants)
.Handle<AdminGrantedInTenant>(AdminPipeline.Handle);
or without:
var builder = EventHandlersBuilder
.Setup()
.Filter<UserAdded>(AdminPipeline.IsAdmin)
.Transform<UserAdded, AdminAdded>(AdminPipeline.ToAdminAdded)
.Handle<AdminAdded>(AdminPipeline.Handle)
.Transform<UserAdded, List<AdminGrantedInTenant>>(AdminPipeline.SendToTenants)
.Handle<AdminGrantedInTenant>(AdminPipeline.Handle);
var eventBus = new EventBus(builder);
Samples
Check different ways of defining and integrating Event Handlers:
- Pure functions with Builder
- Pure functions with Dependency Injection
- Classes with Builder
- Classes with Dependency Injection
And how to integrate with MediatR:
Initial .NET 6 upgrade
- Updated projects to .NET 6,
- Updated packages to the latest versions,
- Migrated to file-scoped namespaces.
More to come in the follow-up pull-request(s).
See details in: #82
Updated Simple Event Sourcing with EventStoreDB sample
- Added
Default
object for ShoppingCart to get rid of nullability and forced not nulls with an exclamation mark, - Replaced EventStoreDBRepository with extension methods, as there is no expectation of having other implementation.
- Added
CommandHandlersBuilder
to simplify the command handlers registration. - Removed underscores from stream category name to not be misleading with dashes.
- Added
Closed
shopping cart status as bit flag ofConfirmed
andCancelled
to simplify the check in the business logic. - Removed redundant Shopping Cart status in the
ShoppingCartInitialized
event (it was always the same).
Thanks, @bartelink for the feedback and suggestions!
Upgraded Marten to v4 and all other packages
See more in PR: #74.
Simple, practical EventSourcing with EventStoreDB and EntityFramework
Simple, practical EventSourcing with EventStoreDB and EntityFramework
The PR is adding a new sample that contains the simple Event Sourcing setup with EventStoreDB. For the Read Model, Postgres and Entity Framework are used.
You can watch the webinar on YouTube where I'm explaining the details of the implementation:
or read the article explaining the read model part: "How to build event-driven projections with Entity Framework"
Main assumptions:
- explain basics of Event Sourcing, both from the write model (EventStoreDB) and read model part (Postgres and EntityFramework),
- CQRS architecture sliced by business features, keeping code that changes together at the same place. Read more in How to slice the codebase effectively?
- no aggregates, just data (records) and functions,
- clean, composable (pure) functions for command, events, projections, query handling instead of marker interfaces (the only one used internally is
IEventHandler
). Thanks to that testability and easier maintenance. - easy to use and self-explanatory fluent API for registering commands and projections with possible fallbacks,
- registering everything into regular DI containers to integrate with other application services.
- pushing the type/signature enforcement on edge, so when plugging to DI.
Overview
It uses:
- pure data entities, functions and handlers,
- Stores events from the command handler result EventStoreDB,
- Builds read models using Subscription to
$all
. - Read models are stored to Postgres relational tables with Entity Framework.
- App has Swagger and predefined docker-compose to run and play with samples.
Write Model
- Sample ShoppingCart entity and events represent the business workflow. All are stored in the same file to be able to understand flow without jumping from one file to another. It also contains When method defining how to apply events to get the entity state. It uses the C#9 switch syntax with records deconstruction.
- Example ProductItemsList value object wrapping the list of product items in the shopping carts. It simplified the main state apply logic and offloaded some of the invariants checks.
- All commands by convention should be created using the factory method to enforce the types,
- Command handlers are defined as static methods in the same file as command definition. Usually, they change together. They are pure functions that take command and/or state and create new events based on the business logic. See sample Adding Product Item to ShoppingCart. This example also shows that you can inject external services to handlers if needed.
- Added syntax for self-documenting command handlers registration. See the details of registration in CommandHandlerExtensions. They differentiate case when a new entity/stream is created from the update case. Update has to support optimistic concurrency.
- Added simple EventStoreDBRepository repository to load entity state and store event created by business logic,
- New, simplified Core infrastructure
Read Model
- Read models are rebuilt with eventual consistency using subscribe to $all stream EventStoreDB feature,
- Used Entity Framework to store projection data into Postgres tables,
- Added sample projection for Shopping cart details and slimmed Shopping cart short info as an example of different interpretations of the same events. Shopping cart details also contain a nested collection of product items to show more advanced use case. All event handling is done by functions. It enables easier unit and integration testing.
- Added syntax for self-documenting projection handlers registration. See the details of registration in EntityFrameworkProjectionBuilder. They differentiate case when a new read model is created from the update case. Update has to support optimistic concurrency.
- example query handlers for reading data together with registration helpers for EntityFramework querying.
- Added service EventStoreDBSubscriptionToAll to handle subscribing to all. It handles checkpointing and simple retries when the connection is dropped. Added also general BackgroundWorker to wrap the general
IHostedService
handling - Added ISubscriptionCheckpointRepository for handling Subscription checkpointing.
- Added checkpointing to EventStoreDB stream with EventStoreDBSubscriptionCheckpointRepository,
- Added custom EventBus implementation to not take an additional dependency on external frameworks like MediatR. It's not needed as no advanced pipelining is used here.
Tests
API integration tests for:
- Initiating shopping cart as an example of creating a new entity,
- Confirming shopping cart as an example of updating an existing entity,
Prerequisities
- Install git - https://git-scm.com/downloads.
- Install .NET Core 5.0 - https://dotnet.microsoft.com/download/dotnet/5.0.
- Install Visual Studio 2019, Rider or VSCode.
- Install docker - https://docs.docker.com/docker-for-windows/install/.
- Open
ECommerce.sln
solution.
Running
- Go to docker and run:
docker-compose up
. - Wait until all dockers got are downloaded and running.
- You should automatically get:
- EventStoreDB UI (for event store): http://localhost:2113/
- Postgres DB running (for read models)
- PG Admin - IDE for postgres. Available at: http://localhost:5050.
- Login:
admin@pgadmin.org
, Password:admin
- To connect to server Use host:
postgres
, user:postgres
, password:Password12!
- Login:
- Open, build and run
ECommerce.sln
solution.- Swagger should be available at: http://localhost:5000/index.html
Configure API acceptance tests and test results reporting in GitHub Actions
- Added GitHub action test logger.
- Configured acceptance tests with docker running behind it.
- Refactored docker-compose configuration.
- Updated acceptance tests to be stable in the pipeline.
Besides that applied small refactorings and upgraded Marten to rc3
.
See details in: #57.
Added short descriptions and links to all samples in README
Restructured README and added short descriptions and links to all samples.
Removed Bank Accounts samples
Removed Bank Accounts example as it's far from:
- the real-world handling in the banking domain (it's oversimplified),
- it's lacking the proper description, so some of the assumptions used (like not using repositories, etc. to show the simplest flow) may be misleading and target to the wrong direction,
- showing that you can if you want to store more than one aggregate is okay if it's explained. Without explanation, it may be wrongly interpreted that's the right approach.
- there is already another, better ECommerce sample that's showing how to integrate EventSourcing with the traditional approach.
Refactored domain structure into slices (feature folders)
Currently, samples modules were split by aggregate, but then into technical split like Events
, Commands
etc.
Inspired by my latest samples of simple CQRS I decided to split also other samples by feature folders.
Now Domain is split by the business operations (command and queries). Inside the folders are both contracts and handlers.
Sample structure:
Commands
Command folders contain:
- file with command and handler, e.g.
Carts/AddingProduct/AddProduct.cs
using System;
using System.Threading;
using System.Threading.Tasks;
using Ardalis.GuardClauses;
using Carts.Carts.Products;
using Carts.Pricing;
using Core.Commands;
using Core.Repositories;
using MediatR;
namespace Carts.Carts.AddingProduct
{
public class AddProduct: ICommand
{
public Guid CartId { get; }
public ProductItem ProductItem { get; }
private AddProduct(Guid cartId, ProductItem productItem)
{
CartId = cartId;
ProductItem = productItem;
}
public static AddProduct Create(Guid cartId, ProductItem productItem)
{
Guard.Against.Default(cartId, nameof(cartId));
Guard.Against.Null(productItem, nameof(productItem));
return new AddProduct(cartId, productItem);
}
}
internal class HandleAddProduct:
ICommandHandler<AddProduct>
{
private readonly IRepository<Cart> cartRepository;
private readonly IProductPriceCalculator productPriceCalculator;
public HandleAddProduct(
IRepository<Cart> cartRepository,
IProductPriceCalculator productPriceCalculator
)
{
this.cartRepository = cartRepository;
this.productPriceCalculator = productPriceCalculator;
}
public Task<Unit> Handle(AddProduct command, CancellationToken cancellationToken)
{
return cartRepository.GetAndUpdate(
command.CartId,
cart => cart.AddProduct(productPriceCalculator, command.ProductItem),
cancellationToken);
}
}
}
- file with an event command is creation, e.g.
Carts/AddingProduct/ProductAdded.cs
using System;
using Ardalis.GuardClauses;
using Carts.Carts.Products;
using Core.Events;
namespace Carts.Carts.AddingProduct
{
public class ProductAdded: IEvent
{
public Guid CartId { get; }
public PricedProductItem ProductItem { get; }
private ProductAdded(Guid cartId, PricedProductItem productItem)
{
CartId = cartId;
ProductItem = productItem;
}
public static ProductAdded Create(Guid cartId, PricedProductItem productItem)
{
Guard.Against.Default(cartId, nameof(cartId));
Guard.Against.Null(productItem, nameof(productItem));
return new ProductAdded(cartId, productItem);
}
}
}
Queries
Query folders contain:
- query with handler, e.g.
Carts/GettingCartById/GetCartById.cs
using System;
using System.Threading;
using System.Threading.Tasks;
using Ardalis.GuardClauses;
using Core.Queries;
using Marten;
namespace Carts.Carts.GettingCartById
{
public class GetCartById : IQuery<CartDetails>
{
public Guid CartId { get; }
private GetCartById(Guid cartId)
{
CartId = cartId;
}
public static GetCartById Create(Guid cartId)
{
Guard.Against.Default(cartId, nameof(cartId));
return new GetCartById(cartId);
}
}
internal class HandleGetCartById :
IQueryHandler<GetCartById, CartDetails?>
{
private readonly IDocumentSession querySession;
public HandleGetCartById(IDocumentSession querySession)
{
this.querySession = querySession;
}
public Task<CartDetails?> Handle(GetCartById request, CancellationToken cancellationToken)
{
return querySession.LoadAsync<CartDetails>(request.CartId, cancellationToken);
}
}
}
- read model with projection, e.g.
Carts/GettingCartById/CartDetails.cs
using System;
using System.Collections.Generic;
using System.Linq;
using Carts.Carts.AddingProduct;
using Carts.Carts.ConfirmingCart;
using Carts.Carts.InitializingCart;
using Carts.Carts.Products;
using Carts.Carts.RemovingProduct;
using Core.Extensions;
using Marten.Events.Aggregation;
namespace Carts.Carts.GettingCartById
{
public class CartDetails
{
public Guid Id { get; set; }
public Guid ClientId { get; set; }
public CartStatus Status { get; set; }
public IList<PricedProductItem> ProductItems { get; set; } = default!;
public decimal TotalPrice => ProductItems.Sum(pi => pi.TotalPrice);
public int Version { get; set; }
public void Apply(CartInitialized @event)
{
Version++;
Id = @event.CartId;
ClientId = @event.ClientId;
ProductItems = new List<PricedProductItem>();
Status = @event.CartStatus;
}
public void Apply(ProductAdded @event)
{
Version++;
var newProductItem = @event.ProductItem;
var existingProductItem = FindProductItemMatchingWith(newProductItem);
if (existingProductItem is null)
{
ProductItems.Add(newProductItem);
return;
}
ProductItems.Replace(
existingProductItem,
existingProductItem.MergeWith(newProductItem)
);
}
public void Apply(ProductRemoved @event)
{
Version++;
var productItemToBeRemoved = @event.ProductItem;
var existingProductItem = FindProductItemMatchingWith(@event.ProductItem);
if(existingProductItem == null)
return;
if (existingProductItem.HasTheSameQuantity(productItemToBeRemoved))
{
ProductItems.Remove(existingProductItem);
return;
}
ProductItems.Replace(
existingProductItem,
existingProductItem.Substract(productItemToBeRemoved)
);
}
public void Apply(CartConfirmed @event)
{
Version++;
Status = CartStatus.Confirmed;
}
private PricedProductItem? FindProductItemMatchingWith(PricedProductItem productItem)
{
return ProductItems
.SingleOrDefault(pi => pi.MatchesProductAndPrice(productItem));
}
}
public class CartDetailsProjection : AggregateProjection<CartDetails>
{
public CartDetailsProjection()
{
ProjectEvent<CartInitialized>((item, @event) => item.Apply(@event));
ProjectEvent<ProductAdded>((item, @event) => item.Apply(@event));
ProjectEvent<ProductRemoved>((item, @event) => item.Apply(@event));
ProjectEvent<CartConfirmed>((item, @event) => item.Apply(@event));
}
}
}