Skip to content

feat(cqrs): project orders into durable read model#121

Merged
blackms merged 5 commits into
mainfrom
codex/projected-order-read-model
Apr 26, 2026
Merged

feat(cqrs): project orders into durable read model#121
blackms merged 5 commits into
mainfrom
codex/projected-order-read-model

Conversation

@blackms

@blackms blackms commented Apr 26, 2026

Copy link
Copy Markdown
Owner

Summary

  • Replaces the repository-backed order query adapter with a durable JSON order read-model projection.
  • Adds order lifecycle events for canceled, rejected, and linked-to-position transitions so the read side can stay event-driven.
  • Registers an order projection handler through the domain event dispatcher and isolates event persistence in integration tests.

Acceptance Criteria

  • Given an order domain event, when it is dispatched, then the order read model is updated through a projection handler.
  • Given order queries after projection, when handlers read orders, then they read the durable read model instead of the command-side repository.
  • Given the process restarts, when the projected JSON read model exists, then order queries can reload the projected state.

Changes

  • Added durable JsonOrderReadModel plus projection writer and domain event handler.
  • Added OrderCanceledEvent, OrderRejectedEvent, and OrderLinkedToPositionEvent; enriched OrderPlacedEvent with quote currency, intent, and related position metadata.
  • Removed the old RepositoryOrderReadModel adapter and registered the projected read model in AppModule.
  • Added projection tests and adjusted application/integration tests for event-driven cancellation and isolated event persistence.

Testing

  • dotnet test tests/IntelliTrader.Infrastructure.Tests/IntelliTrader.Infrastructure.Tests.csproj --no-restore -c Release --filter "FullyQualifiedName~OpenPositionCommandDispatchIntegrationTests|FullyQualifiedName~OrderLifecycleCommandDispatchIntegrationTests|FullyQualifiedName~RefreshOrderStatusCommandDispatchIntegrationTests|FullyQualifiedName~RefreshSubmittedOrdersCommandDispatchIntegrationTests|FullyQualifiedName~OrderReadModelProjectionTests"
  • dotnet test tests/IntelliTrader.Infrastructure.Tests/IntelliTrader.Infrastructure.Tests.csproj --no-restore -c Release
  • dotnet test tests/IntelliTrader.Application.Tests/IntelliTrader.Application.Tests.csproj --no-restore -c Release --filter "FullyQualifiedName~ExchangeOrderLifecycleFactoryTests|FullyQualifiedName~RefreshOrderStatusHandlerTests"
  • dotnet test IntelliTrader.sln --no-restore -c Release
  • git diff --check

Checklist

  • Tests pass
  • Coverage maintained
  • Linter clean
  • Documentation updated or not required

Summary by CodeRabbit

  • New Features

    • Orders now track cancellation and rejection status with proper event logging.
    • Order data enriched with trading intent, quote currency, and position linkage information.
  • Improvements

    • Enhanced order history and active orders queries with persistent data storage and event-driven updates.

@coderabbitai

coderabbitai Bot commented Apr 26, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

This pull request introduces a complete event-driven order read model system. It adds four new domain events (OrderCanceledEvent, OrderRejectedEvent, OrderLinkedToPositionEvent) and enhances OrderPlacedEvent with additional context fields, updates OrderLifecycle to emit these events on transitions, replaces a repository-backed read model with a JSON-file-backed JsonOrderReadModel that projects events into a queryable order state, and wires these components into the dependency injection container.

Changes

Cohort / File(s) Summary
Domain Events
IntelliTrader.Domain/Events/OrderCanceledEvent.cs, IntelliTrader.Domain/Events/OrderRejectedEvent.cs, IntelliTrader.Domain/Events/OrderLinkedToPositionEvent.cs
Added three new sealed record domain events implementing IDomainEvent to represent order lifecycle terminal states (cancel, reject) and position linking, each with auto-generated EventId and OccurredAt defaults.
Domain Event Enhancement
IntelliTrader.Domain/Events/OrderPlacedEvent.cs
Extended constructor with quoteCurrency, intent, and relatedPositionId parameters; added corresponding public properties with intent mapping to "Unknown" when null/whitespace.
Domain Logic
IntelliTrader.Domain/Trading/Orders/OrderLifecycle.cs
Modified Submit, Cancel, and Reject transitions to emit enriched OrderPlacedEvent and new terminal-state events; added LinkRelatedPosition method to emit OrderLinkedToPositionEvent.
Order Read Model Interface & Projection
IntelliTrader.Infrastructure/Adapters/Persistence/ReadModels/IOrderReadModelProjectionWriter.cs, IntelliTrader.Infrastructure/Adapters/Persistence/ReadModels/OrderReadModelProjectionHandler.cs
Added projection writer interface defining five async projection methods for order events; created handler that dispatches events to the projection writer implementation.
JSON-Backed Order Read Model
IntelliTrader.Infrastructure/Adapters/Persistence/ReadModels/JsonOrderReadModel.cs
Implemented sealed class with in-memory case-insensitive DTO dictionary persisted to JSON file, supporting read queries (GetByIdAsync, GetRecentAsync, GetActiveAsync, GetTradingHistoryAsync) and event projection with thread-safe mutations via SemaphoreSlim.
Read Model Migration
IntelliTrader.Infrastructure/Adapters/Persistence/ReadModels/RepositoryOrderReadModel.cs
Removed 142-line repository-backed read model implementation and its filtering/mapping logic.
Dependency Injection
IntelliTrader.Infrastructure/AppModule.cs
Replaced RepositoryOrderReadModel DI registration with JsonOrderReadModel backed by order-read-model.json file; retained single-instance scope and added IOrderReadModelProjectionWriter binding.
Unit Test Updates
tests/IntelliTrader.Application.Tests/Trading/Handlers/ExchangeOrderLifecycleFactoryTests.cs, tests/IntelliTrader.Application.Tests/Trading/Handlers/RefreshOrderStatusHandlerTests.cs
Updated assertions to expect OrderCanceledEvent in domain events; revised "submitted order canceled" test to validate event enqueue, dispatcher invocation, and outbox processing.
Infrastructure Test Additions
tests/IntelliTrader.Infrastructure.Tests/Adapters/Persistence/OrderReadModelProjectionTests.cs, tests/IntelliTrader.Infrastructure.Tests/AssemblyInfo.cs
Added comprehensive projection test suite validating order lifecycle projection and trading history derivation; disabled test parallelization at assembly level.
Integration Test Infrastructure
tests/IntelliTrader.Infrastructure.Tests/Integration/InfrastructureTestFixture.cs
Added RegisterIsolatedEventPersistence method to wire temp JSON files for outbox, inbox, and order read model within test DI containers.
Integration Test Updates
tests/IntelliTrader.Infrastructure.Tests/Integration/OpenPositionCommandDispatchIntegrationTests.cs, tests/IntelliTrader.Infrastructure.Tests/Integration/OrderLifecycleCommandDispatchIntegrationTests.cs, tests/IntelliTrader.Infrastructure.Tests/Integration/RefreshOrderStatusCommandDispatchIntegrationTests.cs, tests/IntelliTrader.Infrastructure.Tests/Integration/RefreshSubmittedOrdersCommandDispatchIntegrationTests.cs
Updated Autofac container setup to register isolated event persistence for each test scenario via fixture helper.

Sequence Diagram

sequenceDiagram
    participant OL as OrderLifecycle<br/>(Domain)
    participant DD as DomainEventDispatcher
    participant PH as OrderReadModelProjectionHandler
    participant JORM as JsonOrderReadModel<br/>(Persistence)
    participant FS as File System

    OL->>OL: Submit Order
    OL->>DD: Publish OrderPlacedEvent
    
    OL->>OL: Receive Fill
    OL->>DD: Publish OrderFilledEvent
    
    OL->>OL: Cancel Order
    OL->>DD: Publish OrderCanceledEvent
    
    DD->>PH: Dispatch all events
    
    PH->>JORM: ProjectAsync(OrderPlacedEvent)
    JORM->>JORM: Upsert DTO to cache
    JORM->>FS: Persist to JSON
    
    PH->>JORM: ProjectAsync(OrderFilledEvent)
    JORM->>JORM: Update quantity & status
    JORM->>FS: Persist to JSON
    
    PH->>JORM: ProjectAsync(OrderCanceledEvent)
    JORM->>JORM: Mark as canceled
    JORM->>FS: Persist to JSON
    
    Note over JORM: Read queries now serve<br/>cached, projected state
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

The JsonOrderReadModel implementation (531 lines) introduces thread-safe in-memory caching with file-backed persistence, complex event projection logic with state derivation for order fills and cancellations, and multi-method filtering/mapping. Combined with substantial domain logic changes, new event types, comprehensive test coverage, and integration test wiring across multiple test files, this requires careful review of synchronization, event handling edge cases, and persistence correctness.

Possibly related PRs

  • feat(cqrs): harden active order refresh #115: Directly related; both PRs introduce event-driven order state management including new domain events, OrderLifecycle transitions, JSON-backed read model projection, and supporting DI/test infrastructure.

Poem

🐰 Hops through the order lifecycle with glee,
Events cascading wild and free,
JSON reads the tales we tell,
Canceled, filled—all persisted well!
State flows smooth, no cache to flee, 🎉

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 2.90% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat(cqrs): project orders into durable read model' directly and clearly summarizes the main change: implementing order projection into a durable read model as part of CQRS architecture.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch codex/projected-order-read-model

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@codecov

codecov Bot commented Apr 26, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 80.44010% with 80 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
...pters/Persistence/ReadModels/JsonOrderReadModel.cs 80.68% 23 Missing and 39 partials ⚠️
IntelliTrader.Domain/Events/OrderRejectedEvent.cs 65.00% 5 Missing and 2 partials ⚠️
IntelliTrader.Domain/Events/OrderCanceledEvent.cs 85.00% 1 Missing and 2 partials ⚠️
...Trader.Domain/Events/OrderLinkedToPositionEvent.cs 88.23% 0 Missing and 2 partials ⚠️
...elliTrader.Domain/Trading/Orders/OrderLifecycle.cs 80.00% 1 Missing and 1 partial ⚠️
...ence/ReadModels/OrderReadModelProjectionHandler.cs 75.00% 1 Missing and 1 partial ⚠️
IntelliTrader.Domain/Events/OrderPlacedEvent.cs 88.88% 0 Missing and 1 partial ⚠️
IntelliTrader.Infrastructure/AppModule.cs 75.00% 1 Missing ⚠️
Files with missing lines Coverage Δ
IntelliTrader.Domain/Events/OrderPlacedEvent.cs 93.18% <88.88%> (-1.11%) ⬇️
IntelliTrader.Infrastructure/AppModule.cs 88.11% <75.00%> (-1.18%) ⬇️
...Trader.Domain/Events/OrderLinkedToPositionEvent.cs 88.23% <88.23%> (ø)
...elliTrader.Domain/Trading/Orders/OrderLifecycle.cs 92.65% <80.00%> (-0.84%) ⬇️
...ence/ReadModels/OrderReadModelProjectionHandler.cs 75.00% <75.00%> (ø)
IntelliTrader.Domain/Events/OrderCanceledEvent.cs 85.00% <85.00%> (ø)
IntelliTrader.Domain/Events/OrderRejectedEvent.cs 65.00% <65.00%> (ø)
...pters/Persistence/ReadModels/JsonOrderReadModel.cs 80.68% <80.68%> (ø)

... and 3 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (4)
tests/IntelliTrader.Application.Tests/Trading/Handlers/ExchangeOrderLifecycleFactoryTests.cs (1)

221-222: Optional: pin the exact event set, not just presence.

Two .Contain assertions allow extra events to slip through unnoticed. Tightening the assertion to the expected set guards against regressions where the cancel-after-fill transition starts emitting additional events.

♻️ Suggested tightening
-        lifecycle.DomainEvents.Should().Contain(e => e is OrderFilledEvent);
-        lifecycle.DomainEvents.Should().Contain(e => e is OrderCanceledEvent);
+        lifecycle.DomainEvents.Should().HaveCount(2);
+        lifecycle.DomainEvents.Should().ContainSingle(e => e is OrderFilledEvent);
+        lifecycle.DomainEvents.Should().ContainSingle(e => e is OrderCanceledEvent);

