Skip to content
kc@kumarChandrachooda.com:~$ cd /blog/correlation-identifier-invented-three-times && read --section="top" 0%
Architecture

Correlation Identifier, Invented Three Times

The event interface has no members, so nothing can carry a correlation id - and three modules each rebuild the pattern as application state, one of them in a static dictionary with a TODO.

By Kumar Chandrachooda 23 Nov 2025 7 min read
Three differently shaped lookup boxes all pointing at one identifier

An asynchronous request needs a way home. You ask another context to do something, it answers minutes or days later, and the answer arrives carrying only the identifier of the thing it created. Somewhere between the two, somebody has to remember what the request was for. Enterprise Integration Patterns calls this the Correlation Identifier, and the standard place to put it is the message header, because that is the one place every participant can see without agreeing on a payload.

Part 11 closed on a risk written down and half-mitigated. This one is about a pattern that was never written down and consequently got solved three separate times, in one repository, with three different levels of care.

The header that does not exist

Here is the entire event contract of the estate:

// Marker
public interface IEvent
{
}

Shared.Types/Events/IEvent.cs, GroupFlights at commit a19b337. No members. No message id, no timestamp, no correlation id, no causation id, no version, no source. Every one of the thirteen integration event types is a record implementing that, and the dispatcher enqueues it as an object into a ConcurrentQueue<IEvent>.

That is a defensible starting point for an in-process bus and I want to be fair about it. A marker interface keeps the contract projects clean — recall from part 7 that this marker deliberately lives in Shared.Types rather than in the plumbing assembly, so a contract can be markable without importing a framework. Adding an abstract base class with an EventId and a CorrelationId would drag inheritance into every published record and would be exactly the kind of ceremony the README's “small complexity” framing rejects.

But the requirement does not go away because the header does. It just moves into application code, once per module that needs it, and nobody coordinates the three answers.

Answer one: a persisted registry

Sales needs a deadline. It publishes DeadlineRequestedIntegrationEvent, whose payload carries the correlation forwards:

public record DeadlineRequestedIntegrationEvent(
    Guid Id,
    DateTime DueDate,
    Message Message,
    RequestedDeadlineParticipant[] Participants,
    RequestedDeadlineSource Source) : IEvent;

public record RequestedDeadlineSource(string SourceType, Guid SourceId);

Sales.Shared/IntegrationEvents/DeadlineRequestedIntegrationEvent.cs:7-14. Source is the correlation identifier, promoted into the payload because there is no header to hold it: a type name — "Offer" or "UnconfirmedReservation" — and an aggregate id.

The problem is what comes back. TimeManagement publishes DeadlineOverdueIntegrationEvent(DeadlineId Id) and nothing else, because from TimeManagement's point of view a deadline is a deadline. It never saw the Source; the ADR-03 relationship makes Sales conform to TimeManagement's published language, not the reverse. So Sales must remember the mapping itself, and it does:

await _deadlineRegistry.SaveMapping(
    new DeadlineRegistryEntry(@event.Id, @event.Source.SourceType, @event.Source.SourceId), cancellationToken);

Sales.Application/EventHandlers/Internal/DeadlineRequestedIntegrationEventHandler.cs:31-32. The implementation is a real EF-backed table, sales."DeadlineRegistryEntries", with a duplicate guard on write and a DoesNotExistException on a miss. This is the correct answer. It is durable, it is queryable, it survives restarts, and it fails loudly rather than silently.

Answer two: a static dictionary with a TODO

Sales also needs a payment. Same shape exactly — PaymentRequestedIntegrationEvent carries a PaymentRequestedSource(string SourceType, Guid SourceId), the handler stores the mapping, and PaymentCompleted comes back carrying only a payment id. Here is the whole storage implementation:

internal class PaymentRegistry : IPaymentRegistry
{
    //TODO: Przepisac na wlasciwe persistence
    private static readonly Dictionary<Guid, PaymentRegistryEntry> _inMemoryRegistry = new ();
    
    public Task SaveMapping(PaymentRegistryEntry paymentRegistryEntry, CancellationToken cancellationToken = default)
    {
        _inMemoryRegistry.Add(paymentRegistryEntry.PaymentId, paymentRegistryEntry);
        return Task.CompletedTask;
    }

    public Task<PaymentRegistryEntry> GetByPaymentId(Guid paymentId, CancellationToken cancellationToken = default)
    {
        _inMemoryRegistry.TryGetValue(paymentId, out var payment);
        return Task.FromResult(payment);
    }
}

