C# Source Generators: Zero-Overhead CRUD Code Gen
The generic entity pattern I wrote about last time is smart. But smart has a ceiling -- in high-throughput systems and AOT targets, that ceiling is lower than you think.
Source generators blow past it.
Where the generic pattern starts costing you
To be clear: the generic entity service pattern that eliminates your CRUD boilerplate is not slow in most apps. If you are running a standard web API with a handful of entities and reasonable traffic, the cost is negligible. Use it. It is genuinely useful.
But three specific situations change the calculus.
Open generic JIT cost. Every closed generic type -- GenericEntityController<Product>, GenericEntityController<Customer> -- is JIT-compiled separately. With 40 entities, that is 40 separate JIT passes on first access, each one cold. On a beefy server that starts once and runs for weeks, fine. On a container that restarts every deploy, or a Lambda function with a tight cold-start budget, that cost lands directly on your first users.
Reflection at startup. The GenericEntityControllerFeatureProvider scans your assembly for types that inherit BaseEntity. That scan is fast on a small, focused project. On a large monolith with hundreds of types, it adds up. More importantly, it runs on every cold start -- so every restart pays the full price.
NativeAOT and Blazor WASM: they break entirely. Assembly.GetTypes(), virtual dispatch on open generics, and runtime type inspection are all incompatible with NativeAOT and Blazor WebAssembly. If you ever want to publish to either of those targets, the generic pattern is a dead end. Full stop.
What source generators actually do differently
A source generator runs during compilation -- not at startup, not at request time. It reads your code as a Roslyn syntax tree, makes decisions based on what it finds, and emits new .cs files that are compiled right alongside your hand-written code.
The output is concrete typed classes. ProductController, ProductService, ProductDataAccess -- real classes with no type parameters, no virtual dispatch overhead, and no reflection anywhere. The JIT sees simple, concrete types. AOT tools see simple, concrete types. There is nothing to trip over.
Four things make this worth the extra setup:
- Zero runtime overhead. The generated code is identical to code you would have written by hand.
- Full IntelliSense. Generated classes appear in your IDE like any other class. Navigate to them, inspect their members, set breakpoints.
- Auditable output. The generated files sit under
obj/Generated/. Open them, read them, diff them -- you know exactly what is running in production. - AOT-safe by definition. No reflection. No open generics. NativeAOT and Blazor WASM both work without additional trimming annotations.
The [GenerateEntity] attribute
One attribute. That is the entire developer-facing API.
[AttributeUsage(AttributeTargets.Class, Inherited = false)]
public sealed class GenerateEntityAttribute : Attribute { }
The generator emits this attribute definition itself via RegisterPostInitializationOutput, so you do not need to declare it manually or reference a separate package. Apply it to any entity:
using MyApp.Generators;
[GenerateEntity]
public class Product : BaseEntity
{
public string Name { get; set; } = string.Empty;
public decimal Price { get; set; }
public int StockQuantity { get; set; }
}
That is it. Next build: full CRUD API for Product. No other code needed.
The source generator
The generator lives in a separate class library project. Here it is in full -- a working IIncrementalGenerator that handles everything from attribute detection to code emission.
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;
namespace MyApp.Generators;
[Generator]
public sealed class EntityGenerator : IIncrementalGenerator
{
public void Initialize(IncrementalGeneratorInitializationContext context)
{
// Emit the attribute definition at compile time --
// consumers get it for free, no extra NuGet reference needed.
context.RegisterPostInitializationOutput(static ctx =>
{
ctx.AddSource("GenerateEntityAttribute.g.cs", """
// <auto-generated/>
namespace MyApp.Generators;
[System.AttributeUsage(System.AttributeTargets.Class, Inherited = false)]
public sealed class GenerateEntityAttribute : System.Attribute { }
""");
});
var entities = context.SyntaxProvider
.ForAttributeWithMetadataName(
"MyApp.Generators.GenerateEntityAttribute",
predicate: static (node, _) => node is ClassDeclarationSyntax,
transform: static (ctx, _) => Extract(ctx))
.Where(static e => e is not null)
.Select(static (e, _) => e!);
context.RegisterSourceOutput(entities, Emit);
}
private static EntityModel? Extract(GeneratorAttributeSyntaxContext ctx)
{
if (ctx.TargetSymbol is not INamedTypeSymbol symbol)
return null;
string ns = symbol.ContainingNamespace.IsGlobalNamespace
? string.Empty
: symbol.ContainingNamespace.ToDisplayString();
return new EntityModel(symbol.Name, ns);
}
private static void Emit(SourceProductionContext ctx, EntityModel entity)
{
ctx.AddSource($"{entity.Name}DataAccess.g.cs", BuildDataAccess(entity));
ctx.AddSource($"{entity.Name}Service.g.cs", BuildService(entity));
ctx.AddSource($"{entity.Name}Controller.g.cs", BuildController(entity));
}
private static string BuildDataAccess(EntityModel e) => $$"""
// <auto-generated/>
#nullable enable
using Microsoft.EntityFrameworkCore;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using {{e.Namespace}};
namespace {{e.Namespace}}.Generated;
public sealed class {{e.Name}}DataAccess(AppDbContext db)
{
public async Task<{{e.Name}}?> GetAsync(Guid id, CancellationToken ct = default)
=> await db.Set<{{e.Name}}>().AsNoTracking()
.FirstOrDefaultAsync(x => x.Id == id, ct);
public async Task<(IEnumerable<{{e.Name}}> Items, int TotalCount)> GetPagedAsync(
int page, int pageSize, CancellationToken ct = default)
{
var query = db.Set<{{e.Name}}>().AsNoTracking()
.OrderByDescending(x => x.CreatedAt);
var total = await query.CountAsync(ct);
var items = await query.Skip((page - 1) * pageSize).Take(pageSize).ToListAsync(ct);
return (items, total);
}
public async Task<{{e.Name}}> CreateAsync({{e.Name}} entity, CancellationToken ct = default)
{
entity.CreatedAt = DateTime.UtcNow;
entity.UpdatedAt = DateTime.UtcNow;
db.Set<{{e.Name}}>().Add(entity);
await db.SaveChangesAsync(ct);
return entity;
}
public async Task<{{e.Name}}> UpdateAsync({{e.Name}} entity, CancellationToken ct = default)
{
entity.UpdatedAt = DateTime.UtcNow;
db.Set<{{e.Name}}>().Update(entity);
await db.SaveChangesAsync(ct);
return entity;
}
public async Task DeleteAsync(Guid id, CancellationToken ct = default)
{
var entity = await db.Set<{{e.Name}}>().FindAsync([id], ct)
?? throw new KeyNotFoundException($"{{e.Name}} {id} not found.");
db.Set<{{e.Name}}>().Remove(entity);
await db.SaveChangesAsync(ct);
}
}
""";
private static string BuildService(EntityModel e) => $$"""
// <auto-generated/>
#nullable enable
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using {{e.Namespace}};
using {{e.Namespace}}.Generated;
namespace {{e.Namespace}}.Generated;
public partial class {{e.Name}}Service({{e.Name}}DataAccess dataAccess)
{
public Task<{{e.Name}}?> GetAsync(Guid id, CancellationToken ct = default)
=> dataAccess.GetAsync(id, ct);
public Task<(IEnumerable<{{e.Name}}> Items, int TotalCount)> GetPagedAsync(
int page, int pageSize, CancellationToken ct = default)
=> dataAccess.GetPagedAsync(page, pageSize, ct);
public Task<{{e.Name}}> CreateAsync({{e.Name}} entity, CancellationToken ct = default)
=> dataAccess.CreateAsync(entity, ct);
public Task<{{e.Name}}> UpdateAsync({{e.Name}} entity, CancellationToken ct = default)
=> dataAccess.UpdateAsync(entity, ct);
public Task DeleteAsync(Guid id, CancellationToken ct = default)
=> dataAccess.DeleteAsync(id, ct);
}
""";
private static string BuildController(EntityModel e) => $$"""
// <auto-generated/>
#nullable enable
using Microsoft.AspNetCore.Mvc;
using System;
using System.Threading;
using System.Threading.Tasks;
using {{e.Namespace}};
using {{e.Namespace}}.Generated;
namespace {{e.Namespace}}.Generated.Controllers;
[ApiController]
[Route("api/[controller]")]
public sealed class {{e.Name}}Controller({{e.Name}}Service service) : ControllerBase
{
[HttpGet("{id:guid}")]
public async Task<IActionResult> Get(Guid id, CancellationToken ct)
{
var result = await service.GetAsync(id, ct);
return result is null ? NotFound() : Ok(result);
}
[HttpGet("paged")]
public async Task<IActionResult> GetPaged(
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20,
CancellationToken ct = default)
{
var (items, total) = await service.GetPagedAsync(page, pageSize, ct);
return Ok(new { items, total, page, pageSize });
}
[HttpPost]
public async Task<IActionResult> Create([FromBody] {{e.Name}} entity, CancellationToken ct)
{
var created = await service.CreateAsync(entity, ct);
return CreatedAtAction(nameof(Get), new { id = created.Id }, created);
}
[HttpPut("{id:guid}")]
public async Task<IActionResult> Update(
Guid id, [FromBody] {{e.Name}} entity, CancellationToken ct)
{
if (id != entity.Id) return BadRequest("ID mismatch.");
return Ok(await service.UpdateAsync(entity, ct));
}
[HttpDelete("{id:guid}")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
await service.DeleteAsync(id, ct);
return NoContent();
}
}
""";
private sealed record EntityModel(string Name, string Namespace);
}
A few things worth calling out. ForAttributeWithMetadataName is the modern Roslyn API for finding decorated types -- it is incremental, meaning the generator only reruns when something relevant actually changes. RegisterPostInitializationOutput emits the attribute definition before the main pipeline runs, so ForAttributeWithMetadataName can resolve it. The EntityModel record carries just the two facts the templates need: the class name and its namespace. That is all it takes to produce three complete, correct source files.
The generator assumes a DbContext named AppDbContext. If yours is named differently, change that one string in BuildDataAccess -- or read the DbContext name from an MSBuild property via AnalyzerConfigOptionsProvider. Either way, it is a 5-minute change.
What gets generated for Product
Here is the actual ProductDataAccess.g.cs that lands in your obj/Generated/ folder after a build:
// <auto-generated/>
#nullable enable
using Microsoft.EntityFrameworkCore;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using MyApp.Entities;
namespace MyApp.Entities.Generated;
public sealed class ProductDataAccess(AppDbContext db)
{
public async Task<Product?> GetAsync(Guid id, CancellationToken ct = default)
=> await db.Set<Product>().AsNoTracking()
.FirstOrDefaultAsync(x => x.Id == id, ct);
public async Task<(IEnumerable<Product> Items, int TotalCount)> GetPagedAsync(
int page, int pageSize, CancellationToken ct = default)
{
var query = db.Set<Product>().AsNoTracking()
.OrderByDescending(x => x.CreatedAt);
var total = await query.CountAsync(ct);
var items = await query.Skip((page - 1) * pageSize).Take(pageSize).ToListAsync(ct);
return (items, total);
}
public async Task<Product> CreateAsync(Product entity, CancellationToken ct = default)
{
entity.CreatedAt = DateTime.UtcNow;
entity.UpdatedAt = DateTime.UtcNow;
db.Set<Product>().Add(entity);
await db.SaveChangesAsync(ct);
return entity;
}
public async Task<Product> UpdateAsync(Product entity, CancellationToken ct = default)
{
entity.UpdatedAt = DateTime.UtcNow;
db.Set<Product>().Update(entity);
await db.SaveChangesAsync(ct);
return entity;
}
public async Task DeleteAsync(Guid id, CancellationToken ct = default)
{
var entity = await db.Set<Product>().FindAsync([id], ct)
?? throw new KeyNotFoundException($"Product {id} not found.");
db.Set<Product>().Remove(entity);
await db.SaveChangesAsync(ct);
}
}
No T. No open generics. No typeof(Product). Every type reference is concrete. The JIT sees exactly the same IL as if you had written this class by hand -- because for all intents and purposes, you did. The generator just wrote it faster and will never make a typo.
You get ProductController.g.cs and ProductService.g.cs alongside it, following the same pattern. Add Customer : BaseEntity with [GenerateEntity], build again, and you have a complete customer CRUD API without writing a single new class.
Wiring it up
The generator project
Create a new class library. It needs two specific properties in the .csproj:
<!-- MyApp.Generators/MyApp.Generators.csproj -->
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<IsRoslynComponent>true</IsRoslynComponent>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp"
Version="4.14.0"
PrivateAssets="all" />
</ItemGroup>
</Project>
netstandard2.0 is required for Roslyn components -- not net10.0. The IsRoslynComponent flag tells the SDK this is an analyzer/generator project and sets up the correct build targets. LangVersion=latest lets you write the generator itself in C# 13 while still targeting netstandard2.0.
Referencing it from your API project
<!-- MyApp.Api/MyApp.Api.csproj -->
<ItemGroup>
<ProjectReference Include="..\MyApp.Generators\MyApp.Generators.csproj"
OutputItemType="Analyzer"
ReferenceOutputAssembly="false" />
</ItemGroup>
OutputItemType="Analyzer" tells MSBuild to load the assembly as a Roslyn component rather than a runtime dependency. ReferenceOutputAssembly="false" keeps the generator DLL out of your published package -- it has no business being there at runtime.
Registering with DI
The generated controller is a concrete type named ProductController. ASP.NET Core's MVC controller discovery finds it automatically. No custom IApplicationFeatureProvider, no reflection-based registration loop. You do need to register the service and data access classes explicitly:
// Program.cs
builder.Services.AddScoped<ProductDataAccess>();
builder.Services.AddScoped<ProductService>();
Yeah, that is manual per entity. You could write a second generator that emits a RegisterGeneratedServices extension method -- that is a natural follow-up. For most teams, two lines per entity is still a fraction of the work compared to writing the entire stack by hand.
What you keep from the generic pattern
The developer experience is the same: add [GenerateEntity] to a class, build, and a full CRUD API exists. But you gain three things the generic pattern cannot give you.
Inspectable, diffable output. Navigate to obj/Generated/MyApp.Generators/MyApp.Generators.EntityGenerator/ and you will find every generated file sitting there as readable .g.cs files. Open them in your IDE, read them, step through them in the debugger. There is no magic to understand.
Partial class extensibility. The generated ProductService is partial. Drop a new file anywhere in your project:
// ProductService.Custom.cs -- your code, not generated
namespace MyApp.Entities.Generated;
public partial class ProductService
{
public async Task<bool> IsInStockAsync(Guid id, CancellationToken ct = default)
{
var product = await dataAccess.GetAsync(id, ct);
return product?.StockQuantity > 0;
}
}
The generator owns the CRUD scaffold. You own the business logic. They live in the same class, compiled together, without conflicting. Primary constructor parameters are in scope across all partial declarations, so dataAccess is accessible directly.
AOT compatibility out of the box. Publish to NativeAOT, publish to Blazor WASM -- the generated code works without any additional trimming annotations or suppression attributes. There is nothing to annotate because there is no reflection to suppress.
Generics or source generators -- which should you use?
Neither is universally better. Here is the honest trade-off:
| Criterion | Generic pattern | Source generators |
|---|---|---|
| Setup time | ~5 minutes | ~30 minutes |
| Runtime overhead | Open generic JIT cost per entity | Zero |
| Reflection at startup | Yes -- assembly scan | No |
| NativeAOT / Blazor WASM | Not compatible | Fully compatible |
| Generated code visible | No | Yes -- obj/Generated/ |
| Custom business logic | Subclass and override | Partial class |
| Cold start cost | Scan + JIT on first access | None |
| Maintenance surface | One generic class | One generator |
My rule: if you are building a standard web API with modest traffic and no AOT requirement, the generic pattern is fine. Ship it. If you are targeting NativeAOT, running on Blazor WASM, or operating containers with aggressive cold-start SLAs, use source generators from day one. Retrofitting later is painful.
The takeaway
One [GenerateEntity] attribute. One build. A full CRUD API -- controller, service, data access -- all concrete types, all AOT-safe, all inspectable. No generics at runtime. No reflection anywhere in the path.
The developer experience is identical to the generic pattern: add an entity class and an attribute, and the rest appears. The difference is what happens after the build. Instead of a fragile runtime abstraction that breaks on NativeAOT and adds JIT cost under load, you have plain compiled C# that any developer on your team can open and read without knowing how the generator works.
If the generic pattern cut your CRUD boilerplate by 75%, source generators cut it by the same 75% -- and remove the ceiling entirely.
For the full generic entity pattern this approach replaces, read: the generic entity service pattern that eliminates your CRUD boilerplate.