Also worth confirming the emission order is intentionally Filled-then-Canceled (the sibling test at lines 250–251 still expects only OrderFilledEvent for an already-canceled lifecycle that later reports executed quantity, which seems consistent — just flagging for awareness).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@tests/IntelliTrader.Application.Tests/Trading/Handlers/ExchangeOrderLifecycleFactoryTests.cs`
around lines 221 - 222, Replace the two loose presence assertions on
lifecycle.DomainEvents with a precise assertion that the sequence equals the
expected events (both content and order) — e.g., assert that
lifecycle.DomainEvents.Select(d => d.GetType()) (or map to types) is exactly the
sequence [typeof(OrderFilledEvent), typeof(OrderCanceledEvent)] using an
equality/equivalence assertion (BeEquivalentTo or Should().Equal depending on
style) so no extra events can appear and order is verified; target the
DomainEvents collection and the OrderFilledEvent and OrderCanceledEvent types
when making the replacement.
IntelliTrader.Infrastructure/Adapters/Persistence/ReadModels/JsonOrderReadModel.cs (3)

18-32: Document that KnownQuoteCurrencies ordering is significant.

The match in ResolveQuoteCurrency (line 489) is a FirstOrDefault over EndsWith, so longer/more-specific suffixes must come first (e.g., USDT/USDC/BUSD before USD, FDUSD before USD, BNB/ETH/BTC after the stablecoin suffixes since the ambiguous "ETH"/"BTC" base case is rare). The current order is correct, but a one-line comment will save a future contributor from re-sorting alphabetically and quietly breaking pair resolution for things like BTCUSDTUSDT.

📝 Suggested comment
-    private static readonly string[] KnownQuoteCurrencies =
-    [
+    // Ordering matters: ResolveQuoteCurrency does a longest-suffix match via FirstOrDefault+EndsWith,
+    // so multi-character stablecoin suffixes (e.g. USDT/FDUSD) must appear before shorter ones (USD).
+    private static readonly string[] KnownQuoteCurrencies =
+    [
         "USDT",
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@IntelliTrader.Infrastructure/Adapters/Persistence/ReadModels/JsonOrderReadModel.cs`
around lines 18 - 32, The KnownQuoteCurrencies array ordering is significant
because ResolveQuoteCurrency uses FirstOrDefault with EndsWith to pick the first
matching suffix; update the KnownQuoteCurrencies declaration to include a
one-line comment above the array explaining that entries must be ordered from
longest/most-specific to shortest to avoid incorrect matches (e.g.,
USDT/USDC/BUSD/FDUSD before USD), and reference ResolveQuoteCurrency in the
comment so future contributors understand why the ordering must not be
alphabetized or changed.

437-441: Readability nit: parenthesize the mixed is/==/&&/|| expression.

Operator precedence makes the current expression correct (|| binds looser than &&), but mixing pattern-matching is … or … with ==/&&/|| on the same line is easy to misread during future edits. A small refactor pays off in clarity.

