Your Type Name Is the Wire Contract
Inflow has no subscribe call and no topic string. A module receives an event by declaring a class with the same simple name, and a single attribute decides whether the delivery is allowed - with a cache that changes the decision, not just its speed.
Every message bus I have used makes you say where a message goes. RabbitMQ has exchanges and routing keys. Kafka has topics. MediatR has the handler's generic parameter. NServiceBus has a routing configuration. Somewhere there is a string, or a type, that binds a publisher to a subscriber.
Inflow has neither. Part 3 showed how a module's identity is derived from a namespace segment; this part is about the other half of addressing, which is smaller still. On master, the entire subscription mechanism is one expression-bodied property:
internal sealed class ModuleBroadcastRegistration
{
public Type ReceiverType { get; }
public Func<object, CancellationToken, Task> Action { get; }
public string Key => ReceiverType.Name;
// ...
}
ReceiverType.Name is the simple name — no namespace, no assembly. CustomerCompleted. That is the routing key for the whole estate.
Registration: every message type in every assembly
The registry is built once, at startup, in Modules/Extensions.cs. It takes the loaded assemblies, selects every class assignable to ICommand and every class assignable to IEvent, and adds a broadcast action for each:
foreach (var type in eventTypes)
{
registry.AddBroadcastAction(type, (@event, cancellationToken) =>
(Task) eventDispatcherType.GetMethod(nameof(eventDispatcher.PublishAsync))
?.MakeGenericMethod(type)
.Invoke(eventDispatcher, new[] {@event, cancellationToken}));
}
Note what is not here. There is no filter for “types this module consumes”, no attribute required, no interface beyond the marker. Every command and event type in every loaded assembly gets a registration, whether anything handles it or not. In the shipped four-module solution that is around forty message types, each with a closure that will do MakeGenericMethod(...).Invoke(...) at call time.
The delegate is worth a second look for a different reason: the generic method is resolved and invoked per dispatch, inside a closure that already captured type at registration time. The information needed to build a cached, compiled delegate was in hand at startup and is thrown away, so every publish pays reflection costs. The same pattern appears in QueryDispatcher and DomainEventDispatcher. For a course repository running on a laptop this is invisible; in a service doing thousands of messages a second it is the first thing a profiler finds.
Publication: a linear scan and a name match
ModuleClient.PublishAsync opens like this:
var module = message.GetModuleName();
var key = message.GetType().Name;
var registrations = _moduleRegistry
.GetBroadcastRegistrations(key)
.Where(r => r.ReceiverType != message.GetType());
Three lines, and the whole addressing model is in them.
key is the sender's simple type name. Publish Inflow.Modules.Customers.Core.Events.CustomerCompleted and you are addressing the string "CustomerCompleted".
GetBroadcastRegistrations(key) is _broadcastRegistrations.Where(x => x.Key == key) over a List<ModuleBroadcastRegistration> holding one entry per message type in the estate. Every publish is an O(n) scan over the entire message vocabulary. A Dictionary<string, List<...>> would make this O(1) and is a five-line change; the registry already has a dictionary for request paths, sitting in the same class.
.Where(r => r.ReceiverType != message.GetType()) excludes the sender's own type, so publishing does not deliver back to the publishing module's copy. This is the line that makes same-name-different-namespace work rather than loop.
So a module subscribes to an event by declaring a type with the same simple name. That is it. There is no Subscribe<CustomerCompleted>() anywhere in the codebase for events — the only Subscribe is IModuleSubscriber.Subscribe<TRequest, TResponse>(path, ...), which is the synchronous request/response path and takes an explicit string path like "customers/get".
The attribute that decides delivery
Name matching alone would be too loose: CustomerVerified exists in Customers, Payments, Wallets and Saga, and Customers publishing it should not necessarily reach all three. MessageAttribute is the filter:
[AttributeUsage(AttributeTargets.Class)]
public class MessageAttribute : Attribute
{
public string Module { get; }
public bool Enabled { get; }
public MessageAttribute(string module = null, bool enabled = true)
{
Module = module ?? string.Empty;
Enabled = enabled;
}
}
And the gate in ModuleClient:
if (messageAttribute is not null && !string.IsNullOrWhiteSpace(messageAttribute.Module) &&
(!messageAttribute.Enabled || messageAttribute.Module != module))
{
continue;
}
Read the polarity carefully, because it is permissive by default. A receiver type with no attribute is delivered to unconditionally. A receiver type with [Message] and no module argument is also delivered to unconditionally, because Module is the empty string. Only [Message("customers")] actually restricts anything, and it restricts by requiring the publishing module's name to match.
In the shipped estate there are exactly four [Message(...)] attributes, and every one of them sits on a Contract class rather than on the event record — [Message("users")] on SignedUpContract and UserStateUpdatedContract in Customers, [Message("customers")] on CustomerCompletedContract and CustomerVerifiedContract in Wallets. Every other receiver copy in the estate — and there are fourteen more — has no attribute at all and therefore accepts a same-named message from anywhere.
That is a defensible default for a monolith where you control every module. It is a very different default from any broker you would replace this with, where an unbound queue receives nothing. Worth knowing before you take this design to a system where a third party can add an assembly.
A cache that changes the answer
Now the part that took me three readings. The attribute lookup is cached in a ConcurrentDictionary<Type, MessageAttribute>, and the cache-miss branch does more than fill the cache:
foreach (var registration in registrations)
{
if (!_messages.TryGetValue(registration.ReceiverType, out var messageAttribute))
{
messageAttribute = registration.ReceiverType.GetCustomAttribute<MessageAttribute>();
if (message is ICommand)
{
messageAttribute = message.GetType().GetCustomAttribute<MessageAttribute>();
module = registration.ReceiverType.GetModuleName();
}
if (messageAttribute is not null)
{
_messages.TryAdd(registration.ReceiverType, messageAttribute);
}
}
// ... the gate above, then translate and dispatch
}
Two things are wrong here, and they compound.
module is declared outside the loop and reassigned inside it. It starts as the sender's module name. For commands, the miss branch reassigns it to the receiver's module name. But it is never reset, so on the second iteration of the foreach — a second receiver for the same command — the gate is evaluated against the first receiver's module. The classic refactoring for this is Split Temporary Variable, and here it is not a tidiness fix but a correctness one: a loop-invariant variable being mutated per iteration means iteration n inherits iteration n−1's state.
The reassignment only happens on a cache miss. Once registration.ReceiverType is in _messages, the if (message is ICommand) branch is skipped entirely, so module keeps the sender's value for every subsequent publish. The first publish of a given command and the second publish of the same command evaluate the gate against different values. That is a caching layer that changes the routing decision, not just how fast it is reached.
Because commands with [Message(...)] attributes do not exist in the shipped estate — the four attributes are all on event contracts — neither branch does damage today. That is exactly why it survived: the code is only reachable via a combination nobody built. But it is a beautiful small example of a category worth naming. A cache is safe only when the cached and uncached paths compute the same thing; the moment the miss path has a side effect, the cache is a control-flow switch wearing a performance optimisation's clothes.
Commands ride the same rails
One structural decision underpins all of this and it is easy to miss, because it is expressed as a single colon in an interface declaration:
public interface ICommand : IMessage
{
}
A command is a message. That means commands and events travel the same registry, the same publish method, the same name-matching, the same attribute gate and the same translator. AddModuleRegistry iterates command types and event types in two loops that differ only in which dispatcher they invoke — ICommandDispatcher.SendAsync for one, IEventDispatcher.PublishAsync for the other.
The difference between the two shows up only at the far end of the delegate. CommandDispatcher resolves ICommandHandler<TCommand> with GetRequiredService, so exactly one handler must exist and a missing one throws. EventDispatcher uses GetServices<IEventHandler<TEvent>>() and Task.WhenAll, so zero handlers is a successful no-op. Command semantics and event semantics differ by one method call on the container, and everything upstream of that call is identical.
The Saga module leans on this. It publishes an AddFunds record that lives in Inflow.Modules.Saga.Api.Messages, implements ICommand, and never touches Wallets:
await _messageBroker.PublishAsync(new AddFunds(Data.WalletId, Data.Currency, BonusFunds, TransferName));
ModuleClient matches the simple name AddFunds against the Wallets module's own AddFunds command, translates the record across, and CommandDispatcher resolves the one handler. A cross-module command — the thing most modular-monolith designs forbid outright — with no coupling, no interface, and no registration, expressed as a publish.
Whether that is a feature is a real question. Publishing a command through a broadcast channel means the sender cannot know whether a handler exists, cannot receive a result, and cannot distinguish “handled” from “nobody was listening” — because GetRequiredService throws inside a dispatcher whose exception the async path swallows. If you want request semantics, IModuleClient.SendAsync(path, request) is the channel that gives them to you. Using the broadcast channel for commands is convenient and quietly gives up the one property commands have that events do not.
What this buys and what it costs
The gain is real. There is no subscription registry to maintain, no topic naming convention to enforce, no drift between “what I publish” and “what someone declared they wanted”. Adding a consumer is one file: declare a record with the right name, write a handler for it, done. The framework finds both by scanning.
The cost is that the contract is now a spelling. Rename CustomerVerified to CustomerIdentityVerified in the producing module and every consumer silently stops receiving it — not an error, not a warning, not a log line, just an event with no registrations matching its key. ModuleClient.PublishAsync will happily await Task.WhenAll(new List<Task>()) and return successfully.
Inflow knows this is a risk, and it has an answer: a boot-time verifier that checks whether the properties a consumer requires still exist on the producer's type. It is the best idea in the repository, and it is part 5.