Skip to content
Kumar Chandrachooda
Microservices

Subscribing to Everything Without Referencing Anything

Pacco's Operations service listens to all eighty messages in the estate while owning zero contract classes - it reads a JSON registry and forges the CLR types at runtime with Reflection.Emit. The estate's answer to the shared-contracts problem, and the drift it cannot see.

By Kumar Chandrachooda 16 Dec 2025 6 min read
Eighty message types forged at startup from one JSON file

Pacco.Services.Operations subscribes to every command, every event and every rejected event in the estate — eighty messages across eight exchanges — and if you open its project looking for the contract classes, you will not find one. No OrderCreated, no ReserveResource, no shared package reference, nothing. The types it subscribes with do not exist when the service compiles. They exist about a second later, when it starts.

Part 1 called Operations the estate's nervous system and promised the mechanism; this part reads it. The whole trick lives in two files: a JSON registry and 160 lines of Reflection.Emit.

A registry of everything anyone says

The first file is messages.json, which sits at the root of the API project. It is a hand-maintained catalogue of the estate's entire message surface — per service: the exchange name, then the commands, events and rejected events that flow through it, all in snake_case routing-key form. A trimmed excerpt:

{
  "orders-service": {
    "exchange": "orders",
    "commands": [
      "add_parcel_to_order",
      "approve_order",
      "create_order"
    ],
    "events": [
      "order_approved",
      "order_created",
      "parcel_added_to_order"
    ],
    "rejectedEvents": [
      "create_order_rejected",
      "order_for_delivery_not_found"
    ]
  }
}

The real file covers eight services. Counting it out: 24 commands, 29 events and 27 rejected events — 80 messages that one JSON document claims to know about. Notice what the schema encodes beyond the names: the three-way kind split (command, event, rejection), and — critically — which exchange each message travels on. Both of those facts are about to become type metadata.

The forge

The second file is Infrastructure/Subscriptions.cs, and its core is the most audacious method in the estate. Here is the type-forging heart of it, from the source:

private static IEnumerable<T> BindMessages<T>(ModuleBuilder moduleBuilder, string exchange,
    IEnumerable<string> messages) where T : class, IMessage, new()
{
    foreach (var message in messages)
    {
        var type = typeof(T);
        var typeBuilder = moduleBuilder.DefineType(message, TypeAttributes.Public, type);
        var attributeConstructorParams = new[] {typeof(string), typeof(string), typeof(string), typeof(bool)};
        var constructorInfo = typeof(MessageAttribute).GetConstructor(attributeConstructorParams);
        var customAttributeBuilder = new CustomAttributeBuilder(constructorInfo,
            new object[] {exchange, null, null, true});
        typeBuilder.SetCustomAttribute(customAttributeBuilder);
        var newType = typeBuilder.CreateType();
        var instance = Activator.CreateInstance(newType);

        yield return instance as T;
    }
}

Read it slowly, because every line is doing contract work that would normally be a compiled class in a shared package.

  • moduleBuilder.DefineType(message, ...) — the CLR type is named after the routing key. The dynamic assembly (declared a few lines up as Pacco.Services.Operations.Api.Messages) ends up containing a public class literally called order_created. Convey's conventions derive routing keys from type names by snake-casing them, so a type that is already snake_case round-trips to itself. The naming convention is not decoration here; it is the mechanism.
  • The base type is a marker. T is one of three tiny classes in the Types folder — Command, Event, RejectedEvent — empty shells implementing Convey's ICommand or IEvent plus a local IMessage. The forged type inherits everything it needs to be a legal Convey message by deriving from a class with no members.
  • CustomAttributeBuilder — this is the line most people never see in production code: it constructs a [Message(exchange, null, null, true)] attribute at runtime and stamps it onto the forged type. That attribute is how Convey decides which exchange to bind the queue to, and the final true marks the message as external — someone else's contract. The exchange name flows straight out of messages.json into type metadata.
  • Activator.CreateInstance — an instance is created purely so the caller can hand something typed back; the object itself is inert. All the value is in the type.

The subscription half is just as reflective. IBusSubscriber.Subscribe<T> is a generic method and the forged types are runtime values, so the code fetches the method by name and closes it over each type:

subscribeMethod.MakeGenericMethod(message.GetType()).Invoke(subscriber,
    new object[] {(Func<IServiceProvider, ICommand, object, Task>) Handle});

