# Obfuscating a MassTransit App Without Breaking Message Routing


A MassTransit application is a tempting obfuscation target: consumers, sagas and routing-slip activities are where your business logic lives, and most of it can be renamed, string-encrypted and virtualized without a second thought. But MassTransit has one seam that a naive pass walks straight into, and the failure is the worst kind — completely silent. The build succeeds, the service starts, the producer publishes, the broker reports no error, and the consumer that worked yesterday simply never fires. No exception, no stack trace. The messages are going somewhere; they are just not going *there*.

The cause is specific to how MassTransit decides what a message *is*, and once you see it the fix is a short keep-list. This post is about that mechanism — exactly how MassTransit derives a message's identity from its contract type, why that identity is written into both the broker topology and the message envelope, and how to protect the valuable interior with [Nebula.NET](https://delta1labs.com/products/nebula) without moving the names the wire depends on.

## TL;DR

- MassTransit derives a message's **MessageUrn** — `urn:message:Namespace:TypeName` — from the **contract type's .NET namespace and name**.
- That URN names the **broker exchange/topic** the producer publishes to, and is stamped into the envelope's **`messageType`** header so consumers can match it.
- Rename the contract type or its namespace and the URN changes: the producer publishes to a **different exchange** and stamps a **different `messageType`**, and existing consumers **silently stop receiving**.
- The **message contract assembly** is the fragile surface — it is shared between separately deployed services. **Consumers, sagas and internals rename freely.**
- Keep-list the contract **namespaces, type names and public property names** (the body binds by property name too); obfuscate everything else.

## Why obfuscate a MassTransit application at all

The contracts are thin, but the consumers and sagas are the product: the fraud-scoring that runs when an order is submitted, the settlement state machine, the pricing recalculation, the reconciliation workflow. That code runs headless on a server — often one your customer administers — and the deployed artifact is a DLL anyone with file access can open in a decompiler and read back as clean C#. The messaging tier is also where the sensitive constants cluster: downstream endpoint URLs, partner credentials, queue and header names, signing secrets. Protecting your own shipped code here is exactly the legitimate case for obfuscation — raise the cost of reading your business logic back out of the binary you handed over.

## How MassTransit builds a message's identity

Here is an ordinary message contract and the consumer that handles it, each in its own project:

```csharp
namespace Contoso.Ordering.Contracts;

// The contract. Its namespace + name ARE the message's identity.
public record OrderSubmitted(Guid OrderId, Guid CustomerId, decimal Total);
```

```csharp
namespace Contoso.Ordering.Fulfillment.Consumers;

public sealed class OrderSubmittedConsumer : IConsumer<OrderSubmitted>
{
    private readonly IFulfillmentService _fulfillment;

    public OrderSubmittedConsumer(IFulfillmentService fulfillment)
        => _fulfillment = fulfillment;

    public async Task Consume(ConsumeContext<OrderSubmitted> context)
    {
        // proprietary — the reason this service exists
        await _fulfillment.Reserve(context.Message.OrderId, context.Message.Total);
    }
}
```

The producer publishes by type:

```csharp
await bus.Publish(new OrderSubmitted(orderId, customerId, 149.95m));
```

Nothing in that `Publish` call names an exchange or a routing key. MassTransit works it out from the type. For the type `Contoso.Ordering.Contracts.OrderSubmitted` it computes a **MessageUrn**:

```text
urn:message:Contoso.Ordering.Contracts:OrderSubmitted
```

The shape is fixed: `urn:message:` + the type's **namespace** + `:` + the type's **name**. That single string drives two separate things, on two separate deployments.

First, **broker topology**. The default entity-name formatter takes the URN, drops the `urn:message:` prefix, and uses the rest as the exchange (RabbitMQ) or topic (Azure Service Bus) name:

```text
Contoso.Ordering.Contracts:OrderSubmitted
```

The producer publishes to *that* exchange. A consumer for `OrderSubmitted` binds a queue to *that* exchange. They find each other only because both sides compute the same name from the same type.

Second, the **message envelope**. MassTransit wraps your message in an envelope and writes the URN into the `messageType` header — as an array, because a message can carry its implemented interfaces and base types too:

```json
{
  "messageId": "7b1e...-...",
  "conversationId": "9c22...-...",
  "sourceAddress": "rabbitmq://broker/orders-api",
  "messageType": [
    "urn:message:Contoso.Ordering.Contracts:OrderSubmitted"
  ],
  "message": {
    "orderId": "3f2504e0-4f89-41d3-9a0c-0305e82c3301",
    "customerId": "b8f9...",
    "total": 149.95
  }
}
```

