# Obfuscating a multi-project .NET solution: one consistent protection strategy


A solution with ten class libraries and an app looks like ten obfuscation problems. It is one. The projects reference each other, expose types to each other, share internals through `[InternalsVisibleTo]`, and ship together as a single unit a customer installs or restores as one package. Protect them one at a time and you get the worst of every world: transitive copies obfuscated twice, cross-assembly references that no longer resolve, friend access broken between your own DLLs, and a drawer of rename maps that do not line up when a crash report walks through three of them. This post is the playbook for doing it as the single problem it is — deciding what to protect, where to run the pass, how to drive every assembly from one policy, and how to prove the result across the whole set in [Nebula.NET](https://delta1labs.com/products/nebula).

## Step 1 — Protect what you ship, and only that

Before any config, settle the scope. Your solution builds far more than it ships: test projects, benchmark harnesses, source-generator helpers, sample apps. None of those go to a customer, so none of them need protecting — obfuscating a test assembly you never distribute is pure cost. Start from the list of assemblies that actually leave the building, which is exactly what your `dotnet publish` (or your `.nupkg`) contains.

The subtler trap is **transitive copies**. When your app publishes, every dependency it pulls in lands in the output folder — including third-party packages and, if you are not careful, copies of libraries from *other* solutions. You protect the assemblies you authored and ship; you do not re-obfuscate a dependency someone else already shipped (you have no map for it, no right to alter it, and nothing to gain). So the inputs list names your own assemblies by name, not "everything in the folder." Enumerate the ones with your namespace and leave the rest alone.

## Step 2 — Run the pass once, over the publish output

The single most common multi-project mistake is running obfuscation per project, in each project's `bin/Debug`, as part of every developer build. That is wrong on two axes. It protects the wrong artifact — `Debug` binaries nobody ships — and it protects them in isolation, before the solution has been assembled into one coherent output. Developers also lose the thing they need most: a readable, debuggable build.

Run the pass **once**, late, over the assembled publish output, on the build server only. Publish the whole solution, then hand Nebula the published assemblies as a single run. This is the same discipline as [obfuscating only on your build server](https://delta1labs.com/blog/obfuscate-on-build-server-only/): developer machines build clean and debuggable; the agent that produces the shippable package is the one place protection happens, so the binary you ship is the only binary that is ever obfuscated.

<figure class="my-8"><svg viewBox="0 0 660 236" role="img" aria-label="Many source projects — app, core, rules, plugins-abstractions, plus a test project — compile and publish into one publish folder. Only the four shipped assemblies flow into a single Nebula run with one shared config; the test project and third-party dependencies are not inputs. The run emits protected assemblies and one coherent set of rename maps for the release." style="width:100%;height:auto"><defs><style>.src{fill:#eef4ff;stroke:#8eb4ff}.skip{fill:#f1f5f9;stroke:#cbd5e1}.neb{fill:#e6fffb;stroke:#2dd4bf}.out{fill:#bbf7d0;stroke:#4ade80}.cfg{fill:#fef9c3;stroke:#fde047}.t{font:600 12px system-ui;fill:#1547e0}.x{font:11px system-ui;fill:#475569}.ar{stroke:#8eb4ff;stroke-width:2;fill:none}</style><marker id="hm" markerWidth="8" markerHeight="8" refX="6" refY="3" orient="auto"><path d="M0 0l6 3-6 3z" fill="#8eb4ff"/></marker></defs><text class="t" x="16" y="18">solution projects</text><rect class="src" x="16" y="28" width="150" height="24" rx="5"/><text class="x" x="26" y="44">Acme.App</text><rect class="src" x="16" y="58" width="150" height="24" rx="5"/><text class="x" x="26" y="74">Acme.Core</text><rect class="src" x="16" y="88" width="150" height="24" rx="5"/><text class="x" x="26" y="104">Acme.Rules</text><rect class="src" x="16" y="118" width="150" height="24" rx="5"/><text class="x" x="26" y="134">Acme.Plugins.Abstractions</text><rect class="skip" x="16" y="148" width="150" height="24" rx="5"/><text class="x" x="26" y="164">Acme.Tests (not shipped)</text><path class="ar" d="M166 100 h40 v-20 h20" marker-end="url(#hm)"/><path class="ar" d="M166 100 h40 v20 h20" marker-end="url(#hm)"/><rect class="neb" x="226" y="70" width="150" height="60" rx="6"/><text class="t" x="238" y="94">one Nebula run</text><text class="x" x="238" y="112">4 shipped assemblies</text><text class="x" x="238" y="126">crossAssemblyRename</text><rect class="cfg" x="226" y="18" width="150" height="34" rx="6"/><text class="t" x="238" y="39">one shared config</text><path class="ar" d="M301 52 v14" marker-end="url(#hm)"/><path class="ar" d="M376 100 h30" marker-end="url(#hm)"/><rect class="out" x="406" y="58" width="150" height="40" rx="6"/><text class="x" x="418" y="76">protected assemblies</text><text class="x" x="418" y="92">(publish/protected)</text><rect class="out" x="406" y="108" width="150" height="40" rx="6"/><text class="x" x="418" y="126">one map set</text><text class="x" x="418" y="142">per release</text><path class="ar" d="M481 98 v10" marker-end="url(#hm)"/></svg></figure>

## Step 3 — One shared policy, not a config per project

The transforms you run, what you keep public at the boundary, where the seed map lives — these are properties of the *product*, not of any single project. So they belong in one solution-level `nebula.config.json`, read by the one publish-output run. A config fragment per project is how a solution drifts into being protected inconsistently: one library ends up with string encryption, the next without, and nobody notices until a decompiler does.

Here is the whole policy for a four-assembly product, in one file. It names the shipped assemblies as inputs, renames across them as a set, keeps the one namespace that is a public contract, and seeds from last release's map:

```json
{
  "schemaVersion": 1,
  "inputs": [
    "publish/Acme.App.dll",
    "publish/Acme.Core.dll",
    "publish/Acme.Rules.dll",
    "publish/Acme.Plugins.Abstractions.dll"
  ],
  "outputDirectory": "publish/protected",
  "crossAssemblyRename": true,
  "encryptStrings": true,
  "controlFlowObfuscation": true,
  "obfuscateConstants": true,
  "seedMapFile": "maps/Acme-prev.symbols.json",
  "renaming": {
    "rules": [
      { "pattern": "Acme.Plugins.Abstractions.*", "action": "keep" },
      { "pattern": "Acme.*", "action": "rename" }
    ]
  }
}
```

`crossAssemblyRename`, unlimited string encryption, control-flow obfuscation and constant masking are **Pro** features; identifier renaming is in every edition. The rename rules are evaluated top to bottom, first match wins — the narrow `keep` for the plugin contract sits above the broad `Acme.*` catch-all, exactly as in [building a Nebula keep-list](https://delta1labs.com/blog/nebula-keep-list-rename-exclusions/). One file, one policy, every assembly treated the same.

## Step 4 — Rename across the solution, not per project

`crossAssemblyRename: true` is the line that makes a solution work. Your assemblies reference each other constantly: `Acme.App` calls a type in `Acme.Core`, which calls one in `Acme.Rules`. When Nebula renames `Acme.Core.PricingEngine` to `a`, it must rewrite the call sites in `Acme.App` and `Acme.Rules` to `a` as well, or those references dangle. A single run with cross-assembly renaming on does exactly that: it treats the input set as one namespace to rename and rewrites every reference across it.

The sharp edge is `[InternalsVisibleTo]` between your own assemblies — the `internal` types one of your projects exposes to another (most often a test project, but also a split-for-layering sibling). Those internal names are a compile-time contract; rename the producer alone and the consumer still references the old names and fails to bind. Because every assembly that references the renamed internals is an input to the same run, cross-assembly renaming keeps both sides in step — the internal type gets the same new name at its definition and at every friend reference. The full mechanics, including the runtime-reflection variant that no compiler warns you about, are in [obfuscating InternalsVisibleTo and friend assemblies](https://delta1labs.com/blog/obfuscate-internalsvisibleto-friend-assemblies/). The rule that falls out of it is the rule of this whole post: if two of your assemblies share names, they share a run.

## Step 5 — One seeded map per release

A per-project obfuscation leaves you with a per-project map, generated against that project's isolated rename decisions, and nothing guarantees they agree. A stack trace from the field rarely stays inside one assembly — it walks from `Acme.App` through `Acme.Core` into `Acme.Rules` — and decoding it means every frame's map was produced by the same run. Running the whole solution once gives you exactly that: one coherent set of `.symbols.json` maps for the release, all generated against the same decisions.

Then carry the names forward. Point `seedMapFile` at the previous release's maps so members that did not change keep the obfuscated name they already had; only new code gets new names. Across a release series that means one archived map keeps decoding the same method, and a binary diff shows your actual change instead of a global renumber. The full treatment — why stable names make crash symbolication and delta patching tractable, and why seeding is an operational tool rather than a security knob — is in [keeping obfuscated names stable with a seed map](https://delta1labs.com/blog/stable-rename-map-seeded-obfuscation/). Archive a version-stamped copy of the maps for every release; they are what decode that build's crash reports forever.

## Step 6 — Keep the solution boundary, rename freely inside it

A multi-project product has exactly one surface that must survive renaming: whatever the *outside world* binds to by name. For a shipped SDK that is the public API; for an app with a plugin model it is the plugin contract — the abstractions assembly a third party compiles their plugin against. Everything inside the solution boundary, no matter how many assemblies it spans, is yours to rename freely, because nothing outside references it.

That is why the config above keeps `Acme.Plugins.Abstractions.*` whole and renames everything else, rather than flipping on `preservePublicApi` for the entire set. `preservePublicApi` keeps *every* public type across *every* input — but inside your own solution, "public" is just the access modifier your assemblies use to see each other; it is not an external contract. Keeping it all would surrender most of your protection for no reason. Scope the keep to the one assembly that is genuinely a boundary, let cross-assembly renaming rename the public-but-internal-to-the-product surface, and you protect the maximum while keeping the contract your plugin authors depend on intact.

## Step 7 — Wire it into CI

Assemble the whole thing into a pipeline step that runs on the agent and nowhere else: publish the solution, fetch the previous release's maps to seed from, run the single protection pass, then archive this build's maps so the next release can seed from them.

```yaml
# Release pipeline — runs on the build agent only.
steps:
  - run: dotnet publish Acme.sln -c Release -o publish
  # Seed from last release's maps (a no-op on the very first release).
  - run: fetch-artifact Acme-latest.symbols/ -> maps/Acme-prev.symbols.json
  - run: nebula --config nebula.config.json      # one run, all shipped assemblies
  # Archive this build's maps for seeding the next release and for decoding crashes.
  - run: publish-artifact publish/protected/*.symbols.json as Acme-latest.symbols/
  - run: publish-artifact publish/protected/*.symbols.json as Acme-${VERSION}.symbols/
  - run: pack-and-sign publish/protected
```

Gate it on a signal that exists only on the agent — the `NEBULA_OBFUSCATE` environment variable from the build-server-only setup — so a developer who runs `dotnet publish` locally still gets a clean, debuggable build. The pack-and-sign step points at `publish/protected`, never at the raw publish output, so the artifact that leaves the pipeline is the protected one. Every shipped build now runs the identical policy over the identical input set: protection is a property of the release, not of whoever happened to build it.

## Verify across assemblies in Glass.NET

The failure mode this playbook guards against is invisible at build time — the app runs, the tests pass, and one assembly quietly shipped in the clear or one cross-reference quietly dangles. So check the output, and check *all* of it. [Glass.NET](https://delta1labs.com/products/glass) is the free, static-only companion for this: it reads metadata and IL and never runs the binary, so open each protected assembly from `publish/protected` in turn.

Confirm three things across the set. First, every shipped assembly is actually protected — internals read as `a`, `b`, `c` and your strings are encrypted in each one, not just the first. Second, a type renamed in one assembly is renamed to the *same* name where another assembly references it — cross-assembly renaming is consistent, with no dangling reference to an old name. Third, the boundary survived: your plugin-contract assembly still reads with its real public names. Then run the integration suite against the protected output, because name-resolution breakage across assemblies is only visible once a path executes. The full before/after procedure is in [did your obfuscation actually work?](https://delta1labs.com/blog/verify-obfuscation-with-glass/).

Treat the solution as one unit and the whole thing gets simpler, not harder: one config, one run, one map set per release, one boundary kept, one CI gate. [Download Nebula.NET free](https://delta1labs.com/download?product=nebula) and run it over your publish output as a single pass.

