C# Source Generators let you eliminate reflection-based metaprogramming by generating code at compile time, effectively giving you zero-cost abstractions that were previously impossible.
Let’s see this in action. Imagine you have a simple IEntity interface and some concrete implementations. You want to automatically generate code to handle common operations like updating properties based on incoming data, without reflection.
public interface IEntity
{
// Marker interface
}
public class User : IEntity
{
public int Id { get; set; }
public string Name { get; set; }
public DateTime CreatedAt { get; set; }
}
public class Product : IEntity
{
public int Sku { get; set; }
public string Description { get; set; }
public decimal Price { get; set; }
}
Without source generators, you might write something like this to update an entity from a dictionary:
public static class EntityUpdater
{
public static void UpdateFromDictionary(IEntity entity, Dictionary<string, object> data)
{
var entityType = entity.GetType();
var properties = entityType.GetProperties(BindingFlags.Public | BindingFlags.Instance);
foreach (var kvp in data)
{
var property = properties.FirstOrDefault(p => p.Name.Equals(kvp.Key, StringComparison.OrdinalIgnoreCase));
if (property != null && property.CanWrite)
{
var value = kvp.Value;
if (value != null && property.PropertyType.IsAssignableFrom(value.GetType()))
{
property.SetValue(entity, value);
}
// Handle type conversions or logging here
}
}
}
}
This works, but GetType(), GetProperties(), and SetValue() all incur runtime overhead. For frequent updates or performance-critical paths, this can be a bottleneck.
Now, let’s build a source generator. The core idea is to inspect your existing code during compilation and add new C# code to the project.
First, you need a SourceGenerator class that inherits from ISourceGenerator.
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Text;
using System.Text;
[Generator]
public class EntityUpdaterGenerator : ISourceGenerator
{
public void Execute(GeneratorExecutionContext context)
{
// We'll generate code here
var generatedCode = GenerateUpdaterClass(context);
context.AddSource("EntityUpdater.g.cs", SourceText.From(generatedCode, Encoding.UTF8));
}
public void Initialize(GeneratorInitializationContext context)
{
// We're not doing anything special during initialization for this example.
}
private string GenerateUpdaterClass(GeneratorExecutionContext context)
{
// This is where the magic happens: we'll dynamically build the C# code string.
// For a real-world scenario, you'd parse the project's syntax trees to find
// all types implementing IEntity and generate specific updaters for them.
// For simplicity, let's hardcode a few for demonstration.
var sb = new StringBuilder();
sb.AppendLine("using System;");
sb.AppendLine("using System.Collections.Generic;");
sb.AppendLine("using System.Reflection; // Still needed for GetType() if you want to support unknown types");
sb.AppendLine();
sb.AppendLine("public static class GeneratedEntityUpdater");
sb.AppendLine("{");
// Generate specific updater for User
sb.AppendLine(" public static void UpdateUser(User user, Dictionary<string, object> data)");
sb.AppendLine(" {");
sb.AppendLine(" if (user == null) throw new ArgumentNullException(nameof(user));");
sb.AppendLine(" if (data == null) throw new ArgumentNullException(nameof(data));");
sb.AppendLine();
sb.AppendLine(" if (data.TryGetValue(\"Id\", out var idValue) && idValue is int id)");
sb.AppendLine(" user.Id = id;");
sb.AppendLine(" if (data.TryGetValue(\"Name\", out var nameValue) && nameValue is string name)");
sb.AppendLine(" user.Name = name;");
sb.AppendLine(" if (data.TryGetValue(\"CreatedAt\", out var createdAtValue) && createdAtValue is DateTime createdAt)");
sb.AppendLine(" user.CreatedAt = createdAt;");
sb.AppendLine(" }");
sb.AppendLine();
// Generate specific updater for Product
sb.AppendLine(" public static void UpdateProduct(Product product, Dictionary<string, object> data)");
sb.AppendLine(" {");
sb.AppendLine(" if (product == null) throw new ArgumentNullException(nameof(product));");
sb.AppendLine(" if (data == null) throw new ArgumentNullException(nameof(data));");
sb.AppendLine();
sb.AppendLine(" if (data.TryGetValue(\"Sku\", out var skuValue) && skuValue is int sku)");
sb.AppendLine(" product.Sku = sku;");
sb.AppendLine(" if (data.TryGetValue(\"Description\", out var descriptionValue) && descriptionValue is string description)");
sb.AppendLine(" product.Description = description;");
sb.AppendLine(" if (data.TryGetValue(\"Price\", out var priceValue) && priceValue is decimal price)");
sb.AppendLine(" product.Price = price;");
sb.AppendLine(" }");
sb.AppendLine();
// You could also generate a generic fallback or a dispatcher
sb.AppendLine(" // Optional: a generic dispatcher that uses reflection as a fallback");
sb.AppendLine(" public static void UpdateEntity(IEntity entity, Dictionary<string, object> data)");
sb.AppendLine(" {");
sb.AppendLine(" if (entity is User user) UpdateUser(user, data);");
sb.AppendLine(" else if (entity is Product product) UpdateProduct(product, data);");
sb.AppendLine(" else");
sb.AppendLine(" {");
sb.AppendLine(" // Fallback to reflection for unknown types, or throw an exception");
sb.AppendLine(" var entityType = entity.GetType();");
sb.AppendLine(" var properties = entityType.GetProperties(BindingFlags.Public | BindingFlags.Instance);");
sb.AppendLine(" foreach (var kvp in data)");
sb.AppendLine(" {");
sb.AppendLine(" var property = properties.FirstOrDefault(p => p.Name.Equals(kvp.Key, StringComparison.OrdinalIgnoreCase));");
sb.AppendLine(" if (property != null && property.CanWrite)");
sb.AppendLine(" {");
sb.AppendLine(" var value = kvp.Value;");
sb.AppendLine(" if (value != null && property.PropertyType.IsAssignableFrom(value.GetType()))");
sb.AppendLine(" {");
sb.AppendLine(" property.SetValue(entity, value);");
sb.AppendLine(" }");
sb.AppendLine(" }");
sb.AppendLine(" }");
sb.AppendLine(" }");
sb.AppendLine(" }");
sb.AppendLine("}");
return sb.ToString();
}
}
To use this, you’d add a new project to your solution of type " .NET Standard library" or ".NET Core library" and set its Output type to Library. Then, in the .csproj file of this new project, add the following:
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.0.1" PrivateAssets="all" />
</ItemGroup>
<ItemGroup>
<CompilerVisibleItemMetadata Include="PackageReference" ItemType="Package" />
</ItemGroup>
<PropertyGroup>
<GetPackageDependsOn>
$(GetPackageDependsOn);
PackProjectOutputGroups;
</GetPackageDependsOn>
</PropertyGroup>
Crucially, in the project where you want to use the generated code (e.g., your main application project), you add a reference to this source generator project.
<ItemGroup>
<ProjectReference Include="..\YourSourceGeneratorProject\YourSourceGeneratorProject.csproj" OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
</ItemGroup>
Now, when you build your application project, the EntityUpdaterGenerator will run. It will inspect your code, find User and Product types (if you were to implement proper parsing), and generate the GeneratedEntityUpdater class.
Your application code would then use the generated class:
var user = new User { Id = 1, Name = "Alice" };
var userData = new Dictionary<string, object> { { "Name", "Alicia" }, { "Id", 2 } };
GeneratedEntityUpdater.UpdateUser(user, userData);
Console.WriteLine($"User: {user.Id}, {user.Name}"); // Output: User: 2, Alicia
var product = new Product { Sku = 101, Price = 19.99m };
var productData = new Dictionary<string, object> { { "Price", 25.50m } };
GeneratedEntityUpdater.UpdateProduct(product, productData);
Console.WriteLine($"Product: {product.Sku}, {product.Price}"); // Output: Product: 101, 25.50
Notice how GeneratedEntityUpdater.UpdateUser and GeneratedEntityUpdater.UpdateProduct are specific, type-safe methods. There’s no reflection involved in their execution. The UpdateEntity method shows how you can combine generated dispatchers with a reflection fallback for types not explicitly handled by the generator.
The true power comes from the generator’s ability to parse your project’s syntax trees using the Roslyn API. Instead of hardcoding User and Product, a sophisticated generator would:
- Get the compilation context:
GeneratorExecutionContextprovides access to theCompilationobject. - Find relevant types: Iterate through
compilation.SyntaxTrees, parse them intoSyntaxNodes, and useSemanticModelto identify types implementingIEntity. - Extract property information: For each identified type, get its properties and their types.
- Generate code dynamically: Construct the C# code string for the specific updater methods based on the extracted information.
This allows you to create abstractions that look like they use reflection but are entirely compile-time generated, offering the flexibility of dynamic code generation with the performance of statically compiled code.
The most counterintuitive part of source generators is how they integrate into the build process. They aren’t just tools you run; they become part of the compiler’s pipeline. The generated code is treated as if you had written it yourself, meaning it’s subject to all the same compile-time checks, optimizations, and tooling as your hand-written code. This tight integration is what enables the "zero-cost" aspect.
The next hurdle is error handling and diagnostics. When your generator fails or produces invalid code, you’ll want to report diagnostics to the user, just like the compiler does.