When a consumer receives the envelope, it reads `messageType` and checks whether any URN in that array matches a contract it consumes. If `OrderSubmitted` is in the list, it deserializes the `message` object onto its copy of the contract and calls `Consume`. If not, the message is *not for it* — it goes to the `_skipped` queue.

Notice the second dependency hiding in the `message` object: the body is keyed **by property name** — `orderId`, `customerId`, `total`. The consumer binds those fields back onto `OrderId`, `CustomerId`, `Total` by name. So the contract has *two* name surfaces that cross the wire: the **type's namespace + name** (the URN, for routing and matching) and its **public property names** (the body, for binding).

<figure class="my-8"><svg viewBox="0 0 720 350" role="img" aria-label="The producer service computes a MessageUrn from the contract type's namespace and name. That URN names the broker exchange and is stamped into the envelope's messageType header. The consumer service binds its queue to the same exchange name and matches the same messageType, computed from its own copy of the contract. If the contract type or namespace is kept identical on both sides, routing works while consumer and saga internals are freely renamed. If it is renamed on one side, the URN diverges and the consumer silently receives nothing." style="width:100%;height:auto"><defs><style>.ttl{font:600 12px system-ui;fill:#1547e0}.lbl{font:11px system-ui;fill:#475569}.mono{font:600 10.5px ui-monospace,monospace;fill:#0f172a}.urn{font:600 10.5px ui-monospace,monospace;fill:#1d4ed8}.box{fill:#ffffff;stroke:#cbd5e1}.keep{fill:#eff6ff;stroke:#93c5fd}.hub{fill:#f1f5f9;stroke:#94a3b8}.good{fill:#ecfdf5;stroke:#6ee7b7}.flow{stroke:#94a3b8;stroke-width:1.5;fill:none}</style><marker id="m1" markerWidth="8" markerHeight="8" refX="6" refY="3" orient="auto"><path d="M0 0l6 3-6 3z" fill="#94a3b8"/></marker></defs><text class="ttl" x="16" y="20">Producer service</text><rect class="box" x="16" y="30" width="300" height="46" rx="7"/><text class="mono" x="28" y="50">bus.Publish(new OrderSubmitted(...))</text><text class="lbl" x="28" y="68">internals renamed · encrypted · virtualized</text><rect class="keep" x="16" y="88" width="300" height="40" rx="7"/><text class="lbl" x="28" y="106">URN from kept type name →</text><text class="urn" x="28" y="122">urn:message:Contoso.Ordering.Contracts:OrderSubmitted</text><rect class="hub" x="222" y="150" width="276" height="54" rx="7"/><text class="ttl" x="234" y="170">Broker exchange / messageType</text><text class="urn" x="234" y="190">Contoso.Ordering.Contracts:OrderSubmitted</text><path class="flow" d="M166 128 V150 H222" marker-end="url(#m1)"/><text class="ttl" x="404" y="20">Consumer service</text><rect class="box" x="404" y="30" width="300" height="46" rx="7"/><text class="mono" x="416" y="50">IConsumer&lt;OrderSubmitted&gt;</text><text class="lbl" x="416" y="68">internals renamed · encrypted · virtualized</text><rect class="keep" x="404" y="88" width="300" height="40" rx="7"/><text class="lbl" x="416" y="106">same URN from same kept type →</text><text class="urn" x="416" y="122">urn:message:Contoso.Ordering.Contracts:OrderSubmitted</text><path class="flow" d="M554 128 V150 H498" marker-end="url(#m1)"/><rect class="good" x="140" y="230" width="440" height="96" rx="8"/><text class="ttl" x="360" y="252" text-anchor="middle" style="fill:#047857">Keep the contract namespace + name + properties</text><text class="lbl" x="360" y="274" text-anchor="middle">Both sides compute the identical URN → routing + binding hold</text><text class="lbl" x="360" y="294" text-anchor="middle">Consumers, sagas, services: renamed, string-encrypted, virtualized</text><text class="lbl" x="360" y="314" text-anchor="middle">Rename the contract on one side → URN diverges → consumer gets nothing</text></svg></figure>

### Generic messages and interface contracts

Two variations keep the same rule. If your message is **generic** — say a `Batch<T>` envelope — the URN nests both type names:

```text
urn:message:Contoso.Ordering.Contracts:Batch[[Contoso.Ordering.Contracts:OrderSubmitted]]
```

Both the outer `Batch` and the inner `OrderSubmitted` appear, so **both** namespaces and names must be kept. If MassTransit contracts are defined as **interfaces** — a common pattern, with MassTransit generating the concrete implementation at runtime — the URN derives from the *interface's* namespace and name, and the property names come from the interface's getters. Keeping the interface type and its members covers it; the generated implementation type is internal and never crosses the wire.

## Why the contract assembly is the fragile surface

The asymmetry is the whole point. A consumer, a saga, a routing-slip activity — MassTransit resolves these *inside one process*, from its DI container and the registrations you configured at startup. It looks them up by type identity, not by any string written to a broker or a database. Rename `OrderSubmittedConsumer` to `a` and nothing notices: the registration still points at the same type, the container still resolves it, the queue it is bound to is named after the *message*, not the consumer.

The contract is the opposite. It is the one thing two independently deployed services must agree on, and they agree on it through a *string* — the URN — that each computes from its own copy of the type. There is no shared registry, no negotiation; the agreement is purely that both sides name the type the same way. The moment obfuscation renames the contract on one side only — because you redeployed the producer but not the consumer, or ran the obfuscator over one solution and not the other — the URNs diverge and the two services stop talking, with no error anywhere, because from the broker's point of view everything is working: it is routing a message to an exchange, and a different queue is quietly bound to a different exchange.

## The audit: what renames, what doesn't

Walk every name that crosses the wire, and the keep-list writes itself.

- **The contract types** — `Contoso.Ordering.Contracts.OrderSubmitted` and every other message record or interface. Their namespace and name build the URN that names the exchange and fills `messageType`. **Keep the namespaces and type names.**
- **The contract properties** — `OrderId`, `CustomerId`, `Total`. The envelope's `message` body is keyed by property name and the consumer binds back by name. **Keep the public property names.** A `type-and-members` keep rule covers the type and its properties in one line.
- **Generic argument types** — if a message is `Batch<OrderSubmitted>`, both `Batch` and `OrderSubmitted` appear in the URN. **Keep every type used as a message or as a message's generic argument.**
- **The consumers** — `OrderSubmittedConsumer`, `IConsumer<T>` implementations. Resolved by registration and type identity within the process; nothing on the wire names them. **Rename freely.**
- **The sagas and state machines** — the `MassTransitStateMachine`, the saga instance type. The saga *correlates* on a property value (a `CorrelationId` GUID), which is data, not a symbol, and its persisted state is keyed by that value, not by the type name. **Rename the logic.** (If the saga is persisted and its *instance* type is serialized to a store by name, treat that store exactly like a contract and keep the instance type's members — the same reasoning as any serialized persistence, covered in our [EF Core](https://delta1labs.com/blog/obfuscate-ef-core-without-breaking-migrations/) and serialization posts.)
- **The endpoint/queue names** — `"orders-api"`, receive-endpoint names you pass as string literals. These are data you reproduce the same way at runtime; string encryption leaves their runtime value intact. **No action needed.**
- **Everything the consumers and sagas call into** — `IFulfillmentService`, the scoring, the reconciliation, the private helpers. Nothing outside the process names these. **Rename, encrypt, virtualize.**

One surface to keep, then — the contract types, their namespaces and their public properties — and everything else is fair game.

## The keep-list

Model the contracts under one namespace so a single rule covers them, and `nebula.config.json` reads:

```json
{
  "schemaVersion": 1,
  "inputs": [
    "publish/Contoso.Ordering.Fulfillment.dll",
    "publish/Contoso.Ordering.Contracts.dll"
  ],
  "outputDirectory": "protected",

  "preservePublicApi": false,
  "encryptStrings": true,
  "controlFlowObfuscation": true,

  "renaming": {
    "rules": [
      { "pattern": "Contoso.Ordering.Contracts.*",  "action": "keep",   "scope": "type-and-members" },
      { "pattern": "Contoso.Ordering.*",            "action": "rename" }
    ]
  }
}
```

The first rule pins every type under `Contoso.Ordering.Contracts` **and its members**, so each contract keeps the namespace and name the URN is built from *and* the public property names the body binds by — `scope: "type-and-members"` is what preserves `OrderId`, not just `OrderSubmitted`. Everything else in the assemblies — consumers, sagas, services, helpers — is renamed. String encryption and control-flow obfuscation apply everywhere, including inside the contracts assembly, because neither touches names.

If you would rather not rely on a namespace convention, annotate the contracts directly — the attribute travels with the code and cannot drift out of sync with a pattern:

```csharp
[Obfuscation(Exclude = true, ApplyToMembers = true)]   // keep the URN + property names for routing/binding
public record OrderSubmitted(Guid OrderId, Guid CustomerId, decimal Total);
```

## Should you obfuscate the shared contracts assembly at all?

Often the cleanest answer is: don't, or keep all of it. A contracts assembly is deliberately thin — records and interfaces, no proprietary logic — so renaming it hides nothing of value while every renamed name is a chance to break routing or binding across a service you deploy on a different schedule. Two sound patterns:

- **Leave the contracts project unobfuscated.** Ship it as-is and run Nebula.NET only over the consumer/saga assemblies. The contract names are not secrets; your algorithms are, and they are not in this assembly.
- **Obfuscate it with the whole public API kept.** If you prefer one uniform pipeline, keep every public type and member in the contracts assembly (the `type-and-members` rule above, or `preservePublicApi: true` scoped to that DLL) and let string encryption and control-flow still apply. You gain uniformity and lose nothing, because there was nothing to hide.

Either way, the rule is the same across every service that references the contracts: the namespace, type name and public properties must be **byte-for-byte identical** wherever the URN is computed. If two services ship their own copy of the contracts, both copies must be kept the same way — obfuscating one and not the other is exactly how the URNs diverge.

## Folding protection into the build

Protect the published output, and only on the build server so local debugging stays fast and readable:

```bash
dotnet publish Contoso.Ordering.Fulfillment -c Release -o publish
nebula protect --config nebula.config.json
```

Hook it after publish in CI so the deployed artifact is always the protected one:

```xml
<Target Name="NebulaProtect" AfterTargets="Publish"
        Condition="'$(CI)' == 'true'">
  <Exec Command="nebula protect --config nebula.config.json" />
</Target>
```

Ship the `protected/` folder, not the raw publish folder. `nebula protect` returns a non-zero exit code on failure, so a broken protection step fails the build instead of shipping an assembly that renamed a contract your other services still route by.

## Protecting the interior

Keeping a contract's *name* does not mean leaving anything readable. The URN stays stable while everything behind the consumer becomes opaque. **String encryption** hides the downstream endpoint URLs, credentials and header names the messaging tier accumulates. The **strongest transforms** — code virtualization and whole-method encryption, both **Pro** features in Nebula.NET — go on the crown jewels: the scoring, settlement or reconciliation logic the consumers and sagas wrap, applied to that handful of methods rather than the whole assembly:

```csharp
public sealed class FulfillmentService : IFulfillmentService
{
    [Obfuscation(Feature = "virtualization")]   // Pro — the algorithm, not the plumbing
    public Task Reserve(Guid orderId, decimal total) { /* ... */ }
}
```

Renaming and control-flow obfuscation run broadly underneath both, turning the rest of the consumer and saga interiors into meaningless symbols and tangled branches while the contract the broker routes by stays exactly where it was.

## Verifying it actually works

Unit tests will not catch this: they construct a consumer and call `Consume` with an in-memory `OrderSubmitted`, never routing a real envelope through a real broker against the protected DLLs. Two checks will. First, run it end to end against a real broker — publish from the *protected* producer and confirm the *protected* consumer (ideally deployed from a separately built artifact, to mimic independent deployment) receives and processes the message. A message that lands in `_skipped` or simply never arrives, with no exception, is the exact signature of a diverged URN. MassTransit's own diagnostics help here: compare the `messageType` on a published envelope against the URN the consumer is bound to; if they differ by a renamed namespace or type, you have found the missed keep rule.

Second, open the shipped assembly in a decompiler and read it the way an attacker will. [Glass.NET](https://delta1labs.com/products/glass) is the free, static-only companion for that pass: load the protected DLL, confirm `Contoso.Ordering.Contracts.OrderSubmitted` and its `OrderId`/`CustomerId`/`Total` properties are still present **by name** — they must be, to match the URN and body on the wire — then confirm there is nothing left to read inside `OrderSubmittedConsumer` or the `FulfillmentService` it calls.

## The takeaway

MassTransit identifies a message by a string — its MessageUrn, `urn:message:Namespace:TypeName` — derived from the contract type's .NET namespace and name. That URN names the broker exchange or topic the producer publishes to and is stamped into the envelope's `messageType` header, so a producer and its separately deployed consumers agree on routing only because both compute the same URN from the same type. Rename the contract type or its namespace and the URN changes on one side: the producer publishes to a different exchange, stamps a different `messageType`, and the consumer silently receives nothing — no exception, just messages going nowhere. The body binds by property name, so the contract's public properties are load-bearing too. Keep exactly what crosses the wire — the contract namespaces, type names and public properties — decide deliberately whether to obfuscate the shared contracts assembly at all, and hand the entire consumer and saga interior to [Nebula.NET](https://delta1labs.com/products/nebula) to rename, string-encrypt and virtualize. Then publish a real message through the protected build, watch the right consumer fire, and read the shipped DLL to confirm the logic behind it is gone.