♻️ Suggested readability tweak
-    return status is OrderLifecycleStatus.PartiallyFilled or OrderLifecycleStatus.Filled ||
-           status == OrderLifecycleStatus.Canceled && filledQuantity > 0m;
+    return status is OrderLifecycleStatus.PartiallyFilled or OrderLifecycleStatus.Filled
+        || (status == OrderLifecycleStatus.Canceled && filledQuantity > 0m);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@IntelliTrader.Infrastructure/Adapters/Persistence/ReadModels/JsonOrderReadModel.cs`
around lines 437 - 441, The expression in CanAffectPosition mixes
pattern-matching ("is ... or ...") with ==, && and || which is correct but hard
to read; refactor by adding parentheses to make intent explicit—e.g., group the
filled status check together and parenthesize the canceled-with-fill check (so
it's clear the canceled check is combined with filledQuantity > 0m) or rewrite
the pattern into explicit comparisons—update the return expression in
CanAffectPosition accordingly to improve readability.

338-361: Harden cache load against malformed JSON and duplicate ids.

JsonSerializer.Deserialize will throw on a corrupted/partial file (very possible given the non-atomic write above), and dtos.ToDictionary(dto => dto.Id, …) will throw ArgumentException if the file ever ends up with two entries sharing an Id (case-insensitively). Either failure surfaces on the very first read/projection call after restart and prevents the read model from recovering.

Consider catching JsonException (and ArgumentException from the dictionary build) and degrading gracefully — e.g., logging, optionally backing up the bad file, and starting from an empty cache so subsequent projections heal the state.

♻️ Suggested resiliency around load
-        var dtos = JsonSerializer.Deserialize<List<OrderReadModelDto>>(json, _jsonOptions)
-                   ?? new List<OrderReadModelDto>();
-        _cache = new ConcurrentDictionary<string, OrderReadModelDto>(
-            dtos.ToDictionary(dto => dto.Id, StringComparer.OrdinalIgnoreCase),
-            StringComparer.OrdinalIgnoreCase);
-        _cacheLoaded = true;
+        List<OrderReadModelDto> dtos;
+        try
+        {
+            dtos = JsonSerializer.Deserialize<List<OrderReadModelDto>>(json, _jsonOptions)
+                   ?? new List<OrderReadModelDto>();
+        }
+        catch (JsonException)
+        {
+            // Corrupt or partially-written file; start from empty and let projections heal.
+            dtos = new List<OrderReadModelDto>();
+        }
+
+        var dict = new ConcurrentDictionary<string, OrderReadModelDto>(StringComparer.OrdinalIgnoreCase);
+        foreach (var dto in dtos)
+        {
+            if (!string.IsNullOrEmpty(dto.Id))
+            {
+                dict[dto.Id] = dto; // last-write-wins instead of throwing on duplicates
+            }
+        }
+        _cache = dict;
+        _cacheLoaded = true;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@IntelliTrader.Infrastructure/Adapters/Persistence/ReadModels/JsonOrderReadModel.cs`
around lines 338 - 361, LoadCacheUnlockedAsync should be hardened against
malformed JSON and duplicate ids: wrap the JsonSerializer.Deserialize and the
dtos.ToDictionary logic in a try/catch that catches JsonException,
ArgumentException (and optionally IOException), logs the error (include the
problematic _filePath and exception), moves/backs up the bad file (e.g., rename
to a .bad timestamped copy) and then initialize _cache = new
ConcurrentDictionary<string,
OrderReadModelDto>(StringComparer.OrdinalIgnoreCase) and set _cacheLoaded = true
so the read model can recover; alternatively when building the dictionary avoid
ToDictionary (which throws on duplicates) by grouping dtos by dto.Id with
StringComparer.OrdinalIgnoreCase and choosing a deterministic survivor (e.g.,
Last()) before creating the ConcurrentDictionary, and keep
JsonSerializer.Deserialize, _filePath, _cache, _cacheLoaded and _jsonOptions as
the referenced symbols to locate the change.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@IntelliTrader.Infrastructure/Adapters/Persistence/ReadModels/JsonOrderReadModel.cs`:
- Around line 237-248: ProjectAsync quietly drops OrderLinkedToPositionEvent
when the DTO is missing; change it to defensively upsert a minimal DTO instead
of doing nothing: inside ProjectAsync (the lambda passed to MutateAsync) detect
when cache.TryGetValue(domainEvent.OrderId, out var dto) is false and create a
new minimal OrderReadModel/DTO with OrderId = domainEvent.OrderId and
RelatedPositionId = domainEvent.PositionId (and any required key defaults
similar to ProjectTerminalStatusAsync), then add it to the cache so later events
can fill remaining fields; preserve the existing path when dto exists and keep
using MutateAsync with the cancellationToken.
- Around line 363-376: PersistCacheUnlockedAsync currently writes directly to
_filePath which can leave a truncated/partial file on crash; change it to write
atomically by serializing dtos to a sibling temp file (e.g., same directory +
unique suffix), flush and close that temp file, then if the destination exists
call File.Replace(tempPath, _filePath, null) to atomically swap, otherwise call
File.Move(tempPath, _filePath); keep this logic inside PersistCacheUnlockedAsync
and ensure any exceptions clean up the temp file so LoadCacheUnlockedAsync
always reads a fully-written JSON file.

---

Nitpick comments:
In
`@IntelliTrader.Infrastructure/Adapters/Persistence/ReadModels/JsonOrderReadModel.cs`:
- Around line 18-32: The KnownQuoteCurrencies array ordering is significant
because ResolveQuoteCurrency uses FirstOrDefault with EndsWith to pick the first
matching suffix; update the KnownQuoteCurrencies declaration to include a
one-line comment above the array explaining that entries must be ordered from
longest/most-specific to shortest to avoid incorrect matches (e.g.,
USDT/USDC/BUSD/FDUSD before USD), and reference ResolveQuoteCurrency in the
comment so future contributors understand why the ordering must not be
alphabetized or changed.
- Around line 437-441: The expression in CanAffectPosition mixes
pattern-matching ("is ... or ...") with ==, && and || which is correct but hard
to read; refactor by adding parentheses to make intent explicit—e.g., group the
filled status check together and parenthesize the canceled-with-fill check (so
it's clear the canceled check is combined with filledQuantity > 0m) or rewrite
the pattern into explicit comparisons—update the return expression in
CanAffectPosition accordingly to improve readability.
- Around line 338-361: LoadCacheUnlockedAsync should be hardened against
malformed JSON and duplicate ids: wrap the JsonSerializer.Deserialize and the
dtos.ToDictionary logic in a try/catch that catches JsonException,
ArgumentException (and optionally IOException), logs the error (include the
problematic _filePath and exception), moves/backs up the bad file (e.g., rename
to a .bad timestamped copy) and then initialize _cache = new
ConcurrentDictionary<string,
OrderReadModelDto>(StringComparer.OrdinalIgnoreCase) and set _cacheLoaded = true
so the read model can recover; alternatively when building the dictionary avoid
ToDictionary (which throws on duplicates) by grouping dtos by dto.Id with
StringComparer.OrdinalIgnoreCase and choosing a deterministic survivor (e.g.,
Last()) before creating the ConcurrentDictionary, and keep
JsonSerializer.Deserialize, _filePath, _cache, _cacheLoaded and _jsonOptions as
the referenced symbols to locate the change.

In
`@tests/IntelliTrader.Application.Tests/Trading/Handlers/ExchangeOrderLifecycleFactoryTests.cs`:
- Around line 221-222: Replace the two loose presence assertions on
lifecycle.DomainEvents with a precise assertion that the sequence equals the
expected events (both content and order) — e.g., assert that
lifecycle.DomainEvents.Select(d => d.GetType()) (or map to types) is exactly the
sequence [typeof(OrderFilledEvent), typeof(OrderCanceledEvent)] using an
equality/equivalence assertion (BeEquivalentTo or Should().Equal depending on
style) so no extra events can appear and order is verified; target the
DomainEvents collection and the OrderFilledEvent and OrderCanceledEvent types
when making the replacement.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: b2b411eb-3740-495f-83f0-21467fff704a

📥 Commits

Reviewing files that changed from the base of the PR and between fe91fbc and d966f3d.

📒 Files selected for processing (19)
  • IntelliTrader.Domain/Events/OrderCanceledEvent.cs
  • IntelliTrader.Domain/Events/OrderLinkedToPositionEvent.cs
  • IntelliTrader.Domain/Events/OrderPlacedEvent.cs
  • IntelliTrader.Domain/Events/OrderRejectedEvent.cs
  • IntelliTrader.Domain/Trading/Orders/OrderLifecycle.cs
  • IntelliTrader.Infrastructure/Adapters/Persistence/ReadModels/IOrderReadModelProjectionWriter.cs
  • IntelliTrader.Infrastructure/Adapters/Persistence/ReadModels/JsonOrderReadModel.cs
  • IntelliTrader.Infrastructure/Adapters/Persistence/ReadModels/OrderReadModelProjectionHandler.cs
  • IntelliTrader.Infrastructure/Adapters/Persistence/ReadModels/RepositoryOrderReadModel.cs
  • IntelliTrader.Infrastructure/AppModule.cs
  • tests/IntelliTrader.Application.Tests/Trading/Handlers/ExchangeOrderLifecycleFactoryTests.cs
  • tests/IntelliTrader.Application.Tests/Trading/Handlers/RefreshOrderStatusHandlerTests.cs
  • tests/IntelliTrader.Infrastructure.Tests/Adapters/Persistence/OrderReadModelProjectionTests.cs
  • tests/IntelliTrader.Infrastructure.Tests/AssemblyInfo.cs
  • tests/IntelliTrader.Infrastructure.Tests/Integration/InfrastructureTestFixture.cs
  • tests/IntelliTrader.Infrastructure.Tests/Integration/OpenPositionCommandDispatchIntegrationTests.cs
  • tests/IntelliTrader.Infrastructure.Tests/Integration/OrderLifecycleCommandDispatchIntegrationTests.cs
  • tests/IntelliTrader.Infrastructure.Tests/Integration/RefreshOrderStatusCommandDispatchIntegrationTests.cs
  • tests/IntelliTrader.Infrastructure.Tests/Integration/RefreshSubmittedOrdersCommandDispatchIntegrationTests.cs
💤 Files with no reviewable changes (1)
  • IntelliTrader.Infrastructure/Adapters/Persistence/ReadModels/RepositoryOrderReadModel.cs

Comment on lines +237 to +248
public Task ProjectAsync(OrderLinkedToPositionEvent domainEvent, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(domainEvent);

return MutateAsync(cache =>
{
if (cache.TryGetValue(domainEvent.OrderId, out var dto))
{
dto.RelatedPositionId = domainEvent.PositionId;
}
}, cancellationToken);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

OrderLinkedToPositionEvent is silently dropped if the DTO is missing.

If the link event is ever processed before its corresponding OrderPlacedEvent (recovery/replay scenarios, out-of-order delivery, or projection lag), the else branch is implicit and the position link is permanently lost — the order will never appear in GetTradingHistoryAsync because the filter requires RelatedPositionId to be non-empty. Consider creating a minimal DTO when missing, mirroring the pattern in ProjectTerminalStatusAsync, so the link still lands and later events fill in the rest.

🛡️ Suggested defensive upsert
     return MutateAsync(cache =>
     {
-        if (cache.TryGetValue(domainEvent.OrderId, out var dto))
-        {
-            dto.RelatedPositionId = domainEvent.PositionId;
-        }
+        var dto = cache.GetValueOrDefault(domainEvent.OrderId) ?? new OrderReadModelDto
+        {
+            Id = domainEvent.OrderId,
+            SubmittedAt = domainEvent.OccurredAt
+        };
+        dto.RelatedPositionId = domainEvent.PositionId;
+        cache[domainEvent.OrderId] = dto;
     }, cancellationToken);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
public Task ProjectAsync(OrderLinkedToPositionEvent domainEvent, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(domainEvent);
return MutateAsync(cache =>
{
if (cache.TryGetValue(domainEvent.OrderId, out var dto))
{
dto.RelatedPositionId = domainEvent.PositionId;
}
}, cancellationToken);
}
public Task ProjectAsync(OrderLinkedToPositionEvent domainEvent, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(domainEvent);
return MutateAsync(cache =>
{
var dto = cache.GetValueOrDefault(domainEvent.OrderId) ?? new OrderReadModelDto
{
Id = domainEvent.OrderId,
SubmittedAt = domainEvent.OccurredAt
};
dto.RelatedPositionId = domainEvent.PositionId;
cache[domainEvent.OrderId] = dto;
}, cancellationToken);
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@IntelliTrader.Infrastructure/Adapters/Persistence/ReadModels/JsonOrderReadModel.cs`
around lines 237 - 248, ProjectAsync quietly drops OrderLinkedToPositionEvent
when the DTO is missing; change it to defensively upsert a minimal DTO instead
of doing nothing: inside ProjectAsync (the lambda passed to MutateAsync) detect
when cache.TryGetValue(domainEvent.OrderId, out var dto) is false and create a
new minimal OrderReadModel/DTO with OrderId = domainEvent.OrderId and
RelatedPositionId = domainEvent.PositionId (and any required key defaults
similar to ProjectTerminalStatusAsync), then add it to the cache so later events
can fill remaining fields; preserve the existing path when dto exists and keep
using MutateAsync with the cancellationToken.