Sales.Application/PaymentRegistry/PaymentRegistry.cs:3-19, the file in full — nineteen lines. The comment translates as “rewrite to proper persistence”. Read the consequences in ascending severity.

  1. static, so it is per-process, not per-scope. That is deliberate — the registry is registered as a scoped or transient service and the data has to outlive the request, so the field is static for the same reason the event dispatcher's queue is. Two known ways to hold state across scopes, both reached for the same way.
  2. Dictionary, not ConcurrentDictionary. The dispatcher fans handlers out with Task.WhenAll, so two SaveMapping calls can be in flight together. Dictionary.Add is not thread-safe; a concurrent resize can corrupt the buckets or throw.
  3. Add rather than an upsert. A duplicate payment id throws ArgumentException from a background timer thread, where it is logged and the event is discarded.
  4. It does not survive a restart, and the loss is silent. This is the one that matters. When PaymentCompleted arrives after a restart, GetByPaymentId returns null, and the consuming handler does this:
var paymentRegistryEntry = await _paymentRegistry.GetByPaymentId(@event.PaymentId, cancellationToken);

if (paymentRegistryEntry is null)
{
    return; //Payment z innego miejsca w systemie
}

Sales.Application/EventHandlers/External/PaymentCompletedEventHandler.cs:27-32. The comment reads “payment from another place in the system”, and the guard is correct for its stated purposePaymentCompleted is a broadcast that both Sales and Postsale receive, so Sales must ignore payments that are not its own. But the same null also means “we forgot”. The reservation never learns it was paid, and nothing anywhere records that this happened. A missing correlation and a foreign correlation are indistinguishable, because both are null.

The two registries sit in the same assembly, three folders apart, implementing one pattern with a durable version and a TODO version. To be fair to the authors: the TODO is there, in the source, naming exactly what is wrong. That is more honest than most production code manages, and in a teaching repository whose fourteen-step walkthrough runs in a single process lifetime, nothing the reader does will expose it. It is a declared shortcut in the sense that matters — just declared in a comment rather than in the README.

Answer three: no registry at all

Postsale has the identical problem — it also requests payments and deadlines through Finance and TimeManagement, and gets back bare identifiers. It solves it a third way, by not storing a mapping at all and instead querying its aggregate by the embedded field:

public async Task<ReservationChangeRequest> GetByPaymentId(Guid paymentId, CancellationToken cancellationToken = default)
{
    return await _dbContext.ChangeRequests
        .Include("_reservationToChange")
        .Include("_changeToApply")
        .SingleOrDefaultAsync(c => 
                EF.Property<RequiredPayment>(c, "_paymentRequiredToApplyChange").PaymentId.Equals(paymentId), 
            cancellationToken);
}

Postsale.Infrastructure/Repositories/ReservationChangeRequestRepository.cs:52-60, with a GetByDeadlineId sibling immediately below it doing the same through .Deadline.Id.

Architecturally this is the best of the three, and it is worth saying so plainly. There is no separate registry to keep in step with the aggregate, no second write to get wrong, no lookup table to go stale. The correlation lives where the fact lives: the change request knows which payment it is waiting for, so ask the change request.

Its costs are real but ordinary. The query reaches through EF's string-based property API, so a rename of _paymentRequiredToApplyChange breaks it at runtime rather than at compile time — the price of the fully-encapsulated model that ADR 04 demanded. And SingleOrDefaultAsync returns null for a foreign identifier, which requires the caller to guard. Postsale's payment handler guards. Its deadline handler does not, and that is part 13.

Three answers, one missing decision

Sales — deadlines Sales — payments Postsale — both
Storage EF table static Dictionary none — query the aggregate
Survives restart yes no yes
Thread-safe yes no yes
Missing mapping throws returns null, silently swallowed returns null, guarded once
Rename safety compile-time compile-time runtime string

Every one of these is a defensible local choice. What is missing is the thing that would have made them one choice: nobody named the pattern. There is no ADR about correlation, no shared abstraction, no convention. The estate has four ADRs and they cover module dependencies, time, collaboration and the Postsale split — all strategic, all excellent. Correlation slipped through as an implementation detail, and implementation details that recur across modules are exactly the things that need a decision record.

The general lesson is about where a requirement lands when the mechanism cannot hold it. IEvent has no members, so correlation could not live in the envelope; it therefore landed in three application layers, invisibly, at three levels of quality. If you are building an in-process bus and you are tempted by the bare marker interface — and it is a genuinely tempting simplification — the question to ask is not “do I need a correlation id today?” It is “if I do not provide one, how many of these will my team build?”

The cheapest possible answer, and the one I would take from this estate, is three fields:

public interface IEvent
{
    Guid EventId { get; }
    Guid? CorrelationId { get; }
    DateTime OccurredAtUtc { get; }
}

Fresh illustrative code. It costs each record three properties and one base implementation, and it deletes two of the three registries above.

Next, what happens when a broadcast channel meets a handler that never asked whether the message was addressed to it: broadcast without a filter.