Skip to content

[Bug]: C#. .NET mediator dispatch is unmodeled, disconnecting the primary call path in CQRS codebases #936

Description

@merlinhu1

code-review-graph version

2.3.8

Operating system

macOS

Python version

3.12.4

AI platform

claude-code

Output of code-review-graph status

IMediator.Send / Publish calls produce a dangling CALLS edge to the bare name Send, with no link to the handler that services the request. In a MediatR or Mediator codebase this is the main edge in the system — controller to handler — so the graph presents the application as two disconnected halves. flows finds no execution paths and architecture reports the caller and handler as unrelated communities with zero edges between them, neither carrying any uncertainty caveat.

Steps to reproduce

mkdir -p /tmp/crg-mediator && cd /tmp/crg-mediator && git init -q .
cat > App.cs <<'EOF'
using MediatR;

namespace App;

public record GetUserQuery(int Id) : IRequest;

public class GetUserQueryHandler : IRequestHandler<GetUserQuery, string>
{
public Task Handle(GetUserQuery request, CancellationToken ct) => Task.FromResult("user");
}

public class UserController
{
private readonly IMediator _mediator;
public UserController(IMediator m) { _mediator = m; }

public async Task<string> Get(int id) => await _mediator.Send(new GetUserQuery(id));

public async Task<string> GetVar(int id)
{
    var query = new GetUserQuery(id);
    return await _mediator.Send(query);
}

}
EOF
code-review-graph build
code-review-graph query callers_of Handle
code-review-graph flows
code-review-graph architecture

Expected vs actual behavior

Actual
The call edges are dangling and record nothing about the request type:

CALLS UserController.Get -> 'Send' {receiver: _mediator, receiver_type: IMediator, …}
CALLS UserController.GetVar -> 'Send' {receiver: _mediator, receiver_type: IMediator, …}

query callers_of Handle → result_count: 0
confidence: "c# di-container registrations are not statically traced,
so interface-typed callers can be missing here"

flows → "Found 0 execution flow(s)" warnings: []
architecture → "2 communities, 0 community pairs" warnings: []
cross_community_edges: []

Expected

An inferred CALLS edge from UserController.Get to GetUserQueryHandler.Handle, marked INFERRED with recorded resolution evidence. Where the request type cannot be resolved unambiguously, no edge — but the absence should be caveated in a way that names the actual cause.

Additional context

The existing caveat is present but inadequate
uncertainty.py:140 does emit a note for C# under _CALL_PATTERNS. Three problems:
It mis-describes the cause. "DI-container registrations are not statically traced" points at constructor injection. A reader concludes interface-typed receivers are imprecise, not that request dispatch is entirely absent. The mechanism is runtime type-based dispatch, a different and much larger hole.
It is scoped to _CALL_PATTERNS only. flows, architecture, communities, and detect-changes risk scoring carry no note, and present a disconnected graph as though it were the real structure — see the zero-warning output above.
The hedge is sized wrong. "Callers can be missing here" describes edge cases, not "the primary dispatch mechanism of this codebase is unrepresented."

Scope

Applies to both MediatR and the Mediator source generator. The latter emits its concrete dispatcher into obj/, which incremental.py:179 ignores, so the mapping cannot be recovered from generated code — interface-based inference is the only available route.
Two real-world shapes to design against:
gothinkster/aspnetcore-realworld-example-app — requests are nested types (Details.Query), and the handler's IRequestHandler<Query, string> names the request by its bare name, resolved against the enclosing type. csharp_disambiguate (scoped_resolver.py:330) implements same-file and namespace-visibility tiers only; with Details.Query and Edit.Query in the same file and namespace, both tiers return multiple candidates and the correct answer is unreachable. An enclosing-type tier is needed ahead of the existing two.
dotnet/eShop — handlers inherit through a closed generic:
Csharp
Deriving the request type takes two hops with positional type substitution (bind <T, R> := <CreateOrderCommand, bool>, substitute into IRequestHandler<IdentifiedCommand, R>). CRG records no declared type-parameter list anywhere — the node is IdentifiedCommandHandler and <T, R> is discarded — so substitution is not currently expressible. Concrete handlers also declare no Handle of their own; it is inherited from the abstract base.

Design constraints

The join key must be a constructed type built over resolved nodes (e.g. App.Orders.IdentifiedCommand<App.Orders.CreateOrderCommand>), not a class node. Class nodes drop type arguments — IdentifiedCommand is stored as IdentifiedCommand — so resolving to a node discards the discriminator and every IdentifiedCommand would match every identified-command handler.
Ambiguous request types and unresolved open generics must produce no edge, matching csharp_disambiguate's existing None-on-ambiguity policy. Names like Query and Command are near-universal in this style of codebase, so name-based matching would link unrelated types across features.
Emit method-to-method into Handle, so impact and flow analysis can traverse the body.

Derived edges must be rebuilt globally after .cs changes, following event_resolver.py:44, so a handler rename or deletion cannot leave a stale edge behind.
Do not copy the Spring event resolver's fixed confidence: 0.95. Record the resolution evidence and tier accordingly, retaining INFERRED.
INotificationHandler / Publish fan out to N handlers. IPipelineBehavior<,> sits between Send and Handle and should be connected only where registration evidence establishes that it applies; note that eShop registers behaviors as open generics (typeof(IPipelineBehavior<,>)), so the honest evidence is container-wide.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions