From 1fc19de286ab81584b080623e8ba79cb8fda3ab3 Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Tue, 10 Mar 2026 12:21:39 +0100 Subject: [PATCH 1/2] feat: add Created timestamp to StreamEvent record Expose the event creation timestamp through StreamEvent so consumers can access it when reading streams. All store implementations already had this data available but were not passing it through. Also update the serialisation docs to document the source generator and EVTC001 analyzer for type registration. Co-Authored-By: Claude Opus 4.6 --- .../content/docs/persistence/serialisation.md | 39 +++++++++---------- .../src/Eventuous.Persistence/StreamEvent.cs | 1 + .../Store/ElasticEventStore.cs | 3 +- .../KurrentDBEventStore.cs | 3 +- src/Redis/src/Eventuous.Redis/RedisStore.cs | 2 +- .../Eventuous.Sql.Base/SqlEventStoreBase.cs | 2 +- .../Eventuous.Testing/InMemoryEventStore.cs | 8 ++-- 7 files changed, 30 insertions(+), 28 deletions(-) diff --git a/docs/src/content/docs/persistence/serialisation.md b/docs/src/content/docs/persistence/serialisation.md index 06e12ea57..9ca3b2bc1 100644 --- a/docs/src/content/docs/persistence/serialisation.md +++ b/docs/src/content/docs/persistence/serialisation.md @@ -52,18 +52,29 @@ Then, you can call this code in your bootstrap code: BookingEvents.MapBookingEvents(); ``` -### Auto-registration of types +### Auto-registration with source generator -For convenience purposes, you can avoid manual mapping between type names and types by using the `EventType` attribute. +The recommended way to register event types is to use the `[EventType]` attribute combined with the Eventuous source generator. The generator automatically discovers all types decorated with `[EventType]` in your project and generates a module initializer that registers them at startup — no manual registration code needed. -Annotate your events with it like this: +Annotate your events with the `[EventType]` attribute: ```csharp [EventType("V1.FullyPaid")] public record BookingFullyPaid(string BookingId, DateTimeOffset FullyPaidAt); + +[EventType("V1.RoomBooked")] +public record RoomBooked(string RoomId, LocalDate CheckIn, LocalDate CheckOut, float Price); ``` -Then, use the registration code in the bootstrap code: +That's it. The source generator produces a module initializer class per assembly, which calls `TypeMap.Instance.AddType(...)` for each annotated event type. Registration happens automatically when the assembly is loaded — you don't need to write any startup code. + +:::tip +Eventuous also includes a diagnostic analyzer (`EVTC001`) that warns you when an event type is used in aggregates or state projections but is missing the `[EventType]` attribute. +::: + +### Reflection-based registration + +As an alternative to the source generator, you can use reflection-based registration. This scans assemblies at runtime for types decorated with `[EventType]`: ```csharp TypeMap.RegisterKnownEventTypes(); @@ -75,23 +86,9 @@ The registration won't work if event classes are defined in another assembly, wh TypeMap.RegisterKnownEventTypes(typeof(BookingFullyPaid).Assembly); ``` -If you use the .NET version that supports module initializers, you can register event types in the module. For example, if the domain event classes are located in a separate project, add the file `DomainModule.cs` to that project with the following code: - -```csharp title="DomainModule.cs" -using System.Diagnostics.CodeAnalysis; -using System.Runtime.CompilerServices; -using Eventuous; - -namespace Bookings.Domain; - -static class DomainModule { - [ModuleInitializer] - [SuppressMessage("Usage", "CA2255", MessageId = "The \'ModuleInitializer\' attribute should not be used in libraries")] - internal static void InitializeDomainModule() => TypeMap.RegisterKnownEventTypes(); -} -``` - -Then, you won't need to call the `TypeMap` registration in the application code at all. +:::note +With the source generator in place, calling `RegisterKnownEventTypes()` is typically unnecessary. The generator handles registration at compile time, which is both more reliable and avoids the overhead of runtime assembly scanning. +::: ### Default serializer diff --git a/src/Core/src/Eventuous.Persistence/StreamEvent.cs b/src/Core/src/Eventuous.Persistence/StreamEvent.cs index 1903bd006..4da7cf4da 100644 --- a/src/Core/src/Eventuous.Persistence/StreamEvent.cs +++ b/src/Core/src/Eventuous.Persistence/StreamEvent.cs @@ -18,5 +18,6 @@ public record struct StreamEvent( Metadata Metadata, string ContentType, long Revision, + DateTime Created = default, bool FromArchive = false ); diff --git a/src/Experimental/src/Eventuous.ElasticSearch/Store/ElasticEventStore.cs b/src/Experimental/src/Eventuous.ElasticSearch/Store/ElasticEventStore.cs index 09d753097..7421bea9d 100644 --- a/src/Experimental/src/Eventuous.ElasticSearch/Store/ElasticEventStore.cs +++ b/src/Experimental/src/Eventuous.ElasticSearch/Store/ElasticEventStore.cs @@ -87,7 +87,8 @@ async Task ReadEvents(Func new(Guid.Parse(evt[MessageId].ToString()), payload, meta ?? new Metadata(), ContentType, evt.Id.ToLong()); + => new(Guid.Parse(evt[MessageId].ToString()), payload, meta ?? new Metadata(), ContentType, evt.Id.ToLong(), DateTime.Parse(evt[Created]!, CultureInfo.InvariantCulture)); } } diff --git a/src/Relational/src/Eventuous.Sql.Base/SqlEventStoreBase.cs b/src/Relational/src/Eventuous.Sql.Base/SqlEventStoreBase.cs index 1a207f874..886400226 100644 --- a/src/Relational/src/Eventuous.Sql.Base/SqlEventStoreBase.cs +++ b/src/Relational/src/Eventuous.Sql.Base/SqlEventStoreBase.cs @@ -144,7 +144,7 @@ StreamEvent ToStreamEvent(PersistedEvent evt) { _ => throw new("Unknown deserialization result") }; - StreamEvent AsStreamEvent(object payload) => new(evt.MessageId, payload, meta ?? new Metadata(), ContentType, evt.StreamPosition); + StreamEvent AsStreamEvent(object payload) => new(evt.MessageId, payload, meta ?? new Metadata(), ContentType, evt.StreamPosition, evt.Created); } /// diff --git a/src/Testing/src/Eventuous.Testing/InMemoryEventStore.cs b/src/Testing/src/Eventuous.Testing/InMemoryEventStore.cs index b7ece66fc..fc08e5469 100644 --- a/src/Testing/src/Eventuous.Testing/InMemoryEventStore.cs +++ b/src/Testing/src/Eventuous.Testing/InMemoryEventStore.cs @@ -22,7 +22,8 @@ CancellationToken cancellationToken ) { var existing = _storage.GetOrAdd(stream, s => new(s)); existing.AppendEvents(expectedVersion, events); - _global.AddRange(events.Select((x, i) => new StreamEvent(x.Id, x.Payload, x.Metadata, "application/json", _global.Count + i))); + var now = DateTime.UtcNow; + _global.AddRange(events.Select((x, i) => new StreamEvent(x.Id, x.Payload, x.Metadata, "application/json", _global.Count + i, now))); return Task.FromResult(new AppendEventsResult((ulong)(_global.Count - 1), existing.Version)); } @@ -33,9 +34,10 @@ public Task AppendEvents(IReadOnlyCollection new(s)); existing.AppendEvents(append.ExpectedVersion, append.Events); - _global.AddRange(append.Events.Select((x, j) => new StreamEvent(x.Id, x.Payload, x.Metadata, "application/json", _global.Count + j))); + _global.AddRange(append.Events.Select((x, j) => new StreamEvent(x.Id, x.Payload, x.Metadata, "application/json", _global.Count + j, now))); results[i++] = new AppendEventsResult((ulong)(_global.Count - 1), existing.Version); } @@ -97,7 +99,7 @@ public void AppendEvents(ExpectedStreamVersion expectedVersion, IReadOnlyCollect foreach (var newEvent in events) { var version = ++Version; - var streamEvent = new StreamEvent(newEvent.Id, newEvent.Payload, newEvent.Metadata, "application/json", version); + var streamEvent = new StreamEvent(newEvent.Id, newEvent.Payload, newEvent.Metadata, "application/json", version, DateTime.UtcNow); _events.Add(new(streamEvent, version)); } } From 86fc4befae2ed7c728de004d0447292b687c733c Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Tue, 10 Mar 2026 12:30:08 +0100 Subject: [PATCH 2/2] fix: use DateTime.UtcNow in ElasticSearch store for consistency All other stores use UTC timestamps; ElasticSearch was using local time. Co-Authored-By: Claude Opus 4.6 --- .../src/Eventuous.ElasticSearch/Store/ElasticEventStore.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Experimental/src/Eventuous.ElasticSearch/Store/ElasticEventStore.cs b/src/Experimental/src/Eventuous.ElasticSearch/Store/ElasticEventStore.cs index 7421bea9d..f89893ce0 100644 --- a/src/Experimental/src/Eventuous.ElasticSearch/Store/ElasticEventStore.cs +++ b/src/Experimental/src/Eventuous.ElasticSearch/Store/ElasticEventStore.cs @@ -31,7 +31,7 @@ PersistedEvent AsDocument(NewStreamEvent evt, long position) (ulong)position + 1, evt.Payload, evt.Metadata.ToHeaders(), - DateTime.Now + DateTime.UtcNow ); }