@@ -32,12 +32,12 @@ In traditional applications, we store the **current state** of entities:
3232Instead, 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
9193graph 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
110113See [ 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
236294var 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
240298var 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
342400public 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 . 99 m );
598-
599- // Assert: Verify event
600- Assert .Equal (49 . 99 m , @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 (20 m , start , end );
674+
675+ // Assert — TUnit async assertions
676+ _ = await Assert .That (result .IsSuccess ).IsTrue ();
677+ _ = await Assert .That (result .Value .Sale .Percentage ).IsEqualTo (20 m );
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
621701This project uses ** Marten** for event sourcing implementation. Marten provides:
0 commit comments