Skip to content

Make IEventSerializer AOT-compatible#524

Open
alexeyzimarev wants to merge 7 commits intodevfrom
feature/aot-serializer-cleanup
Open

Make IEventSerializer AOT-compatible#524
alexeyzimarev wants to merge 7 commits intodevfrom
feature/aot-serializer-cleanup

Conversation

@alexeyzimarev
Copy link
Contributor

Summary

  • Remove [RequiresUnreferencedCode] and [RequiresDynamicCode] from IEventSerializer interface and all 43 consumer files across core and integration packages
  • Extract reflection-based DefaultEventSerializer to new Eventuous.Serialization.Json package (intentionally not AOT-compatible)
  • Add EventSerializer static holder class replacing DefaultEventSerializer.Instance / SetDefaultSerializer()
  • DefaultStaticEventSerializer and core serialization interface are now fully AOT-clean with zero suppression attributes

Breaking changes

  • DefaultEventSerializer moved from Eventuous.Serialization to Eventuous.Serialization.Json package
  • DefaultEventSerializer.Instance replaced by EventSerializer.Default
  • DefaultEventSerializer.SetDefaultSerializer() replaced by EventSerializer.SetDefault()
  • Apps must explicitly configure a serializer (either DefaultStaticEventSerializer for AOT or DefaultEventSerializer from the new package)

Test plan

  • Full solution builds with zero errors (from this change)
  • Core tests pass (26/26)
  • Application tests pass (21/21)
  • Subscription tests pass (30/30)
  • DI extension tests pass (3/3)
  • Integration tests with infrastructure (require docker services)

🤖 Generated with Claude Code

Remove [RequiresUnreferencedCode] and [RequiresDynamicCode] from the
IEventSerializer interface and all 43 consumer files across the codebase.
Extract the reflection-based DefaultEventSerializer to a new
Eventuous.Serialization.Json package. The core serialization interface
and DefaultStaticEventSerializer are now fully AOT-clean.

- Clean IEventSerializer interface (no AOT attributes)
- Add EventSerializer static holder replacing DefaultEventSerializer.Instance
- Move DefaultEventSerializer to Eventuous.Serialization.Json package
- Remove AOT attributes from Persistence, Producers, Subscriptions,
  Application, and all integration packages (KurrentDB, Sql.Base,
  RabbitMQ, Sqlite)
- Delete 6 Constants.cs files with DynamicSerializationMessage
- Update all samples and tests to use new API

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@qodo-free-for-open-source-projects
Copy link
Contributor

qodo-free-for-open-source-projects bot commented Mar 13, 2026

Code Review by Qodo

🐞 Bugs (2) 📘 Rule violations (0) 📎 Requirement gaps (0)

Grey Divider


Action required

1. Ctor overwrites default🐞 Bug ✓ Correctness
Description
DefaultEventSerializer unconditionally calls EventSerializer.SetDefault(this) in its
constructor, so creating an instance for local/DI use silently overwrites any previously configured
global serializer. This contradicts the comment (“if none is set”) and can cause unrelated
components to start serializing/deserializing with a different configuration.
Code

src/Core/src/Eventuous.Serialization.Json/DefaultEventSerializer.cs[R14-20]

+    public DefaultEventSerializer(JsonSerializerOptions options, ITypeMapper? typeMapper = null) {
+        _options    = options;
+        _typeMapper = typeMapper ?? TypeMap.Instance;

-    readonly ITypeMapper _typeMapper = typeMapper ?? TypeMap.Instance;
-
-    public static void SetDefaultSerializer(IEventSerializer serializer) => Instance = serializer;
+        // Auto-register as default if none is set
+        EventSerializer.SetDefault(this);
+    }
Evidence
The DefaultEventSerializer constructor always sets the global default via
EventSerializer.SetDefault(this) (no guard), and EventSerializer.SetDefault is a plain
assignment to the static backing field. This makes every new DefaultEventSerializer(...) a global
mutation, even when used only as a local/DI-registered serializer instance.

