Obfuscating a Roslyn source generator: why the generator renames freely but the attribute and the public constructor are pinned
A source generator is a strange thing to obfuscate: it is a .NET Standard 2.0 assembly that the compiler loads by reflection and runs inside your customer's build, and it is where your most valuable compile-time logic lives — exactly the code you ship and would rather not hand to a decompiler. The good news is that Roslyn discovers generators by attribute, not by name, so Nebula can rename the generator type and its whole private surface with no effect on discovery. The parts that are pinned are small and specific: the [Generator] attribute must survive, the public parameterless constructor must stay public (the compiler instantiates it with Activator.CreateInstance), and the interface method it calls must keep its slot. This post reads what the GeneratorDriver actually reflects on, draws the line between what renames and what is load-bearing, shows the Nebula config, and clears up the one thing people get backwards — obfuscating the generator does not obfuscate the code it emits.
A source generator is an awkward thing to protect. It is a .NET Standard 2.0 assembly, it is loaded by reflection, it runs inside your customer’s build rather than inside your own app, and it tends to hold some of the most valuable compile-time logic you ship — the analysis, the templates, the decisions that make the generator worth selling. So “can I obfuscate it?” is a reasonable question, and the answer is yes, with a pleasantly small list of things to be careful about.
The reason the list is small is that Roslyn finds generators by attribute, not by name. That one fact means Nebula can rename nearly the entire assembly — the generator class, its namespace, every private helper — and the compiler still finds and runs it. This post draws the exact line between what renames freely and what is load-bearing.
What the compiler actually reflects on
Here is a minimal incremental generator, the shape you ship:
using Microsoft.CodeAnalysis;
namespace Acme.Generators;
[Generator(LanguageNames.CSharp)]
public sealed class JsonContextGenerator : IIncrementalGenerator
{
// Public, parameterless — the compiler calls Activator.CreateInstance on it.
public JsonContextGenerator() { }
public void Initialize(IncrementalGeneratorInitializationContext context)
{
var models = context.SyntaxProvider
.ForAttributeWithMetadataName(
"Acme.JsonSerializableAttribute",
predicate: static (_, _) => true,
transform: static (ctx, _) => BuildModel(ctx))
.Collect();
context.RegisterSourceOutput(models, static (spc, items) =>
{
foreach (var m in items)
spc.AddSource($"{m.TypeName}.g.cs", Emit(m)); // hintName + source text
});
}
private static Model BuildModel(GeneratorAttributeSyntaxContext ctx) { /* ... */ }
private static string Emit(Model m) { /* ... the template ... */ }
}
When the compiler builds a project that references this analyzer, the GeneratorDriver does something very specific: it loads the assembly, enumerates its types, and keeps the ones decorated with GeneratorAttribute. For each one it calls Activator.CreateInstance(type) to get an instance, then invokes Initialize. Note what is not in that sequence: the type’s name. Nobody — not the driver, not the consumer’s .csproj, not the NuGet metadata — ever looks the generator up by JsonContextGenerator. It is found by its attribute and instantiated by reflection.
What renames freely
Because discovery is by attribute, the generator class name JsonContextGenerator is dead weight to the outside world — rename it to a and nothing notices. The same goes for the namespace Acme.Generators, the private statics BuildModel and Emit, the Model type and its fields, and any internal helpers the template logic uses. This is where the value is, and it is all fair game. In practice the great majority of a generator assembly obfuscates exactly like any other library.
What is pinned, and why
Three things are load-bearing, and all three are about loading and calling the generator, not naming it.
The [Generator] attribute must survive on the type. It is the marker the driver scans for; if your renamer or a custom-attribute-stripping pass removes it, the generator is simply never discovered and your build quietly produces nothing — no error, just missing output, which is a miserable bug to chase. Nebula does not strip attributes it recognises as externally meaningful, but this is the one to be explicit about in config.
The public parameterless constructor must stay public and parameterless. The driver calls Activator.CreateInstance(type), which needs a nullary public ctor. If obfuscation were to privatise it or you add a parameter, instantiation throws at generator-load time. Keep it public.
The Initialize method is an interface implementation (IIncrementalGenerator.Initialize), so renaming preserves its v-table slot automatically — the driver calls it through the interface, not by name. The only rule is: do not remove it or change its signature.
One more, easy to overlook: if your generator also reports diagnostics, the DiagnosticDescriptor ID strings (“ACME001”) are a public contract — consumers suppress them in .editorconfig with dotnet_diagnostic.ACME001.severity. String encryption is fine (the value is reconstructed at runtime, so the emitted diagnostic still carries the right ID), but the value must not change. Do not “tidy” an ID.
The config
In Nebula terms this is a mostly-default library profile with a couple of guards:
{
"rename": { "enabled": true, "autoDetect": true },
"strings": { "encrypt": true },
"preserve": [
{ "attribute": "Microsoft.CodeAnalysis.GeneratorAttribute" },
{ "member": "*..ctor", "where": "public && parameterless" },
{ "interfaceImpl": "Microsoft.CodeAnalysis.IIncrementalGenerator" }
],
"controlFlow": { "enabled": true, "exclude": [ "*Initialize*" ] }
}
autoDetect already keeps external interface implementations callable; the explicit preserve entries are belt-and-braces for the attribute and the constructor, which are the two failure modes that produce silent breakage rather than a crash. Excluding Initialize from control-flow flattening is a performance choice, not a correctness one — it runs on every build in the IDE, so keep its path lean.
The thing people get backwards
Obfuscating the generator does not obfuscate the code the generator emits. The source text your Emit method returns becomes part of your customer’s compilation; it is compiled into their assembly and looks like whatever you wrote in the template, regardless of how your generator assembly is obfuscated. The two live on opposite sides of the build:
- Your generator assembly ships in your NuGet, runs in the customer’s compiler, and is what you obfuscate to protect how the generator works.
- The emitted source is the customer’s code now; if they want it protected, they obfuscate their output.
Confusing the two leads people to expect that a protected generator yields protected generated code, and then to file a “the generated code is still readable” bug that is not a bug. You are hiding the machine, not the thing it stamps out.
The takeaway
A Roslyn source generator obfuscates well precisely because the compiler loads it the way it does: by scanning for the [Generator] attribute and instantiating the type by reflection, never by name. That buys you free rename coverage over the whole generator — class, namespace, helpers, templates — which is where your compile-time logic actually lives. The pinned set is tiny and all about the load path: keep the attribute, keep the public nullary constructor, keep the Initialize slot and any diagnostic ID values. Scope the heavy transforms off the per-build hot path, remember that you are protecting the generator and not the code it writes, and verify by building a real consumer project against the obfuscated analyzer rather than by loading the DLL in isolation.
Try Nebula.NET
Harden your .NET code in minutes — start with the free edition.