Skip to content

Commit 5b730d2

Browse files
committed
docs: audit and update event-sourcing-guide.md
1 parent 61da4db commit 5b730d2

1 file changed

Lines changed: 173 additions & 93 deletions

File tree

docs/guides/event-sourcing-guide.md

Lines changed: 173 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -32,12 +32,12 @@ In traditional applications, we store the **current state** of entities:
3232
Instead, we store **all changes as events**:
3333

3434
```csharp
35-
// Event Store: mt_events table
36-
| id | stream_id | type | data | timestamp |
37-
|----|-----------|----------------|-------------------------|------------|
38-
| 1 | book-123 | BookAdded | {title: "Clean Code"} | 2025-01-01 |
39-
| 2 | book-123 | PriceChanged | {price: 45.00} | 2025-01-02 |
40-
| 3 | book-123 | BookPublished | {status: "published"} | 2025-01-03 |
35+
// Event Store: mt_events table (stream_id is a Guid, e.g. Guid.CreateVersion7())
36+
| id | stream_id | type | data | timestamp |
37+
|----|--------------------------------------|------------------|----------------------------|------------|
38+
| 1 | 0193f4e6-3b5a-7000-b234-... | book_added | {title: "Clean Code", ...} | 2025-01-01 |
39+
| 2 | 0193f4e6-3b5a-7000-b234-... | book_updated | {title: "Clean Code 2e"} | 2025-01-02 |
40+
| 3 | 0193f4e6-3b5a-7000-b234-... | book_soft_deleted| {timestamp: ...} | 2025-01-03 |
4141
```
4242