static Task Handle(IServiceProvider sp, ICommand command, object ctx) =>
    sp.GetService<ICommandHandler<ICommand>>().HandleAsync(command);

Eighty invocations of that line, and the service is bound to the entire estate.

Three handlers for eighty messages

Every forged command routes to one GenericCommandHandler<ICommand>, every event to one GenericEventHandler<IEvent>, every rejection to one GenericRejectedEventHandler<IRejectedEvent> — three registrations in Infrastructure/Extensions.cs, and the whole reason the trick is sound: the handlers never read a message body. Deserialising a payload would require real properties on real contracts; Operations only ever touches broker metadata. The command handler is representative:

var correlationId = messageProperties?.CorrelationId;
var context = _contextAccessor.GetCorrelationContext();
var name = string.IsNullOrWhiteSpace(context?.Name) ? command.GetType().Name : context.Name;
var userId = context?.User?.Id;
var state = messageProperties.GetSagaState() ?? OperationState.Pending;
var (updated, operation) = await _operationsService.TrySetAsync(correlationId, userId, name, state);

Correlation id, the correlation context the gateway stamped on the message, the Saga header, and the message's kind — command means an operation is now Pending, event means Completed, rejection means Rejected. That is the entire information diet. The payload could be anything; the forged types have no properties to receive it, and none are needed. Operations is a service that has formalised the observation that you can track the fate of every request in a system while understanding none of them.

The problem this is an answer to

Why would anyone do this? Because the alternative answers to the shared-contracts problem are all painful, and this estate had already lived one of them. DShop — Pacco's predecessor by the same authors — kept a shared DShop.Messages package, the classic contract library that every service references and every change to which ripples through every consumer. Pacco went the other way: each service re-declares the foreign events it consumes as local copies. That works — the companion craft series counts what it costs — right up until you hit the one service whose job requires every contract in the estate. Copy eighty classes into Operations and you have created the estate's largest maintenance liability; reference every service and you have created a dependency cycle with all ten.

Operations' answer: since it only needs the names and addresses of messages, not their shapes, synthesise exactly that much type and no more. When a service adds a message, Operations needs a JSON edit — not a code change, not a package bump, not a redeploy of anything else. As an answer to “how does the watcher subscribe to everyone without coupling to anyone”, it is genuinely elegant, and I have not seen it elsewhere in a public codebase.

The registry rots silently

Now the honest ledger, because a hand-maintained registry is a second copy of the truth, and second copies drift. The file already disagrees with the estate it describes: messages.json lists sign_in as a subscribable command on the identity exchange, but Pacco.Services.Identity subscribes only SignUp (Infrastructure/Extensions.cs, .SubscribeCommand<SignUp>() — the sole subscription in the file). Operations dutifully forges a sign_in type and binds a queue that no publisher will ever target with a command. Nothing validates the registry against the services: a typo in a routing key binds a dead queue, and the only symptom is an operation that never resolves.

The startup posture amplifies this. SubscribeMessages opens with:

if (!File.Exists(path))
{
    return subscriber;
}

No file, no error — the nervous system boots healthy, registers with Consul, serves its API, and is subscribed to nothing. Absence-tolerant startup is a kindness in development and a silent lobotomy in production. If I were operating this, the first thing I would add is a startup assertion that the forged subscription count matches an expected number, and the second is a periodic reconciliation between the registry and the brokers' actual bindings. The registry-as-contract-governance problem is big enough that the companion series gives it a full treatment in a shadow registry of every message.

To be fair to the design: every alternative also fails silently somewhere. Shared packages fail at deploy-time coupling; copied contracts fail by drifting exchange names — this estate contains a live example of that, which part 14 dissects. The JSON registry at least concentrates the drift in one greppable file. It just never checks itself.

What I take from it

Three durable lessons. First, type identity can be data — when a framework routes on names and attributes, a name-plus-attribute is all a subscriber needs, and Reflection.Emit can manufacture that from config. Second, marker base classes are a beautifully cheap way to make synthesised types legal citizens of a typed pipeline. Third, any registry that humans maintain and machines trust needs a machine checking the humans — the pattern is fine; the missing validator is the defect.

Next, the front door: the same estate can answer a POST with a proxy call or a bus publish, and the difference is one word in a YAML file — one YAML flag turns REST into a message.