Skip to content
kc@kumarChandrachooda.com:~$ cd /blog/a-namespace-segment-is-a-routing-key && read --section="top" 0%
Architecture

A Namespace Segment Is a Routing Key

One method in Inflow returns the third segment of a type's namespace, lower-cased. Four subsystems route on that string, and nothing anywhere validates it - a namespace rename is a silent behavioural change across the estate.

By Kumar Chandrachooda 19 Jan 2026 6 min read
A namespace split into segments, with the third one lit up

Part 2 ended on a module name derived by splitting a DLL path. That turns out to be the easy case, because a filename at least sits in a folder somebody deliberately arranged. The version that runs on every message, in every log line and in three separate registries derives the same string from something even more incidental: the third segment of a CLR namespace.

Here it is, from Inflow.Shared.Infrastructure/Extensions.cs:

public static string GetModuleName(this Type type, string namespacePart = "Modules", int splitIndex = 2)
{
    if (type?.Namespace is null)
    {
        return string.Empty;
    }

    return type.Namespace.Contains(namespacePart)
        ? type.Namespace.Split(".")[splitIndex].ToLowerInvariant()
        : string.Empty;
}

Nine lines and two defaulted parameters. Given Inflow.Modules.Customers.Core.Events.CustomerCompleted, Split(".") yields ["Inflow", "Modules", "Customers", "Core", "Events", "CustomerCompleted"], index 2 is Customers, and the result is "customers". There is an object overload one line above that does the same via GetType(), so any instance can be asked what module it belongs to.

Four subsystems, one string

That string is not decoration. Trace where it lands:

Delivery gating. ModuleClient.PublishAsync opens with var module = message.GetModuleName(); and then, for each candidate receiver, checks whether the receiver's [Message("...")] attribute names that module. Get the string wrong and the message is filtered out — not routed to the wrong place, just quietly not delivered.

Outbox and inbox resolution. Both type registries key on it, with a small piece of cleverness:

private static string GetKey(Type type)
    => type.IsGenericType
        ? $"{type.GenericTypeArguments[0].GetModuleName()}"
        : $"{type.GetModuleName()}";

Register<EfOutbox<CustomersDbContext>>() is generic, so the key comes from CustomersDbContext's namespace: "customers". Resolve(message) receives a plain CustomerCompleted record, which is not generic, so the key comes from its namespace: "customers". The same nineteen-line registry serves both directions because the module name is recoverable from any type that lives in the module. That is genuinely neat, and it only works because the convention holds everywhere.

Unit-of-work resolution. UnitOfWorkTypeRegistry uses the same idea with GetKey<T>() => $"{typeof(T).GetModuleName()}", keyed on the command type at resolution time and the unit-of-work type at registration time.

Logging. Every message log line in the estate carries a {Module} property, and every one of them gets it from GetModuleName(). The broker, all three logging decorators and EfOutbox each call it independently.

So: one string derived from namespace position 2 decides whether a message is delivered, which database context persists it, which transaction wraps it, and what every log line says it belongs to. Nothing validates it. The nearest thing to a check is in ModuleRegistry.AddBroadcastAction, which throws if a type's namespace is empty — but an empty namespace is a compilation curiosity, not a realistic failure.

What breaks, and how quietly

Suppose you decide Inflow.Modules.Customers.Core reads awkwardly and reorganise to Inflow.Customers.Modules.Core. It compiles. Every test that does not span modules passes. Then:

  • type.Namespace.Contains("Modules") is still true — Modules appears at index 2 now — so the guard clause does not save you.
  • Split(".")[2] returns "modules" for every type in the module.
  • [Message("customers")] on the Wallets-side copy no longer matches, so CustomerCompleted stops being delivered to Wallets.
  • The outbox registry keys everything under "modules", so two modules renamed the same way collide on one key and the second Register overwrites the first.
  • Every log line says the message came from module modules.

None of that raises an exception. The system starts, serves HTTP, writes convincing logs, and stops integrating. A namespace rename is a refactoring your IDE offers to do for you with a keyboard shortcut, and in this codebase it is a production behaviour change.

The failure has a second gear, too. Drop the Modules segment entirely — flatten to Inflow.Customers.Core — and Contains("Modules") is false, so GetModuleName() returns string.Empty for every type in that module. An empty module name still keys a dictionary perfectly well. OutboxTypeRegistry would happily register EfOutbox<CustomersDbContext> under "" and resolve every message from every namespace-less type to it.

Why it is still the right shape

I want to be careful here, because it would be easy to read the above as “convention-based routing is bad”, and that is not the lesson.

The alternative designs are all heavier. An explicit [Module("customers")] attribute on every message type is fifty attributes to keep in sync with a folder structure that already says the same thing. A ModuleName property on IModule plus a registry mapping assemblies to modules is a lookup table that duplicates the namespace tree. A configuration file listing modules and their namespaces is a third place for the truth to live. Inflow's version has the property that the two things it correlates — where a type lives and which module owns it — are the same fact, so they cannot drift.

What is missing is not a different mechanism. It is a guard. The correct amount of defensive code here is about six lines, once, at startup:

// Fresh illustrative code, not from the repository.
internal static void AssertModuleNamesResolve(IEnumerable<Assembly> assemblies, IEnumerable<string> expected)
{
    var resolved = assemblies
        .SelectMany(a => a.GetTypes())
        .Where(t => typeof(IMessage).IsAssignableFrom(t))
        .Select(t => t.GetModuleName())
        .Where(name => !string.IsNullOrWhiteSpace(name))
        .Distinct()
        .ToArray();

    var unknown = resolved.Except(expected, StringComparer.Ordinal).ToArray();
    if (unknown.Any())
    {
        throw new InvalidOperationException(
            $"Message types resolved to unknown modules: {string.Join(", ", unknown)}. " +
            "Check that namespaces follow Inflow.Modules.<Module>.*");
    }
}

The module names are already known at boot — Startup holds the IModule list, and every one of them has a Name. Cross-checking the namespace-derived set against the declared set costs one LINQ query and converts the estate's most silent failure into a startup exception with a message that tells you exactly what happened. Inflow already does exactly this kind of boot-time verification for message shapes, which is part 5; it just does not do it for message addresses.

To be fair to the repository: the convention is stated in the folder structure, in every namespace, and in the parameter names of GetModuleName itself. For a course codebase where every module is created in the same session by the same person, that is enough. The rule only bites on the second team, in the second year, during the refactor nobody thought was risky — which is precisely why it makes such good teaching material.

The second derivation, and why they can disagree

There is a detail I skipped, and it is the sort that only shows up when you read two files next to each other. Module identity is derived from a namespace here, but part 2 showed it derived from a filename in ModuleLoader:

var moduleName = file.Split(modulePart)[1].Split(".")[0].ToLowerInvariant();
var enabled = configuration.GetValue<bool>($"{moduleName}:module:enabled");

.../Inflow.Modules.Customers.Core.dll yields customers. The namespace derivation yields customers too, and the configuration key customers:module:enabled agrees with both — but only because assembly names and root namespaces happen to match in this solution, which is the default MSBuild behaviour and nothing more.

Set <RootNamespace> or <AssemblyName> to anything else in one module's .csproj and the two derivations diverge. The loader would read customers:module:enabled from the filename while every message in the module routes under a different name, and the configuration key that enables the module would no longer be the key that names it in a log line. Neither derivation is wrong on its own terms; they are simply two independent guesses at the same fact, and nothing in the codebase asserts that they agree.

That is the deeper version of the problem. A convention is a claim that two things will always be equal. When you derive the same fact twice, by two different string operations, from two different sources, you have made that claim twice — and you now need both to hold, with no compiler and no test checking either.

The rule of thumb

Conventions that derive one fact from another are excellent when the derivation is total and terrible when it is partial. GetModuleName is partial in two directions at once: it returns an empty string for types outside the convention, and it returns a wrong string for types that satisfy the Contains check by accident. Both failures produce a value the rest of the system will happily use.

If a convention can fail into a valid-looking value rather than an exception, it needs an assertion at the boundary where it is first derived. Not everywhere it is consumed — once, at boot, where you can still print a useful message.

Next, your type name is the wire contract — because the module name only decides whether delivery is allowed. What decides where a message goes at all is even simpler, and even less defended.