src/Core/src/Eventuous.Serialization.Json/DefaultEventSerializer.cs[14-20]
src/Core/src/Eventuous.Serialization/EventSerializer.cs[12-28]
src/Core/test/Eventuous.Tests.Persistence.Base/Fixtures/StoreFixtureBase.cs[28-33]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`DefaultEventSerializer` currently overwrites the process-wide default serializer every time it is constructed, which makes local/DI usage mutate global state and can break previously configured serializer behavior.
## Issue Context
The constructor comment says it should only auto-register &amp;quot;if none is set&amp;quot;, but the implementation calls `EventSerializer.SetDefault(this)` unconditionally. `EventSerializer.SetDefault` is a simple assignment.
## Fix Focus Areas
- src/Core/src/Eventuous.Serialization.Json/DefaultEventSerializer.cs[14-20]
- src/Core/src/Eventuous.Serialization/EventSerializer.cs[12-28]
## Implementation notes
- Add `public static bool TrySetDefault(IEventSerializer serializer)` in `EventSerializer` that sets the default only when currently null (e.g., via `Interlocked.CompareExchange`).
- Update `DefaultEventSerializer` ctor to call `TrySetDefault(this)` (or remove ctor side-effect entirely and require explicit `EventSerializer.SetDefault(...)`).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Unconfigured default in tests🐞 Bug ⛯ Reliability
Description
Several test projects now call EventSerializer.Default without configuring a default serializer in
that test assembly, so the first access will throw InvalidOperationException. This breaks tests
like Azure ServiceBus ConvertEventToMessage, Kafka BasicProducerTests, and KurrentDB
CustomDependenciesTests.
Code