4343
**Benefits**:
@@ -63,11 +63,13 @@ public record BookAdded(
6363
Guid Id,
6464
string Title,
6565
string? Isbn,
66-
string? Description,
67-
DateOnly? PublicationDate,
66+
string Language,
67+
Dictionary<string, BookTranslation> Translations,
68+
PartialDate? PublicationDate,
6869
Guid? PublisherId,
6970
List<Guid> AuthorIds,
70-
List<Guid> CategoryIds);
71+
List<Guid> CategoryIds,
72+
Dictionary<string, decimal> Prices);
7173
```
7274

7375
**Event Naming**:
@@ -89,11 +91,11 @@ A **stream** is a sequence of events for a single aggregate instance.
8991

9092
```mermaid
9193
graph TD
92-
Stream[Stream: book-123]
94+
Stream["Stream: 0193f4e6-3b5a-7000-b234-..."]
9395
V1[Version 1: BookAdded]
9496
V2[Version 2: BookUpdated]
95-
V3[Version 3: PriceChanged]
96-
V4[Version 4: BookPublished]
97+
V3[Version 3: BookCoverUpdated]
98+
V4[Version 4: BookSoftDeleted]
9799
98100
Stream --> V1
99101
V1 --> V2
@@ -102,10 +104,11 @@ graph TD
102104
```
103105

104106
**Stream Properties**:
105-
- Each stream has a unique ID (typically the aggregate ID)
107+
- Each stream has a unique ID — always a `Guid` (the aggregate's ID, created with `Guid.CreateVersion7()`)
108+
- Streams are typed: `session.Events.StartStream<BookAggregate>(id, firstEvent)` binds the stream to the aggregate type
106109
- Events are ordered by version number
107110
- Streams are append-only (events never deleted)
108-
- Stream version increments with each new event
111+
- Stream version increments with each new event and is exposed on the aggregate as `public long Version { get; private set; }`
109112

110113
See [Marten Guide - Working with Streams](marten-guide.md#working-with-streams) for stream operations.
111114

@@ -117,44 +120,99 @@ An **aggregate** is a domain object that:
117120
- Generates new events from commands
118121

119122
```csharp
120-
public class BookAggregate
123+
using Marten.Metadata;
124+
125+
// Aggregates implement ISoftDeleted to support Marten's soft-delete conventions
126+
public class BookAggregate : ISoftDeleted
121127
{
122-
// Current state (built from events)
123-
public Guid Id { get; set; }
124-
public string Title { get; set; } = string.Empty;
125-
public decimal Price { get; set; }
126-
public bool IsPublished { get; set; }
127-
128-
// Apply methods: Rebuild state from events
128+
// All properties use private setters (analyzer rule BS3005)
129+
public Guid Id { get; private set; }
130+
public string Title { get; private set; } = string.Empty;
131+
public string Language { get; private set; } = string.Empty;
132+
public Dictionary<string, BookTranslation> Translations { get; private set; } = [];
133+
public Dictionary<string, decimal> Prices { get; private set; } = [];
134+
135+
// Marten sets Version automatically; used for ETag-based optimistic concurrency
136+
public long Version { get; private set; }
137+
138+
// ISoftDeleted requires public setters; suppressed via pragma (Marten requirement)
139+
#pragma warning disable BS3005
140+
public bool Deleted { get; set; }
141+
public DateTimeOffset? DeletedAt { get; set; }
142+
#pragma warning restore BS3005
143+
144+
// Apply methods: rebuild state only — NO validation or side effects here
129145
void Apply(BookAdded @event)
130146
{
131147
Id = @event.Id;
132148
Title = @event.Title;
133-
IsPublished = false;
149+
Language = @event.Language;
150+
Translations = @event.Translations;
151+
Prices = @event.Prices;
152+
Deleted = false;
134153
}
135-
136-
void Apply(PriceChanged @event)
154+
155+
void Apply(BookUpdated @event)
137156
{
138-
Price = @event.NewPrice;
157+
Title = @event.Title;
158+
Language = @event.Language;
159+
Translations = @event.Translations;
160+
Prices = @event.Prices;
139161
}
140-
141-
void Apply(BookPublished @event)
162+
163+
void Apply(BookSoftDeleted _)
142164
{
143-
IsPublished = true;
165+
Deleted = true;
166+
DeletedAt = DateTimeOffset.UtcNow;
144167
}
145-
146-
// Command methods: Generate new events
147-
public PriceChanged ChangePrice(decimal newPrice)
168+
169+
void Apply(BookRestored _)
170+
{
171+
Deleted = false;
172+
DeletedAt = null;
173+
}
174+
175+
// Static factory: generates the creation event for a new stream.
176+
// Returns Result<TEvent> — business errors flow through Result, never thrown.
177+
public static Result<BookAdded> CreateEvent(
178+
Guid id, string title, string? isbn, string language,
179+
Dictionary<string, BookTranslation> translations,
180+
PartialDate? publicationDate, Guid? publisherId,
181+
List<Guid> authorIds, List<Guid> categoryIds,
182+
Dictionary<string, decimal> prices)
183+
{
184+
if (id == Guid.Empty)
185+
return Result.Failure<BookAdded>(Error.Validation(ErrorCodes.Books.IdRequired, "Book ID is required"));
186+
187+
// ... additional validation
188+
189+
return new BookAdded(id, title, isbn, language, translations,
190+
publicationDate, publisherId, authorIds, categoryIds, prices);
191+
}
192+
193+
// Instance command: generates an update event from an existing aggregate.
194+
public Result<BookUpdated> UpdateEvent(string title, ...)
195+
{
196+
if (Deleted)
197+
return Result.Failure<BookUpdated>(Error.Conflict(ErrorCodes.Books.AlreadyDeleted, "Cannot update a deleted book"));
198+
199+
return new BookUpdated(Id, title, ...);
200+
}
201+
202+
public Result<BookSoftDeleted> SoftDeleteEvent()
203+
{
204+
if (Deleted)
205+
return Result.Failure<BookSoftDeleted>(Error.Conflict(ErrorCodes.Books.AlreadyDeleted, "Book is already deleted"));
206+
207+
return new BookSoftDeleted(Id, DateTimeOffset.UtcNow);
208+
}
209+
210+
public Result<BookRestored> RestoreEvent()
148211
{
149-
// Business rule: Can't change price of unpublished book
150-
if (!IsPublished)
151-
throw new InvalidOperationException("Cannot change price of unpublished book");
152-
153-
// Business rule: Price must be positive
154-
if (newPrice <= 0)
155-
throw new ArgumentException("Price must be positive");
156-
157-
return new PriceChanged(Id, Price, newPrice);
212+
if (!Deleted)
213+
return Result.Failure<BookRestored>(Error.Conflict(ErrorCodes.Books.NotDeleted, "Book is not deleted"));
214+
215+
return new BookRestored(Id, DateTimeOffset.UtcNow);
158216
}
159217
}
160218
```
@@ -234,7 +292,7 @@ var currentBook = await session.Events
234292

235293
// Get state as of specific date
236294
var pastBook = await session.Events
237-
.AggregateStreamAsync<BookAggregate>(bookId, timestamp: DateTime.Parse("2025-01-15"));
295+
.AggregateStreamAsync<BookAggregate>(bookId, timestamp: DateTimeOffset.Parse("2025-01-15T00:00:00Z"));
238296

239297
// Get state at specific version
240298
var versionBook = await session.Events
@@ -335,36 +393,42 @@ foreach (var evt in errorContext)
335393

336394
**Example**:
337395
```csharp
338-
// 1. Command
339-
public record ChangeBookPrice(Guid BookId, decimal NewPrice);
396+
// 1. Command (imperative record — only events use past tense)
397+
public record UpdateBook(Guid Id, string Title, ...);
340398

