Bot releases are visible (Hide)
Published by oskardudycz over 2 years ago
Event Sourcing is perceived as a complex pattern. Some believe that it's like Nessie, everyone's heard about it, but rarely seen it. In fact, Event Sourcing is a pretty practical and straightforward concept. It helps build predictable applications closer to business. Nowadays, storage is cheap, and information is priceless. In Event Sourcing, no data is lost.
The workshop aims to build the knowledge of the general concept and its related patterns for the participants. The acquired knowledge will allow for the conscious design of architectural solutions and the analysis of associated risks.
The emphasis will be on a pragmatic understanding of architectures and applying it in practice using Marten and EventStoreDB.
You can do the workshop as a self-paced kit. That should give you a good foundation for starting your journey with Event Sourcing and learning tools like Marten and EventStoreDB. If you'd like to get full coverage with all nuances of the private workshop, feel free to contact me via email.
Published by oskardudycz over 2 years ago
Fixed Kafka integration to correctly publish and get messages:
landoop/kafka-topics-ui
to landoop/kafka-ui
.See details in https://github.com/oskardudycz/EventSourcing.NetCore/pull/120.
Besides that:
See details in https://github.com/oskardudycz/EventSourcing.NetCore/pull/122.
Published by oskardudycz over 2 years ago
aggregateId
part of streamId
to properly handle EventStoreDB category projections: https://github.com/oskardudycz/EventSourcing.NetCore/pull/117
Published by oskardudycz over 2 years ago
Implemented correlation id and causation id for tracing operations fully.
Refactored previously existing CorrelationIdMiddleware
by adding causation id handling and renamed it to more general TracingMiddleware. It takes correlation id and causation id from HTTP headers and caches them into TracingScope.
TracingScope is also setting logging scope internally to keep Correlation id and causation id in logs. Thanks to that, the logic for tracing setup can be reused in middleware and subscriptions. The new causation id is generated based on the event id in subscriptions. Thanks to that, we can build the tree with a history of event handling.
Updated Optimistic Concurrency Scopes and generalised into AppendScope. It wraps both tracing and expected version handling. Pushed tracing metadata into Marten and EventStoreDB storage.
Replaced custom EventBus instead of Mediator one to have more flexibility (e.g. be able to create new DI and logging scopes).
See more in PRs: #106 and #108.
Published by oskardudycz over 2 years ago
Added Marten Outbox Pattern/Subscription with custom Projection Plugged it into samples removing calling EventBus from the repository, by that getting at-least-once processing guarantee.
Read more about that in: https://event-driven.io/en/integrating_Marten/.
Other changes:
See more in PR: https://github.com/oskardudycz/EventSourcing.NetCore/pull/104
Published by oskardudycz over 2 years ago
Removed not working correctly Github Action with Test Results.
Besides that:
Published by oskardudycz over 2 years ago
Added optimistic concurrency to samples and did a huge all-around refactoring.
The most significant changes:
See more in: https://github.com/oskardudycz/EventSourcing.NetCore/pull/100.
Published by oskardudycz over 2 years ago
Upgraded packages to the latest version
Upgraded also ESDB docker images to the latest LTS version.
Published by oskardudycz over 2 years ago
Strongly typed ids (or, in general, a proper type system) can make your code more predictable. It reduces the chance of trivial mistakes, like accidentally changing parameters order of the same primitive type.
So for such code:
var reservationId = "RES/01";
var seatId = "SEAT/22";
var customerId = "CUS/291";
var reservation = new ReservationId (
reservationId,
seatId,
customerId
);
the compiler won't catch if you switch reservationId
with seatId
.
If you use strongly typed ids, then compile will catch that issue:
var reservationId = new ReservationId("RES/01");
var seatId = new SeatId("SEAT/22");
var customerId = new CustomerId("CUS/291");
var reservation = new ReservationId (
reservationId,
seatId,
customerId
);
They're not ideal, as they're usually not playing well with the storage engines. Typical issues are: serialisation, Linq queries, etc. For some cases they may be just overkill. You need to pick your poison.
To reduce tedious, copy/paste code, it's worth defining a strongly-typed id base class, like:
public class StronglyTypedValue<T>: IEquatable<StronglyTypedValue<T>> where T: IComparable<T>
{
public T Value { get; }
public StronglyTypedValue(T value)
{
Value = value;
}
public bool Equals(StronglyTypedValue<T>? other)
{
if (ReferenceEquals(null, other)) return false;
if (ReferenceEquals(this, other)) return true;
return EqualityComparer<T>.Default.Equals(Value, other.Value);
}
public override bool Equals(object? obj)
{
if (ReferenceEquals(null, obj)) return false;
if (ReferenceEquals(this, obj)) return true;
if (obj.GetType() != this.GetType()) return false;
return Equals((StronglyTypedValue<T>)obj);
}
public override int GetHashCode()
{
return EqualityComparer<T>.Default.GetHashCode(Value);
}
public static bool operator ==(StronglyTypedValue<T>? left, StronglyTypedValue<T>? right)
{
return Equals(left, right);
}
public static bool operator !=(StronglyTypedValue<T>? left, StronglyTypedValue<T>? right)
{
return !Equals(left, right);
}
}
Then you can define specific id class as:
public class ReservationId: StronglyTypedValue<Guid>
{
public ReservationId(Guid value) : base(value)
{
}
}
You can even add additional rules:
public class ReservationNumber: StronglyTypedValue<string>
{
public ReservationNumber(string value) : base(value)
{
if (string.IsNullOrEmpty(value) || value.StartsWith("RES/") || value.Length <= 4)
throw new ArgumentOutOfRangeException(nameof(value));
}
}
The base class working with Marten, can be defined as:
public abstract class Aggregate<TKey, T>
where TKey: StronglyTypedValue<T>
where T : IComparable<T>
{
public TKey Id { get; set; } = default!;
[Identity]
public T AggregateId {
get => Id.Value;
set {}
}
public int Version { get; protected set; }
[JsonIgnore] private readonly Queue<object> uncommittedEvents = new();
public object[] DequeueUncommittedEvents()
{
var dequeuedEvents = uncommittedEvents.ToArray();
uncommittedEvents.Clear();
return dequeuedEvents;
}
protected void Enqueue(object @event)
{
uncommittedEvents.Enqueue(@event);
}
}
Marten requires the id with public setter and getter of string
or Guid
. We used the trick and added AggregateId
with a strongly-typed backing field. We also informed Marten of the Identity attribute to use this field in its internals.
Example aggregate can look like:
public class Reservation : Aggregate<ReservationId, Guid>
{
public CustomerId CustomerId { get; private set; } = default!;
public SeatId SeatId { get; private set; } = default!;
public ReservationNumber Number { get; private set; } = default!;
public ReservationStatus Status { get; private set; }
public static Reservation CreateTentative(
SeatId seatId,
CustomerId customerId)
{
return new Reservation(
new ReservationId(Guid.NewGuid()),
seatId,
customerId,
new ReservationNumber(Guid.NewGuid().ToString())
);
}
// (...)
}
See the full sample here.
Read more in the article:
Published by oskardudycz almost 3 years ago
Shows how to handle basic event schema versioning scenarios using event and stream transformations (e.g. upcasting):
See the whole sample at: https://github.com/oskardudycz/EventSourcing.NetCore/tree/main/Sample/EventsVersioning
And details of changes in: https://github.com/oskardudycz/EventSourcing.NetCore/pull/75
Published by oskardudycz almost 3 years ago
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.
JsonObjectContractProvider
that resolves Json object constructor, to support non-default, private constructor by default.JsonSerializerSettings
and used in Newtonsoft related projects,NonDefaultConstructorContractResolver
and NonDefaultConstructorMartenJsonNetContractResolver
that use JsonObjectContractProvider
internallyNote: JsonObjectContractProvider
will be added to Marten in the follow-up PR.
Published by oskardudycz almost 3 years ago
Shows how to compose event handlers in the processing pipelines to:
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);
Check different ways of defining and integrating Event Handlers:
And how to integrate with MediatR:
Published by oskardudycz almost 3 years ago
More to come in the follow-up pull-request(s).
See details in: https://github.com/oskardudycz/EventSourcing.NetCore/pull/82
Published by oskardudycz almost 3 years ago
Default
object for ShoppingCart to get rid of nullability and forced not nulls with an exclamation mark,CommandHandlersBuilder
to simplify the command handlers registration.Closed
shopping cart status as bit flag of Confirmed
and Cancelled
to simplify the check in the business logic.ShoppingCartInitialized
event (it was always the same).Thanks, @bartelink for the feedback and suggestions!
Published by oskardudycz about 3 years ago
See more in PR: https://github.com/oskardudycz/EventSourcing.NetCore/pull/74.
Published by oskardudycz about 3 years ago
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"
IEventHandler
). Thanks to that testability and easier maintenance.It uses:
$all
.IHostedService
handlingAPI integration tests for:
ECommerce.sln
solution.docker-compose up
.[email protected]
, Password: admin
postgres
, user: postgres
, password: Password12!
ECommerce.sln
solution.
Published by oskardudycz about 3 years ago
Besides that applied small refactorings and upgraded Marten to rc3
.
See details in: https://github.com/oskardudycz/EventSourcing.NetCore/pull/57.
Published by oskardudycz over 3 years ago
Restructured README and added short descriptions and links to all samples.
Published by oskardudycz over 3 years ago
Removed Bank Accounts example as it's far from:
Published by oskardudycz over 3 years ago
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.
Command folders contain:
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);
}
}
}
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);
}
}
}
Query folders contain:
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);
}
}
}
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));
}
}
}