Comment on lines +363 to +376
private async Task PersistCacheUnlockedAsync(CancellationToken cancellationToken)
{
var directory = Path.GetDirectoryName(_filePath);
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
{
Directory.CreateDirectory(directory);
}

var dtos = _cache.Values
.OrderByDescending(dto => dto.SubmittedAt)
.ToList();
var json = JsonSerializer.Serialize(dtos, _jsonOptions);
await File.WriteAllTextAsync(_filePath, json, cancellationToken).ConfigureAwait(false);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

File.Replace vs File.Move atomic file replacement semantics on Linux and Windows .NET

💡 Result:

File.Replace provides atomic file replacement semantics on both Linux and Windows in .NET, while File.Move does not guarantee atomic replacement when overwriting. File.Replace replaces the destination file with the source file atomically, creating a backup if specified. It requires the destination to exist and source/destination on the same volume. On Windows, it uses Win32 ReplaceFile, which performs an atomic swap of file IDs on NTFS, ensuring no intermediate state where the destination is missing (though not contractually documented as atomic, it's the recommended replacement for transactional operations). On Linux/Unix, it uses POSIX rename(2) after checks (stat destination if no backup, link/copy for backup), which is atomic: the destination remains accessible, switching instantly from old to new content. File.Move (with overwrite=true, .NET Core 3.0+) primarily uses rename on Unix (atomic within filesystem) or MoveFileEx on Windows (atomic on same-volume NTFS with REPLACE_EXISTING). However, cross-volume it falls back to copy+delete, which is non-atomic (partial writes possible on crash). File.Move also works when destination doesn't exist, unlike Replace. Key differences: - Replace: Requires destination exists; always atomic replacement (fails cross-volume); backup support. - Move: Handles non-existing destination; non-atomic cross-volume; no backup. For atomic replacement assuming destination exists and same volume, prefer File.Replace(null backup for no backup). Use if (!File.Exists(dest)) File.Move else File.Replace for full POSIX rename semantics.

Citations:


Persisting via a single File.WriteAllTextAsync is not crash-safe — write atomically.

This is the durability point of the new "durable read-side projection." File.WriteAllTextAsync truncates and rewrites the destination in place, so a process crash, host kill, or power loss mid-write can leave order-read-model.json truncated or partially written. On the next start, LoadCacheUnlockedAsync will then either deserialize a partial document and throw, or silently lose every projected order recorded since the last successful flush.

Given every projection mutation calls this on the hot path, the exposure is non-trivial. Write to a sibling temp file and atomically replace using File.Replace (when destination exists) or File.Move (when it doesn't).

🛡️ Suggested atomic-write pattern
 private async Task PersistCacheUnlockedAsync(CancellationToken cancellationToken)
 {
     var directory = Path.GetDirectoryName(_filePath);
     if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
     {
         Directory.CreateDirectory(directory);
     }

     var dtos = _cache.Values
         .OrderByDescending(dto => dto.SubmittedAt)
         .ToList();
     var json = JsonSerializer.Serialize(dtos, _jsonOptions);
-    await File.WriteAllTextAsync(_filePath, json, cancellationToken).ConfigureAwait(false);
+
+    var tempPath = _filePath + ".tmp";
+    await File.WriteAllTextAsync(tempPath, json, cancellationToken).ConfigureAwait(false);
+    if (File.Exists(_filePath))
+    {
+        File.Replace(tempPath, _filePath, destinationBackupFileName: null);
+    }
+    else
+    {
+        File.Move(tempPath, _filePath);
+    }
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
private async Task PersistCacheUnlockedAsync(CancellationToken cancellationToken)
{
var directory = Path.GetDirectoryName(_filePath);
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
{
Directory.CreateDirectory(directory);
}
var dtos = _cache.Values
.OrderByDescending(dto => dto.SubmittedAt)
.ToList();
var json = JsonSerializer.Serialize(dtos, _jsonOptions);
await File.WriteAllTextAsync(_filePath, json, cancellationToken).ConfigureAwait(false);
}
private async Task PersistCacheUnlockedAsync(CancellationToken cancellationToken)
{
var directory = Path.GetDirectoryName(_filePath);
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
{
Directory.CreateDirectory(directory);
}
var dtos = _cache.Values
.OrderByDescending(dto => dto.SubmittedAt)
.ToList();
var json = JsonSerializer.Serialize(dtos, _jsonOptions);
var tempPath = _filePath + ".tmp";
await File.WriteAllTextAsync(tempPath, json, cancellationToken).ConfigureAwait(false);
if (File.Exists(_filePath))
{
File.Replace(tempPath, _filePath, destinationBackupFileName: null);
}
else
{
File.Move(tempPath, _filePath);
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@IntelliTrader.Infrastructure/Adapters/Persistence/ReadModels/JsonOrderReadModel.cs`
around lines 363 - 376, PersistCacheUnlockedAsync currently writes directly to
_filePath which can leave a truncated/partial file on crash; change it to write
atomically by serializing dtos to a sibling temp file (e.g., same directory +
unique suffix), flush and close that temp file, then if the destination exists
call File.Replace(tempPath, _filePath, null) to atomically swap, otherwise call
File.Move(tempPath, _filePath); keep this logic inside PersistCacheUnlockedAsync
and ensure any exceptions clean up the temp file so LoadCacheUnlockedAsync
always reads a fully-written JSON file.

@blackms blackms merged commit 0d7bdec into main Apr 26, 2026
3 checks passed
@blackms blackms deleted the codex/projected-order-read-model branch April 26, 2026 21:05
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