Comparing source generators, MSBuild-generated files (gRPC, OpenAPI), T4, IL weaving and interceptors for build-time code generation, with trade-offs.
Build-time options: source generators (in the compiler, see the user's code, add C#), MSBuild targets or tools that write .cs files before compilation from external inputs (gRPC .proto via Grpc.Tools, OpenAPI clients via Kiota or NSwag), T4 templates (text templates run at design time or build time), and IL weaving (for example Fody), which rewrites the compiled assembly after build. Choose source generators when output depends on C# code, MSBuild generation when the input is a non-C# contract, T4 only for legacy cases, and avoid IL weaving in new code because the generated behaviour is invisible and fragile. Interceptors are a specialised tool for generator authors.
Comparison:
| Option | Input | When it runs | Visible in IDE | Typical uses |
|---|---|---|---|---|
| Source generator | User's C# code, additional files | During compilation, also live in IDE | Yes | JSON contexts, logging, DI registration, mappers |
| MSBuild target or tool | .proto, OpenAPI, SQL, resx |
Before CoreCompile |
After build, in obj/ |
gRPC stubs, API clients, resource classes |
| T4 template | Template plus any data | Design time or build (with extra setup) | Generated file committed | Legacy code, simple text expansion |
| IL weaving | Compiled IL | After compile | No (only via decompiler) | INotifyPropertyChanged injection, legacy AOP |
| Interceptors | Generator-produced call redirection | During compilation | Yes | Framework internals, for example ASP.NET Core request delegate generation |
Decision guide:
Inputs/Outputs for incremental builds.Committed versus generated-on-build: generated-on-build stays in sync automatically; committed code is reviewable and works without the tool. API clients are often regenerated in CI and diffed, so contract changes appear in pull requests.
Production example: a team used Fody to inject INotifyPropertyChanged into view models. After an SDK upgrade the weaver broke the build, and debugging showed code that did not exist in source. Replacing it with the CommunityToolkit.Mvvm source generator ([ObservableProperty]) gave the same result with code visible in the IDE and no post-compile step.
The interviewer checks architectural judgement about build tooling.
Green flags: compares several mechanisms with inputs and timing, prefers source generators for C#-driven output, MSBuild for contract files, incremental targets, cautions against IL weaving, considers maintenance.
Red flags: knows only one option; recommends IL weaving by default; ignores build performance.
CoreCompile (BeforeTargets="CoreCompile") and add outputs to @(Compile).Inputs and Outputs so the target is skipped when nothing changed.$(IntermediateOutputPath) and add files to @(FileWrites) so clean removes them.<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles> writes source-generator output under obj/<config>/<tfm>/generated. Useful in code review and when debugging.
Source generators plus partial members (including partial properties since C# 13) cover most former weaving use cases.
T4 still works in Visual Studio and with the dotnet-t4 tool, but it does not see the semantic model and is not incremental. It is acceptable for existing code; new work usually uses generators or scripts.
Add the MSBuild target from the code example to a project, build twice and check in the build log (-v:n) that the second build skips the target because inputs did not change.
<!-- Generate a constants class from version.txt (for example "1.4.2") before compilation, incrementally -->
<Target Name="GenerateBuildInfo"
BeforeTargets="CoreCompile"
Inputs="$(MSBuildProjectDirectory)/version.txt"
Outputs="$(IntermediateOutputPath)BuildInfo.g.cs">
<PropertyGroup>
<_BuildInfoVersion>$([System.IO.File]::ReadAllText('$(MSBuildProjectDirectory)/version.txt').Trim())</_BuildInfoVersion>
</PropertyGroup>
<!-- ';' separates lines; %3B is a literal semicolon -->
<WriteLinesToFile File="$(IntermediateOutputPath)BuildInfo.g.cs"
Overwrite="true"
Lines="// <auto-generated/>;internal static class BuildInfo { public const string Version = "$(_BuildInfoVersion)"%3B }" />
<ItemGroup>
<Compile Include="$(IntermediateOutputPath)BuildInfo.g.cs" />
<FileWrites Include="$(IntermediateOutputPath)BuildInfo.g.cs" />
</ItemGroup>
</Target>
<!-- gRPC: contract-first generation through the tool package -->
<ItemGroup>
<PackageReference Include="Grpc.Tools" Version="2.*" PrivateAssets="all" />
<Protobuf Include="Protos/orders.proto" GrpcServices="Server" />
</ItemGroup>
<!-- See what source generators produced -->
<PropertyGroup>
<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
</PropertyGroup>
// Source generator alternative to IL weaving (CommunityToolkit.Mvvm 8.4+ partial property support)
using CommunityToolkit.Mvvm.ComponentModel;
public partial class OrderViewModel : ObservableObject
{
[ObservableProperty]
public partial string? Status { get; set; } // the generator implements the property with change notification
}
// Using the MSBuild-generated constant
static class About
{
public static string Describe() => $"Version {BuildInfo.Version}";
}
Inputs and Outputs.
Why it is wrong: the target runs on every build and forces recompilation.
Instead: declare inputs and outputs and write to the intermediate folder.Code generation is a long-term maintenance commitment, so seniors pick the most visible, least magical option.
| Priority | Reason |
|---|---|
| Visible output | Reviewable, debuggable, explainable to new team members |
| Incremental | Does not slow builds or the IDE |
| Standard tooling | Survives SDK upgrades |
| Clear ownership | Someone maintains the generator or template |
The senior question to ask: would hand-written code plus a test be simpler for this case?
A source generator is a class marked [Generator] that implements IIncrementalGenerator (the older ISourceGenerator is deprecated). It runs inside the compiler, reads the user's code through Roslyn and adds new C# files to the compilation; it cannot modify existing code. The generator project targets netstandard2.0, references Microsoft.CodeAnalysis.CSharp (a version no newer than your consumers' compilers) with PrivateAssets="all", and sets EnforceExtendedAnalyzerRules. Consumers reference it as an analyser (OutputItemType="Analyzer" ReferenceOutputAssembly="false"), or you pack it into analyzers/dotnet/cs in a NuGet package. Use RegisterPostInitializationOutput for marker attributes and EmitCompilerGeneratedFiles to see the output.
Interceptors are a compiler feature, first shipped experimentally in .NET 8 / C# 12 and stable from the .NET 9.0.2xx SDK onwards, that lets a source generator replace one specific method call in your code with a call to generated code, at compile time. The generator emits a method marked [InterceptsLocation(...)] that identifies the exact call site; the compiler then emits a call to the interceptor instead of the original method. It exists so generators can make existing APIs reflection-free and AOT-safe without you changing your code: ASP.NET Core's Request Delegate Generator intercepts MapGet/MapPost, and the configuration binding generator intercepts Bind, Get<T> and Configure<T>. You opt in per namespace with the InterceptorsNamespaces MSBuild property (the SDK does this for those generators). Developers should know they exist when debugging, but not hand-write them: the location data is generated and tied to the exact file contents.
MSBuild is the build engine under dotnet build, Visual Studio and Rider, and a .csproj is an MSBuild file. Properties are named values such as $(Configuration). Items are lists with metadata, such as @(Compile) holding every .cs file. Targets are named steps, ordered with DependsOnTargets, BeforeTargets and AfterTargets. Each target runs tasks, the real units of work, such as Csc to compile or Copy. The build has two phases. Evaluation reads imports, properties and items. Execution runs the targets. Targets that declare Inputs and Outputs are skipped when nothing has changed, which is what makes builds incremental.
How to tell it (STAR)
Our [WPF app] used IL weaving, and an SDK upgrade broke the build. I led a migration to the MVVM Toolkit source generator. The behaviour stayed the same, but the generated code became visible and debuggable and our builds no longer depended on a post-compile step.
Last updated 23 Oct 2026.