THIS IS NOT AN EXAMPLE OF HOW YOU SHOULD IMPLEMENT EVENT SOURCING ON PRODUCTION.
The content was generated by long discussions with ChatGPT and additonal changes. It's a starting point for a discussions and exercises for the workshop. It appears (surprise!) that ChatGPT is a great source for the code that looks fine, and close to what you could see in some real-world projects. Yet it brings a significant flaws and mistakes, that might not be easy to spot and will ends up badly on production.
So please, don't copy it, but look for issues like:
Check in my Event Sourcing in .NET Repository or on my blog to find out how to do it the right way.
Imagine you're organizing a Beyonce concert in Warsaw, and you need a system to manage every aspect of the event, from ticket reservations to financial transactions. Our system has you covered with the following modules:
This module takes care of all the behind-the-scenes work that goes into planning the concert. As the organizer, you can create the concert event, set the location (Warsaw), date, and time, and even decide on the different types of tickets available (such as Regular and Golden Circle). You'll also have the power to update or cancel the concert and manage ticket pricing.
In this module, users can collect tickets from multiple concerts in a single shopping cart. Once they're ready to checkout, they can confirm their selection and proceed with the payment. If any issues arise, like a failed payment, the system will handle the necessary compensation process.
Once you have the concert details sorted, fans can start reserving their tickets through this module. They'll be able to choose from the different ticket types you've set up, and the system will make sure there aren't more reservations than available spots.
This module coordinates the whole process of placing an order, including creating orders based on confirmed shopping carts, tracking the order status, and managing any changes or cancellations. It also ensures that the order details, such as ticket reservations and delivery methods, are properly handled.
In this module, the system creates and manages tickets based on user reservations. It takes care of delivering the tickets to users via email or, if they prefer, arranging for printed tickets to be sent through courier services like FedEx.
This module oversees invoicing, tracking user payments, and handling refunds. As the organizer, you'll have a clear view of the financial aspects of the event.
This module handles user registration, authentication, and role management. It enables users to create an account, log in, and perform actions based on their assigned roles, such as regular user, administrator, or concert organizer.
In the case of a Beyonce concert in Warsaw, the system would allow the event organizer to create the concert, set ticket levels and pricing, and manage any updates. Users interested in attending the concert could reserve their tickets, add them to their shopping cart, and complete the payment process. The system would ensure that no more tickets are reserved than available spots and would handle the financial aspects of the transaction, including invoicing and payment processing. Users would then receive their tickets through their chosen delivery method.
Our system is built using a microservices architecture with event-driven design and event sourcing, ensuring scalability and resilience. We have implemented various bounded contexts, such as Concert Management, Reservation, Shopping Cart, Financial Management, and Ticket Management, to segregate the responsibilities and functionalities.
We use C# 11 and Marten as the underlying technology stack for the implementation of commands, events, and aggregates. Marten's support for event sourcing and document storage simplifies the development and maintenance of our system.
Each bounded context consists of aggregates, commands, events, and event handlers. We follow the Command-Query Responsibility Segregation (CQRS) pattern, separating read and write operations. The system relies on integration events to communicate between bounded contexts, allowing for a decoupled architecture.
To send emails, such as ticket delivery, we have integrated the system with Mailgun, an email service provider that offers a powerful API and idempotency support. This ensures that users receive their tickets reliably and securely.
Overall, the architecture is designed to be modular, maintainable, and efficient in handling the various aspects of concert management, ticket reservations, and sales.
The main components of the system's architecture are:
1. Bounded Contexts: The system is divided into multiple bounded contexts, each encapsulating a specific domain within the concert management system. These include the Concert Management, Reservation, ShoppingCart, Finance, and User Management modules. Bounded contexts promote separation of concerns and reduce coupling between different parts of the system.
2. Aggregates: Within each bounded context, aggregates are responsible for enforcing the domain invariants and maintaining the consistency of the business rules. Aggregates are event-sourced, meaning that their state is derived from a sequence of events. This approach provides strong consistency guarantees, auditability, and the ability to rebuild the state of the system at any point in time.
3. Commands and Events: Commands represent actions that can be performed in the system and are processed by command handlers. Events represent the results of these actions and are emitted by aggregates. Command handlers update the state of aggregates and emit events, while event handlers react to events and perform side effects, such as updating other aggregates, sending notifications, or integrating with external systems.
4. Event Store: The system uses an event store to persist and manage the events generated by aggregates. The event store acts as a source of truth for the system's state and enables event sourcing. In our system, we're using Marten as the event store, which provides a robust and scalable solution for storing and querying events.
5. External Integrations: The system integrates with external services, such as payment gateways (e.g., Stripe) to process payments and handle other related functionalities. This integration is done through event-driven communication, which allows for a loosely coupled connection between the system and the external services.
6. CQRS (Command Query Responsibility Segregation): The system follows the CQRS pattern, which separates the read and write operations of the system. This allows for better scalability, as the read and write loads can be optimized and scaled independently.
In summary, the Concert Management System's architecture is modular, event-driven, and event-sourced. It is composed of multiple bounded contexts that encapsulate specific domains, aggregates that enforce business rules and invariants, and an event store that manages the system's state. The system also integrates with external services and follows the CQRS pattern for improved scalability.
graph LR
User((User)) -->|Use| ConcertSystem((Concert Ticketing System))
Organizer((Organizer)) -->|Manage| ConcertSystem
ConcertSystem -->|Send Email| Mailgun((Mailgun))
ConcertSystem -->|Ship Tickets| FedEx((FedEx))
ConcertSystem -->|Process Payments| Stripe((Stripe))
graph LR
A[User Interface] --> B[Reservation Module]
A --> C[Concert Management Module]
A --> D[Shopping Cart Module]
A --> E[Financial Management Module]
A --> F[Ticket Management Module]
A --> G[Order Module]
A --> H[User Management Module]
B --> I[Event Store]
C --> I
D --> I
E --> I
F --> I
G --> I
H --> I
F --> J[Mailgun]
F --> K[FedEx]
E --> L[Stripe]
graph LR
A[User Interface] --> B[Reservation Module]
B --> D[Shopping Cart Module]
D --> E[Financial Management Module]
D --> F[Order Module]
E --> F
F --> G[Ticket Management Module]
G --> H[Ticket Delivery Module]
A --> I[Concert Management Module]
A --> J[User Management Module]
I --> B
J --> B
B --> K[Event Store]
D --> K
E --> K
F --> K
G --> K
H --> K
I --> K
J --> K
H --> L[Mailgun]
H --> M[FedEx]
E --> N[Stripe]
K -.-> B
K -.-> D
K -.-> E
K -.-> F
K -.-> G
K -.-> H
K -.-> I
K -.-> J
D -- ShoppingCartConfirmed --> F
E -- PaymentSucceeded --> F
F -- OrderCreated --> B
F -- OrderCreated --> E
F -- OrderConfirmed --> G
G -- TicketCreated --> H
Sequence diagram with the flow:
sequenceDiagram
participant UI as User Interface
participant R as Reservation Module
participant S as Shopping Cart Module
participant F as Financial Management Module
participant O as Order Module
participant T as Ticket Management and Delivery Module
participant SP as Stripe Payment
participant MG as Mailgun
participant FE as FedEx
participant CV as Concert Venue
UI->>R: ReserveTickets
R->>R: CreateReservation
R-->>UI: ReservationCreated
UI->>S: AddTicketsToCart
S->>S: AddTickets
S-->>UI: TicketsAdded
UI->>S: ConfirmShoppingCart
S->>S: Confirm
S-->>UI: ShoppingCartConfirmed
S->>O: CreateOrder
O->>O: Create
O-->>UI: OrderCreated
UI->>F: InitiatePayment
F->>SP: ProcessPayment
SP-->>F: PaymentSucceeded
F->>O: ConfirmOrder
O->>O: Confirm
O-->>UI: OrderConfirmed
O->>R: ReserveTicketsForOrder
R->>R: ConfirmReservation
R-->>UI: ReservationConfirmed
O->>T: CreateTicket
T->>T: Create
T-->>UI: TicketCreated
T->>T: DeliverTicket
T->>MG: SendEmail
MG-->>T: EmailSent
T->>FE: SendViaCourier
FE-->>T: CourierDelivery
T-->>UI: TicketDelivered
UI->>T: ValidateTicket
T->>CV: CheckTicket
CV-->>T: ValidationResult
T-->>UI: ValidationResult
Activity diagram showing flow:
graph LR
A[Start] --> B[Reserve Tickets]
B --> C[Add Tickets to Shopping Cart]
C --> D[Confirm Shopping Cart]
D --> E[Create Order]
E --> F[Initiate Payment]
F --> G[Payment Succeeded]
G --> H[Confirm Order]
H --> I[Reserve Tickets for Order]
I --> J[Reservation Confirmed]
J --> K[Create Ticket]
K --> L[Deliver Ticket]
L --> M[Send Email]
M --> N[Email Sent]
N --> O[Courier Delivery]
O --> P[Ticket Delivered]
P --> Q[Validate Ticket]
Q --> R[End]
F -->|Payment Failed| S[Cancel Order]
S --> T[End]
I -->|Reservation Failed| U[Cancel Order]
U --> V[End]
L -->|Delivery Failed| W[Compensate Ticket Creation]
W -->|Retry Delivery| L
Concert
CreateConcert
, UpdateConcert
, CancelConcert
, UpdateTicketTypes
ConcertCreated
, ConcertUpdated
, ConcertCancelled
, TicketTypesUpdated
ConcertDetails
, AvailableConcerts
ConcertCancelled
event.1. Business Rules
2. Invariants
3. Commands
public record CreateConcert(
string ConcertId,
string ConcertName,
string Artist,
DateTimeOffset ConcertDate,
string Location,
IReadOnlyList<TicketTypeInfo> TicketTypes
);
public record UpdateConcert(
string ConcertId,
string ConcertName,
string Artist,
DateTimeOffset ConcertDate,
string Location
);
public record UpdateTicketTypes(
string ConcertId,
IReadOnlyList<TicketTypeInfo> TicketTypes
);
public record CancelConcert(
string ConcertId
);
public record TicketTypeInfo(
string TicketType,
decimal Price,
int AvailableTickets
);
3. Events
public record ConcertCreated(
string ConcertId,
string ConcertName,
string Artist,
DateTimeOffset ConcertDate,
string Location,
IReadOnlyList<TicketTypeInfo> TicketTypes
);
public record ConcertUpdated(
string ConcertId,
string ConcertName,
string Artist,
DateTimeOffset ConcertDate,
string Location
);
public record TicketTypesUpdated(
string ConcertId,
IReadOnlyList<TicketTypeInfo> TicketTypes
);
public record ConcertCancelled(
string ConcertId
);
4. Aggregate
public class Concert: Aggregate
{
public string ConcertId { get; private set; } = default!;
public string ConcertName { get; private set; } = default!;
public string Artist { get; private set; } = default!;
public bool IsCancelled { get; private set; } = default!;
public Dictionary<string, int> TicketLevels { get; private set; } = default!;
public Dictionary<string, decimal> Pricing { get; private set; } = default!;
public static Concert New(CreateConcert command)
{
var concert = new Concert();
concert.ApplyAndEnqueue(
new ConcertCreated(
command.ConcertId,
command.ConcertName,
command.Artist,
command.ConcertDate,
command.Location,
command.TicketTypes
)
);
return concert;
}
public void Update(UpdateConcert command)
{
if (IsCancelled)
{
throw new InvalidOperationException("Cannot update ticket levels for a cancelled concert.");
}
ApplyAndEnqueue(
new ConcertUpdated(
command.ConcertId,
command.ConcertName,
command.Artist,
command.ConcertDate,
command.Location
)
);
}
public void UpdateTicketLevels(UpdateTicketTypes command)
{
if (IsCancelled)
{
throw new InvalidOperationException("Cannot update ticket levels for a cancelled concert.");
}
ApplyAndEnqueue(new TicketTypesUpdated(command.ConcertId, command.TicketTypes));
}
public void Cancel(CancelConcert command)
{
if (IsCancelled)
{
throw new InvalidOperationException("Cannot cancel an already cancelled concert.");
}
ApplyAndEnqueue(new ConcertCancelled(command.ConcertId));
}
private void Apply(ConcertCreated e)
{
ConcertId = e.ConcertId;
Artist = e.Artist;
TicketLevels = e.TicketTypes.ToDictionary(t => t.TicketType, t => t.AvailableTickets);
Pricing = e.TicketTypes.ToDictionary(t => t.TicketType, t => t.Price);
}
private void Apply(TicketTypesUpdated e)
{
TicketLevels = e.TicketTypes.ToDictionary(t => t.TicketType, t => t.AvailableTickets);
Pricing = e.TicketTypes.ToDictionary(t => t.TicketType, t => t.Price);
}
private void Apply(ConcertCancelled e)
{
IsCancelled = true;
}
}
5. Command Handler
public class ConcertCommandHandler
{
private readonly IEventStore eventStore;
public ConcertCommandHandler(IEventStore eventStore)
{
this.this.eventStore = eventStore;
}
public async Task HandleAsync(CreateConcert command)
{
var concert = Concert.New(command);
eventStore.Append(command.ConcertId, concert);
await eventStore.SaveChangesAsync();
}
public async Task HandleAsync(UpdateTicketTypes command)
{
var concert = await eventStore.AggregateStreamAsync<Concert>(command.ConcertId);
concert.UpdateTicketLevels(command);
eventStore.Append(command.ConcertId, concert);
await eventStore.SaveChangesAsync();
}
public async Task HandleAsync(CancelConcert command)
{
var concert = await eventStore.AggregateStreamAsync<Concert>(command.ConcertId);
concert.Cancel(command);
eventStore.Append(command.ConcertId, concert);
await eventStore.SaveChangesAsync();
}
}
Display concert details for users to view and reserve tickets
View
public record ConcertDetails(
string ConcertId,
string ConcertName,
string Artist,
DateTimeOffset ConcertDate,
string Location,
IReadOnlyList<TicketTypeInfo> TicketTypes
);
Read Model
public class ConcertDetailsProjection: Projection<ConcertDetails>
{
public ConcertDetailsProjection()
{
Projects<ConcertCreated>(ev => ev.ConcertId, Apply);
Projects<ConcertUpdated>(ev => ev.ConcertId, Apply);
Projects<TicketTypesUpdated>(ev => ev.ConcertId, Apply);
Projects<TicketReserved>(ev => ev.ConcertId, Apply);
Projects<TicketReservationCancelled>(ev => ev.ConcertId, Apply);
}
private ConcertDetails Apply(ConcertDetails view, ConcertCreated @event) =>
new ConcertDetails(
@event.ConcertId,
@event.ConcertName,
@event.Artist,
@event.ConcertDate,
@event.Location,
@event.TicketTypes
);
private ConcertDetails Apply(ConcertDetails view, ConcertUpdated @event) =>
view with
{
ConcertName = @event.ConcertName,
ConcertDate = @event.ConcertDate,
Artist = @event.Artist,
Location = @event.Location
};
private ConcertDetails Apply(ConcertDetails view, TicketTypesUpdated @event) =>
view with { TicketTypes = @event.TicketTypes };
public ConcertDetails Apply(ConcertDetails view, TicketReserved @event)
{
var updatedTicketTypes = view.TicketTypes.Select(tt => tt.TicketType == @event.TicketType
? tt with { AvailableTickets = tt.AvailableTickets - 1 }
: tt).ToList();
return view with { TicketTypes = updatedTicketTypes };
}
public ConcertDetails Apply(ConcertDetails view, TicketReservationCancelled @event)
{
var updatedTicketTypes = view.TicketTypes.Select(tt => tt.TicketType == @event.TicketType
? tt with { AvailableTickets = tt.AvailableTickets + 1 }
: tt).ToList();
return view with { TicketTypes = updatedTicketTypes };
}
}
Display a list of available concerts for users to browse
View
public record AvailableConcert(
string ConcertId,
string ConcertName,
DateTimeOffset ConcertDate,
string Location
);
public record AvailableConcerts(
IReadOnlyList<AvailableConcert> Concerts
);
Read Model
public class AvailableConcertsProjection : Projection<AvailableConcerts>
{
public AvailableConcertsProjection()
{
Projects<ConcertCreated>(ev => ev.ConcertId, Apply);
Projects<ConcertUpdated>(ev => ev.ConcertId, Apply);
Projects<ConcertCancelled>(ev => ev.ConcertId, Apply);
}
private AvailableConcerts Apply(AvailableConcerts view, ConcertCreated @event)
{
var newConcert = new AvailableConcert(@event.ConcertId, @event.ConcertName, @event.ConcertDate, @event.Location);
return view with { Concerts = view.Concerts.Append(newConcert).ToList() };
}
private AvailableConcerts Apply(AvailableConcerts view, ConcertUpdated @event)
{
var updatedConcerts = view.Concerts.Select(c => c.ConcertId == @event.ConcertId
? c with { ConcertName = @event.ConcertName, ConcertDate = @event.ConcertDate, Location = @event.Location }
: c).ToList();
return view with { Concerts = updatedConcerts };
}
private AvailableConcerts Apply(AvailableConcerts view, ConcertCancelled @event)
{
var updatedConcerts = view.Concerts.Where(c => c.ConcertId != @event.ConcertId).ToList();
return view with { Concerts = updatedConcerts };
}
}
ShoppingCart
CreateCart
, AddTicketToCart
, RemoveTicketFromCart
, ClearCart
, ConfirmCart
CartCreated
, TicketAddedToCart
, TicketRemovedFromCart
, CartCleared
, CartConfirmed
ShoppingCartContents
, ShoppingCartSummary
1. Business Rules
2. Invariants
3. Commands
public record AddItemToCart(string UserId, string ConcertId, string TicketLevelId, int Quantity);
public record RemoveItemFromCart(string UserId, string ConcertId, string TicketLevelId);
public record UpdateItemQuantityInCart(string UserId, string ConcertId, string TicketLevelId, int NewQuantity);
public record ConfirmShoppingCart(string UserId);
public record CancelShoppingCart(string UserId);
3. Events
public record ShoppingCartCreated(string UserId);
public record ShoppingCartItemAdded(string ShoppingCartId, string ConcertId, string TicketLevelId, int Quantity);
public record ShoppingCartItemUpdated(string ShoppingCartId, string ConcertId, string TicketLevelId, int NewQuantity);
public record ShoppingCartItemRemoved(string ShoppingCartId, string ConcertId, string TicketLevelId);
public record ShoppingCartConfirmed(string ShoppingCartId, string UserId, List<ReservedTicket> ReservedTickets,
DeliveryMethod DeliveryMethod, string CustomerName, string CustomerAddress, string CustomerEmail);
public record ShoppingCartCancelled(string ShoppingCartId);
5. Aggregate
public class ShoppingCart: Aggregate
{
public string Id { get; private set; }
public Dictionary<string, CartItem> Items { get; private set; } = new();
public bool IsOpened { get; private set; }
public DeliveryMethod? DeliveryMethod { get; private set; }
public void AddItem(string userId, string concertId, string ticketLevelId, int quantity)
{
if (!IsOpened)
{
ApplyAndEnqueue(new ShoppingCartCreated(userId));
}
var key = $"{concertId}-{ticketLevelId}";
if (!Items.ContainsKey(key))
{
ApplyAndEnqueue(new ShoppingCartItemAdded(Id, concertId, ticketLevelId, quantity));
return;
}
ApplyAndEnqueue(new ShoppingCartItemUpdated(Id, concertId, ticketLevelId, Items[key].Quantity + quantity));
}
public void RemoveItem(string concertId, string ticketLevelId)
{
EnsureOpened();
var key = $"{concertId}-{ticketLevelId}";
if (!Items.ContainsKey(key)) return;
Items.Remove(key);
ApplyAndEnqueue(new ShoppingCartItemRemoved(Id, concertId, ticketLevelId));
}
public void UpdateItemQuantity(string concertId, string ticketLevelId, int newQuantity)
{
EnsureOpened();
var key = $"{concertId}-{ticketLevelId}";
if (!Items.ContainsKey(key)) return;
Items[key].Quantity = newQuantity;
ApplyAndEnqueue(new ShoppingCartItemUpdated(Id, concertId, ticketLevelId, newQuantity));
}
public void Confirm()
{
EnsureOpened();
if (!Items.Any())
{
throw new InvalidOperationException("Cannot confirm an empty shopping cart");
}
ApplyAndEnqueue(new ShoppingCartConfirmed(Id));
}
public void Cancel()
{
EnsureOpened();
ApplyAndEnqueue(new ShoppingCartCancelled(Id));
}
public void SetDeliveryMethod(DeliveryMethod deliveryMethod)
{
if (DeliveryMethod != null) throw new InvalidOperationException("Delivery method has already been set");
AApplyAndEnqueuepply(new DeliveryMethodSet(Id, UserId, deliveryMethod));
}
private void EnsureOpened()
{
if (!IsOpened)
{
throw new InvalidOperationException("Shopping cart is not open.");
}
}
private void Apply(DeliveryMethodSet e)
{
DeliveryMethod = e.DeliveryMethod;
}
private void Apply(ShoppingCartCreated @event)
{
Id = @event.UserId;
IsOpened = true;
}
private void Apply(ShoppingCartItemAdded @event)
{
var key = $"{@event.ConcertId}-{@event.TicketLevelId}";
var item = new CartItem(@event.ConcertId, @event.TicketLevelId, @event.Quantity);
Items.Add(key, item);
}
private void Apply(ShoppingCartItemUpdated @event)
{
var key = $"{@event.ConcertId}-{@event.TicketLevelId}";
Items[key].Quantity = @event.NewQuantity;
}
private void Apply(ShoppingCartItemRemoved @event)
{
var key = $"{@event.ConcertId}-{@event.TicketLevelId}";
Items.Remove(key);
}
private void Apply(ShoppingCartConfirmed @event)
{
Items.Clear();
IsOpened = false;
}
private void Apply(ShoppingCartCancelled @event)
{
Items.Clear();
IsOpened = false;
}
}
6. Command Handler
public class ShoppingCartCommandHandler
{
private readonly IEventStore eventStore;
public ShoppingCartCommandHandler(IEventStore eventStore)
{
this.eventStore = eventStore;
}
public async Task HandleAsync(AddItemToCart command)
{
var shoppingCart = await eventStore.AggregateStreamAsync<ShoppingCart>(command.UserId);
shoppingCart.AddItem(command.UserId, command.ConcertId, command.TicketLevelId, command.Quantity);
await eventStore.Events.AppendAsync(command.UserId, shoppingCart);
await eventStore.SaveChangesAsync();
}
public async Task HandleAsync(RemoveItemFromCart command)
{
var shoppingCart = await eventStore.AggregateStreamAsync<ShoppingCart>(command.UserId);
shoppingCart.RemoveItem(command.ConcertId, command.TicketLevelId);
await eventStore.Events.AppendAsync(command.UserId, shoppingCart);
await eventStore.SaveChangesAsync();
}
public async Task HandleAsync(UpdateItemQuantityInCart command)
{
var shoppingCart = await eventStore.AggregateStreamAsync<ShoppingCart>(command.UserId);
shoppingCart.UpdateItemQuantity(command.ConcertId, command.TicketLevelId, command.NewQuantity);
await eventStore.Events.AppendAsync(command.UserId, shoppingCart);
await eventStore.SaveChangesAsync();
}
public async Task HandleAsync(ConfirmShoppingCart command)
{
var shoppingCart = await eventStore.AggregateStreamAsync<ShoppingCart>(command.UserId);
shoppingCart.Confirm();
await eventStore.Events.AppendAsync(command.UserId, shoppingCart);
await eventStore.SaveChangesAsync();
}
public async Task HandleAsync(CancelShoppingCart command)
{
var shoppingCart = await eventStore.AggregateStreamAsync<ShoppingCart>(command.UserId);
shoppingCart.Cancel();
await eventStore.Events.AppendAsync(command.UserId, shoppingCart);
await eventStore.SaveChangesAsync();
}
}
Display the contents of a user's shopping cart
View
public record ShoppingCartItem(string ConcertId, string ConcertName, string TicketType, int Quantity, decimal Price);
public record ShoppingCartContents(string UserId, IReadOnlyList<ShoppingCartItem> Items);
Read Model
public class ShoppingCartContentsProjection : Projection<ShoppingCartContents>
{
public ShoppingCartContentsProjection()
{
Projects<ShoppingCartCreated>(e => e.UserId, Apply);
Projects<ShoppingCartItemAdded>(e => e.UserId, Apply);
Projects<ShoppingCartItemRemoved>(e => e.UserId, Apply);
Projects<ShoppingCartItemUpdated>(e => e.UserId, Apply);
Projects<ShoppingCartCancelled>(e => e.UserId, Apply);
}
private ShoppingCartContents Apply(ShoppingCartContents view, ShoppingCartCreated @event) =>
new ShoppingCartContents(@event.UserId, new ReadOnlyList());
private ShoppingCartContents Apply(ShoppingCartContents view, ShoppingCartItemAdded @event)
{
var newItem = new ShoppingCartItem(@event.ConcertId, @event.ConcertName, @event.TicketType, @event.Quantity, @event.Price);
return view with { Items = view.Items.Append(newItem).ToList() };
}
private ShoppingCartContents Apply(ShoppingCartContents view, ShoppingCartItemRemoved @event)
{
var updatedItems = view.Items.Where(item => item.ConcertId != @event.ConcertId || item.TicketType != @event.TicketType).ToList();
return view with { Items = updatedItems };
}
private ShoppingCartContents Apply(ShoppingCartContents view, ShoppingCartItemUpdated @event)
{
var updatedItems = view.Items.Select(item =>
item.ConcertId == @event.ConcertId && item.TicketType == @event.TicketType
? item with { Quantity = @event.NewQuantity }
: item).ToList();
return view with { Items = updatedItems };
}
private ShoppingCartContents Apply(ShoppingCartContents view, ShoppingCartCancelled @event)
{
return view with { Items = new List<ShoppingCartItem>() };
}
}
Display a summary of a user's shopping cart
View
public record ShoppingCartSummary(string UserId, int TotalItems, decimal TotalPrice);
Read Model
public class ShoppingCartSummaryProjection : Projection<ShoppingCartSummary>
{
public ShoppingCartSummaryProjection()
{
Projects<ShoppingCartCreated>(ev => ev.UserId, Apply);
Projects<ShoppingCartItemAdded>(ev => ev.UserId, Apply);
Projects<ShoppingCartItemRemoved>(ev => ev.UserId, Apply);
Projects<ShoppingCartItemUpdated>(ev => ev.UserId, Apply);
}
private ShoppingCartSummary Apply(ShoppingCartContents view, ShoppingCartCreated @event) =>
new ShoppingCartSummary(@event.UserId, 0, 0);
private ShoppingCartSummary Apply(ShoppingCartSummary view, ShoppingCartItemAdded @event) =>
view with
{
TotalItems = view.TotalItems + @event.Quantity,
TotalPrice = view.TotalPrice + (@event.Price * @event.Quantity)
};
private ShoppingCartSummary Apply(ShoppingCartSummary view, ShoppingCartItemRemoved @event)
{
int removedQuantity = view.Items.FirstOrDefault(item => item.ConcertId == @event.ConcertId && item.TicketType == @event.TicketType)?.Quantity ?? 0;
decimal removedPrice = view.Items.FirstOrDefault(item => item.ConcertId == @event.ConcertId && item.TicketType == @event.TicketType)?.Price ?? 0;
return view with
{
TotalItems = view.TotalItems - removedQuantity,
TotalPrice = view.TotalPrice - (removedPrice * removedQuantity)
};
}
private ShoppingCartSummary Apply(ShoppingCartSummary view, ShoppingCartItemUpdated @event)
{
var currentItem = view.Items.FirstOrDefault(item => item.ConcertId == @event.ConcertId && item.TicketType == @event.TicketType);
int oldQuantity = currentItem?.Quantity ?? 0;
decimal itemPrice = currentItem?.Price ?? 0;
int newQuantity = @event.NewQuantity;
return view with
{
TotalItems = view.TotalItems - oldQuantity + newQuantity,
TotalPrice = view.TotalPrice - (itemPrice * oldQuantity) + (itemPrice * newQuantity)
};
}
private ShoppingCartSummary Apply(ShoppingCartSummary view, ShoppingCartCancelled @event)
{
return view with { TotalItems = 0, TotalPrice = 0 };
}
}
Reservation
, ConcertTicketsAvailability
CreateReservation
, CancelReservation
, ExpireReservation
ReservationCreated
, ReservationCancelled
, ReservationExpired
UserReservations
, ConcertReservations
ConcertCreated
event is raised. The ConcertEventHandler
listens to this event, creates a ConcertTicketsAvailability
aggregate, and initializes it with the available tickets for each level.TicketLevelsUpdated
event is raised. The ConcertEventHandler
listens to this event, loads the ConcertTicketsAvailability
aggregate, and updates the available tickets accordingly.ConcertCancelled
event is raised. The ConcertEventHandler
listens to this event, loads the ConcertTicketsAvailability
aggregate, and updates the available tickets to zero.ReserveTickets
command is sent for each concert in the cart. The reservation command handler checks if there are enough available tickets for each level in the ConcertTicketsAvailability
aggregate, updates the available tickets accordingly, and creates a new Reservation
aggregate.Reservation
aggregate is updated accordingly.1. Business Rules
2. Invariants:
3. Commands
public record CreateReservation(string ReservationId, string ConcertId, Dictionary<string, int> TicketLevels, string UserId);
public record CancelReservation(string ReservationId);
public record ExpireReservation(string ReservationId);
4. Events:
public record ReservationCreated(string ReservationId, string ConcertId, Dictionary<string, int> TicketLevels, string UserId);
public record ReservationCancelled(string ReservationId);
public record ReservationExpired(string ReservationId);
5. Aggregate
public class Reservation: Aggegate
{
public string ReservationId { get; private set; }
public string ConcertId { get; private set; }
public Dictionary<string, int> TicketLevels { get; private set; }
public string UserId { get; private set; }
public bool IsCancelled { get; private set; }
public bool IsExpired { get; private set; }
public void Create(CreateReservation command)
{
if (string.IsNullOrWhiteSpace(command.UserId))
{
throw new InvalidOperationException("User must be authenticated to create a reservation.");
}
Apply(new ReservationCreated(command.ReservationId, command.ConcertId, command.TicketLevels, command.UserId));
}
public void Cancel(CancelReservation command)
{
if (IsCancelled || IsExpired)
{
throw new InvalidOperationException("Cannot cancel an already cancelled or expired reservation.");
}
Apply(new ReservationCancelled(command.ReservationId));
}
public void Expire(ExpireReservation command)
{
if (IsCancelled || IsExpired)
{
throw new InvalidOperationException("Cannot expire an already cancelled or expired reservation.");
}
Apply(new ReservationExpired(command.ReservationId));
}
private void Apply(ReservationCreated e)
{
ReservationId = e.ReservationId;
ConcertId = e.ConcertId;
TicketLevels = e.TicketLevels;
UserId = e.UserId;
}
private void Apply(ReservationCancelled e)
{
IsCancelled = true;
}
private void Apply(ReservationExpired e)
{
IsExpired = true;
}
}
6. Command Handler
public class ReservationCommandHandler
{
private readonly IEventStore eventStore;
public ReservationCommandHandler(IEventStore eventStore)
{
this.eventStore = eventStore;
}
public async Task HandleAsync(CreateReservation command)
{
// Load the concert aggregate and reserve tickets.
var concert = await eventStore.AggregateStreamAsync<Concert>(command.ConcertId);
concert.ReserveTickets(new ReserveTickets(command.ConcertId, command.TicketLevels));
// Create the reservation.
var reservation = new Reservation();
reservation.Create(command);
// Append events to both aggregates.
eventStore.Append(command.ConcertId, concert);
eventStore.Append(command.ReservationId, reservation);
// Save the changes in the same transaction.
await eventStore.SaveChangesAsync();
}
public async Task HandleAsync(CancelReservation command)
{
var reservation = await eventStore.AggregateStreamAsync<Reservation>(command.ReservationId);
reservation.Cancel(command);
eventStore.Append(command.ReservationId, reservation);
await eventStore.SaveChangesAsync();
}
public async Task HandleAsync(ExpireReservation command)
{
var reservation = await eventStore.AggregateStreamAsync<Reservation>(command.ReservationId);
reservation.Expire(command);
eventStore.Append(command.ReservationId, reservation);
await eventStore.SaveChangesAsync();
}
}
1. Business Rules
2. Invariants:
3. Commands
None. Just using events from the concert module.
4. Events:
None. Just using events from the concert module and reservation aggregate.
5. Aggregate
public class ConcertTicketsAvailability: Aggregate
{
public string ConcertId { get; private set; }
public Dictionary<string, int> AvailableTickets { get; private set; }
public void ReserveTickets(ReserveTickets command)
{
foreach (var ticketLevel in command.TicketLevels)
{
if (!AvailableTickets.ContainsKey(ticketLevel.Key) || AvailableTickets[ticketLevel.Key] < ticketLevel.Value)
{
throw new InvalidOperationException("Not enough available tickets for the requested ticket level.");
}
AvailableTickets[ticketLevel.Key] -= ticketLevel.Value;
}
}
private void Apply(ConcertCreated e)
{
ConcertId = e.ConcertId;
AvailableTickets = e.TicketLevels;
}
private void Apply(TicketLevelsUpdated e)
{
AvailableTickets = e.TicketLevels;
}
private void Apply(ConcertCancelled e)
{
AvailableTickets = new Dictionary<string, int>();
}
}
6. Command Handler
None.
7. Event Handler
public class ConcertEventHandler
{
private readonly IEventStore eventStore;
public ConcertEventHandler(IEventStore eventStore)
{
this.eventStore = eventStore;
}
public async Task HandleAsync(ConcertCreated e)
{
var concertTicketsAvailability = new ConcertTicketsAvailability();
concertTicketsAvailability.Apply(e);
eventStore.Append(e.ConcertId, concertTicketsAvailability);
await eventStore.SaveChangesAsync();
}
public async Task HandleAsync(TicketLevelsUpdated e)
{
var concertTicketsAvailability = await eventStore.AggregateStreamAsync<ConcertTicketsAvailability>(e.ConcertId);
concertTicketsAvailability.Apply(e);
eventStore.Append(e.ConcertId, concertTicketsAvailability);
await eventStore.SaveChangesAsync();
}
public async Task HandleAsync(ConcertCancelled e)
{
var concertTicketsAvailability = await eventStore.AggregateStreamAsync<ConcertTicketsAvailability>(e.ConcertId);
concertTicketsAvailability.Apply(e);
eventStore.Append(e.ConcertId, concertTicketsAvailability);
await eventStore.SaveChangesAsync();
}
}
Display a list of reservations made by a user
View
public record UserReservations(string UserId, IReadOnlyList<Reservation> Reservations);
public record Reservation(string ConcertId, string ConcertName, DateTimeOffset ConcertDate, string TicketType, int ReservedTickets);
Read Model
public class UserReservationsProjection : Projection<UserReservations>
{
public UserReservationsProjection()
{
Projects<ReservationMade>(ev => ev.UserId, Apply);
Projects<ReservationCancelled>(ev => ev.UserId, Apply);
}
private UserReservations Apply(UserReservations view, ReservationMade @event)
{
var reservation = new Reservation(@event.ConcertId, @event.ConcertName, @event.ConcertDate, @event.TicketType, @event.Quantity);
return view with { Reservations = view.Reservations.Append(reservation).ToList() };
}
private UserReservations Apply(UserReservations view, ReservationCancelled @event) =>
view with { Reservations = view.Reservations.Where(r => r.ConcertId != @event.ConcertId || r.TicketType != @event.TicketType).ToList() };
}
Display a summary of reservations for a specific concert
View
public record ConcertReservations(string ConcertId, string ConcertName, DateTimeOffset ConcertDate, IReadOnlyList<ReservationSummary> Reservations);
public record ReservationSummary(string UserId, string UserName, string TicketType, int ReservedTickets);
Read Model
public class ConcertReservationsProjection : Projection<ConcertReservations>
{
public ConcertReservationsProjection()
{
Projects<ReservationMade>(ev => ev.ConcertId, Apply);
Projects<ReservationCancelled>(ev => ev.ConcertId, Apply);
}
private ConcertReservations Apply(ConcertReservations view, ReservationMade @event)
{
var reservationSummary = new ReservationSummary(@event.UserId, @event.UserName, @event.TicketType, @event.Quantity);
return view with { Reservations = view.Reservations.Append(reservationSummary).ToList() };
}
private ConcertReservations Apply(ConcertReservations view, ReservationCancelled @event) =>
view with { Reservations = view.Reservations.Where(r => r.UserId != @event.UserId || r.TicketType != @event.TicketType).ToList() };
}
TicketOrder
CreateOrder
, CompleteOrder
, CancelOrder
, CompensateOrder
OrderCreated
, OrderCompleted
, OrderCancelled
UserOrders
, OrderDetails
The Order represents a confirmed purchase made by a user. It is created when the user confirms their ShoppingCart, and it serves as a bridge between the ShoppingCart, Reservations, Tickets, Payment, and Invoice. Here's how the Order is related to these entities:
1. ShoppingCart: When the user confirms their ShoppingCart, the system creates an Order with the details from the ShoppingCart. The Order holds information about the selected concerts, ticket levels, and quantities.
2. Reservations: After the Order is created, the system reserves the tickets for each concert in the Order. Each reservation is associated with the Order.
3. Tickets: Once the reservations are confirmed, the system generates tickets for the Order. These tickets include details such as the concert, ticket level, and user information.
4. Payment: When the Order is created, the system initiates the payment process. The Payment is linked to the Order, and the payment status is updated based on the success or failure of the payment transaction.
5. Invoice: After the payment is successful, the system generates an Invoice for the Order. The Invoice includes details of the ticket purchases, such as the concert, ticket levels, quantities, and the total amount.
The relationships between the Order and other entities ensure a smooth flow of information and actions throughout the ticket purchasing process. The Order serves as a central point connecting the ShoppingCart, Reservations, Tickets, Payment, and Invoice, providing a comprehensive view of the user's purchase.
1. Business Rules
When a user confirms their ShoppingCart, an event (e.g., ShoppingCartConfirmed
) is emitted. This event should contain the necessary data to create an Order, such as the user's ID, shopping cart ID, and selected items.
An event handler within the Order bounded context listens for the ShoppingCartConfirmed
event. When this event is received, the handler creates a new Order using the CreateOrder
command, which in turn emits the OrderCreated
event.
The Reservation bounded context listens for the OrderCreated
event. When this event is received, the handler reserves tickets for each concert in the Order. The Reservations are associated with the Order, and their status is updated accordingly (e.g., ReservationCreated
and ReservationConfirmed
events).
The Financial bounded context also listens for the OrderCreated
event. When this event is received, the handler initiates the payment process by creating a Payment related to the Order. The payment status is updated based on the success or failure of the payment transaction (e.g., PaymentCreated
, PaymentCompleted
, and PaymentFailed
events).
If the payment is successful, the Financial bounded context generates an Invoice for the Order and emits an InvoiceCreated event.
Once the payment is successful and the reservations are confirmed, the Order bounded context can update the Order status to confirmed by handling the ConfirmOrder
command, which emits the OrderConfirmed
event. In case of any failure or cancellation, the CancelOrder
command can be issued, emitting the OrderCancelled
event.
If the payment fails, the Financial bounded context should emit a PaymentFailed event, which includes the Order ID and the reason for the failure. The Order bounded context listens for this event and handles it by issuing a CancelOrder command, which in turn emits the OrderCancelled event.
8.The Reservation bounded context should also listen for the OrderCancelled event. When this event is received, the handler cancels any Reservations associated with the Order by issuing CancelReservation commands, which emit ReservationCancelled events.
2. Invariants:
3. Commands
public record CreateOrder(string UserId, string ShoppingCartId, List<OrderItem> OrderItems);
public record ConfirmOrder(string OrderId);
public record CancelOrder(string OrderId);
public record CancelOrderDueToTimeout(string OrderId);
4. Events:
public record OrderCreated(string OrderId, string UserId, string ShoppingCartId, List<OrderItem> OrderItems, CustomerInfo CustomerInfo);
public record OrderConfirmed(string OrderId);
public record OrderCancelled(string OrderId);
public record OrderCancelledDueToTimeout(string OrderId);
5. Aggregate
using System;
using System.Collections.Generic;
using System.Linq;
using Marten.Schema;
public record ReservedTicket(string ConcertId, string TicketLevel, int Quantity);
public enum OrderStatus { Pending, Confirmed, Paid, Cancelled }
public class Order: Aggregate
{
public string Id { get; private init; }
public string UserId { get; private set; }
public OrderStatus Status { get; private set; }
public decimal TotalAmount { get; private set; }
public IReadOnlyList<ReservedTicket> ReservedTickets { get; private set; } = new List<ReservedTicket>();
public string? PaymentTransactionId { get; private set; }
private CustomerInfo CustomerInfo { get; private set; }
private Order() { } // For Marten
public Order(string id, string userId, CustomerInfo customerInfo)
{
Id = id;
UserId = userId;
ApplyAndEnqueue(new OrderCreated(id, userId, CustomerInfo));
}
public void AddReservedTickets(IEnumerable<ReservedTicket> reservedTickets)
{
if (Status != OrderStatus.Pending)
{
throw new InvalidOperationException("Reserved tickets can only be added when the order is in pending status.");
}
ApplyAndEnqueue(new ReservedTicketsAdded(Id, reservedTickets.ToList()));
}
public void ConfirmOrder()
{
if (Status != OrderStatus.Pending)
{
throw new InvalidOperationException("The order can only be confirmed when it is in pending status.");
}
ApplyAndEnqueue(new OrderConfirmed(Id));
}
public void ProcessPaymentSuccess(decimal amount, string transactionId)
{
if (Status != OrderStatus.Confirmed)
{
throw new InvalidOperationException("The payment can only be processed when the order is in confirmed status.");
}
ApplyAndEnqueue(new PaymentSucceeded(Id, amount, transactionId));
}
public void ProcessPaymentFailure(string transactionId)
{
if (Status != OrderStatus.Confirmed)
{
throw new InvalidOperationException("The payment failure can only be processed when the order is in confirmed status.");
}
ApplyAndEnqueue(new PaymentFailed(Id, transactionId));
}
public void CancelOrder()
{
if (Status != OrderStatus.Pending && Status != OrderStatus.Confirmed)
{
throw new InvalidOperationException("The order can only be cancelled when it is in pending or confirmed status.");
}
ApplyAndEnqueue(new OrderCancelled(Id));
}
public void CancelOrderDueToTimeout()
{
if (Status != OrderStatus.Pending)
{
throw new InvalidOperationException("The order can only be cancelled due to timeout when it is in pending status.");
}
ApplyAndEnqueue(new OrderCancelledDueToTimeout(Id));
}
private void Apply(OrderCreated e)
{
Id = e.OrderId;
_tickets = e.Tickets;
_userId = e.UserId;
_customerInfo = e.CustomerInfo;
_status = OrderStatus.Created;
}
private void Apply(OrderCancelled @event)
{
Status = OrderStatus.Cancelled;
}
private void Apply(OrderCancelledDueToTimeout @event)
{
Status = OrderStatus.Cancelled;
}
private void Apply(ReservedTicketsAdded @event)
{
_reservedTickets.AddRange(e.ReservedTickets);
}
private void Apply(OrderConfirmed @event)
{
IsConfirmed = true;
}
private void Apply(PaymentSucceeded @event)
{
IsPaymentSucceeded = true;
TransactionId = e.TransactionId;
}
private void Apply(PaymentFailed @event)
{
IsPaymentSucceeded = false;
TransactionId = null;
}
}
public record ReservedTicket(string ConcertId, string TicketLevel, int Quantity, decimal Price);
public record ReservedTicketsAdded(string OrderId, List<ReservedTicket> ReservedTickets);
public record OrderConfirmed(string OrderId);
public record PaymentSucceeded(string OrderId, decimal Amount, string TransactionId);
public record PaymentFailed(string OrderId, decimal Amount, string Reason);
6. Command Handler
public record AddReservedTicketsCommand(string OrderId, IEnumerable<ReservedTicket> ReservedTickets);
public record ConfirmOrderCommand(string OrderId);
public record ProcessPaymentSuccessCommand(string OrderId, decimal Amount, string TransactionId);
public record ProcessPaymentFailureCommand(string OrderId, decimal Amount, string Reason);
public class OrderCommandHandler
{
private readonly IEventStore eventStore;
public OrderCommandHandler(IEventStore eventStore)
{
this.eventStore = eventStore;
}
public async Task HandleAsync(AddReservedTicketsCommand command)
{
var order = await eventStore.LoadAsync<Order>(command.OrderId);
order.AddReservedTickets(command.ReservedTickets);
await eventStore.SaveChangesAsync();
}
public async Task HandleAsync(ConfirmOrderCommand command)
{
var order = await eventStore.LoadAsync<Order>(command.OrderId);
order.ConfirmOrder();
await eventStore.SaveChangesAsync();
}
public async Task HandleAsync(ProcessPaymentSuccessCommand command)
{
var order = await eventStore.LoadAsync<Order>(command.OrderId);
order.ProcessPaymentSuccess(command.Amount, command.TransactionId);
await eventStore.SaveChangesAsync();
}
public async Task HandleAsync(ProcessPaymentFailureCommand command)
{
var order = await eventStore.LoadAsync<Order>(command.OrderId);
order.ProcessPaymentFailure(command.Amount, command.Reason);
await eventStore.SaveChangesAsync();
}
public async Task HandleAsync(CancelOrderCommand command)
{
var order = await eventStore.LoadAsync<Order>(command.OrderId);
order.CancelOrder();
await eventStore.SaveChangesAsync();
}
public async Task HandleAsync(CancelOrderDueToTimeoutCommand command)
{
var order = await eventStore.LoadAsync<Order>(command.OrderId);
order.CancelOrderDueToTimeout();
await eventStore.SaveChangesAsync();
}
}
Display a list of orders made by a user
View
public record UserOrders(string UserId, IReadOnlyList<OrderSummary> Orders);
public record OrderSummary(string OrderId, string ConcertId, string ConcertName, DateTimeOffset ConcertDate, IReadOnlyList<OrderItem> OrderItems, OrderStatus Status);
public record OrderItem(string TicketType, int Quantity, decimal Price);
public enum OrderStatus
{
Created,
Confirmed,
Cancelled
}
Read Model
public class UserOrdersProjection : Projection<UserOrders>
{
public UserOrdersProjection()
{
Projects<OrderCreated>(ev => ev.UserId, Apply);
Projects<OrderConfirmed>(ev => ev.UserId, Apply);
Projects<OrderCancelled>(ev => ev.UserId, Apply);
}
private UserOrders Apply(UserOrders view, OrderCreated @event)
{
var orderItems = @event.Tickets.Select(t => new OrderItem(t.TicketType, t.Quantity, t.Price)).ToList();
var orderSummary = new OrderSummary(@event.OrderId, @event.ConcertId, @event.ConcertName, @event.ConcertDate, orderItems, OrderStatus.Created);
return view with { Orders = view.Orders.Append(orderSummary).ToList() };
}
private UserOrders Apply(UserOrders view, OrderConfirmed @event) =>
view with
{
Orders = view.Orders.Select(o => o.OrderId == @event.OrderId ? o with { Status = OrderStatus.Confirmed } : o).ToList()
};
private UserOrders Apply(UserOrders view, OrderCancelled @event) =>
view with
{
Orders = view.Orders.Select(o => o.OrderId == @event.OrderId ? o with { Status = OrderStatus.Cancelled } : o).ToList()
};
}
Use case: Display the details of a specific order for a user
View
public record OrderDetails(string OrderId, string UserId, string ConcertId, string ConcertName, DateTimeOffset ConcertDate, IReadOnlyList<OrderItem> OrderItems, OrderStatus Status, decimal TotalAmount);
public record OrderItem(string TicketType, int Quantity, decimal Price);
public enum OrderStatus
{
Created,
Confirmed,
Cancelled
}
Read Model
public class OrderDetailsProjection : Projection<OrderDetails>
{
public OrderDetailsProjection()
{
Projects<OrderCreated>(ev => ev.OrderId, Apply);
Projects<OrderConfirmed>(ev => ev.OrderId, Apply);
Projects<OrderCancelled>(ev => ev.OrderId, Apply);
}
private OrderDetails Apply(OrderDetails view, OrderCreated @event)
{
var orderItems = @event.Tickets.Select(t => new OrderItem(t.TicketType, t.Quantity, t.Price)).ToList();
decimal totalAmount = orderItems.Sum(item => item.Quantity * item.Price);
return new OrderDetails(@event.OrderId, @event.UserId, @event.ConcertId, @event.ConcertName, @event.ConcertDate, orderItems, OrderStatus.Created, totalAmount);
}
private OrderDetails Apply(OrderDetails view, OrderConfirmed @event) =>
view with { Status = OrderStatus.Confirmed };
private OrderDetails Apply(OrderDetails view, OrderCancelled @event) =>
view with { Status = OrderStatus.Cancelled };
}
7. Event Handlers
Shopping Cart Events:
using System.Threading;
using System.Threading.Tasks;
using Marten;
using MediatR;
public class ShoppingCartConfirmedHandler : INotificationHandler<ShoppingCartConfirmed>
{
private readonly IEventStore eventStore;
public ShoppingCartConfirmedHandler(IEventStore eventStore)
{
this.eventStore = eventStore;
}
public async Task Handle(ShoppingCartConfirmed notification, CancellationToken cancellationToken)
{
// Extract necessary data from the ShoppingCartConfirmed event
var userId = notification.UserId;
var shoppingCartId = notification.ShoppingCartId;
var orderItems = new List<OrderItem>();
foreach (var item in notification.Items)
{
var orderItem = new OrderItem
{
ConcertId = item.ConcertId,
TicketLevelId = item.TicketLevelId,
Quantity = item.Quantity
};
orderItems.Add(orderItem);
}
// Create the Order aggregate using the new CreateFromShoppingCart method
var orderId = Guid.NewGuid().ToString();
var customerInfo = new CustomerInfo(e.CustomerName, e.CustomerAddress, e.CustomerEmail);
var order = Order.CreateFromShoppingCart(orderId, userId, shoppingCartId, orderItems, customerInfo);
// Save the Order aggregate
eventStore.Append(orderId, order.PendingEvents.ToArray());
await eventStore.SaveChangesAsync(cancellationToken);
}
}
Reservation Events:
using System.Threading;
using System.Threading.Tasks;
using Marten;
using MediatR;
public class ReservationConfirmedHandler
: INotificationHandler<ReservationConfirmed>, INotificationHandler<ReservationFailed>
{
private readonly IEventStore eventStore;
public ReservationConfirmedHandler(IEventStore eventStore)
{
this.eventStore = eventStore;
}
public async Task Handle(ReservationConfirmed notification, CancellationToken cancellationToken)
{
// Extract necessary data from the ReservationConfirmed event
var orderId = notification.OrderId;
var reservationId = notification.ReservationId;
var reservedTickets = notification.ReservedTickets;
// Load the Order aggregate
var order = await eventStore.AggregateStreamAsync<Order>(orderId, cancellationToken);
// Process the reservation confirmation
order.ProcessReservationConfirmation(reservationId, reservedTickets);
// Save the Order aggregate
eventStore.Append(orderId, order.PendingEvents.ToArray());
await eventStore.SaveChangesAsync(cancellationToken);
}
public async Task Handle(ReservationFailed notification, CancellationToken cancellationToken)
{
// Extract necessary data from the ReservationFailed event
var orderId = notification.OrderId;
var reservationId = notification.ReservationId;
var reason = notification.Reason;
// Load the Order aggregate
var order = await eventStore.AggregateStreamAsync<Order>(orderId, cancellationToken);
// Process the reservation failure
order.ProcessReservationFailure(reservationId, reason);
// Save the Order aggregate
eventStore.Append(orderId, order.PendingEvents.ToArray());
await eventStore.SaveChangesAsync(cancellationToken);
}
}
Payments Events:
using System.Threading;
using System.Threading.Tasks;
using Marten;
using MediatR;
public class PaymentSucceededHandler
: INotificationHandler<PaymentSucceeded>, INotificationHandler<PaymentFailed>
{
private readonly IEventStore eventStore;
public PaymentSucceededHandler(IEventStore eventStore)
{
this.eventStore = eventStore;
}
public async Task Handle(PaymentSucceeded notification, CancellationToken cancellationToken)
{
// Extract necessary data from the PaymentSucceeded event
var orderId = notification.OrderId;
var amount = notification.Amount;
var transactionId = notification.TransactionId;
// Load the Order aggregate
var order = await eventStore.AggregateStreamAsync<Order>(orderId, cancellationToken);
// Process the payment success
order.ProcessPaymentSuccess(amount, transactionId);
// Save the Order aggregate
eventStore.Append(orderId, order.PendingEvents.ToArray());
await eventStore.SaveChangesAsync(cancellationToken);
}
public async Task Handle(PaymentFailed notification, CancellationToken cancellationToken)
{
// Extract necessary data from the PaymentFailed event
var orderId = notification.OrderId;
var amount = notification.Amount;
var reason = notification.Reason;
// Load the Order aggregate
var order = await eventStore.AggregateStreamAsync<Order>(orderId, cancellationToken);
// Process the payment failure
order.ProcessPaymentFailure(amount, reason);
// Save the Order aggregate
eventStore.Append(orderId, order.PendingEvents.ToArray());
await eventStore.SaveChangesAsync(cancellationToken);
}
}
Ticket
, TicketDelivery
PrepareTicketDelivery
, DeliverOnlineTicket
, DeliverPrintedTicket
, ValidateTicket
TicketCreated
, TicketDeliveryPrepared
, OnlineTicketDelivered
, PrintedTicketDelivered
, TicketValidated
, TicketValidationFailed
UserTickets
, TicketDetails
, ConcertTicketSummary
, TicketDeliveryStatus
, TicketValidationStatus
When a reservation is confirmed in the Reservation module, it will emit a ReservationConfirmed event. The Ticket Management module will have an event handler that listens for this event. Upon receiving the event, it will trigger the CreateTicket command to create the tickets associated with that reservation.
Similarly, when an order is confirmed in the Order module, it will emit an OrderConfirmed event. The Ticket Management module will have an event handler that listens for this event and triggers the appropriate ticket delivery method (SendTicketByEmail or SendTicketByCourier) based on the delivery information provided in the event.
If an admin needs to manually trigger the sending of a ticket, instead of invoking a direct command, they can emit a custom event (e.g., AdminTicketSendRequested) which the Ticket Management module will listen to and trigger the appropriate ticket delivery method.
The Ticket Management module will integrate with an external email service like Mailgun to send email tickets. When the SendTicketByEmail command is executed, it will interact with the Mailgun API to send the ticket to the user's email address.
Similarly, the Ticket Management module will integrate with courier services to send printed tickets. When the SendTicketByCourier command is executed, it will interact with the courier service's API to initiate the shipping process and obtain tracking information.
1. Business Rules
2. Invariants:
3. Commands
public record ValidateTicket(string TicketId, string ConcertId);
4. Events:
public record TicketCreated(string TicketId, string ConcertId, string UserId, string TicketLevel, bool IsPrinted);
public record TicketValidated(string TicketId, string ConcertId);
public record TicketValidationFailed(string TicketId, string ConcertId, string Reason);
5. Aggregate
public class Ticket: Aggregate
{
public string Id { get; private set; }
public string ConcertId { get; private set; }
public string UserId { get; private set; }
public string TicketLevel { get; private set; }
public bool IsPrinted { get; private set; }
public bool IsValidated { get; private set; }
private Ticket() { }
public Ticket(string id, string concertId, string userId, string ticketLevel, bool isPrinted)
{
ApplyAndEnqueue(new TicketCreated(id, concertId, userId, ticketLevel, isPrinted));
}
public void Validate(string concertId)
{
if (IsValidated)
{
ApplyAndEnqueue(new TicketValidationFailed(Id, concertId, "Ticket has already been validated."));
return;
}
if (ConcertId != concertId)
{
ApplyAndEnqueue(new TicketValidationFailed(Id, concertId, "Ticket is not valid for this concert."));
return;
}
ApplyAndEnqueue(new TicketValidated(Id, concertId));
}
// Apply methods
private void Apply(TicketCreated e)
{
Id = e.TicketId;
ConcertId = e.ConcertId;
UserId = e.UserId;
TicketLevel = e.TicketLevel;
IsPrinted = e.IsPrinted;
IsValidated = false;
}
private void Apply(TicketValidated e)
{
IsValidated = true;
}
private void Apply(TicketValidationFailed e)
{
// No state changes are required for a failed validation
}
}
6. Event Handler
public class TicketEventHandler
{
private readonly IEventStore eventStore;
public TicketEventHandler(IEventStore eventStore)
{
this.eventStore = eventStore;
}
public async Task HandleAsync(OrderCreated @event)
{
foreach (var reservation in @event.Reservations)
{
var ticket = Ticket.Create(reservation.UserId, reservation.ConcertId, reservation.TicketType, reservation.Quantity);
await eventStore.Events.AppendAsync(ticket.Id, ticket.PendingEvents.ToArray());
}
await eventStore.SaveChangesAsync();
}
}
1. Business Rules
2. Invariants:
3. Commands
public record SendTicketByEmail(string TicketId, string Email);
public record ShipTicket(string TicketId, string CourierName, string TrackingNumber);
4. Events:
public record TicketEmailSent(string TicketId, string Email);
public record TicketShipped(string TicketId, string CourierName, string TrackingNumber);
5. Aggregate
public class TicketDelivery: Aggregate
{
public string TicketId { get; private set; }
public string Email { get; private set; }
public string CourierName { get; private set; }
public string TrackingNumber { get; private set; }
private TicketDelivery() { }
public TicketDelivery(string ticketId)
{
TicketId = ticketId;
}
public void SendByEmail(string email)
{
ApplyAndEnqueue(new TicketEmailSent(TicketId, email));
}
public void Ship(string courierName, string trackingNumber)
{
ApplyAndEnqueue(new TicketShipped(TicketId, courierName, trackingNumber));
}
// Apply methods
private void Apply(TicketEmailSent e)
{
Email = e.Email;
}
private void Apply(TicketShipped e)
{
CourierName = e.CourierName;
TrackingNumber = e.TrackingNumber;
}
}
6. Event Handler
public class TicketDeliveryEventHandler
{
private readonly IEventStore eventStore;
public TicketDeliveryEventHandler(IEventStore eventStore)
{
this.eventStore = eventStore;
}
public async Task Handle(OrderCreated e)
{
foreach (var ticket in e.Tickets)
{
for (int i = 0; i < ticket.Value; i++)
{
var ticketDelivery = new TicketDelivery($"{e.OrderId}-{ticket.Key}-{i}", e.DeliveryMethod);
await eventStore.Append(ticketDelivery.TicketId, ticketDelivery);
}
}
await eventStore.SaveChangesAsync();
}
}
To implement the event handler that listens to TicketEmailSent event and sends an email using Mailgun
, first, you need to install the Mailgun.Extensions.DependencyInjection
NuGet package. Then, you can create a class named TicketEmailSentHandler
:
using System.Threading.Tasks;
using Mailgun.Core.Messages;
using Mailgun.Extensions.DependencyInjection;
using Mailgun.Webhooks.Events;
using Marten.Events.Projections.Async;
using Microsoft.Extensions.Logging;
public class TicketEmailSentHandler : IProjection<TicketEmailSent>
{
private readonly IMailgunClient _mailgunClient;
private readonly ILogger<TicketEmailSentHandler> _logger;
public TicketEmailSentHandler(IMailgunClient mailgunClient, ILogger<TicketEmailSentHandler> logger)
{
_mailgunClient = mailgunClient;
_logger = logger;
}
public async Task ApplyAsync(TicketEmailSent @event, IAsyncProjectionContext context)
{
var idempotencyKey = $"ticket-email-{@event.TicketId}";
var message = new SendMessage
{
From = "[email protected]",
To = @event.UserEmail,
Subject = "Your Ticket",
Text = $"Hello, here is your ticket for the concert: {@event.TicketUrl}"
};
var requestOptions = new RequestOptions
{
IdempotencyKey = idempotencyKey
};
try
{
var response = await _mailgunClient.Messages.SendMessageAsync(message, requestOptions);
_logger.LogInformation($"Email sent successfully to {@event.UserEmail}, messageId: {response.Id}");
}
catch (Exception ex)
{
_logger.LogError(ex, $"Failed to send email to {@event.UserEmail}");
}
}
}
Don't forget to register the event handler in your application's dependency injection container:
services.AddMailgun(Configuration.GetSection("Mailgun"));
services.AddMartenAsyncProjection<TicketEmailSent, TicketEmailSentHandler>();
In this example, we are using the IMailgunClient
from the Mailgun.Extensions.DependencyInjection
package to send emails. The event handler listens for the TicketEmailSent event and sends an email to the user with their ticket information. The idempotency key is set to a unique value based on the ticket ID to ensure that the email is sent only once.
Display a list of tickets owned by a user.
View
public record UserTickets(string UserId, IReadOnlyList<TicketSummary> Tickets);
public record TicketSummary(string TicketId, string ConcertId, string ConcertName, DateTimeOffset ConcertDate, string TicketType, decimal TicketPrice);
Projection
public class UserTicketsProjection : Projection<UserTickets>
{
public UserTicketsProjection()
{
Projects<TicketCreated>(ev => ev.UserId, Apply);
Projects<TicketEmailSent>(ev => ev.UserId, Apply);
Projects<TicketPrintedAndSent>(ev => ev.UserId, Apply);
Projects<TicketCancelled>(ev => ev.UserId, Apply);
}
private UserTickets Apply(UserTickets view, TicketCreated @event)
{
view.Tickets.Add(new TicketDetails
{
TicketId = @event.TicketId,
ConcertId = @event.ConcertId,
ConcertName = @event.ConcertName,
Date = @event.Date,
Venue = @event.Venue,
TicketType = @event.TicketType,
Price = @event.Price,
Status = TicketStatus.Active
});
return view;
}
private UserTickets Apply(UserTickets view, TicketEmailSent @event)
{
var ticket = view.Tickets.Find(t => t.TicketId == @event.TicketId);
if (ticket != null)
{
ticket.EmailSent = true;
}
return view;
}
private UserTickets Apply(UserTickets view, TicketPrintedAndSent @event)
{
var ticket = view.Tickets.Find(t => t.TicketId == @event.TicketId);
if (ticket != null)
{
ticket.PrintedAndSent = true;
}
return view;
}
private UserTickets Apply(UserTickets view, TicketCancelled @event)
{
var ticket = view.Tickets.Find(t => t.TicketId == @event.TicketId);
if (ticket != null)
{
ticket.Status = TicketStatus.Cancelled;
}
return view;
}
}
Display the details of a specific ticket for a user.
View
public record TicketDetails(string TicketId, string UserId, string ConcertId, string ConcertName, DateTimeOffset ConcertDate, string VenueName, string TicketType, decimal TicketPrice, string Barcode, DateTimeOffset CreatedAt);
Projection
public class TicketDetailsProjection : Projection<TicketDetails>
{
public TicketDetailsProjection()
{
Projects<TicketCreated>(ev => ev.UserId, Apply);
Projects<TicketCancelled>(ev => ev.UserId, Apply);
}
private TicketDetails Apply(TicketDetails view, TicketCreated @event)
{
return new TicketDetails(
@event.TicketId;
@event.ConcertId;
@event.ConcertName;
@event.Date;
@event.Venue;
@event.TicketType;
@event.Price;
TicketStatus.Active;
);
}
private TicketDetails Apply(TicketDetails view, TicketCancelled @event) =>
view with { Status = TicketStatus.Cancelled };
}
Display a summary of tickets for a specific concert.
View
public record ConcertTicketSummary(string ConcertId, string ConcertName, DateTimeOffset ConcertDate, IReadOnlyList<TicketTypeSummary> TicketTypes);
public record TicketTypeSummary(string TicketType, int TotalTickets, int AvailableTickets, int ReservedTickets, int SoldTickets);
Projection
public class ConcertTicketSummaryProjection : Projection<ConcertTicketSummary>
{
public ConcertTicketSummaryProjection()
{
Projects<ConcertCreated>(ev => ev.ConcertId, Apply);
Projects<TicketTypeCreated>(ev => ev.ConcertId, Apply);
Projects<TicketsReserved>(ev => ev.ConcertId, Apply);
Projects<TicketsPurchased>(ev => ev.ConcertId, Apply);
Projects<TicketsReservationCancelled>(ev => ev.ConcertId, Apply);
}
public void Apply(ConcertTicketSummary view, ConcertCreated @event)
{
view.ConcertId = @event.ConcertId;
view.ConcertName = @event.ConcertName;
view.ConcertDate = @event.ConcertDate;
view.TicketTypes = new List<TicketTypeSummary>();
}
private ConcertTicketSummary Apply(ConcertTicketSummary view, TicketTypeCreated @event)
{
var ticketTypeSummary = new TicketTypeSummary(
@event.TicketType,
@event.TotalTickets,
@event.TotalTickets, // Initially, all tickets are available
0, // No tickets reserved initially
0 // No tickets sold initially
);
view.TicketTypes.Add(ticketTypeSummary);
}
private ConcertTicketSummary Apply(ConcertTicketSummary view, TicketsReserved @event)
{
var ticketTypeSummary = view.TicketTypes.Single(t => t.TicketType == @event.TicketType);
ticketTypeSummary.AvailableTickets -= @event.Quantity;
ticketTypeSummary.ReservedTickets += @event.Quantity;
return view;
}
private ConcertTicketSummary Apply(ConcertTicketSummary view, TicketsPurchased @event)
{
var ticketTypeSummary = view.TicketTypes.Single(t => t.TicketType == @event.TicketType);
ticketTypeSummary.ReservedTickets -= @event.Quantity;
ticketTypeSummary.SoldTickets += @event.Quantity;
return view;
}
private ConcertTicketSummary Apply(ConcertTicketSummary view, TicketsReservationCancelled @event)
{
var ticketTypeSummary = view.TicketTypes.Single(t => t.TicketType == @event.TicketType);
ticketTypeSummary.AvailableTickets += @event.Quantity;
ticketTypeSummary.ReservedTickets -= @event.Quantity;
return view;
}
}
Display the delivery status of a specific ticket for a user.
View
public record TicketDeliveryStatus(string TicketId, string UserId, DeliveryMethod DeliveryMethod, string DeliveryStatus, string TrackingNumber, DateTimeOffset? DeliveryDate);
Projection
public class TicketDeliveryStatusProjection : Projection<TicketDeliveryStatus>
{
public TicketDeliveryStatusProjection()
{
Projects<TicketCreated>(ev => ev.TicketId, Apply);
Projects<TicketDeliveryInitiated>(ev => ev.TicketId, Apply);
Projects<TicketDelivered>(ev => ev.TicketId, Apply);
Projects<TicketDeliveryFailed>(ev => ev.TicketId, Apply);
}
private TicketDeliveryStatus Apply(TicketDeliveryStatus view, TicketCreated @event)
{
view.TicketId = @event.TicketId;
view.UserId = @event.UserId;
view.DeliveryMethod = @event.DeliveryMethod;
view.DeliveryStatus = "Not yet delivered";
view.TrackingNumber = null;
view.DeliveryDate = null;
return view;
}
private TicketDeliveryStatus Apply(TicketDeliveryStatus view, TicketDeliveryInitiated @event)
{
view.DeliveryStatus = "In progress";
view.TrackingNumber = @event.TrackingNumber;
return view;
}
private TicketDeliveryStatus Apply(TicketDeliveryStatus view, TicketDelivered @event)
{
view.DeliveryStatus = "Delivered";
view.DeliveryDate = @event.DeliveryDate;
return view;
}
private TicketDeliveryStatus Apply(TicketDeliveryStatus view, TicketDeliveryFailed @event)
{
view.DeliveryStatus = "Failed";
return view;
}
}
Display the validation status of a specific ticket at the concert venue.
View
public record TicketValidationStatus(string TicketId, string ConcertId, bool IsValid, string ValidationMessage);
Projection
public class TicketValidationStatusProjection : Projection<TicketValidationStatus>
{
public TicketValidationStatusProjection()
{
Projects<TicketCreated>(ev => ev.TicketId, Apply);
Projects<TicketValidated>(ev => ev.TicketId, Apply);
Projects<TicketValidationFailed>(ev => ev.TicketId, Apply);
}
private TicketValidationStatus Apply(TicketValidationStatus view, TicketCreated @event)
{
view.TicketId = @event.TicketId;
view.ConcertId = @event.ConcertId;
view.IsValid = false;
view.ValidationMessage = "Not validated";
return view;
}
private TicketValidationStatus Apply(TicketValidationStatus view, TicketValidated @event)
{
view.IsValid = true;
view.ValidationMessage = "Validated";
return view;
}
private TicketValidationStatus Apply(TicketValidationStatus view, TicketValidationFailed @event)
{
view.IsValid = false;
view.ValidationMessage = @event.Reason;
return view;
}
}
Payment
, Invoice
, UserFinancialInfo
CreateInvoice
, UpdateInvoice
, CreateUserFinancialInfo
, UpdateUserFinancialInfo
, InitiatePayment
, ConfirmPayment
, RefundPayment
InvoiceCreated
, InvoiceUpdated
, UserFinancialInfoCreated
, UserFinancialInfoUpdated
UserPayments
, PaymentDetails
, UserInvoices
, InvoiceDetails
PaymentProcessed
event, updating the payment status to Processed.PaymentFailed
event, updating the payment status to Failed.1. Business Rules
2. Invariants:
3. Commands
public record CreatePayment(string UserId, string OrderId, decimal PaymentAmount, string PaymentMethod);
public record CompletePayment(string PaymentId);
public record FailPayment(string PaymentId);
4. Events:
public record PaymentCreated(string PaymentId, string UserId, string OrderId, decimal PaymentAmount, string PaymentMethod);
public record PaymentCompleted(string PaymentId);
public record PaymentFailed(string PaymentId);
5. Aggregate
public class Payment : Aggregate
{
private string _orderId;
private string _userId;
private decimal _amount;
private PaymentStatus _status;
// Add any additional fields if necessary
public Payment() { }
public Payment(string paymentId, string orderId, string userId, decimal amount)
{
if (amount <= 0)
{
throw new ArgumentException("Payment amount must be greater than 0.", nameof(amount));
}
var paymentStarted = new PaymentStarted(paymentId, orderId, userId, amount);
ApplyAndEnqueue(paymentStarted);
}
public void ProcessPayment(string stripeTransactionId)
{
if (_status != PaymentStatus.Started)
{
throw new InvalidOperationException("Payment must be in Started status to be processed.");
}
var paymentProcessed = new PaymentProcessed(Id, stripeTransactionId);
ApplyAndEnqueue(paymentProcessed);
}
public void FailPayment(string failureReason)
{
if (_status != PaymentStatus.Started)
{
throw new InvalidOperationException("Payment must be in Started status to fail.");
}
var paymentFailed = new PaymentFailed(Id, failureReason);
ApplyAndEnqueue(paymentFailed);
}
// Apply methods
public void Apply(PaymentStarted e)
{
Id = e.PaymentId;
_orderId = e.OrderId;
_userId = e.UserId;
_amount = e.Amount;
_status = PaymentStatus.Started;
}
public void Apply(PaymentProcessed e)
{
_status = PaymentStatus.Processed;
}
public void Apply(PaymentFailed e)
{
_status = PaymentStatus.Failed;
}
}
public enum PaymentStatus
{
Started,
Processed,
Failed
}
1. Business Rules
2. Invariants:
3. Commands
public record CreateInvoice(string OrderId, List<InvoiceItem> Items, string UserId, CustomerInfo CustomerInfo);
public record InvoiceItem(string Description, int Quantity, decimal UnitPrice);
public record CustomerInfo(string Name, string Address, string Email);
4. Events:
public record InvoiceCreated(string InvoiceId, string OrderId, List<InvoiceItem> Items, string UserId, CustomerInfo CustomerInfo);
5. Aggregate
public class Invoice: Aggregate
{
public string Id { get; private init; }
public string OrderId { get; private init; }
public List<InvoiceItem> Items { get; private init; }
public decimal TotalAmount { get; private init; }
public string UserId { get; private init; }
public CustomerInfo CustomerInfo { get; private init; }
public bool IsPaid { get; private set; }
private Invoice() { }
public static Invoice Create(string orderId, List<InvoiceItem> items, string userId, CustomerInfo customerInfo)
{
if (string.IsNullOrEmpty(orderId)) throw new ArgumentNullException(nameof(orderId));
if (items == null || items.Count == 0) throw new ArgumentException("Items list must contain at least one item.", nameof(items));
if (string.IsNullOrEmpty(userId)) throw new ArgumentNullException(nameof(userId));
if (customerInfo == null) throw new ArgumentNullException(nameof(customerInfo));
var totalAmount = items.Sum(item => item.Quantity * item.UnitPrice);
var invoice = new Invoice
{
Id = Guid.NewGuid().ToString(),
OrderId = orderId,
Items = items,
TotalAmount = totalAmount,
UserId = userId,
CustomerInfo = customerInfo,
};
invoice.ApplyAndEnqueue(new InvoiceCreated(invoice.Id, orderId, items, userId, customerInfo));
return invoice;
}
private void Apply(InvoiceCreated e)
{
Id = e.InvoiceId;
OrderId = e.OrderId;
Items = e.Items;
TotalAmount = Items.Sum(item => item.Quantity * item.UnitPrice);
UserId = e.UserId;
CustomerInfo = e.CustomerInfo;
}
}
6. Event Handler
public class OrderEventHandler
{
private readonly IEventStore eventStore;
public OrderEventHandler(IEventStore eventStore)
{
this.eventStore = eventStore;
}
public async Task HandleAsync(OrderCreated e)
{
var invoiceItems = e.Tickets.Select(ticket => new InvoiceItem(
$"{ticket.ConcertName} - {ticket.TicketType}",
1,
ticket.Price
)).ToList();
var invoice = Invoice.Create(e.OrderId, invoiceItems, e.UserId, e.CustomerInfo);
eventStore.Append(invoice.Id, invoice);
await eventStore.SaveChangesAsync();
}
}
The Stripe integration will be done using Stripe's official .NET SDK. We'll create a dedicated service for handling Stripe-specific operations, like creating charges, refunds, and other Stripe-related actions. This service will be called from our command handlers or application services when needed.
By encapsulating Stripe-related operations in a separate service and interacting with it through command handlers or application services, we can keep our Payment aggregate clean and focused on its core business rules and invariants.
Command Handler:
using Marten;
public class PaymentCommandHandler
{
private readonly IEventStore eventStore;
private readonly StripeService _stripeService;
public PaymentCommandHandler(IEventStore eventStore, StripeService stripeService)
{
this.eventStore = eventStore;
_stripeService = stripeService;
}
public async Task Handle(ProcessPaymentCommand command)
{
// Load the payment aggregate from the event store
var payment = await eventStore.LoadAsync<Payment>(command.PaymentId);
// Call the StripeService to create a charge
var charge = _stripeService.CreateCharge(payment.Amount, payment.Currency, command.PaymentMethodId);
// Check if the charge was successful
if (charge.Status == "succeeded")
{
payment.ProcessPayment(charge.Id);
}
else
{
payment.FailPayment(charge.FailureMessage);
}
// Save the changes to the event store
eventStore.Store(payment);
await eventStore.SaveChangesAsync();
}
}
Stripe integration:
using Stripe;
using System;
public class StripeService
{
public StripeService(string apiKey)
{
StripeConfiguration.ApiKey = apiKey;
}
public Charge CreateCharge(decimal amount, string currency, string paymentMethodId)
{
var chargeOptions = new ChargeCreateOptions
{
Amount = Convert.ToInt64(amount * 100), // Convert to the lowest currency unit (e.g., cents)
Currency = currency,
PaymentMethod = paymentMethodId,
Confirm = true,
};
var chargeService = new ChargeService();
var charge = chargeService.Create(chargeOptions);
return charge;
}
}
Display a list of payments made by a user
View
public record UserPayment(string PaymentId, string UserId, string OrderId, decimal Amount, PaymentStatus Status, DateTimeOffset PaymentDate);
public record UserPayments(string UserId, IReadOnlyList<UserPayment> Payments);
Projection
public class UserPaymentsProjection : Projection<UserPayments>
{
public UserPaymentsProjection()
{
Projects<PaymentCreated>(ev => ev.UserId, Apply);
Projects<PaymentSucceeded>(ev => ev.UserId, Apply);
Projects<PaymentFailed>(ev => ev.UserId, Apply);
}
private UserPayments Apply(UserPayments view, PaymentCreated @event)
{
var payment = new UserPayment(@event.PaymentId, @event.UserId, @event.OrderId, @event.Amount, PaymentStatus.Pending, @event.CreatedAt);
view.Payments = view.Payments.Append(payment).ToList();
return view;
}
private UserPayments Apply(UserPayments view, PaymentSucceeded @event)
{
var payment = view.Payments.FirstOrDefault(p => p.PaymentId == @event.PaymentId);
if (payment is not null)
{
var updatedPayment = payment with { Status = PaymentStatus.Successful };
view.Payments = view.Payments.Remove(payment).Append(updatedPayment).ToList();
}
return view;
}
private UserPayments Apply(UserPayments view, PaymentFailed @event)
{
var payment = view.Payments.FirstOrDefault(p => p.PaymentId == @event.PaymentId);
if (payment is not null)
{
var updatedPayment = payment with { Status = PaymentStatus.Failed };
view.Payments = view.Payments.Remove(payment).Append(updatedPayment).ToList();
}
return view;
}
}
Display the details of a specific payment for a user
View
public record PaymentDetails(string PaymentId, string UserId, string OrderId, decimal Amount, PaymentStatus Status, DateTimeOffset PaymentDate, string StripePaymentId);
Projection
Display a list of invoices for a user
View
public record UserPayment(string PaymentId, string UserId, string OrderId, decimal Amount, PaymentStatus Status, DateTimeOffset PaymentDate);
public record UserPayments(string UserId, IReadOnlyList<UserPayment> Payments);
Projection
public class PaymentDetailsProjection : Projection<PaymentDetails>
{
public PaymentDetailsProjection()
{
Projects<PaymentCreated>(ev => ev.UserId, Apply);
Projects<PaymentSucceeded>(ev => ev.UserId, Apply);
Projects<PaymentFailed>(ev => ev.UserId, Apply);
}
private PaymentDetails Apply(PaymentDetails view, PaymentCreated @event) =>
new PaymentDetails(@event.PaymentId, @event.UserId, @event.OrderId, @event.Amount, PaymentStatus.Pending, @event.CreatedAt, @event.StripePaymentId);
private PaymentDetails Apply(PaymentDetails view, PaymentSucceeded @event)
{
if (view.PaymentId != @event.PaymentId)
return view;
return view with { Status = PaymentStatus.Successful };
}
private PaymentDetails Apply(PaymentDetails view, PaymentFailed @event)
{
if (view.PaymentId != @event.PaymentId)
return view;
return view with { Status = PaymentStatus.Failed };
}
}
Display the details of a specific invoice for a user
View
public record InvoiceDetails(string InvoiceId, string UserId, string OrderId, decimal Amount, DateTimeOffset InvoiceDate, IReadOnlyList<InvoiceItem> Items);
public record InvoiceItem(string Description, int Quantity, decimal Price);
Projection
public class InvoiceDetailsProjection : Projection<InvoiceDetails>
{
public InvoiceDetailsProjection()
{
Projects<InvoiceCreated>(ev => ev.InvoiceId, Apply);
}
public void Apply(InvoiceDetails view, InvoiceCreated @event) =>
new InvoiceDetails(@event.InvoiceId, @event.UserId, @event.OrderId, @event.Amount, @event.CreatedAt, @event.Items);
}
User
RegisterUser
, UpdateUserRole
UserRegistered
, UserRoleUpdated