How we ship source generators that survive refactors
Lessons from two years of building Roslyn generators that don't silently break when downstream code moves.
Source generators are easy to write and almost impossible to maintain. Most teams ship one, celebrate the wins for a sprint, then watch it slowly rot as the codebase moves around it.
Two years into shipping the Roslyn generators that power Kanject Core, here’s what we’ve learned about keeping them robust through real-world refactors.
The three failure modes we kept hitting
Every generator we shipped failed in roughly the same way:
- Symbol resolution drift — a class moves namespaces and the generator silently emits stale code that compiles but runs wrong.
- Attribute matching by string — someone renames the attribute and the generator quietly stops finding any matches.
- Diagnostic noise — once we got robust matching, the generator started flagging legitimate patterns as errors because the diagnostic logic ran before the type-resolution logic.
What actually fixes it
Stop matching by string. Match by symbol identity. Roslyn’s INamedTypeSymbol is the
right primitive. If you’re ever using name == "MyAttribute", you’re going to lose.
// ❌ Brittle — survives one rename and breaks
var hasAttr = decl.AttributeLists
.SelectMany(a => a.Attributes)
.Any(a => a.Name.ToString() == "Repository");
// ✅ Resilient — moves with the type
var attrSymbol = compilation.GetTypeByMetadataName(
"Kanject.Core.NoSqlDatabase.Annotations.RepositoryAttribute");
var hasAttr = symbol.GetAttributes()
.Any(a => SymbolEqualityComparer.Default.Equals(a.AttributeClass, attrSymbol));
The second pattern is more code. It’s also the only one that survives a namespace rename.
Why we keep generators small
Every generator we ship is a single responsibility. RepositoryGenerator generates
repository scaffolding. KeyComposerGenerator generates key-composition methods.
DbContextGenerator registers DI services.
Composing five small generators is easier than maintaining one big one. The build pipeline runs them in order; each one reads the source tree fresh and emits its own files. None of them talk to each other. None of them depend on each other’s output existing.
This is the part most teams get wrong: they ship one generator that does everything, and when one piece needs to change, the whole thing has to be rewritten.
The shipping checklist
Before any generator goes into a release, it has to pass:
- A golden file test — a known input that produces a known output. If the output changes, someone has to look at the diff and approve it. No silent drift.
- An end-to-end build test in a real consumer project. We have a sample app that reproduces every shape we expect customers to use. If the build breaks there, we know.
- A diagnostic snapshot — every diagnostic the generator can emit, with its severity, message, and code. Snapshot test it. Catches accidental severity escalations.
Two years in, this is what works. Generators that survive refactors are generators that match by symbol, do one thing, and have golden tests guarding their output.