src/Azure/test/Eventuous.Tests.Azure.ServiceBus/ConvertEventToMessage.cs[R11-13]

       var builder = new ServiceBusMessageBuilder(
-            DefaultEventSerializer.Instance,
+            EventSerializer.Default,
           "test-stream",
Evidence
EventSerializer.Default throws when _default is null. Multiple tests call
EventSerializer.Default directly, and their test projects do not reference the
Eventuous.Tests.Subscriptions assembly that contains the new [ModuleInitializer] setting a
default serializer, so those calls will throw at runtime.

src/Core/src/Eventuous.Serialization/EventSerializer.cs[18-22]
src/Azure/test/Eventuous.Tests.Azure.ServiceBus/ConvertEventToMessage.cs[10-14]
src/Kafka/test/Eventuous.Tests.Kafka/BasicProducerTests.cs[68-75]
src/KurrentDB/test/Eventuous.Tests.KurrentDB/Subscriptions/CustomDependenciesTests.cs[107-111]
src/Core/test/Eventuous.Tests.Subscriptions/TestSetup.cs[6-10]
src/Azure/test/Eventuous.Tests.Azure.ServiceBus/Eventuous.Tests.Azure.ServiceBus.csproj[3-6]
src/Kafka/test/Eventuous.Tests.Kafka/Eventuous.Tests.Kafka.csproj[8-11]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Some test projects call `EventSerializer.Default` without configuring a default serializer in that test process, so tests throw `InvalidOperationException` immediately.
## Issue Context
Only `Eventuous.Tests.Subscriptions` adds a module initializer to construct `DefaultEventSerializer`, but Azure/Kafka/KurrentDB test projects reference `Eventuous.Tests.Subscriptions.Base` instead, so they do not get this initializer.
## Fix Focus Areas
- src/Azure/test/Eventuous.Tests.Azure.ServiceBus/ConvertEventToMessage.cs[10-14]
- src/Kafka/test/Eventuous.Tests.Kafka/BasicProducerTests.cs[68-75]
- src/KurrentDB/test/Eventuous.Tests.KurrentDB/Subscriptions/CustomDependenciesTests.cs[107-111]
- src/Core/test/Eventuous.Tests.Subscriptions.Base/Eventuous.Tests.Subscriptions.Base.csproj[1-20]
## Implementation notes
- Add a new `TestSetup.cs` with `[ModuleInitializer]` to `Eventuous.Tests.Subscriptions.Base` (since it is referenced by Azure/Kafka/KurrentDB tests) that calls `EventSerializer.SetDefault(new DefaultEventSerializer(new JsonSerializerOptions(JsonSerializerDefaults.Web)))`.
- Add a `ProjectReference` from `Eventuous.Tests.Subscriptions.Base` to `$(CoreRoot)\Eventuous.Serialization.Json\Eventuous.Serialization.Json.csproj` so the initializer can use `DefaultEventSerializer`.
- Alternatively, add equivalent initializers directly to each affected test project if you want to avoid pulling Json serializer into the base project.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

ⓘ The new review experience is currently in Beta. Learn more

Grey Divider

Qodo Logo

Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 19ac3f8952

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".


public static void SetDefaultSerializer(IEventSerializer serializer) => Instance = serializer;
// Auto-register as default if none is set
EventSerializer.SetDefault(this);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Avoid resetting global serializer in constructor

Calling EventSerializer.SetDefault(this) inside DefaultEventSerializer's constructor makes every new instance mutate global process state, which is a behavioral regression from the previous implementation where construction was side-effect free unless callers explicitly set the default. This can break apps that create multiple serializers (for different ITypeMapper/options scopes) because the last constructed instance silently changes what all ... ?? EventSerializer.Default call sites use; at minimum this should only set the default when none is configured, as the comment indicates.

Useful? React with 👍 / 👎.

@github-actions
Copy link

github-actions bot commented Mar 13, 2026

Test Results

   60 files  + 40     60 suites  +40   39m 23s ⏱️ + 25m 14s
  344 tests + 10    344 ✅ + 10  0 💤 ±0  0 ❌ ±0 
1 035 runs  +690  1 035 ✅ +690  0 💤 ±0  0 ❌ ±0 

Results for commit f1dae72. ± Comparison against base commit a604f77.

This pull request removes 5 and adds 15 tests. Note that renamed tests count towards both.
Eventuous.Tests.Azure.ServiceBus.IsSerialisableByServiceBus ‑ Passes(24277a32-3379-4a2d-bc31-db90e55c211c)
Eventuous.Tests.Azure.ServiceBus.IsSerialisableByServiceBus ‑ Passes(3/11/2026 3:37:40 PM +00:00)
Eventuous.Tests.Azure.ServiceBus.IsSerialisableByServiceBus ‑ Passes(3/11/2026 3:37:40 PM)
Eventuous.Tests.Subscriptions.SequenceTests ‑ ShouldReturnFirstBefore(CommitPosition { Position: 0, Sequence: 1, Timestamp: 2026-03-11T15:37:40.2161634+00:00 }, CommitPosition { Position: 0, Sequence: 2, Timestamp: 2026-03-11T15:37:40.2161634+00:00 }, CommitPosition { Position: 0, Sequence: 4, Timestamp: 2026-03-11T15:37:40.2161634+00:00 }, CommitPosition { Position: 0, Sequence: 6, Timestamp: 2026-03-11T15:37:40.2161634+00:00 }, CommitPosition { Position: 0, Sequence: 2, Timestamp: 2026-03-11T15:37:40.2161634+00:00 })
Eventuous.Tests.Subscriptions.SequenceTests ‑ ShouldReturnFirstBefore(CommitPosition { Position: 0, Sequence: 1, Timestamp: 2026-03-11T15:37:40.2161634+00:00 }, CommitPosition { Position: 0, Sequence: 2, Timestamp: 2026-03-11T15:37:40.2161634+00:00 }, CommitPosition { Position: 0, Sequence: 6, Timestamp: 2026-03-11T15:37:40.2161634+00:00 }, CommitPosition { Position: 0, Sequence: 8, Timestamp: 2026-03-11T15:37:40.2161634+00:00 }, CommitPosition { Position: 0, Sequence: 2, Timestamp: 2026-03-11T15:37:40.2161634+00:00 })
Eventuous.Tests.Azure.ServiceBus.IsSerialisableByServiceBus ‑ Passes(005884b6-da3e-449f-93d1-ab3627abe886)
Eventuous.Tests.Azure.ServiceBus.IsSerialisableByServiceBus ‑ Passes(3/13/2026 6:04:25 PM +00:00)
Eventuous.Tests.Azure.ServiceBus.IsSerialisableByServiceBus ‑ Passes(3/13/2026 6:04:25 PM)
Eventuous.Tests.Azure.ServiceBus.IsSerialisableByServiceBus ‑ Passes(3/13/2026 6:04:27 PM +00:00)
Eventuous.Tests.Azure.ServiceBus.IsSerialisableByServiceBus ‑ Passes(3/13/2026 6:04:27 PM)
Eventuous.Tests.Azure.ServiceBus.IsSerialisableByServiceBus ‑ Passes(3/13/2026 6:04:38 PM +00:00)
Eventuous.Tests.Azure.ServiceBus.IsSerialisableByServiceBus ‑ Passes(3/13/2026 6:04:38 PM)
Eventuous.Tests.Azure.ServiceBus.IsSerialisableByServiceBus ‑ Passes(59f2e776-1117-46c7-b1f0-51ec50d8da74)
Eventuous.Tests.Azure.ServiceBus.IsSerialisableByServiceBus ‑ Passes(839c496d-8602-435e-9e7a-cc9a520ec023)
Eventuous.Tests.Subscriptions.SequenceTests ‑ ShouldReturnFirstBefore(CommitPosition { Position: 0, Sequence: 1, Timestamp: 2026-03-13T18:04:26.0139060+00:00 }, CommitPosition { Position: 0, Sequence: 2, Timestamp: 2026-03-13T18:04:26.0139060+00:00 }, CommitPosition { Position: 0, Sequence: 4, Timestamp: 2026-03-13T18:04:26.0139060+00:00 }, CommitPosition { Position: 0, Sequence: 6, Timestamp: 2026-03-13T18:04:26.0139060+00:00 }, CommitPosition { Position: 0, Sequence: 2, Timestamp: 2026-03-13T18:04:26.0139060+00:00 })
…

♻️ This comment has been updated with latest results.

alexeyzimarev and others added 6 commits March 13, 2026 18:30
- Add TrySetDefault to EventSerializer using Interlocked.CompareExchange
  so DefaultEventSerializer constructor doesn't overwrite an existing
  serializer configuration
- Add module initializer to Eventuous.Tests.Subscriptions.Base so
  integration tests (Azure, Kafka, KurrentDB) have EventSerializer.Default
  configured before use

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
These test projects use EventSerializer.Default without configuring it.
Add module initializers with DefaultEventSerializer to fix CI failures.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Use EventSerializer.SetDefault() explicitly instead of relying on
the constructor's hidden TrySetDefault side effect.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…Dynamic

The old name was ambiguous since DefaultStaticEventSerializer also uses
System.Text.Json. The new name clarifies this package contains the
reflection-based (non-AOT) dynamic serializer.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Rewrite the default serializer section to document both
DefaultStaticEventSerializer (AOT, recommended) and
DefaultEventSerializer (reflection-based, separate package).
Update API references from the old DefaultEventSerializer.SetDefaultSerializer
to the new EventSerializer.SetDefault.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@cloudflare-workers-and-pages
Copy link

cloudflare-workers-and-pages bot commented Mar 13, 2026

Deploying eventuous-main with  Cloudflare Pages  Cloudflare Pages

Latest commit: f1dae72
Status: ✅  Deploy successful!
Preview URL: https://058533bf.eventuous-main.pages.dev
Branch Preview URL: https://feature-aot-serializer-clean.eventuous-main.pages.dev

View logs

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant