Skip to content

Releases: oskardudycz/EventSourcing.NetCore

Fixed serialisation issue in samples using Newtonsoft Json.NET

03 Dec 18:31
Compare
Choose a tag to compare

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 and NonDefaultConstructorMartenJsonNetContractResolver that use JsonObjectContractProvider 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

25 Nov 08:37
25b6147
Compare
Choose a tag to compare

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:

And how to integrate with MediatR:

Initial .NET 6 upgrade

17 Nov 12:13
Compare
Choose a tag to compare
  • 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

02 Nov 18:33
Compare
Choose a tag to compare
  1. Added Default object for ShoppingCart to get rid of nullability and forced not nulls with an exclamation mark,
  2. Replaced EventStoreDBRepository with extension methods, as there is no expectation of having other implementation.
  3. Added CommandHandlersBuilder to simplify the command handlers registration.
  4. Removed underscores from stream category name to not be misleading with dashes.
  5. Added Closed shopping cart status as bit flag of Confirmed and Cancelled to simplify the check in the business logic.
  6. 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

18 Oct 08:09
Compare
Choose a tag to compare

Simple, practical EventSourcing with EventStoreDB and EntityFramework

02 Oct 15:38
Compare
Choose a tag to compare

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:

Practical introduction to Event Sourcing with EventStoreDB

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

Read Model

Tests

API integration tests for:

Prerequisities

  1. Install git - https://git-scm.com/downloads.
  2. Install .NET Core 5.0 - https://dotnet.microsoft.com/download/dotnet/5.0.
  3. Install Visual Studio 2019, Rider or VSCode.
  4. Install docker - https://docs.docker.com/docker-for-windows/install/.
  5. Open ECommerce.sln solution.

Running

  1. Go to docker and run: docker-compose up.
  2. Wait until all dockers got are downloaded and running.
  3. 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!
  4. Open, build and run ECommerce.sln solution.

Configure API acceptance tests and test results reporting in GitHub Actions

20 Jul 19:18
Compare
Choose a tag to compare
  1. Added GitHub action test logger.
  2. Configured acceptance tests with docker running behind it.
  3. Refactored docker-compose configuration.
  4. 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

04 Jul 19:10
Compare
Choose a tag to compare

Restructured README and added short descriptions and links to all samples.

Removed Bank Accounts samples

04 Jul 18:17
Compare
Choose a tag to compare

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)

04 Jul 17:14
Compare
Choose a tag to compare

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:

obraz

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));
        }
    }
}