341-
// 2. Handler validates and generates event
399+
// 2. Wolverine handler — IDocumentSession is auto-committed by Wolverine
342400
public static async Task<IResult> Handle(
343-
ChangeBookPrice command,
401+
UpdateBook command,
344402
IDocumentSession session)
345403
{
346-
var book = await session.Events
347-
.AggregateStreamAsync<BookAggregate>(command.BookId);
348-
349-
// 3. Aggregate validates and returns event
350-
var @event = book.ChangePrice(command.NewPrice);
351-
352-
// 4. Store event
353-
session.Events.Append(command.BookId, @event);
354-
404+
// Rehydrate aggregate by replaying its event stream
405+
var aggregate = await session.Events
406+
.AggregateStreamAsync<BookAggregate>(command.Id);
407+
408+
if (aggregate is null)
409+
return Results.NotFound();
410+
411+
// 3. Aggregate validates business rules and returns Result<TEvent>
412+
var eventResult = aggregate.UpdateEvent(command.Title, ...);
413+
if (eventResult.IsFailure)
414+
return eventResult.ToProblemDetails(); // maps to ProblemDetails response
415+
416+
// 4. Append event to stream (Wolverine commits the session after handler returns)
417+
_ = session.Events.Append(command.Id, eventResult.Value);
418+
355419
return Results.NoContent();
356420
}
357421

358-
// 5. Event is applied automatically by Marten
359-
void Apply(PriceChanged @event)
422+
// 5. Event is applied automatically by Marten during stream rehydration
423+
void Apply(BookUpdated @event)
360424
{
361-
Price = @event.NewPrice;
425+
Title = @event.Title;
362426
}
363427

364-
// 6. Projection updates asynchronously
365-
public void Apply(PriceChanged @event, BookSearchProjection projection)
428+
// 6. Async projection updates the read model (via Marten async daemon)
429+
void Apply(BookUpdated @event, BookSearchProjection projection)
366430
{
367-
projection.Price = @event.NewPrice;
431+
projection.Title = @event.Title;
368432
}
369433
```
370434

@@ -467,13 +531,17 @@ public class OrderFulfillmentSaga
467531

468532
```csharp
469533
// Return immediately after command
470-
public static IResult Handle(CreateBook command, IDocumentSession session)
534+
public static async Task<IResult> Handle(CreateBook command, IDocumentSession session)
471535
{
472-
var @event = BookAggregate.Create(...);
473-
session.Events.StartStream(command.Id, @event);
474-
475-
// Don't wait for projection - return immediately
476-
return Results.Created($"/api/books/{command.Id}", new { id = command.Id });
536+
var eventResult = BookAggregate.CreateEvent(command.Id, command.Title, ...);
537+
if (eventResult.IsFailure)
538+
return eventResult.ToProblemDetails();
539+
540+
// StartStream is typed — binds stream to the aggregate type
541+
_ = session.Events.StartStream<BookAggregate>(command.Id, eventResult.Value);
542+
543+
// Don't wait for projection — return immediately
544+
return Results.Created($"/api/admin/books/{command.Id}", new { id = command.Id });
477545
}
478546
```
479547

@@ -558,15 +626,21 @@ public async Task AnonymizeUserData(Guid userId)
558626
- ✅ Make events immutable (`record` types)
559627
- ✅ Add XML documentation explaining business meaning
560628
- ✅ Design for evolution (optional fields, metadata)
629+
- ✅ Use `DateTimeOffset.UtcNow` for timestamps — never `DateTime.Now`
561630
- ❌ Don't include computed values (calculate in projections)
562631
- ❌ Don't reference other aggregates (use IDs only)
563632

564633
### 2. Aggregate Design
565634

566635
- ✅ Keep aggregates focused (single responsibility)
636+
- ✅ All properties use `private set` (analyzer rule BS3005)
637+
- ✅ Implement `ISoftDeleted` for aggregates that support soft-delete
638+
- ✅ Expose `public long Version { get; private set; }` for ETag concurrency
567639
- ✅ Validate in command methods, not Apply methods
568-
- ✅ Apply methods should only update state
569-
- ✅ Use static factory methods for creation
640+
- ✅ Apply methods should only update state — no validation, no I/O
641+
- ✅ Command methods return `Result<TEvent>` — never throw for business errors
642+
- ✅ Use static factory (`CreateEvent`) for creation, instance methods for mutations
643+
- ✅ Use `Guid.CreateVersion7()` — never `Guid.NewGuid()`
570644
- ✅ Keep streams small (< 1000 events per aggregate)
571645
- ❌ Don't load other aggregates in command methods
572646
- ❌ Don't perform I/O in Apply methods
@@ -575,7 +649,8 @@ public async Task AnonymizeUserData(Guid userId)
575649

576650
- ✅ Create separate projections for different queries
577651
- ✅ Denormalize data for query performance
578-
- ✅ Use async projections for scalability
652+
- ✅ Use `SnapshotLifecycle.Async` for simple single-stream snapshots
653+
- ✅ Use `ProjectionLifecycle.Async` + custom `MultiStreamProjection` for cross-stream projections
579654
- ✅ Keep projections simple (no business logic)
580655
- ✅ Make projections idempotent (can replay safely)
581656
- ❌ Don't share projections across bounded contexts
@@ -584,38 +659,43 @@ public async Task AnonymizeUserData(Guid userId)
584659
### 4. Testing
585660

586661
```csharp
587-
// Test aggregates with events
588-
[Fact]
589-
public void ChangePrice_WhenPublished_ShouldGenerateEvent()
662+
// Tests use TUnit ([Test] not [Fact]) and await Assert.That(...)
663+
// Apply methods are package-private on classes; test through command methods instead
664+
[Test]
665+
public async Task ScheduleSale_Valid_ShouldSucceed()
590666
{
591-
// Arrange: Build aggregate from events
592-
var book = new BookAggregate();
593-
book.Apply(new BookAdded(Guid.NewGuid(), "Clean Code", ...));
594-
book.Apply(new BookPublished(book.Id));
595-
596-
// Act: Execute command
597-
var @event = book.ChangePrice(49.99m);
598-
599-
// Assert: Verify event
600-
Assert.Equal(49.99m, @event.NewPrice);
667+
// Arrange
668+
var aggregate = new SaleAggregate();
669+
var start = DateTimeOffset.UtcNow.AddHours(1);
670+
var end = DateTimeOffset.UtcNow.AddHours(2);
671+
672+
// Act — command method returns Result<TEvent>, not raw event
673+
var result = aggregate.ScheduleSale(20m, start, end);
674+
675+
// Assert — TUnit async assertions
676+
_ = await Assert.That(result.IsSuccess).IsTrue();
677+
_ = await Assert.That(result.Value.Sale.Percentage).IsEqualTo(20m);
601678
}
602679

603-
// Test projections with events
604-
[Fact]
605-
public async Task Apply_BookAdded_ShouldCreateProjection()
680+
[Test]
681+
public async Task SoftDeleteEvent_WhenAlreadyDeleted_ShouldFail()
606682
{
607-
// Arrange
608-
var builder = new BookSearchProjectionBuilder();
609-
var @event = new BookAdded(Guid.NewGuid(), "Clean Code", ...);
610-
683+
// Arrange: build state by applying events manually (Apply is internal)
684+
// For classes with internal Apply, integration tests via handlers are preferred
685+
// For record aggregates (SaleAggregate) direct construction is possible
686+
var aggregate = new SaleAggregate { Id = Guid.CreateVersion7() };
687+
611688
// Act
612-
var projection = await builder.Create(@event, session);
613-
689+
var result = aggregate.CancelSale(DateTimeOffset.UtcNow); // no sale exists
690+
614691
// Assert
615-
Assert.Equal("Clean Code", projection.Title);
692+
_ = await Assert.That(result.IsFailure).IsTrue();
616693
}
617694
```
618695

696+
> [!NOTE]
697+
> The project uses **TUnit** for all tests (`[Test]`, `await Assert.That(...)`). Never use `[Fact]` (xUnit) or `Assert.Equal` (xUnit) — use the TUnit assertion API instead.
698+
619699
## Integration with Marten
620700

621701
This project uses **Marten** for event sourcing implementation. Marten provides:

0 commit comments

Comments
 (0)