Skip to content

How It Works

Pragmatic Design is built around a unified source generator — Pragmatic.SourceGeneratorplus three standalone generators that live with the modules they serve. Every one of them is an IIncrementalGenerator, targets netstandard2.0, and ships as a NuGet analyzer reference (no runtime code is shipped by the generator itself).

GeneratorAssemblyRole
Unified PragmaticSourceGeneratorPragmatic.SourceGeneratorDetects referenced modules and activates ~21 feature pipelines
Result ResultSourceGenerator + ErrorLocalizationGeneratorPragmatic.Result.SourceGeneratorResult/VoidResult variants; IError localization keys
I18n codes Country/Currency/Language generatorsPragmatic.Internationalization.SourceGeneratorStatic ISO code tables from embedded JSON, gated by marker files
Logging LoggingSourceGeneratorPragmatic.Logging.SourceGeneratorZero-allocation [LoggerMethod] partials

These three are separate [Generator] assemblies — they are not feature pipelines inside the unified generator. The rest of this page describes the unified generator; see the feature catalog for the standalone ones.

At compile time the unified generator:

  1. Detects which Pragmatic modules are referenced in your project (via assembly lookup)
  2. Scans your attributes and partial classes to find declarations it can act on
  3. Runs the Transform for each declaration to build an immutable Model
  4. Hands the model to a Template (a subclass of CSharpTemplate) that emits C# source
  5. Adds the emitted source to the compilation with a stable hint name

The generated wiring uses no runtime reflection or service discovery — it’s emitted into your assembly at build time. (EF Core, where you use it, keeps its own runtime behaviour — the documented exception.)

