How It Works
Pragmatic Design is built around a unified source generator — Pragmatic.SourceGenerator — plus 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).
| Generator | Assembly | Role |
|---|---|---|
Unified PragmaticSourceGenerator | Pragmatic.SourceGenerator | Detects referenced modules and activates ~21 feature pipelines |
Result ResultSourceGenerator + ErrorLocalizationGenerator | Pragmatic.Result.SourceGenerator | Result/VoidResult variants; IError localization keys |
I18n codes Country/Currency/Language generators | Pragmatic.Internationalization.SourceGenerator | Static ISO code tables from embedded JSON, gated by marker files |
Logging LoggingSourceGenerator | Pragmatic.Logging.SourceGenerator | Zero-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:
- Detects which Pragmatic modules are referenced in your project (via assembly lookup)
- Scans your attributes and partial classes to find declarations it can act on
- Runs the Transform for each declaration to build an immutable Model
- Hands the model to a Template (a subclass of
CSharpTemplate) that emits C# source - 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.)
Pipeline
Section titled “Pipeline”┌─ 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) │└──────────────────────────────┘Three rules behind the whole architecture
Section titled “Three rules behind the whole architecture”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.
3. CSharpTemplate, not StringBuilder
Section titled “3. CSharpTemplate, not StringBuilder”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.
What lives where
Section titled “What lives where”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 foldersPragmaticSourceGenerator.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:
- Add a flag in
DetectedFeatures - Add a probe in
FeatureDetector - Create
Features/{Feature}/with Models/Transforms/Templates - Register the pipeline in
PragmaticSourceGenerator.Initialize
See feature-detection for how detection works in practice.
Hint names
Section titled “Hint names”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:
| Pattern | When | Helper |
|---|---|---|
{Type}.{Artifact}.g.cs | Per-type output (invoker, repository, factory) | VirtualFolderHints.ForType() |
_Boundary.{Boundary}.{Artifact}.g.cs | Per-boundary (endpoint handler aggregates) | VirtualFolderHints.ForBoundary() |
_Infra.{Category}.{Extension}.g.cs | Per-assembly infrastructure (DI registration) | VirtualFolderHints.ForAssembly() |
_Metadata.{Category}.g.cs | Host metadata aggregation | VirtualFolderHints.ForMetadata() |
EntityConfig.{Entity}.g.cs | Host-level EF Core entity configuration | VirtualFolderHints.ForEntityConfig() |
Endpoint.{Action}.{Boundary}.g.cs | Host-level endpoint registration | direct |
The _ prefix pushes infrastructure files to the bottom of the sorted list, so per-type outputs appear first.
How to inspect the generated code
Section titled “How to inspect the generated code”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/.
Authoring a generator (contributor note)
Section titled “Authoring a generator (contributor note)”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.
TransformsturnISymbolinto plain immutable records; never storeISymbol/Compilationon a model, or caching breaks (and you leak Roslyn objects). - Use
EquatableArray<T>for collections. PlainImmutableArray<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 soCancelReservationMutation+MutationInvokerdoesn’t become…MutationMutationInvoker. - Always route hint names through
VirtualFolderHints— never hardcode.g.csnames — so files land in the right virtual folder and sort correctly.
Reading more
Section titled “Reading more”- Feature catalog — every pipeline, its trigger, and what it generates
docs/completed/naming-sg.md— hint-name convention playbookdocs/architecture/sg-extension-playbook.md— how to add a new featureshared/SourceGen/README.md—CSharpTemplateAPIshared/SourceGen/Testing/README.md— how to write Verify-based tests for generator output