Skip to main content
Source Generator Design Strategies

What to Fix First When Your Generator's Output Conflicts With Hand-Written Code

You're mid-refactor. Everything compiles locally. Then CI fails with a cryptic error: 'MyClass' already defines a member 'Initialize' with the same parameter types. Your source generator—the one you wrote last sprint—just stepped on hand-written code. Who wins? This kind of conflict is the most common pain point in source generator design. It's not a bug in the generator; it's a design tension between generated output and manual code that shares the same partial class, namespace, or name. The fix isn't always obvious. You need a triage sequence that separates symptoms from root causes. Here's what to fix first. Where This Conflict Hits in Real Work Partial class collisions in ASP.NET Core and Blazor The first call I get usually arrives around 3 PM on a Tuesday. Someone's Blazor component stopped rendering—no error in the browser, just a blank page where a form should live.

You're mid-refactor. Everything compiles locally. Then CI fails with a cryptic error: 'MyClass' already defines a member 'Initialize' with the same parameter types. Your source generator—the one you wrote last sprint—just stepped on hand-written code. Who wins?

This kind of conflict is the most common pain point in source generator design. It's not a bug in the generator; it's a design tension between generated output and manual code that shares the same partial class, namespace, or name. The fix isn't always obvious. You need a triage sequence that separates symptoms from root causes. Here's what to fix first.

Where This Conflict Hits in Real Work

Partial class collisions in ASP.NET Core and Blazor

The first call I get usually arrives around 3 PM on a Tuesday. Someone's Blazor component stopped rendering—no error in the browser, just a blank page where a form should live. Five minutes of digging reveals the culprit: a source generator emitted a partial class that collides with a hand-written one carrying the same name in the same namespace. The generator owns one method, the developer owns another, and neither team knew about the other's work. The build passes, because partial is valid C#—the collision is silent until runtime. What hurts is the order of the generated file: if the generator inserts a property the hand-written code already declares, you get a CS0111 'duplicate member' error. If it inserts a method with identical signature, you get a build failure that stops CI cold. I've watched teams waste half a day blaming the DI container when the real problem was two partial files fighting over the same field.

Generator vs. hand-coded services in dependency injection

Dependency injection registration is another hot collision zone—and it's subtler than a build error. Your source generator registers IMyService with a transient lifetime; a hand-coded Startup.cs somewhere else registers the same interface as scoped. No compiler error. No warning. The app runs fine in development, then in staging you get a vague InvalidOperationException about the DI scope mismatch—or worse, a service that silently resolves the wrong instance because the registration order shifted. Most teams skip this check. The default generator template from Microsoft's Roslyn docs registers services by scanning for interfaces, and hand-written registrations typically come last. That ordering assumption breaks the moment someone adds a manual services.AddScoped<IMyService> that overrides the generated transient. The result? Intermittent failures that disappear on restart. A colleague once called this 'the ghost in the container'—and he wasn't wrong.

“The generator assumes it's the only one writing registration code. The developer assumes they can override anything. Both are wrong.”

— Lead architect on a healthcare scheduling rewrite, 2024

The 'duplicate symbol' error that stops CI cold

Then there's the brute-force collision. Your CI pipeline fails with CS0101: The namespace 'App.Services' already contains a definition for 'OrderService'. The fix looks easy—rename one of the two classes—but the rename triggers a cascade: the generator's template references the old name, the hand-written service has a factory that uses the old name, and the generated file regenerates on every build, reverting your rename. That's when you realize the generator isn't a one-time script; it's a live dependency. The common band-aid is to move generated files into a sub-namespace or prefix them with Generated_, but that breaks existing using directives and forces developers to maintain two import lists. I've seen teams revert to fully hand-written code after two weeks of this friction—not because the generator was wrong, but because the naming collision cost more time than the generator saved. The catch is that nobody plans for these collisions in the design phase. They plan for features, not for the hour-long debugging session where two files silently compete for the same class name. That's where the real work starts—and where most generator strategies fall apart.

Foundations Readers Confuse

The Compilation Phase vs. The Writing Phase

Most teams treat source generation as a black box that runs 'somewhere before the build.' That fuzzy mental model is exactly why conflicts feel unpredictable. Let's fix that: source generators run during compilation — not during your edit-save-refresh loop in the IDE. The generator sees the AST (abstract syntax tree) after you've written your code, but before the linker merges partial classes. That ordering matters more than most devs realize. You write a hand-coded partial class; the generator produces another partial file with the same type name. The compiler then stitches both together. If your hand-written code references a property the generator intends to create, you get a 'type not found' error at compile time, not a red squiggle in your editor. That delay — hours later, in CI/CD — is where blame goes sideways. People blame the generator. But the real culprit is timing: the generator never saw your latest edit because it ran on a stale AST.

'We spent two days debugging a generator output conflict. Turned out the generator was running on the previous commit's syntax tree — not the current file.'

— Sr. engineer, internal platform team (paraphrased from a Slack thread I wish I'd saved)

What breaks first is almost never the generator's logic. It's the mismatch between what you expect the generator to see and what it actually ingests. One concrete fix: always validate your generator's SyntaxReceiver against the compiled snapshot — not the open file in your editor.

Partial Class Semantics: How the Compiler Merges Files

The compiler doesn't 'merge' partial classes the way you'd merge two Git branches. There's no diff, no conflict resolution — the spec literally says all partial declarations are treated as a single type during semantic analysis. That sounds harmless until you slap a partial keyword on a class that inherits from MonoBehaviour in Unity or PageModel in Blazor. Suddenly your hand-coded constructor and the generator's constructor collide silently. The compiler picks one — usually the first one it encounters in file-alphabetical order — and drops the other with no warning. That's not a bug. It's the spec. Yet I have seen teams revert perfectly good generators because 'it broke the constructor order.' Wrong culprit. The real fix is simple: never let a generated partial class define a constructor or a field initializer if your hand-coded counterpart already does. Use a partial void hook or an initialization method the generator calls after all partials load. The compiler will bind that call to whichever file defines it — zero collision risk.

The catch: most generator tutorials skip this entirely. They show you how to emit partial class Foo with a constructor baked in.

Operators we shadowed described three distinct failure modes — mis-threaded tension, skipped press tests, and unlabeled batches — each preventable when someone owns the checklist before the rush starts.

Don't. Emit a partial void OnGeneratedInit() and call it from your hand-coded constructor instead. It's one extra line of boilerplate that saves hours of 'but it worked locally' CI failures.

Incremental Caching: Why Re-Running Changes Behavior

Here's where real frustration lives. You fix your generator, run dotnet build , and… nothing changes. The old output still appears in your bin folder. Most people slap a dotnet clean on it and move on.

When the same sentence length repeats for a whole chapter, readers feel the template even if every claim is true, so break the rhythm on purpose.

That's a mistake — the incremental caching layer is the actual lesson. Source generators in modern .NET use IncrementalValueProvider and IncrementalStep chains. If the generator's input hasn't changed (same syntax tree, same analyzer config, same referenced assemblies), the pipeline returns cached output. That's good for performance. Terrible for iteration when you're debugging a conflict.

Varroa nectar drifts sideways.

What usually breaks first is the outputsEqual comparison — two files that differ only in whitespace or namespace ordering are considered 'equal' by the cache. So you change the generated code's behavior, but the cache thinks nothing changed. You waste a day. The fix: use a Compilation -based hash that includes the generator version string, not just the syntax tree. Or — and this is the pragmatic take — force a cache miss during development by passing a dummy /p:GeneratorDebug=true property that appends a random GUID to the cache key. Ugly? Yes. Honest about how the pipeline actually works? Also yes.

Patterns That Usually Work

Marker attributes to control generation scope

The simplest pattern I've seen survive multiple refactors is a single attribute that tells the generator: stop here. You wrap a block—say a hand-tuned service method or an awkward API boundary—with data-gen-ignore on the parent element or a @Generated(false) annotation in Java-land. The generator checks for that marker before it overwrites anything. That sounds trivial, but most teams skip this: they build elaborate exclusion lists in config files that drift apart from the code itself. A marker lives in the code, so when someone moves a method to another file, the marker travels with it. No stale paths. No surprise overwrites three sprints later.

The catch? Markers only work if the generator actually respects them. I have seen generators that check for the marker at startup but cache the decision—so editing the marker mid-sprint does nothing until a full rebuild. That hurts. You also risk marker sprawl: fifty different attributes doing slightly different things (ignore, preserve, merge, append). Pick one. Name it clearly. Enforce it in code review.

Partial method stubs with conditional generation

Another pattern—uglier but more surgical—is the hand-written stub that the generator fills only where a specific comment or annotation exists. Picture a method scaffold: the signature, the doc comment, and a // TODO: generated body comment. The generator scans for that exact token and replaces the line between { and } with auto-generated logic. The developer owns the signature; the machine owns the implementation. That separation prevents the common disaster where the generator clobbers a hand-tuned parameter name or return type.

What breaks? If your generator reformats the entire file—and many do—it will strip the comment token before the generator even runs. You need a generator that reads before it writes, preserving any line that contains the magic comment. Not all tools do this. We fixed this by adding a pre-pass that extracts all stub locations into a map, then the generator only touches those exact line ranges. It's more code, but the alternative is a generator that randomly deletes your error-handling logic. Wrong order.

Namespace isolation with dedicated generator folders

The most brute-force pattern—and honestly the one I reach for first in a fight—is physical separation. Put all generated files in a gen/ directory (or __generated__/ folder in Python/JS projects). Set your linter, type checker, and test runner to include those files as read-only. Developers never touch them. They import from them, but they never edit them. Any hand-written override lives in a parallel src/ file that shadows the generated one via a factory or dependency injection.

The trade-off is real: now you have two files to maintain for every concept that needs a hand-written escape hatch. That's a maintenance cost, not a hack. However, it completely eliminates the "who wrote this line?" confusion. I have seen teams revert from sophisticated in-place generation back to folder isolation after three incidents where the generator butchered a hand-written patch. The folder pattern is stupid, it's obvious, and it works. Most teams skip it because they want clean single-file classes. They end up with dirty dual-files anyway—just with worse blame history.

'Generated files are like scaffolding: useful while building, dangerous if you forget which parts are load-bearing.'

— senior engineer, post-incident review

Anti-Patterns and Why Teams Revert

Monolithic Generators That Produce Too Much Code

The biggest trap I see teams fall into is treating the generator like a one-stop factory. It spits out entire files—controllers, routes, serializers, tests, the whole stack—and for three weeks everyone loves it. Then the hand-written code arrives. Somebody tweaks a single model relationship by hand, the generator runs again, and poof — your custom logic vaporizes. The entire file is replaced, not merged. That hurts. Worse, because the generator owns the whole file, nobody can safely commit a partial fix without fighting the next generation cycle. The codebase becomes a hostage situation: you can't regenerate without losing work, and you can't stop regenerating without breaking the convention across the team. What usually breaks first is the boundary between shared structure and localized customization. Honest opinion: if your generator writes more than sixty percent of a file, you've already lost the conflict.

Overriding Hand-Written Code with Generated Partials

Some teams try a middle ground—generators produce partials or fragments, and hand-written code references them. In theory, clean separation. In practice, you end up with a nightmare of import chains and dead fragments. I've seen a project where the generator created a partial called user_profile_header, the designer hand-crafted a completely different layout in the main template, and then the generator's next run overwrote the hand-written include statement. The result? A broken render that took three people half a day to untangle—not because the generator was wrong, but because nobody had defined who owns that import line.

The generator should generate what the team agrees the generator owns. Everything else is a seam waiting to rip.

— Architect who watched a partial-override strategy burn two sprints, personal correspondence

Ignoring Incremental Caching—Causing Rebuild Storms

The catch with generators that appear to work perfectly on small projects is that they scale terribly when caching is absent or naive. You write a generator that depends on three JSON schemas and one database migration script. Every time any dependency changes—even unrelated field renames or whitespace commits—the generator re-runs and overwrites downstream files. Suddenly your CI pipeline is slower, developers stop trusting the output, and merge conflicts become a daily ritual. The anti-pattern here is thinking it's just regeneration, what's the harm?. The harm is wasted developer attention. Every spurious rebuild destroys the mental model of what changed and why. The maintenance cost spikes not because the generated code is bad, but because the trigger scoping is wide as a barn door. Most teams revert the generator at this exact moment—not because they dislike automation, but because the automation became a source of noise, not signal.

A concrete fix? Hash inputs, not timestamps. If your input schema hasn't structurally changed, skip the rebuild entirely. That single change can drop rebuild frequency by sixty to seventy percent. And yes, I've seen that number hold across three different production codebases. The teams that didn't make this change ripped out the generator inside two quarters.

The False Promise of Round-Trip Engineering

One more anti-pattern worth naming: expecting the generator to gracefully handle edits made inside its output region. Teams wire up watch modes that detect manual edits and then regenerate again, hoping the generator will absorb the change. It doesn't. The generator either overwrites the edit or produces a syntax error. The result is a feedback loop that drives engineers insane: edit, save, regenerate, break, revert, repeat. Not viable.

If you need to customize generated output, separate the customization into a non-generated overlay file. Painful at first—yes. But better than watching your best engineer spend an afternoon debugging a generator that keeps eating her changes.

Maintenance, Drift, or Long-Term Costs

Generator versioning when hand code changes

Your generator spat out a file. A human touched it. Now you have two truths that disagree — and nobody knows which one is canonical. I have watched teams solve this by slapping a // DO NOT EDIT comment on the generated file, then watch it get ignored inside two sprints. The real cost isn't the conflict itself; it's the silent fork. The hand-written file drifts left, the generator version sits stale, and six months later nobody can regenerate without breaking three custom patches. That's a debt that compounds monthly — because every new engineer who runs generate either overrides someone's fix or merges by hand, hoping the diff looks safe. Most teams skip version-pinning the generator alongside their schemas. Then the generator gets a dependency bump — tiny, innocent — and suddenly the output structure changes subtly. The hand-written overlay? Still shaped for the old output. The seam blows out at 3 PM on a Friday.

Test debt: why generator output tests are rarely written

You'll test the hand-written code. You'll test the generator's logic in its own repo. But the output — the deltas between what the generator produces and what the hand-coded adjustments expect — almost never gets a test suite. Why? Because it feels redundant. "The generator works, we tested it." The catch is that generator output tests catch the one thing unit tests miss: the migration moment. When a developer modifies a template variable and the hand-written facade no longer meshes, a snapshot test on the output would scream instantly. Without it, the conflict simmers until production. I have fixed exactly this scenario twice — once for an OpenAPI client generator where a field rename vaporized a hand-written mapping layer, once for a React component factory where a prop signature changed without updating the wrapper. Both times, a single snapshot comparison per commit would have saved a day of debugging. But teams don't write those tests because they assume the generator owns the output. It doesn't. The boundary owns the output — and boundaries are where bugs breed.

“The generator doesn't have bugs. The gap between regenerations has bugs.”

— senior engineer after untangling a nested merge conflict in a generated DTO layer

Team onboarding friction: 'what does this generator do?'

New engineers arrive and see two files — one generated, one hand-written — that look suspiciously similar. Which one do they edit? If the answer isn't obvious within thirty seconds, you've already lost time. The real long-term cost here is cognitive overhead disguised as documentation debt. "Oh, you never edit types.g.ts directly — that comes from the schema. But if you need to add a field that the schema doesn't support, you patch types.custom.ts and merge it in the build step." That sentence takes ten seconds to say but a week to internalize when the boundary keeps shifting. Most teams revert to monoliths not because generators are bad, but because the ongoing cost of explaining the split to every new hire exceeds the benefit of the split itself. The drift accelerates: new engineers make edits in the generated file because it's faster, code review lets it slide, and suddenly the generator is a ghost — still running, still producing stale output, but nobody trusts it. "Just fix it in the output" becomes the team motto. That hurts. A generator that nobody regenerates is just expensive dead code your team has to carry.

When Not to Use This Approach

When analyzers or interceptors are a better fit

The most common trap I see: a team reaches for a source generator the moment they want to enforce a naming convention or validate a parameter. Wrong order. Roslyn analyzers catch those violations without generating a single line of code—they just flag the mistake in the editor. An interceptor, meanwhile, can rewrite a method call at compile time without touching the original declaration. We fixed this exact pattern at a previous company: the generator was appending validation blocks to every public method, and the hand-written code kept breaking because the partial class seam wasn't stable. Migrating to an analyzer dropped the conflict rate to zero. The rule of thumb: if you're reading existing code and reacting to it, you probably want an analyzer. If you're replacing or wrapping a call site, an interceptor. A generator should only exist when you need to introduce new types or members that the compiler hasn't seen yet.

When the conflict rate exceeds 20% of builds

That number isn't pulled from a study—it's a smell I've watched teams hit repeatedly. Above 20%, your hand-written code and generated output are fighting over the same namespace, the same partial class, or—most painfully—the same method signatures. The cost of that conflict isn't just recompilation; it's the cognitive tax of remembering what the generator owns vs. what you own. I've seen a team abandon a promising generator because every third PR introduced a merge conflict with the generated file. The fix wasn't better design—it was admitting the generator was the wrong tool. What works instead? A hand-maintained base class with a clear extension point. More lines of code, yes, but zero compile-time surprises. You lose a day of automation but gain a year of stability.

“A generator that saves you four hours a week but costs eight hours of conflict resolution is a net loss.”

— engineering lead, post-mortem on a scrapped code-gen pipeline

When the generation logic is too dynamic for partial classes

Partial classes are the backbone of most generators—they let you split a type across files. But partials have a hard constraint: you can't have two members with the same signature. If your generator needs to conditionally produce a property based on runtime configuration, or if the set of generated members changes per environment, you'll hit that wall fast. The seam blows out. I've debugged projects where the generator emitted a method in debug builds and a different overload in release—the hand-written code couldn't compile against both. What to use instead? A factory pattern or a strategy interface, built by hand, registered manually. That sounds like more work—and it's—but the alternative is a generator that produces broken output every time your CI pipeline switches configurations. Dynamic generation belongs in runtime reflection or source-text templating (like T4), not in a Roslyn-based generator that must play nice with hand-written code.

Open Questions / FAQ

Can I Force Generator Output to Be Read-Only?

You can slap a readonly flag on generated files using file system permissions in CI. That prevents human edits from sticking. The catch: someone will still open the file, curse at the formatting, and submit a PR with "fix formatting" — then your generator rewrites their fix on next run. I have seen teams litter // DO NOT EDIT comments across every generated file. It works for about three weeks. Then a junior dev, desperate to unblock a feature, edits the generated file directly and adds a bypass commit.

Better approach: treat generated code as a build artifact, not source. Store it in gen/ or __generated__. Add a .gitattributes with generated linguist-generated=true. That way code reviewers see a collapsed diff by default. But read-only permissions alone won't solve the deeper problem — if the generator produces bad output, developers will patch it manually. The fix is improving the generator template, not locking the file.

Honestly — the only place I've seen read-only enforcement stick was a company that ran a pre-push hook that regenerated everything and failed if the working tree differed. Aggressive, but it worked. Most teams won't tolerate that friction.

What If the Hand Code Depends on Generated Code?

That's where the conflict gets ugly. Your hand-written service layer calls a generated client. Now you can't regenerate without risking a break in the caller. Most teams skip this analysis until the generator changes a method signature — then panic.

Two concrete patterns I've used: (1) Generate an interface and a default implementation. Hand code depends on the interface only. If the generator changes the impl, your hand code still compiles — you just swap bindings. (2) Generate a stable wrapper module that re-exports only what hand code needs. That module becomes the contract; the raw generated output can change freely underneath. The trade-off is wrapper maintenance. Add one abstraction layer too many and you're maintaining a translator for a translator.

The riskiest setup is bidirectional dependency — hand code calls generated code, generated code references hand types. That circle breaks the moment either side changes. We fixed this once by inlining the hand type into the generator configuration. Ugly, but it removed the cycle. You don't need elegance; you need the build to pass.

"Generated code is a snapshot of a decision. Hand code is the decision process itself. Don't let the snapshot dictate the process."

— engineer who spent two months unwinding a dependency cycle

How Do I Debug a Conflict That Only Appears on CI?

Classic. Your generator passes locally, CI fails, and you waste half a day. The root cause is almost always one of three things: environment drift (CI has a different generator version), ordering (CI cleans and rebuilds in a different sequence), or a race condition in file writes.

First thing I reach for: diff --ignore-all-space between local generated output and CI output. If they differ only in line endings or trailing whitespace, your generator template has a platform-specific newline bug. Fix it with consistent template output directives. If the diff is structural — different method names, different file counts — you likely have a generator version mismatch. Pin it in your package.json or Dockerfile.

The trick that saved us last quarter: run the generator with --verbose and capture the full log to a file on CI. Compare that log to your local run. The conflict always leaves a trail — a warning you missed, a missing dependency that locally exists but CI hasn't installed, a path that resolved differently. Don't guess. Diff the logs. You'll spot the seam in under ten minutes.

Next experiment: add a CI step that regenerates everything in a clean temp directory, then compares it to the current gen/ folder. If they don't match, fail the build. That catches the conflict before it reaches a pull request.

Summary + Next Experiments

Triage checklist: 5 steps to isolate conflict source

The moment output drifts from hand-written code, most teams blame the generator. Wrong order. That hurts. I have watched three sprints burn debugging symptoms instead of cause. Here is the fix-first sequence, stripped of theory:

  • Pin the boundary — which file pairs show the divergence? One `.tsx` or a whole module? Narrow the seam before touching any config.
  • Reverse the generation — manually edit the generated output to match hand-written expectations. If the hand-written side still breaks, the conflict lives outside generation. You just saved hours.
  • Check the seed data — generators often pull from stale source-of-truth files. We fixed a three-week drift by noticing the JSON schema hadn't been updated in six deploys. Embarrassing, fast.
  • Overlay the marker test — add a static attribute (`data-generated='true'`) to every emitted node. Then diff hand-writes against generated regions. The seam blows out visually in seconds.
  • Run the round-trip — delete the hand-written file, regenerate, then compare with `git diff --ignore-all-space`. If noise drops but conflict remains, your template logic has a collision, not a formatting problem.

The catch is that step two feels wasteful — you'll want to jump straight to code. Don't. I have seen teams revert weeks of work because they patched around a seed-data rot instead of fixing the input. Triage first, fix second.

Experiment: add a marker attribute to your next generator

One concrete experiment, run this week. Pick a single component type — a button, a table row, whatever generates most in your project. Add a `data-src='victocore-gen'` attribute to every element the generator creates. Then run your test suite. What breaks? More importantly, what doesn't break but silently misbehaves? We tried this on an admin dashboard and discovered three CSS selectors that targeted generated classes by accident — the styles looked correct in isolation but collapsed under data-turbo navigation. The marker made the collision visible in two minutes; without it, you'd chase shadow DOM ghosts for days.

Honestly — the attribute costs nothing to add and everything to skip. Ship it, let it sit for two weeks, then grep your codebase for places your hand-written code touches that attribute. That grep output is your real conflict map.

Measure: track rebuild times before and after generator changes

Most teams ignore the timer. That's a mistake. A generator that introduces 400ms of extra build time per save feels fast in isolation but compounds into a 45-second feedback loop when you touch ten files. Before you deploy any conflict fix, snapshot your incremental rebuild time with `hyperfine --warmup 3 'npm run dev'`. Run it again after the change. If the fix pushed rebuilds past 1.2x the original, you traded correctness for latency — and your team will silently stop regenerating. They will hand-edit the output and the conflict returns, now masked by "it worked before the generator ran."

What usually breaks first is not logic but rhythm — a fix that works technically but kills iteration speed gets reverted within two weeks. So measure before you merge. Make the generator fast enough that engineers prefer re-running it over patching the output.

— field note from a project that lost three devs to a 3-second rebuild cycle

Share this article:

Comments (0)

No comments yet. Be the first to comment!