┌─ FeatureDetector ────────────┐
│ Scans referenced assemblies │
│ → DetectedFeatures flags │
└─────────┬────────────────────┘
┌─ Syntax providers ──────────────────────┐
│ ForAttributeWithMetadataName │
│ per feature (Entity, Action, Endpoint, │
│ Query, Handler, Job, …) │
└─────────┬────────────────────────────────┘
│ (per-declaration)
┌─ Transform ──────────────────┐ (reads compilation,
│ Symbol → immutable Model │ emits record values)
│ (records, equatable) │
└─────────┬────────────────────┘
┌─ Compositions (cross-feature) ┐ (e.g. Persistence enriches
│ Enrich models with info │ Action model with repo info)
│ from other features │
└─────────┬─────────────────────┘
┌─ Template (CSharpTemplate) ──┐ (single responsibility:
│ Model → C# source │ take model, render)
└─────────┬────────────────────┘
┌─ Emit ───────────────────────┐
│ AddSource(hintName, code) │
└──────────────────────────────┘

1. IIncrementalGenerator, not ISourceGenerator

Section titled “1. IIncrementalGenerator, not ISourceGenerator”

The old API re-runs on every keystroke; the incremental API caches per-declaration state and only re-runs what changed. Pragmatic commits to incremental — every pipeline step ends with a record type (equatable by value) so the framework can skip unchanged inputs.

2. ForAttributeWithMetadataName, not SyntaxProvider.CreateSyntaxProvider

Section titled “2. ForAttributeWithMetadataName, not SyntaxProvider.CreateSyntaxProvider”

Attribute-targeted declarations are scanned once per attribute name instead of walking the syntax tree. Pragmatic uses this everywhere it can.

All code generation goes through the shared CSharpTemplate base class in shared/SourceGen/. It handles:

  • indentation and formatting
  • namespace / using directives
  • class / method / switch / lambda helpers
  • access modifiers and attribute lists

A per-feature Template subclass just defines RenderFile() and emits a well-formed .g.cs artifact. No string concatenation, no StringBuilder drift.

Pragmatic.SourceGenerator/
├── PragmaticSourceGenerator.cs ← entry point
├── Core/
│ ├── FeatureDetector.cs ← scans referenced DLLs
│ └── DetectedFeatures.cs ← flags (HasMessaging, HasJobs, …)
└── Features/
├── Actions/
│ ├── Models/ ← immutable record models
│ ├── Transforms/ ← Symbol → Model
│ └── Templates/ ← Model → C# source
├── Endpoints/
├── Persistence/ ← orchestrator → 6 sub-features
├── Messaging/
├── Jobs/
└── … ← ~21 feature folders

PragmaticSourceGenerator.Initialize registers these feature pipelines (see PragmaticSourceGenerator.cs):

Resource · Traits · Caching · Actions · Patch · Validation · Mapping · Endpoints · Result · I18n · Persistence (orchestrator → EntityCore, Repository, Query, Projection, Advanced, DbContext) · Composition · Configuration · Identity · Messaging · FastEnum · Jobs · ValueObject

The Manifest generator is not registered separately — it runs inline from EndpointsFeature (pipeline-ordering reasons). The first three groups (Resource/Traits and their action/endpoint/query outputs) feed their models into the Actions, Endpoints and Persistence.Query pipelines rather than emitting independently. FastEnum, Jobs and ValueObject are registered unconditionally — they have no Has* feature gate because their attributes live in lightweight packages.

Each feature is self-contained. Adding a new feature means:

  1. Add a flag in DetectedFeatures
  2. Add a probe in FeatureDetector
  3. Create Features/{Feature}/ with Models/Transforms/Templates
  4. Register the pipeline in PragmaticSourceGenerator.Initialize

See feature-detection for how detection works in practice.

Every generated file has a deterministic hint name that the IDE exposes under Dependencies / Analyzers / Pragmatic.SourceGenerator. Naming is dot-separated and prefixed with category markers:

PatternWhenHelper
{Type}.{Artifact}.g.csPer-type output (invoker, repository, factory)VirtualFolderHints.ForType()
_Boundary.{Boundary}.{Artifact}.g.csPer-boundary (endpoint handler aggregates)VirtualFolderHints.ForBoundary()
_Infra.{Category}.{Extension}.g.csPer-assembly infrastructure (DI registration)VirtualFolderHints.ForAssembly()
_Metadata.{Category}.g.csHost metadata aggregationVirtualFolderHints.ForMetadata()
EntityConfig.{Entity}.g.csHost-level EF Core entity configurationVirtualFolderHints.ForEntityConfig()
Endpoint.{Action}.{Boundary}.g.csHost-level endpoint registrationdirect

The _ prefix pushes infrastructure files to the bottom of the sorted list, so per-type outputs appear first.

Enable emission to disk in your .csproj:

<PropertyGroup>
<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
<CompilerGeneratedFilesOutputPath>$(BaseIntermediateOutputPath)/Generated</CompilerGeneratedFilesOutputPath>
</PropertyGroup>

After dotnet build, inspect obj/Generated/Pragmatic.SourceGenerator/Pragmatic.SourceGenerator.PragmaticSourceGenerator/.

Every template subclasses the shared CSharpTemplate (shared/SourceGen/CSharpTemplate*.cs, a partial class split across Core / ControlFlow / Types / Members). The minimal skeleton:

internal sealed class MyFeatureTemplate(MyModel model) : CSharpTemplate
{
// file name + content — use VirtualFolderHints for the hint name
public override Artifact RenderOutput()
=> new(VirtualFolderHints.ForType(model.TypeName, "MyFeature"), ToSourceText());
// skip emission if the model is unusable
protected override bool Validate() => model.IsValid;
// the actual rendering — AppendNamespace, Class(...), Method(...), etc.
public override void RenderFile() { /* build the file */ }
}

A few rules that keep the incremental cache correct:

  • No symbols in models. Transforms turn ISymbol into plain immutable records; never store ISymbol/Compilation on a model, or caching breaks (and you leak Roslyn objects).
  • Use EquatableArray<T> for collections. Plain ImmutableArray<T> is reference-equal, which defeats value-based incremental caching.
  • Derive names with NamingHelper.AppendSuffix (shared/SourceGen/NamingHelper.cs) instead of $"{TypeName}Invoker" — it dedupes the suffix so CancelReservationMutation + MutationInvoker doesn’t become …MutationMutationInvoker.
  • Always route hint names through VirtualFolderHints — never hardcode .g.cs names — so files land in the right virtual folder and sort correctly.