This is the abridged developer documentation for Purview Dev # Aspire ResourceKit > This wiki is the project documentation hub for Purview.Aspire.ResourceKit, a source-generator-powered framework for structuring .NET Aspire AppHost resource… # Purview Aspire ResourceKit Wiki This wiki is the project documentation hub for `Purview.Aspire.ResourceKit`, a source-generator-powered framework for structuring .NET Aspire AppHost resource composition as strongly typed, test-friendly classes. If your AppHost is getting bigger, ResourceKit keeps resource setup maintainable and discoverable by moving composition into focused resource classes and generating the plumbing for you. ## Start here - [Getting Started](getting-started/) - [Attributes Reference](attributes-reference/) - [Generated Output](generated-output/) - [Lifecycle: Build vs Configure](lifecycle-build-configure/) - [Enablement: IsEnabled vs IsResourceEnabled](enablement/) - [Configuration and Options](configuration-and-options/) - [OptionsHelper](optionshelper/) - [Project Resources](project-resources/) - [Diagnostics](diagnostics/) - [Testing with ResourceKit](testing-with-resourcekit/) - [Examples: Generated vs Manual](examples/) - [Bundled Agent Skills](agent-skills/) - [Source Generator Behaviors](source-generator-behaviors/) - [Contributing](contributing/) - [Release Flow](release-flow/) ## Why teams use it - **Cleaner AppHost code** with resource logic split into dedicated classes. - **Strong typing + IntelliSense** instead of stringly-typed setup. - **Generated wiring** for host/resource options and registration. - **Predictable lifecycle** (`Build` then `Configure`) for inter-resource dependencies. - **Testability** with options overrides and isolated resource composition. ## Feature highlights - **`[HostKit]`** marks the single host kit class per compilation and generates the host wiring and an AppHost extension method (for example `builder.AddAspireResourceKit()`). - **`[ResourceDefinition]`** marks a resource kit class; generic and non-generic styles are both supported. - **Generated typed options** are emitted as nested `sealed partial` classes you can extend with your own settings. - **Build vs Configure** gives a deterministic lifecycle: build each resource, then connect the created resources. - **`IsEnabled` + `IsResourceEnabled(builder)`** lets enablement react to runtime state (environment, publish mode, dynamic configuration). - **`OptionsHelper`** converts typed assignment expressions into command-line args or environment variables for tests and scenario toggles. - **Diagnostics** (`SG0001`–`SG0020`) report model problems early, with an IDE code fix for `OptionsHelper.Assign` multi-assignment actions. - **Bundled agent skills** auto-install into consuming repositories unless opted out. # Bundled agent skills > Purview.Aspire.ResourceKit ships one or more bundled Agent Skillshttps://agentskills.io/ that are auto-installed into consuming repositories so supported… # Bundled agent skills `Purview.Aspire.ResourceKit` ships one or more bundled [Agent Skills](https://agentskills.io/) that are auto-installed into consuming repositories so supported coding agents can discover ResourceKit-specific guidance automatically. ## How auto-install works When a consuming project builds, any `skills/**/SKILL.md` files in the package are copied to `.agents/skills/**` in the consuming repository. Example mapping: - `skills/aspire-apphost-to-resourcekit/SKILL.md` → `.agents/skills/aspire-apphost-to-resourcekit/SKILL.md` A local `.gitignore` is written into each generated skill folder to keep updates out of source control noise, so the skills stay fresh on rebuild without polluting the repository history. ## Opting out To disable this behavior, set the shared opt-out property in your project (or `Directory.Build.props`): ```xml false ``` ## Bundled skill - `aspire-apphost-to-resourcekit` — guides migrating an inline AppHost into ResourceKit composition, covering attributes, lifecycle, options, and configuration. # Attributes reference > ResourceKit ships two public attributes in the Purview.Aspire.ResourceKit namespace. Both are generated into the consuming compilation by the bundled source… # Attributes reference ResourceKit ships two public attributes in the `Purview.Aspire.ResourceKit` namespace. Both are generated into the consuming compilation by the bundled source generator, so you do not reference a separate attribute package. ## `HostKitAttribute` (`[HostKit]`) Marks the single host kit class per compilation. Exactly one host kit may exist per compilation (SG0004). ```csharp [HostKit] sealed partial class ShopHostKit; ``` Optional named arguments: | Argument | Type | Default | Purpose | | --- | --- | --- | --- | | `Name` | `string?` | `null` | Controls generated naming. | | `ExtensionMethodName` | `string?` | Derived from host kit name | Overrides the generated builder extension name (for example `AddAspireResourceKit()`). | | `GenerateOptions` | `bool` | `true` | Enables/disables generated host options. | ## `ResourceDefinitionAttribute` (`[ResourceDefinition]`) Marks a resource kit class that participates in generation. ```csharp [ResourceDefinition("api", PropertyName = "API")] partial class APIResourceKit; ``` Options: | Argument | Type | Default | Purpose | | --- | --- | --- | --- | | `Name` | `string?` | Derived from class name (suffix trimmed) | Logical Aspire resource name. | | `PropertyName` | `string?` | Derived from class name | Generated host property name. Must be a valid C# identifier (SG0008). | | `AspireResourceType` | generic type argument | `null` | The Aspire `IResource` type (generic style only). | ## `ResourceDefinition` vs `ResourceDefinition` ResourceKit supports two declaration styles with different base-type behavior. ### Generic attribute (recommended) Use `[ResourceDefinition]` when you want the resource type declared directly on the attribute. ```csharp [ResourceDefinition("api")] partial class APIResourceKit { protected override IResourceBuilder BuildResource(IDistributedApplicationBuilder builder) => builder.AddProject(Name); } ``` - Do **not** declare an explicit base type on the class (SG0015). - The generator supplies the host-specific base in generated partial code. The type argument may be: - an Aspire resource type implementing `IResource` (used as-is), or - an `IProjectMetadata` type (the type accepted by `AddProject()`), which is mapped to `ProjectResource` for the generated base class. Anything else is rejected (SG0016). ### Non-generic attribute Use `[ResourceDefinition]` when you prefer (or need) to specify the resource type through an explicit base type. ```csharp [ResourceDefinition("api")] partial class APIResourceKit : ResourceKitBase { protected override IResourceBuilder BuildResource(IDistributedApplicationBuilder builder) => builder.AddProject(Name); } ``` - You **must** declare an explicit valid base type (SG0014). - The base must derive from the generated `ResourceKitBase` or the runtime `ResourceKitBase` (SG0006). ### Rules summary | Situation | Diagnostic | Severity | | --- | --- | --- | | Both styles on one class | SG0013 | Error | | Generic style with explicit base | SG0015 | Error | | Non-generic style without explicit base | SG0014 | Error | | Explicit base not derived from a valid Resource Kit base | SG0006 | Error | | Resource type cannot be inferred/found | SG0016 | Error | ## Constructors Attributed classes must not declare constructors with parameters or executable constructor bodies (SG0012). The generator supplies the constructor wiring in generated partial code; the primary constructor of a resource kit receives the host kit and options. In the manual (non-generated) pattern you declare it yourself — see [Examples](../examples/). ## See also - [Generated Output](../generated-output/) — what the generator emits from these attributes. - [Diagnostics](../diagnostics/) — full diagnostic reference. # Configuration and options > Purview.Aspire.ResourceKit can generate host and resource options types so you can control names and enablement without changing code. # Configuration and options `Purview.Aspire.ResourceKit` can generate host and resource options types so you can control names and enablement without changing code. ## Generated options shape A host options type contains one nested options object per resource. ```csharp public class ShopHostKitOptions { public APIKitOptions Api { get; set; } = new(); public RedisKitOptions Redis { get; set; } = new(); } public class APIKitOptions { public string Name { get; set; } = "api"; public bool IsEnabled { get; set; } = true; } ... ``` The generated host options type exposes a `SectionName` constant that is used to bind configuration: ```csharp public const string SectionName = "ShopHostKit"; ``` Generated options are emitted as nested `sealed partial` classes. You can extend both host and resource option types with custom properties. ## Extend host and resource typed options ### Extend host options ```csharp [HostKit] partial class ShopHostKit { public sealed partial class ShopHostKitOptions { public bool EnablePreviewResources { get; set; } } } ``` ### Extend resource options ```csharp [ResourceDefinition("api")] sealed partial class APIKit { partial class APIKitOptions { public string PublishEnvironmentVariableName { get; set; } = "PUBLISH_MARKER"; } } ``` > Tip: keep custom option members `public` with `get; set;` so configuration binding can populate them. > Data annotations such as `[Required]` are honored by the generated `ValidateOnStart()` registration. ### Use extended options at runtime - Access host-level values through `HostKit.Options`. - Access resource-level values through `Options` in each resource kit. Example: ```csharp protected override void ConfigureResource() { if (HostKit.Options.EnablePreviewResources) { ResourceBuilder.WithEnvironment(Options.PublishEnvironmentVariableName, "true"); } } ``` ## Common configuration keys Typical keys (example): - `ShopHostKit:API:Name` - `ShopHostKit:API:IsEnabled` Configuration is bound by the generated extension method: - Host options bind to `ShopHostKitOptions.SectionName` (for example `ShopHostKit`) and `ValidateOnStart()` is called. - Resource options are nested by generated resource property name. ## Disable a resource Set `IsEnabled=false` for that resource's options section. ```json { "ShopHostKit": { "Redis": { "IsEnabled": false } } } ``` ## `IsEnabled` vs `IsResourceEnabled(...)` Use both together for flexible control: - `IsEnabled`: static/configured toggle (usually generated options). - `IsResourceEnabled(builder)`: runtime decision hook. At runtime, `Build` only calls `IsResourceEnabled(builder)` when `IsEnabled` is already `true`. If the hook returns `false`, ResourceKit skips both `BuildResource(...)` and `ConfigureResource()` for that resource. A kit disabled via `IsEnabled=false` is never re-enabled by the hook. See [Enablement](../enablement/) for details. ## OptionsHelper for tests and overrides For tests and scenario toggles, `OptionsHelper` converts typed assignments into command-line arguments or environment variables: ```csharp var args = OptionsHelper.Assign( c => c.API.IsEnabled = false, c => c.API.Name = "api-test" ).Build(); ``` See [OptionsHelper](../optionshelper/) for the complete API, including `PathFor`, `SectionNameFor`, and the `SG0020` single-property-path rule. ## Section-name resolution Generated options types carry a `SectionName` constant (for example `"ShopHostKit"`). When you do not pass a section name explicitly, `OptionsHelper` resolves it as follows: 1. `const string SectionName` on the options type 2. Type name trimmed by one suffix: `Options`, `Settings`, `Configuration`, `Config` 3. Original type name You can look up the resolved section name with [`OptionsHelper.SectionNameFor`](../optionshelper/#sectionnamefor). ## Tip Prefer resource-level toggles over conditional host code. It keeps the composition model declarative and testable. # Contributing > This page covers the repository layout and the day-to-day commands for working on Purview.Aspire.ResourceKit. It is aimed at contributors, not consumers. # Contributing This page covers the repository layout and the day-to-day commands for working on `Purview.Aspire.ResourceKit`. It is aimed at contributors, not consumers. ## Repository layout - `src/src/ResourceKit` — runtime APIs consumed by AppHost projects (the packable project). - `src/src/SourceGeneration` — the Roslyn incremental generator, analyzers, suppressor, and attributes. - `src/src/SourceGeneration.CodeFixes` — the SG0020 IDE code fix provider. - `src/src/Example.*` — sample Aspire applications (`Example.AppHost` generated pattern, `Example.ManualAppHost` manual pattern, `Example.Service`, `Example.ServiceDefaults`). - `src/tests/*` — unit and integration tests for the runtime and the generator. - `docs/wiki` — this documentation suite. The NuGet package is produced from `src/src/ResourceKit/ResourceKit.csproj` and includes the runtime APIs plus the analyzer/code-fix assemblies. ## Building and testing The repository uses [Just](https://just.systems/) recipes (see `Justfile`). All recipes run from the repository root. ```bash just build # build the solution (Debug) just test # run all tests just test-unit # run unit tests only (TUnit [Category=Unit] filter) just restore # restore dependencies just pack # produce NuGet packages into ./artifacts just lint-check # CSharpier formatting check just lint-fix # CSharpier format just scrub # remove bin/obj, clean, restore, stop build server just version # show the current version from package.json ``` ## Testing conventions - Tests use **TUnit** (and TUnit.Mocks); do not introduce NUnit, xUnit, or MSTest patterns. - All tests follow AAA with explicit `// Arrange`, `// Act`, `// Assert` comments. - Test names mirror production structure: `{ClassUnderTest}Tests` and `{SubjectOrMethodUnderTest}_{Scenario}_{Expectation}`. - When a method under test accepts a `CancellationToken`, pass one and make it the final argument. - Unit tests are tagged `[Category=Unit]` and are the only tests CI runs. Test projects: - `src/tests/ResourceKit.UnitTests` — runtime behavior (host app resources, `OptionsHelper`). - `src/tests/ResourceKit.IntegrationTests` — starts the example AppHosts via TUnit.Aspire. - `src/tests/SourceGeneration.UnitTests` — generator diagnostics/severity and attributes. - `src/tests/SourceGeneration.IntegrationTests` — generated source content, caching, diagnostics, suppression, and the SG0020 code fix. Reports are written to `TestResults/`. ## Agent skills and workflows The repository carries agent skills and prompt workflows under `.agents/`. Consult them when working on source generators, tests, the project SDK, or conventional commits. AGENTS.md is the canonical agent guidance and references `.agents/` for reusable workflows. ## Pull requests Pull requests target `main` and are validated by `.github/workflows/pr.yml`, which delegates to the shared [Purview.Build](https://github.com/purview-dev/build) pipeline (restore, build, CSharpier lint, unit tests, pack, package-content validation). See [Release Flow](../release-flow/). Commits follow [Conventional Commits](https://www.conventionalcommits.org/), enforced by commitlint and Lefthook hooks. # Diagnostics > ResourceKit reports diagnostics with SGxxxx IDs to help you fix model issues quickly. The rules are evaluated by the bundled ResourceKitDiagnosticAnalyzer and… # Diagnostics ResourceKit reports diagnostics with `SGxxxx` IDs to help you fix model issues quickly. The rules are evaluated by the bundled `ResourceKitDiagnosticAnalyzer` (and the source generator uses the same shared rule set to decide what can be generated). ResourceKit also ships `ResourceKitDiagnosticSuppressor`, which automatically suppresses `CS8618` for non-nullable `IResourceBuilder` properties declared on resource kits — these are populated at runtime during the `BuildResource`/`ConfigureResource` lifecycle, so the "must contain a non-null value when exiting the constructor" warning does not apply. ## Diagnostic reference | ID | Severity | What it means | | - | - | - | | SG0001 | Error | A participating class must be `partial` | | SG0002 | Info | Host kit exists but no resources were defined | | SG0003 | Warning | Resources exist but no host kit was defined | | SG0004 | Error | More than one `[HostKit]` class was found | | SG0005 | Error | Two resources map to the same generated property name | | SG0006 | Error | A resource does not derive from the expected generated base | | SG0007 | Error | Resource name could not be inferred and `Name` was not set | | SG0008 | Error | Explicit `PropertyName` is not a valid C# identifier | | SG0009 | Error | Missing `IServiceCollection` dependency | | SG0010 | Error | Missing configuration binder dependency | | SG0011 | Error | Missing options configuration extensions dependency | | SG0012 | Error | Non-empty constructors are not supported on attributed classes | | SG0013 | Error | Mixed `ResourceDefinition` and `ResourceDefinition` usage on one class | | SG0014 | Error | Non-generic `ResourceDefinition` requires explicit compatible base type | | SG0015 | Error | Generic `ResourceDefinition` cannot declare explicit base type | | SG0016 | Error | No Aspire resource type could be inferred/found | | SG0017 | Warning | An `IResourceBuilder` property is never assigned in `BuildResource` or `ConfigureResource` | | SG0018 | Warning | A project resource kit does not add the declared project via `AddProject()` | | SG0019 | Warning | A project resource kit declares an explicit base class that does not use `ProjectResource` | | SG0020 | Error | An `OptionsHelper.Assign` action sets more than one property path | See [Attributes Reference](../attributes-reference/) for the rules tied to attribute styles, and [Project Resources](../project-resources/) for SG0018/SG0019 specifics. ## Execution-only vs generation-blocking rules SG0017, SG0018, and SG0019 are **execution-only** rules. They report problems that break the resource at runtime (an unset builder property, a project that is never registered, or a base class that cannot build a project) but they do **not** prevent source generation. They are reported as warnings so generation always proceeds — for example, a resource kit whose `BuildResource` does not yet register its declared project via `AddProject()` is still generated (and the host kit is still emitted) so the user can complete the override instead of losing the whole output. Only Error-severity rules (SG0001–SG0016) block generation. ## `OptionsHelper.Assign` action with multiple property paths (SG0020) Each `OptionsHelper.Assign(...)` action must set exactly one property path. A block-bodied lambda such as the following is rejected at compile time: ```csharp OptionsHelper.Assign(o => { o.API.IsEnabled = false; o.API.Name = "api-test"; }); ``` Split each property into its own assignment argument instead: ```csharp OptionsHelper.Assign( o => o.API.IsEnabled = false, o => o.API.Name = "api-test" ); ``` Visual Studio offers a **"Split into separate assignments"** code fix that performs this conversion for you. See [OptionsHelper](../optionshelper/) and [Source Generator Behaviors](../source-generator-behaviors/). ## Fast troubleshooting checklist 1. Ensure host/resource classes are marked `partial`. 2. Ensure exactly one `[HostKit]` class exists in the compilation. 3. Ensure resource property names are unique (explicit `PropertyName` can help). 4. Ensure each resource uses a compatible base and definition attribute style. 5. Set explicit `Name` when inference cannot determine resource name. ## Common fixes ### Duplicate generated property names (SG0005) Use unique `PropertyName` values: ```csharp [ResourceDefinition("api", PropertyName = "Api")] [ResourceDefinition("admin", PropertyName = "AdminApi")] ``` ### Invalid `PropertyName` (SG0008) Use valid C# identifiers only (`Api`, `RedisCache`, `OrderDb`, etc.). ### Multiple host kits (SG0004) Keep one `[HostKit]` per compilation; split scenarios into separate projects if needed. ### Mixed attribute styles (SG0013) Use exactly one style per class: - `[ResourceDefinition("name")]` - or `[ResourceDefinition("name")]` Do not apply both to the same class. ### Base-type mismatch by style (SG0014 / SG0015) - If you use non-generic `[ResourceDefinition("name")]`, declare an explicit compatible base. - If you use generic `[ResourceDefinition("name")]`, do not declare an explicit base. ### Unassigned `IResourceBuilder` property (SG0017) Every `IResourceBuilder` property on a resource kit (nullable or not) must be assigned in either `BuildResource` or `ConfigureResource`. `CS8618` is automatically suppressed for these properties because they are populated at runtime, so SG0017 is the signal that a property is never set: ```csharp [ResourceDefinition("sql")] sealed partial class SqlServerKit { public IResourceBuilder Database { get; private set; } protected override void ConfigureResource() { Database = ResourceBuilder.AddDatabase("changeops-db", "ChangeOps"); base.ConfigureResource(); } } ``` # Enablement: IsEnabled vs IsResourceEnabled > ResourceKit supports flexible runtime enablement through two related members. # Enablement: IsEnabled vs IsResourceEnabled ResourceKit supports flexible runtime enablement through two related members. ## The two toggles - `IsEnabled` — the current enablement flag, usually sourced from generated options. This is the persisted/configured toggle. - `IsResourceEnabled(builder)` — a runtime decision hook. Its default implementation returns `IsEnabled`, and you override it when enablement should react to runtime conditions. ## How they interact At runtime, `Build` evaluates enablement *before* resource construction: 1. If `IsEnabled` is `true`, ResourceKit calls `IsResourceEnabled(builder)` and assigns the result back to `IsEnabled`. 2. If the resulting `IsEnabled` is `false`, both `BuildResource(...)` and `ConfigureResource()` are skipped for that resource. Two important consequences: - The hook is only invoked when `IsEnabled` is already `true`. A kit disabled via `IsEnabled=false` (for example from options) is **never** re-enabled by the hook. - `ResourceBuilder` cannot be accessed while the resource is disabled — doing so throws an `InvalidOperationException` (the property guards itself). ## When to override `IsResourceEnabled` Use the hook when enablement depends on runtime state rather than only static options. Common examples are environment-specific availability, publish mode, or dynamic configuration checks. ```csharp protected override bool IsResourceEnabled(IDistributedApplicationBuilder builder) { // Example: allow config + environment based behavior. var isEnabledInConfig = IsEnabled; var isProd = builder.Configuration["ASPNETCORE_ENVIRONMENT"] == "Production"; return isEnabledInConfig && isProd; } ``` Publish-only resources are a typical pattern. The example hosts run Key Vault and a publish marker parameter only when publishing: ```csharp protected override bool IsResourceEnabled(IDistributedApplicationBuilder builder) => builder.ExecutionContext.IsPublishMode; ``` ## Disabling a resource from options Because `IsEnabled` is bound from generated options, a resource can be disabled without code changes by setting the options section in configuration: ```json { "ShopHostKit": { "Redis": { "IsEnabled": false } } } ``` See [Configuration and Options](../configuration-and-options/) for the generated options shape. ## See also - [Lifecycle: Build vs Configure](../lifecycle-build-configure/) - [Configuration and Options](../configuration-and-options/) # Examples: Generated vs Manual > The repository ships two example AppHosts that compose the same set of resources in two different styles: # Examples: Generated vs Manual The repository ships two example AppHosts that compose the same set of resources in two different styles: - `src/src/Example.AppHost` — the **source-generated** pattern (attributes, no hand-written wiring). - `src/src/Example.ManualAppHost` — the **manual** pattern (hand-written `HostKitBase` and `ResourceKitBase` composition, no generator). Both hosts build the same logical resources through `Purview.Aspire.ResourceKit.Example` constants in `Example.ServiceDefaults/Platform.cs`: | Resource | Name | Aspire type | | --- | --- | --- | | Postgres | `postgres` (+ `db` database) | `AzurePostgresFlexibleServerResource` | | Azure Storage | `azure-storage` (+ `blob`) | `AzureStorageResource` | | Redis | `redis` | `AzureManagedRedisResource` | | Key Vault | `kv` | `AzureKeyVaultResource` | | API | `api` | `ProjectResource` (`Projects.Example_Service`) | | Publish marker | `publish-marker` | `ParameterResource` | Key Vault and the publish marker are publish-only (`IsResourceEnabled` returns `builder.ExecutionContext.IsPublishMode`). ## Generated pattern (`Example.AppHost`) `AppHost.cs` registers everything with a single generated call: ```csharp var builder = DistributedApplication.CreateBuilder(args); builder.AddAspireResourceKit(); var app = builder.Build(); await app.RunAsync(); ``` The host kit is a one-line attribute declaration: ```csharp [HostKit] sealed partial class ExampleHostKit; ``` Each resource is a `[ResourceDefinition]` partial class that supplies the overrides. For example, the API kit also wires dependencies in `ConfigureResource()` and extends its generated options: ```csharp [ResourceDefinition(Platform.ResourceKits.API)] sealed partial class ExampleAPIKit { protected override IResourceBuilder BuildResource(IDistributedApplicationBuilder builder) => builder.AddProject(Name).WithUrl("/health", "Health"); protected override void ConfigureResource() { if (HostKit.PublishMarker.IsEnabled) ResourceBuilder.WithEnvironment(Options.PublishEnvironmentVariableName, HostKit.PublishMarker); ResourceBuilder.WithReference(HostKit.Postgres.Database).WaitFor(HostKit.Postgres.Database); ResourceBuilder.WithReference(HostKit.AzureStorage.Blobs).WaitFor(HostKit.AzureStorage.Blobs); if (HostKit.KeyVault.IsEnabled) ResourceBuilder.WithReference(HostKit.KeyVault).WaitFor(HostKit.KeyVault); if (HostKit.Redis.IsEnabled) ResourceBuilder.WithReference(HostKit.Redis).WaitFor(HostKit.Redis); } partial class ExampleAPIKitOptions { [Required(AllowEmptyStrings = false)] public string PublishEnvironmentVariableName { get; set; } = "PUBLISH_MARKER"; } } ``` The generator emits the host kit members, options, `ResourceKitBase` base classes, and the extension method. See [Generated Output](../generated-output/). ## Manual pattern (`Example.ManualAppHost`) `AppHost.cs` registers a host kit instance directly via the generic extension: ```csharp builder.AddAspireResourceKit(); ``` The host kit is hand-written and composes the resource kits: ```csharp sealed class ExampleHostKit : HostKitBase { public AzureStorageKit AzureStorage { get; init; } public ExampleAPIKit ExampleAPI { get; init; } // ... public ExampleHostKit() { AzureStorage = new(this); ExampleAPI = new(this); // ... AddResource(AzureStorage); AddResource(ExampleAPI); // ... } } ``` Each resource kit is a hand-written partial class deriving from the runtime base, with a primary constructor that takes the host kit: ```csharp sealed partial class ExampleAPIKit(ExampleHostKit hostKit) : ResourceKitBase(hostKit, Platform.ResourceKits.API) { protected override IResourceBuilder BuildResource(IDistributedApplicationBuilder builder) => builder.AddProject(Name); protected override void ConfigureResource() { if (HostKit.PublishMarker.IsEnabled) ResourceBuilder.WithEnvironment("PUBLISH_MARKER", HostKit.PublishMarker); ResourceBuilder.WithReference(HostKit.Postgres.Database).WaitFor(HostKit.Postgres.Database); // ... } } ``` ## Choosing between the two | Consideration | Generated | Manual | | --- | --- | --- | | Boilerplate | Minimal (attributes + overrides) | Explicit (base classes, constructors, `AddResource`) | | Options | Generated, typed, bindable | You manage options yourself | | Diagnostics | Analyzer checks model rules | No generator diagnostics | | Testability | Both test well | Lifecycle is directly unit-testable | | Best for | Most AppHosts | Learning the model, or when you want full control | Both patterns produce the same runtime behavior: instantiate resources, `Build` enabled resources, `Configure` enabled resources. ## See also - [Getting Started](../getting-started/) - [Attributes Reference](../attributes-reference/) - [Generated Output](../generated-output/) - [Lifecycle: Build vs Configure](../lifecycle-build-configure/) # Generated output > From your HostKit and ResourceDefinition declarations, the generator emits all of the boilerplate that wires your resource kits together. The generated code… # Generated output From your `[HostKit]` and `[ResourceDefinition]` declarations, the generator emits all of the boilerplate that wires your resource kits together. The generated code is marked with `[CompilerGenerated]` and `[GeneratedCode("HostKitGenerator", ...)]` and is excluded from code coverage. ## What gets generated - a host resource base class (the concrete host kit deriving from `HostKitBase`), - resource properties on the host (one per resource definition), - host + per-resource options (when `GenerateOptions` is enabled), - a typed `ResourceKitBase` base per host kit, - each resource kit's `sealed partial` skeleton with a constructor and `Options` property, - an AppHost extension method to build/configure/register the host kit. ## Host kit members For every `[ResourceDefinition]`, the host kit gets a lazily initialized property. Accessing a property before `Build` throws, and setting it twice throws: ```csharp public Purview.Aspire.ResourceKit.Example.AppHost.AppModels.Resources.ExampleAPIKit ExampleAPI { get { return field ?? throw new global::System.InvalidOperationException( "The 'ExampleAPI' resource has not been initialized. Call Build first."); } private set { ... } } ``` ## Generated host Build/Configure `Build` instantiates each resource kit from its options, registers it with the base class, invokes the optional `onBuilt` callback, then calls `base.Build(builder)`: ```csharp public override void Build(global::Aspire.Hosting.IDistributedApplicationBuilder builder) { // Creating ExampleAPIKit Resource Kit. ExampleAPI = new(this, Options.ExampleAPI); // Register the discovered app resources with the base class. AddResource(ExampleAPI); // Now the additional post-build func builder onBuilt?.Invoke(this, builder); base.Build(builder); } public override void Configure() { base.Configure(); onConfigured?.Invoke(this); } ``` ## Generated resource kit skeleton Each resource kit derives from the generated `ResourceKitBase` and gains a constructor that accepts the host kit and its options: ```csharp internal sealed partial class ExampleAPIKit : global::Purview.Aspire.ResourceKit.ResourceKitBase { public ExampleAPIKit(ExampleHostKit hostKit, ExampleAPIKitOptions options) : base(hostKit, (options ?? throw new global::System.ArgumentNullException(nameof(options))).Name) { Options = options; IsEnabled = options.IsEnabled; } public ExampleAPIKitOptions Options { get; } } ``` Your `partial class` supplies `BuildResource(...)` and optionally `ConfigureResource()` and `IsResourceEnabled(builder)`. ## Generated options Host options are nested under the host kit and expose a `SectionName` constant plus one nested options object per resource: ```csharp public sealed partial class ExampleHostKitOptions { public const string SectionName = "ExampleHostKit"; public ExampleAPIKitOptions ExampleAPI { get; init; } = new(); } ``` Each resource options type carries `Name` (defaulting to the logical resource name) and `IsEnabled`: ```csharp public sealed partial class ExampleAPIKitOptions { [global::System.ComponentModel.DataAnnotations.Required(AllowEmptyStrings = false)] public string Name { get; set; } = "api"; public bool IsEnabled { get; set; } = true; } ``` `Name` is decorated with `[Required(AllowEmptyStrings = false)]`; the generated extension method calls `ValidateOnStart()`. ## Generated extension method The extension method binds host options from configuration, creates the host kit, and runs the full lifecycle: ```csharp public static global::Aspire.Hosting.IDistributedApplicationBuilder AddAspireResourceKit( this global::Aspire.Hosting.IDistributedApplicationBuilder builder, global::System.Action? onBuilt = null, global::System.Action? onConfigured = null, global::System.Action>? configureOptions = null ) { var optionsBuilder = builder.Services .AddOptions() .BindConfiguration(ExampleHostKitOptions.SectionName); configureOptions?.Invoke(optionsBuilder); optionsBuilder.ValidateOnStart(); var hostKitOptions = builder.Configuration .GetSection(ExampleHostKitOptions.SectionName) .Get() ?? new(); ExampleHostKit hostKit = new(onBuilt, onConfigured, hostKitOptions); hostKit.Build(builder); hostKit.Configure(); builder.Services.AddSingleton(hostKit); return builder; } ``` The extension method name defaults to `Add()` (for example `AddAspireResourceKit()` for `ExampleHostKit`) and can be overridden with `HostKitAttribute.ExtensionMethodName`. ## See also - [Attributes Reference](../attributes-reference/) - [Lifecycle: Build vs Configure](../lifecycle-build-configure/) - [Configuration and Options](../configuration-and-options/) # Getting started > This guide walks through a minimal host + resource setup using source generation. It assumes you have an Aspire AppHost project and a service project ready to… # Getting started This guide walks through a minimal host + resource setup using source generation. It assumes you have an Aspire AppHost project and a service project ready to compose. :::tip For a quick jump across lifecycle concepts, see [Lifecycle: Build vs Configure](../lifecycle-build-configure/) and [Enablement](../enablement/). ::: ## Install the package Install the `Purview.Aspire.ResourceKit` package in your AppHost project. ```bash dotnet add package Purview.Aspire.ResourceKit ``` :::tip This package can include one or more bundled [Agent Skills](https://agentskills.io/). On build, `skills/**/SKILL.md` entries are copied to `.agents/skills/**` in the consuming repository (for example: `skills/aspire-apphost-to-resourcekit/SKILL.md` → `.agents/skills/aspire-apphost-to-resourcekit/SKILL.md`), along with a local `.gitignore` in each generated skill folder to keep updates out of source control noise. To opt out, set `false` in your project (or `Directory.Build.props`). See [Bundled Agent Skills](../agent-skills/). ::: ## Define a host kit Create a partial class and annotate it with `[HostKit]`. ```csharp using Purview.Aspire.ResourceKit; [HostKit] partial class ShopHostKit; ``` ## Define one or more resources Create a partial resource class per resource and annotate it with `[ResourceDefinition]`. ```csharp using Aspire.Hosting; using Aspire.Hosting.ApplicationModel; using Purview.Aspire.ResourceKit; [ResourceDefinition("api")] partial class ApiResourceKit { protected override IResourceBuilder BuildResource(IDistributedApplicationBuilder builder) => builder.AddProject(Name); } ``` :::note For a project resource you can use the Aspire-generated project reference type (`Projects.Example_Service`, the type accepted by `AddProject()`) instead of the concrete `ProjectResource`. The generator maps it to `ProjectResource` for the generated base class, and the analyzer validates that `BuildResource` adds the same project via `AddProject()` (SG0018) and that an explicit base, when used, resolves to `ProjectResource` (SG0019). See [Project Resources](../project-resources/). ::: ### Choose an attribute style You have two valid styles: - **Generic style**: `[ResourceDefinition(...)]` - Preferred for most cases. - Do not declare an explicit base type. - **Non-generic style**: `[ResourceDefinition(...)]` - Requires an explicit compatible base type that provides the resource type. Example non-generic style: ```csharp [ResourceDefinition("api")] partial class ApiResourceKit : ResourceKitBase { protected override IResourceBuilder BuildResource(IDistributedApplicationBuilder builder) => builder.AddProject(Name); } ``` Avoid mixing both styles on the same class. See [Attributes Reference](../attributes-reference/) for the full rules. ## Register generated wiring in AppHost Call the generated extension method from your AppHost entry point. ```csharp var builder = DistributedApplication.CreateBuilder(args); builder.AddAspireResourceKit(); ``` The method name can be customized with `HostKitAttribute.ExtensionMethodName`. ## Extend generated typed options ResourceKit generates typed options as nested `sealed partial` classes, so you can extend them with your own settings. Host options are extended on the host kit; resource options are extended on each resource kit. ```csharp [ResourceDefinition("api")] sealed partial class ApiResourceKit { partial class ApiResourceKitOptions { public string PublishEnvironmentVariableName { get; set; } = "PUBLISH_MARKER"; } } ``` See [Configuration and Options](../configuration-and-options/) for the full pattern. ## Understand Build vs Configure ResourceKit has two lifecycle pairs: - `Build` / `BuildResource(...)` builds the resource itself. - `Configure` / `ConfigureResource()` wires resources together after build. Think of it as: - **Build phase**: create each resource. - **Configure phase**: connect the created resources. See [Lifecycle: Build vs Configure](../lifecycle-build-configure/) and [Enablement](../enablement/) before adding cross-resource dependencies. ## Expand incrementally A common progression: - Start with one resource kit class. - Add more resource kit classes as the AppHost grows. - Use generated options to toggle resources for local/dev/test scenarios. For configuration details, continue with [Configuration and Options](../configuration-and-options/). # Lifecycle: Build vs Configure > ResourceKit executes resources in a predictable sequence so dependency flow stays explicit and easy to reason about. # Lifecycle: Build vs Configure ResourceKit executes resources in a predictable sequence so dependency flow stays explicit and easy to reason about. ## Runtime order When the generated extension method is invoked, ResourceKit performs: 1. Instantiate resource kits from options. 2. `Build` each enabled resource. 3. `Configure` each enabled resource. This happens before `DistributedApplication.Build()` completes. ## The two lifecycle pairs - `Build` / `BuildResource(...)`: **construct this resource** (`AddProject`, `AddRedis`, `AddAzureStorage`, and so on). - `Configure` / `ConfigureResource()`: **attach resources to each other** after construction (references, bindings, cross-resource wiring). The separation keeps creation and cross-resource wiring explicit and deterministic. ## How the generated host kit drives the lifecycle The generated host kit overrides `Build` and `Configure`: - `Build` creates each discovered resource kit from its options, registers all of them with the base class via `AddResource`, invokes an optional `onBuilt` callback, and then calls `base.Build(builder)`, which runs `BuildResource(...)` for each resource. - `Configure` calls `base.Configure()` first (running each resource's `ConfigureResource()`), then invokes an optional `onConfigured` callback. The extension method signature gives you hooks for extra wiring: ```csharp builder.AddAspireResourceKit( onBuilt: (hostKit, builder) => { /* after resources are created, before Configure */ }, onConfigured: hostKit => { /* after all resources are configured */ }, configureOptions: optionsBuilder => { /* additional options configuration */ } ); ``` `HostKitBase` (the runtime base of the generated host kit) enforces that resources cannot be added after `Build` seals the resource list, keeping the lifecycle single-run and deterministic. ## Build vs Configure per resource Each resource kit implements `IResourceKit`: - `Build(IDistributedApplicationBuilder builder)` calls your `BuildResource(...)` override to **construct** the resource and store the resulting `IResourceBuilder` in `ResourceBuilder`. - `Configure()` calls your `ConfigureResource()` override to **attach** resources to each other after construction. A common Configure example that connects an API to its dependencies: ```csharp protected override void ConfigureResource() { ResourceBuilder.WithReference(HostKit.Postgres.Database).WaitFor(HostKit.Postgres.Database); ResourceBuilder.WithReference(HostKit.AzureStorage.Blobs).WaitFor(HostKit.AzureStorage.Blobs); if (HostKit.Redis.IsEnabled) ResourceBuilder.WithReference(HostKit.Redis).WaitFor(HostKit.Redis); } ``` Because `ConfigureResource()` runs after every resource is built, you can safely reach other resources through the host kit's generated properties. ## Enablement gates both phases Before `BuildResource(...)` runs, ResourceKit checks whether the resource should participate: - If `IsEnabled` is `false`, both `BuildResource(...)` and `ConfigureResource()` are skipped. - During `Build`, `IsResourceEnabled(builder)` is evaluated (only when `IsEnabled` is already `true`) and its result is assigned back to `IsEnabled`. See [Enablement](../enablement/) for the full model. ## See also - [Generated Output](../generated-output/) - [Enablement](../enablement/) # OptionsHelper > OptionsHelper builds configuration arguments or environment variables for options objects by using strongly typed assignment expressions. It is useful for… # OptionsHelper `OptionsHelper` builds configuration arguments or environment variables for options objects by using strongly typed assignment expressions. It is useful for integration-test fixtures, scenario toggles, and CLI overrides. ## Assign `Assign(...)` starts building entries from assignment expressions. ```csharp var args = OptionsHelper.Assign( c => c.API.IsEnabled = false, c => c.API.Name = "api-test" ).Build(); ``` Resulting args are in this form: - `--ShopHostKit:API:IsEnabled=false` - `--ShopHostKit:API:Name=api-test` Each assignment action must set exactly one property path. To set multiple properties, pass one assignment per property (as above). The compiler reports `SG0020` if an action assigns more than one property path, and offers a code fix that splits it into separate assignments. You can also pass an explicit root section name: ```csharp OptionsHelper.Assign("MySection", c => c.API.IsEnabled = false); ``` ## AsEnvironmentVariables Call `AsEnvironmentVariables()` before `Build()` to produce environment variables instead: ```csharp var envVars = OptionsHelper.Assign( c => c.API.IsEnabled = false, c => c.API.Name = "api-test" ).AsEnvironmentVariables().Build(); ``` This returns a dictionary such as `{"ShopHostKit__API__IsEnabled": "false", "ShopHostKit__API__Name": "api-test"}`. ## PathFor If you need a property path as a plain string (for example, to build keys or log config), use `PathFor` with a member selector: ```csharp var path = OptionsHelper.PathFor(f => f.API.Name); // "API.Name" ``` ## SectionNameFor Look up the resolved section name for any options type directly: ```csharp var sectionName = OptionsHelper.SectionNameFor(); // "ShopHostKit" var sectionNameFromType = OptionsHelper.SectionNameFor(typeof(ShopHostKit.ShopHostKitOptions)); // "ShopHostKit" ``` `SectionNameFor` accepts both a generic type argument and a `Type`, and applies the same resolution rules as `Assign`: 1. `const string SectionName` on the options type 2. Type name trimmed by one suffix: `Options`, `Settings`, `Configuration`, `Config` 3. Original type name Suffix trimming works for generic types by ignoring type arguments. ## TUnit.Aspire integration `OptionsHelper` pairs well with [TUnit.Aspire](https://www.nuget.org/packages/TUnit.Aspire/) when passing args to an AppHost under test: ```csharp protected override string[] Args => [ .. base.Args, .. OptionsHelper.Assign( c => c.API.IsEnabled = false, c => c.API.Name = "api-test" ).Build(), ]; ``` See [Testing with ResourceKit](../testing-with-resourcekit/). ## The SG0020 rule An `OptionsHelper.Assign(...)` action must set exactly one property path. A block-bodied lambda such as the following is rejected at compile time: ```csharp OptionsHelper.Assign(o => { o.API.IsEnabled = false; o.API.Name = "api-test"; }); ``` Split each property into its own assignment argument instead: ```csharp OptionsHelper.Assign( o => o.API.IsEnabled = false, o => o.API.Name = "api-test" ); ``` Visual Studio offers a **"Split into separate assignments"** code fix that performs this conversion for you. See [Source Generator Behaviors](../source-generator-behaviors/) for how the fix works. ## See also - [Configuration and Options](../configuration-and-options/) - [Testing with ResourceKit](../testing-with-resourcekit/) # Project resources > ResourceKit has first-class support for Aspire project resources AddProject with generator and analyzer validation to catch wiring mistakes. # Project resources ResourceKit has first-class support for Aspire project resources (`AddProject()`) with generator and analyzer validation to catch wiring mistakes. ## Declaring a project resource kit Use the Aspire-generated project reference type directly in the attribute. The generator maps it to `ProjectResource` for the generated base class: ```csharp using Aspire.Hosting; using Aspire.Hosting.ApplicationModel; using Purview.Aspire.ResourceKit; [ResourceDefinition("api")] partial class ApiResourceKit { protected override IResourceBuilder BuildResource(IDistributedApplicationBuilder builder) => builder.AddProject(Name); } ``` `Projects.Example_Service` is the type generated by the Aspire SDK into the AppHost. It implements `IProjectMetadata` (the type accepted by `AddProject()`). When the type argument implements `IProjectMetadata`, ResourceKit: - validates the resource type (rather than rejecting it), - maps it to `ProjectResource` for the generated base class, - enables the two project-specific execution-only rules below. ## The declared project must be added (SG0018) `BuildResource` must register the same project it declares via `AddProject()`. ```csharp // OK protected override IResourceBuilder BuildResource(IDistributedApplicationBuilder builder) => builder.AddProject(Name); ``` ```csharp // SG0018 (warning): the declared project is never registered at runtime. [ResourceDefinition("api")] partial class ApiResourceKit { protected override IResourceBuilder BuildResource(IDistributedApplicationBuilder builder) => builder.AddContainer("api", "my-image"); } ``` SG0018 is an **execution-only** warning: generation still proceeds so you can complete the override without losing the host kit output. At runtime the declared project is not registered, so the resource fails. ## Explicit base must use ProjectResource (SG0019) When a project resource kit declares an explicit base class (non-generic attribute style), the base must resolve to `ProjectResource`. ```csharp [ResourceDefinition("api")] partial class ApiResourceKit : ResourceKitBase { protected override IResourceBuilder BuildResource(IDistributedApplicationBuilder builder) => builder.AddProject(Name); } ``` If the explicit base resolves to a different resource type, SG0019 is reported (execution-only warning) because that base cannot build the declared project. ## Generic vs non-generic styles - **Generic style** (`[ResourceDefinition(...)]`) is recommended for project resources: the type is declared on the attribute and the base is supplied by the generator. - **Non-generic style** (`[ResourceDefinition(...)]` + explicit base) requires the base to use `ProjectResource` (SG0019). See [Attributes Reference](../attributes-reference/) for the base-type rules shared by all resources. ## See also - [Attributes Reference](../attributes-reference/) - [Diagnostics](../diagnostics/) # Release flow > This repository uses the shared Purview.Buildhttps://github.com/purview-dev/build pipeline for both PR validation and releases. Consuming repositories own… # Release flow This repository uses the shared [Purview.Build](https://github.com/purview-dev/build) pipeline for both PR validation and releases. Consuming repositories own configuration (through `purview-build.json`) but not pipeline source code. - `.github/workflows/pr.yml` — PR validation - `.github/workflows/release.yml` — release on push to `main` - `purview-build.json` — pipeline configuration ## PR validation `pr.yml` runs on pull requests targeting `main` and delegates to the shared `purview-build.yml` workflow. It runs: 1. `dotnet restore` of `src/ResourceKit.slnx` 2. `dotnet build --no-restore --configuration Release` 3. CSharpier lint across the repository 4. Unit tests (discovered under `src/tests` matching `*Tests.csproj`, run with the `/*/*/*/*[Category=Unit]` TUnit tree-node filter) 5. `dotnet pack` and package-content validation Integration tests are never discovered in CI: `purview-build.json` sets `Build:TestPatterns` to `*Tests.csproj` and `Build:TestFilter` to `/*/*/*/*[Category=Unit]`, so only unit-test projects (tagged `[Category=Unit]` by the `Purview.BuildSdk`) are executed; integration tests (which require Docker/Testcontainers) run locally via `just test`. The PR workflow does not tag, release, or publish packages. ## Versioning model `package.json` is the authoritative release version source. The release workflow reads: ```bash bun -p "require('./package.json').version" ``` This flow assumes version prep already happened before release (for example with `@changesets/cli` versioning and changelog updates merged to `main`). The release pipeline does not invent or auto-bump versions. ## Release on push to main `release.yml` triggers on push to `main` and delegates to the shared `purview-release.yml` workflow with `release-mode: NuGet`. The shared workflow: 1. Reads `package.json` `version` and computes the `v` tag. 2. Skips the entire release if `v` already exists (so re-merging to `main`, or merging `main` into a `release` branch, releases exactly once). 3. Restores, builds, lints, runs unit tests, packs, and validates packages. 4. Pushes every `.nupkg` to nuget.org (`--skip-duplicate`). 5. Creates the `v` GitHub release with generated release notes and attaches the package artifacts. A release is therefore produced simply by bumping `package.json` (via changesets) and merging to `main`. Do not create release tags manually. ## Prerelease support Prerelease versions (any SemVer containing a hyphen, for example `1.0.0-prerelease.28`) release through the same push-to-`main` flow. The `v` tag and GitHub release are still created and packages published; the shared pipeline does not mark the GitHub release with the prerelease flag. ## NuGet publishing NuGet publishing uses the shared workflow's API-key path with the organization `NUGET__APIKEY` secret (available through `secrets: inherit`). The pipeline also accepts `NUGET_APIKEY`. No long-lived repository-level API key secrets are required. To use NuGet Trusted Publishing (OIDC) instead, the consuming repository would need to mint the federated credential before the shared pipeline runs; the shared workflow itself does not perform the `NuGet/login` step. ## Shared pipeline configuration `purview-build.json` at the repository root drives the pipeline: | Key | Value | Purpose | | --- | --- | --- | | `Build:Solution` | `src/ResourceKit.slnx` | Solution passed to restore/build/pack | | `Build:TestRoot` | `src/tests` | Test project discovery root | | `Build:TestPatterns` | `*Tests.csproj` | Test projects discovered for the test step | | `Build:TestFilter` | `/*/*/*/*[Category=Unit]` | TUnit tree-node filter (unit-only) | | `PackValidation:RequireSymbolPackage` | `true` | Every `.nupkg` needs a matching `.snupkg` | | `PackValidation:RequireSymbolFiles` | `true` | Every `.snupkg` must contain PDBs | | `PackValidation:RequiredContent` | Expected package contents | Asserts the package ships its expected output — `lib/net8.0|net9.0|net10.0` runtime assemblies + XML docs, the analyzer assembly, `buildTransitive/Purview.Aspire.ResourceKit.props`, `README.md`, and `purview-logo-light.png` | | `Release:Mode` | `None` | Publishing is enabled only by the release workflow | Configuration precedence is command line, environment variables, `purview-build.json`, then the tool's built-in defaults. Nested environment keys use `__`, for example `Release__Mode=NuGet`. # Source generator behaviors > The source generator ships alongside the runtime package and is exposed to consuming projects as an analyzer. This page covers how the generator, analyzers,… # Source generator behaviors The source generator ships alongside the runtime package and is exposed to consuming projects as an analyzer. This page covers how the generator, analyzers, and code fixes are organized and how they behave. ## Assembly layout | Assembly | Contents | | --- | --- | | `Purview.Aspire.ResourceKit` | Runtime abstractions (`HostKitBase`, `ResourceKitBase`, `OptionsHelper`, interfaces) | | `Purview.Aspire.ResourceKit.SourceGeneration` | The `HostKitGenerator` incremental generator, `ResourceKitDiagnosticAnalyzer`, `OptionsHelperAssignAnalyzer`, `ResourceKitDiagnosticSuppressor`, and the shared rule set | | `Purview.Aspire.ResourceKit.SourceGeneration.CodeFixes` | The `OptionsHelperAssignCodeFixProvider` (separate assembly so the main generator assembly never needs `Microsoft.CodeAnalysis.Workspaces`) | The generator and code-fix assemblies are packed under `analyzers/dotnet/cs` of the `Purview.Aspire.ResourceKit` package, so consumers get generation, diagnostics, and IDE code fixes from a single package reference. ## Incremental generation `HostKitGenerator` is an `IIncrementalGenerator`. It: 1. Registers the embedded attributes (`HostKitAttribute`, `ResourceDefinitionAttribute`) as post-initialization output. 2. Runs a set of incremental value providers over the compilation. 3. Emits a single hint-name file per host kit that contains the host kit, resource kit skeletons, options, the typed `ResourceKitBase`, and the AppHost extension method. Generation is skipped when the generator is disabled via settings (`IsSourceGeneratorDisabled`). ## Shared rule set `ResourceKitRules` is the single source of truth for model rules. Both the `ResourceKitDiagnosticAnalyzer` (which reports diagnostics in the IDE/build) and the generator (which uses the same rules to decide whether a kit should be generated) evaluate through this shared helper so the two never drift apart. - **Analyzer-owned rules** are reported only by the analyzer; the generator computes them for its `ShouldProcess` gating but does not re-report them (avoiding duplicate diagnostics). - **Generation-blocking rules** (Error severity, SG0001–SG0016) halt generation via the `GeneratorResult.ShouldProcess` gate. - **Execution-only rules** (SG0017–SG0019, warnings) never block generation, so a resource kit with incomplete `BuildResource`/`ConfigureResource` wiring is still generated and the host kit output is still emitted. See [Diagnostics](../diagnostics/) for the full rule reference. ## `OptionsHelperAssignAnalyzer` (SG0020) A dedicated analyzer reports SG0020 when an `OptionsHelper.Assign` (or chained `IOptionsBuilder.Assign`) action is a block-bodied lambda that assigns more than one property path. It matches `Assign` calls by method name, containing namespace, and the `params Action[]` parameter shape — including the `sectionName` overload. See [OptionsHelper](../optionshelper/). ## IDE code fix: "Split into separate assignments" The `OptionsHelperAssignCodeFixProvider` fixes SG0020 by rewriting a block-bodied lambda that assigns several properties into one assignment argument per property: ```csharp // Before (SG0020) OptionsHelper.Assign(o => { o.API.IsEnabled = false; o.API.Name = "api-test"; }); // After OptionsHelper.Assign( o => o.API.IsEnabled = false, o => o.API.Name = "api-test" ); ``` The fix lives in a separate code-fix assembly and uses a stable equivalence key. The generator assembly never acquires a `Microsoft.CodeAnalysis.Workspaces` dependency; the compiler loads the code-fix assembly without instantiating its Workspaces-dependent types, and the IDE activates them for fixes. ## `ResourceKitDiagnosticSuppressor` (SGSUP0001) The suppressor removes `CS8618` ("Non-nullable property must contain a non-null value when exiting constructor") for **non-nullable** `IResourceBuilder` properties declared on resource kits. These properties are populated at runtime during `BuildResource`/`ConfigureResource`, so the warning does not apply. Nullable `IResourceBuilder?` properties are left untouched. A type qualifies as a resource kit when it carries `[ResourceDefinition]`/`[ResourceDefinition]` or derives from `Purview.Aspire.ResourceKit.ResourceKitBase<,>` (directly or via the generated `ResourceKitBase`). ## Incremental caching The generator uses incremental value providers and equatable models so that unrelated edits do not re-trigger generation. The `GeneratorCachingTests` integration suite verifies that generation is cached across incremental runs and only recomputed when inputs change. ## See also - [Generated Output](../generated-output/) - [Diagnostics](../diagnostics/) - [Contributing](../contributing/) # Testing with ResourceKit > ResourceKit is designed to be test-friendly: resource composition is split into focused classes, and generated options give you a typed way to override names… # Testing with ResourceKit ResourceKit is designed to be test-friendly: resource composition is split into focused classes, and generated options give you a typed way to override names and enablement from tests. ## Test project layout The repository uses [TUnit](https://thomhurst.github.io/TUnit/) with the [TUnit.Aspire](https://www.nuget.org/packages/TUnit.Aspire/) integration. Test projects live under `src/tests`: - `ResourceKit.UnitTests` — runtime behavior of the kit base types and `OptionsHelper`. - `ResourceKit.IntegrationTests` — starts the example AppHosts and asserts against real resources. - `SourceGeneration.UnitTests` / `SourceGeneration.IntegrationTests` — generator output, diagnostics, suppression, code fixes, and caching. Reports are written to `TestResults/`. ## Integration-testing an AppHost Create an `AspireFixture` for the AppHost under test: ```csharp using TUnit.Aspire; namespace Purview.Aspire.ResourceKit.Fixtures; public sealed class ExampleAppHostFixture : AspireFixture where TAppHost : class; ``` Then drive it from a TUnit test class: ```csharp using Projects; [ClassDataSource>(Shared = SharedType.PerTestSession)] public sealed class ExampleAppHostIntegrationTests(ExampleAppHostFixture fixture) { [Test] public async Task AppHost_WhenServicesStarted_APIIsHealthy(CancellationToken cancellationToken) { var client = fixture.CreateHttpClient("api"); var response = await client.GetAsync(new Uri("/health", UriKind.Relative), cancellationToken); await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); } } ``` `TUnit.Aspire` wires the AppHost lifecycle (start/stop) around the fixture. The example API exposes a `/health` endpoint for this purpose. ## Overriding options from tests The generated extension method binds host options from configuration, so you can pass command-line arguments through the fixture to toggle resources. `OptionsHelper` generates these arguments from strongly typed assignments: ```csharp using TUnit.Aspire; public sealed class CustomOptionsExampleAppHostFixture : AspireFixture { public const string AzureStorageName = "custom-options-azure-storage-example"; protected override string[] Args => [ "--ExampleHostKit:Redis:IsEnabled=false", $"--ExampleHostKit:AzureStorage:Name={AzureStorageName}", ]; } ``` The test then asserts the options took effect — Redis is disabled (no connection string), and Azure Storage is registered under the custom name: ```csharp [Test] public async Task AppHost_WithCustomOptions_IsPassedToTheHostKit(CancellationToken cancellationToken) { await Helpers.ConnectionStringIsUnavailableAsync(fixture, "redis", cancellationToken); await Assert .That(fixture.GetResourceSnapshot(CustomOptionsExampleAppHostFixture.AzureStorageName)) .IsNotNull() .Because("The custom Azure Storage name should be passed to the host kit."); } ``` Prefer `OptionsHelper.Assign(...)` for typed, refactor-safe args: ```csharp protected override string[] Args => [ .. base.Args, .. OptionsHelper.Assign( c => c.Redis.IsEnabled = false, c => c.AzureStorage.Name = "custom-options-azure-storage-example" ).Build(), ]; ``` See [OptionsHelper](../optionshelper/) for `Assign`, `AsEnvironmentVariables`, and the `SG0020` single-property-path rule. ## Unit-testing the manual pattern The manual host pattern (see [Examples](../examples/)) composes `ResourceKitBase` without generation, which makes the lifecycle directly unit-testable: instantiate a kit, call `Build(builder)`/`Configure()`, and assert on `ResourceBuilder` and option-driven behavior. ## Running tests Unit tests are filtered with the TUnit tree-node filter: ```bash just test "/*/*/*/*[Category=Unit]" ``` Integration tests (which require Docker/Testcontainers) run locally: ```bash just test ``` See [Contributing](../contributing/) and [Release Flow](../release-flow/) for the CI filter and local workflow. # AspireC4 > This wiki is the project documentation hub for AspireC4.Hosting, an Aspirehttps://learn.microsoft.com/en-us/dotnet/aspire/ extension library that generates… # AspireC4 Wiki This wiki is the project documentation hub for `AspireC4.Hosting`, an [Aspire](https://learn.microsoft.com/en-us/dotnet/aspire/) extension library that generates live [LikeC4](https://likec4.dev) architecture diagrams from the Aspire resource graph. AspireC4 turns your distributed application into a living architecture diagram. On startup it builds a `.c4` model file from the Aspire resource graph, starts the official LikeC4 server as a sidecar, and keeps the diagram refreshed as resources start, stop, and change state at runtime. ## Start here - [Getting Started](getting-started/) - [Configuration](configuration/) - [Customizing Resources](customizing-resources/) - [TypeScript AppHosts](typescript-apphost/) - [Generated Output](generated-output/) - [Dashboard Integration](dashboard-integration/) - [Local CLI Runtimes](local-cli/) - [Source Generator Validation](source-generator/) - [Advanced Configuration](advanced-configuration/) - [Migration Guide](migration/) - [Contributing](contributing/) - [Release Flow](release-flow/) ## Feature highlights - **Live diagram generation.** `AddAspireC4()` registers a lifecycle hook that generates the `.c4` file before startup and regenerates it (debounced) whenever a resource changes state — so the diagram always reflects the current application. - **Docker or local CLI.** The LikeC4 server runs as the `ghcr.io/likec4/likec4` container by default, or through a local JavaScript package manager CLI (`npx`, `pnpm`, `yarn`, `bun`, or `deno`) with `.WithLocalCLI()`. - **Rich per-resource customization.** `WithLikeC4Details()` controls labels, technologies, descriptions, summaries, icons, kinds, tags, links, and metadata; `WithLikeC4Reference()` customizes relationships; `WithLikeC4Group()` groups resources in the generated view. - **Automatic icons.** Icons are inferred from resource type and name using a bundled manifest, with custom `IconResolvers` evaluated first, and optional GraphViz `dot` assistance. - **Compile-time validation.** A Roslyn source generator validates `.WithTag()`, `.WithKind()`, `.WithLikeC4Group()`, and `.WithMetadata()` values against a `[LikeC4Registry]` class and/or LikeC4 `specification` blocks, with severity controls from suggestions to errors. - **TypeScript AppHost support.** The same features are available to TypeScript AppHosts through the Aspire-generated fluent API. - **Dashboard integration.** The diagram URL is surfaced in the Aspire dashboard, with optional per-resource dashboard links, and support for hiding the sidecar in favour of links on project resources. ## Prerequisites - **.NET 8 or later**. - **Aspire 13.5.3 or later**, with `Aspire.Hosting.AppHost` referenced by the AppHost project. - **Docker** (default LikeC4 server), or a local Node.js CLI runtime if you use `.WithLocalCLI()`. See [Getting Started](getting-started/) for installation and the first run. # Advanced Configuration > Configuration topics beyond the core options in ConfigurationConfiguration.md. # Advanced Configuration Configuration topics beyond the core options in [Configuration](../configuration/). ## Additional DSL files Copy extra user-managed `.c4` files into the output directory (and sync them to the Docker volume in container mode). LikeC4 discovers all `.c4` files in the project directory, so they are included in the diagram without further configuration: ```csharp builder.AddAspireC4(options => options.WithAdditionalDSLFile("likec4/views.c4")); ``` Relative paths are resolved from the current working directory; the file must exist. ## Additional DSL folders Register directories whose `.c4` files are included via the `include.paths` field of the generated `likec4.config.json`. LikeC4 recursively scans each directory: ```csharp builder.AddAspireC4(options => options.WithAdditionalDSLFolder("../../assets/likec4-extensions")); ``` Each entry must be an absolute path to an existing directory (the method validates this at call time). In Docker container mode, each folder is bind-mounted read-only into the container at a deterministic path under `/data/ext/`. ## Image aliases Register a shorthand key (e.g. `@icons`) mapping to a directory of image files. Aliases are written to the `imageAliases` section of the generated `likec4.config.json`: ```csharp builder.AddAspireC4(options => options.WithImageAliasFolder("@", "C:/images")); ``` The key must start with `@` and the directory must exist at call time. In Docker container mode, each image directory is bind-mounted read-only at a deterministic path under `/data/img/`. Use aliases in DSL files, for example `icon @/likec4/likec4-logo.svg`. ## Custom icon resolvers Resolvers are evaluated before built-in auto-icon inference, in registration order; the first non-`null` result wins. Each receives an `IconResolverContext` with: - `Resource` — the visible Aspire resource being rendered. - `HiddenOriginal` — the hidden Azure resource when a local surrogate was created via `RunAsContainer()` (useful for richer type-based icon selection). ```csharp builder.AddAspireC4(options => options.WithIconResolver(ctx => ctx.Resource is MyCustomResource ? "tech:dotnet" : null)); ``` ## Element kind specifications Declare custom element kinds with optional style, notation, and technology in the `specification {}` block. These are additive — kinds listed here but not present in the model are still declared: ```csharp builder.AddAspireC4(options => options.WithElementKindSpec( new LikeC4ElementKindSpec("service") .WithTechnology("HTTP") .WithNotation("API service") .WithStyle(new LikeC4ElementKindStyle( Shape: "queue", Color: "blue", Icon: "tech:dotnet", Border: "dashed", Opacity: 80)) )); ``` `LikeC4ElementKindStyle` accepts `Shape`, `Color`, `Icon`, `Border`, and `Opacity` tokens. ## Relationship kind specifications Declare custom relationship kinds with an optional default technology: ```csharp builder.AddAspireC4(options => options.WithRelationshipKindSpec("async", "AMQP")); ``` When a relationship kind in the model matches an entry with a technology, the full body is emitted rather than a bare `relationship KIND` line. ## Relationship kind syntax `RelationshipKindSyntax` controls how typed relationships are emitted: - `Dot` (default): `SOURCE .KIND TARGET` - `Bracket`: `SOURCE -[KIND]-> TARGET` ```csharp builder.AddAspireC4(options => options.WithRelationshipKindSyntax(LikeC4RelationshipKindSyntax.Bracket)); ``` ## Metadata key normalization `NormaliseMetadataBehaviour` controls how invalid characters in metadata keys are handled. Valid LikeC4 metadata key characters are letters, digits, hyphens, and underscores: - `Normalise` (default): replace any other character with `_` (`"Azure SKU"` → `"Azure_SKU"`). - `NormaliseLowercase`: same, but also lowercases (`"Azure SKU"` → `"azure_sku"`). - `Throw`: throw an `ArgumentException` for invalid keys. ## Aspire metadata inclusion `AutoIncludeAspireMetadata` (`WithAutoIncludeAspireMetadata`) controls which Aspire runtime metadata is injected into generated elements: - `None` — no automatic metadata. - `Metadata` — `aspire-name` and `aspire-type` entries. - `Links` — allocated HTTP/HTTPS endpoint URLs as element links. - `All` (default) — both. ## Config file generation `GenerateConfigFile` (default `true`) produces `likec4.config.json` in the output directory with the project title, `include.paths`, and `imageAliases`. Disable with `WithoutConfigFileGeneration()` to manage the config manually. When generation is enabled, `ConfigFileMetadata` adds extra key/value pairs to the config's `metadata` section. ## Type-based exclusions `ExcludedResourceTypes` controls which resource types are omitted from the diagram (type and subclasses). The default excludes `ParameterResource`. See [Customizing Resources](../customizing-resources/). ## Formatting timeouts `ExternalProcessTimeoutSeconds` (default 30) caps how long the optional `npx likec4 format` step can block startup. See [Generated Output](../generated-output/). ## Including the internal resource `WithIncludeAspireC4InternalResource(true)` includes the AspireC4 server sidecar in the diagram, which exists purely for debugging/monitoring. It is excluded by default. ## Next pages - [Configuration](../configuration/) - [Generated Output](../generated-output/) # Configuration > Configure the diagram through AspireC4DiagramOptions. Pass a callback to AddAspireC4: # Configuration Configure the diagram through `AspireC4DiagramOptions`. Pass a callback to `AddAspireC4`: ```csharp builder.AddAspireC4(options => options .WithTitle("My App") .WithAutoIcons(false) .WithHideFromDashboard("Architecture") ); ``` :::tip Options can also be bound from configuration. The section name is `AspireC4`, so `appsettings.json` entries such as `"AspireC4": { "ViewTitle": "Architecture" }` (or matching environment variables) are applied on top of the builder-time snapshot. ::: ## Options reference | Property | Default | Description | | --- | --- | --- | | `GeneratedViewId` | `null` (`index`) | LikeC4 view ID emitted in the generated `.c4` file (e.g. `view index { ... }`). Change it if the ID conflicts with a hand-authored view. | | `DefaultViewId` | `"index"` | View ID used in the `/view/{id}` URL the Aspire dashboard links to. `null`/empty links to the server root instead. | | `Title` | `null` | Title shown in the LikeC4 application. | | `ViewTitle` | `"Architecture"` | Title shown in the generated view. | | `ViewDescription` | `null` | Optional view description (Markdown supported by recent LikeC4 versions). | | `OutputDirectory` | `"./likec4/gen/"` | Directory where the generated `.c4` file is written. | | `FileName` | `"model.gen"` | Generated file name without extension. | | `DisableHMR` | `false` | Disable the Hot Module Replacement channel. | | `HMRPort` | `null` (dynamic) | Fixed HMR port when the LikeC4 server supports configurable ports (v1.57+); ignored on older versions, which always use port `24678`. | | `ContainerImageTag` | `null` (`latest`) | Pin the `ghcr.io/likec4/likec4` image tag (ignored with `.WithLocalCLI()`). | | `CheckLatestImageVersion` | `true` | When using the `latest` tag, run a throwaway container at startup to resolve the actual version so version-gated features (e.g. HMR port mode) are configured correctly. | | `AutoIconsEnabled` | `true` | Infer LikeC4 icons from resource type and name. | | `HideFromDashboard` | `false` | Hide the sidecar from the dashboard and surface the diagram as a link/command on project resources. | | `DashboardLinkDisplayName` | `"Architecture Diagram"` | Display name for the diagram link/command when hidden from the dashboard. | | `RelationshipKindSyntax` | `Dot` | DSL syntax for typed relationships: `Dot` (`SOURCE .KIND TARGET`) or `Bracket` (`SOURCE -[KIND]-> TARGET`). | | `FormatGeneratedFile` | `false` | Run `npx likec4 format --files ` after writing the generated file. Failures are ignored. | | `ExternalProcessTimeoutSeconds` | `30` | Max seconds to wait for the external formatter process before killing it. | | `UseDotIfAvailable` | `true` | Use GraphViz' `dot` executable for more accurate icon inference when on `PATH`. | | `ElementKindSpecs` | `[]` | Custom element kind specifications emitted in the `specification {}` block (style, notation, technology). | | `RelationshipKindSpecs` | `[]` | Custom relationship kind specifications emitted in the `specification {}` block (technology). | | `AutoIncludeAspireMetadata` | `All` | Which Aspire metadata is auto-injected: `None`, `Metadata` (`aspire-name`, `aspire-type`), `Links` (endpoint URLs), or `All`. | | `NormaliseMetadataBehaviour` | `Normalise` | How invalid characters in metadata keys are handled: `Normalise`, `NormaliseLowercase`, or `Throw`. | | `AdditionalDSLFiles` | `[]` | Extra user-managed `.c4` files copied into the output directory and synced to the container volume. | | `AdditionalDSLFolders` | `[]` | Directories scanned recursively for `.c4` files, added to `include.paths` in the generated config. | | `ImageAliases` | `{}` | Image alias definitions (keys start with `@`) written to the `imageAliases` section of the generated config. | | `GenerateConfigFile` | `true` | Generate a `likec4.config.json` in the output directory. | | `IncludeAspireDashboardLinks` | `true` | Add links from each element to the Aspire dashboard console/structured-logs pages (requires `AutoIncludeAspireMetadata.Links`). | | `IncludeAspireTokenInDashboardLinks` | `false` | **Security risk** — embed the Aspire browser token in dashboard links. See below. | | `StateTagMap` | `{}` | Override the `aspire-run-state-*` tag applied for a given resource state; `null` suppresses the tag. | | `IncludeDefaultStateStyles` | `true` | Emit default `style element.tag = #aspire-run-state-* {}` rules in the generated view. | | `IncludeAspireC4InternalResource` | `false` | Include the internal AspireC4 server resource in the diagram (for debugging). | | `ExcludedResourceTypes` | `{ParameterResource}` | Resource types excluded from the diagram (type and subclasses). | | `IconResolvers` | `[]` | Custom icon resolvers evaluated before built-in icon inference. | | `ConfigFileMetadata` | `{}` | Additional metadata included in the generated `likec4.config.json`. | ## Fluent methods Every property has a corresponding fluent `With*` method, for example: - `WithGeneratedViewId(string?)`, `WithDefaultViewId(string?)` - `WithTitle(string?)`, `WithViewTitle(string)`, `WithViewDescription(string?)` - `WithOutputDirectory(string)`, `WithFileName(string)` - `WithHMRDisabled(bool = true)` - `WithContainerImageTag(string?)`, `WithCheckLatestImageVersion(bool = true)` - `WithAutoIcons(bool = true)` - `WithHideFromDashboard(string displayName = "Architecture Diagram")` - `WithRelationshipKindSyntax(LikeC4RelationshipKindSyntax)` - `WithFormatGeneratedFile(bool = true)` - `WithAutoIncludeAspireMetadata(AspireMetadataInclusion)` - `WithNormaliseMetadataBehaviour(NormaliseMetadataBehaviour)` - `WithoutConfigFileGeneration()` - `WithAspireDashboardLinks(bool = true)`, `WithAspireTokenInDashboardLinks(bool = true)` - `WithDefaultStateStyles(bool = true)`, `WithStateTag(string state, string? tag)` - `WithUseDotIfAvailable(bool)` - `WithIconResolver(Func)` - `WithElementKindSpec(LikeC4ElementKindSpec)`, `WithRelationshipKindSpec(...)` - `WithAdditionalDSLFile(string)`, `WithAdditionalDSLFolder(string)`, `WithImageAliasFolder(string, string)` - `WithExcludedResourceType()`, `WithoutExcludedResourceType()` - `WithIncludeAspireC4InternalResource(bool)` See [Advanced Configuration](../advanced-configuration/) for the folder, alias, resolver, and spec extensions, and [Source Generator Validation](../source-generator/) for the `AspireC4Strict` MSBuild property. ## Common examples ### Hide the sidecar from the dashboard ```csharp builder.AddAspireC4(options => options.WithHideFromDashboard()); ``` ### Disable hot reload ```csharp builder.AddAspireC4(options => options.WithHMRDisabled()); ``` ### Pin the LikeC4 container version ```csharp builder.AddAspireC4(options => options.WithContainerImageTag("1.57")); ``` ### Security note — dashboard tokens in links `IncludeAspireTokenInDashboardLinks` embeds the Aspire browser token in generated dashboard links. Only enable this if you understand the implications: the token grants the same access as a browser session and is written into the diagram file, which may be shared or stored in source control. Keep it disabled for normal development, and consider excluding the generated file from source control if you enable it. # Contributing > Thank you for contributing! This guide covers the tools, conventions, and processes used in the repository. # Contributing Thank you for contributing! This guide covers the tools, conventions, and processes used in the repository. ## Prerequisites | Tool | Purpose | |---|---| | [.NET SDK](https://dotnet.microsoft.com/download) (version from `global.json`) | Build and test | | [Bun](https://bun.sh/) (version from `package.json` → `packageManager`) | Repository scripts and commit hooks | | [just](https://just.systems/man/en/packages.html) | Task runner | | [Lefthook](https://github.com/evilmartians/lefthook) | Git hooks | | [Docker](https://www.docker.com/) | Integration tests, local diagram viewer | After cloning, install all dependencies: ```sh just init # JS dependencies (Bun), NuGet packages, local tools, and Git hooks ``` ## Getting started ```sh just build # Build the solution (Debug by default; pass Release to build Release) just test # Run all tests (unit + integration) just lint-check # Check formatting ``` ## Branding Two distinct brands exist in this repository. Use them consistently: | Brand | What it is | Examples | |---|---|---| | **AspireC4** | This library / plugin | `AspireC4.Hosting` NuGet package, `AspireC4DiagramOptions`, `IAspireC4Builder`, `AddAspireC4()` | | **LikeC4** | The third-party visualisation tool this library integrates | `ghcr.io/likec4/likec4` container, `LikeC4Model`, `LikeC4DslGenerator`, `.c4` file format | **Rules:** - Public extension methods and user-facing types use the `AspireC4` prefix. - Types that directly represent LikeC4 DSL concepts keep the `LikeC4` prefix. - Never use `LikeC4` to refer to this library, and never use `AspireC4` to refer to the third-party tool. ## Just — task runner `just` is the single entry point for all development tasks. Run `just` with no arguments to list all recipes. ### .NET | Recipe | Description | |---|---| | `just restore` | Restore NuGet packages and local .NET tools | | `just build [Debug\|Release]` | Build the solution (default: `Debug`) | | `just clean` | Clean build outputs | | `just test` | **Run all tests** (unit + integration) | | `just test-unit` | Run unit tests only | | `just test-integration` | Run integration tests only | | `just lint-check` | Check formatting with CSharpier | | `just lint-fix` | Auto-fix formatting with CSharpier | | `just pack` | Build and pack NuGet artifacts into `artifacts/nuget/` | ### Container runtime tests (local only) | Recipe | Description | |---|---| | `just test-e2e-docker` | Integration tests against the host Docker daemon | | `just test-e2e` | Docker + all local CLI runtimes (npm, pnpm, yarn, bun, deno) | | `just test-e2e-cli` | All local CLI runtimes only (npm, pnpm, yarn, bun, deno) | | `just test-e2e-npm` | Single CLI runtime (also `-pnpm`, `-yarn`, `-bun`, `-deno`) | ### Diagrams | Recipe | Description | |---|---| | `just diagrams` | Open the live LikeC4 diagram viewer for this repository | ### Filtering a single test ```sh dotnet test src/tests/AspireC4.UnitTests/AspireC4.UnitTests.csproj \ -- --filter "FullyQualifiedName~MyTestMethod" dotnet test src/tests/AspireC4.IntegrationTests/AspireC4.IntegrationTests.csproj \ -- --filter "FullyQualifiedName~MyTestMethod" ``` ## Code style — CSharpier All C# code is formatted with [CSharpier](https://csharpier.com/), pinned to the version in `.config/dotnet-tools.json`. It is installed as a local .NET tool via `just restore`. ```sh just lint-check # Report formatting violations just lint-fix # Auto-fix formatting violations ``` CSharpier runs automatically on every `git commit` via Lefthook. **Do not pin a specific CSharpier version in `.csproj` files** — the version lives exclusively in `.config/dotnet-tools.json`. ## Git hooks — Lefthook [Lefthook](https://github.com/evilmartians/lefthook) manages two hooks, configured in `.config/lefthook.yml`: | Hook | What it does | |---|---| | `pre-commit` | Runs `just lint-check` (CSharpier over the repo root). Rejects the commit if any file is mis-formatted. | | `commit-msg` | Runs `commitlint` to enforce conventional commit format. | Lefthook installs when you run `just init`. To verify it is active: ```sh bunx lefthook install ``` To bypass a hook temporarily (e.g. a work-in-progress commit you will amend): ```sh git commit --no-verify -m "wip: ..." ``` Do not bypass hooks on commits intended for `main`. ## Commit messages Commit messages must follow [Conventional Commits](https://www.conventionalcommits.org/) and are enforced by `commitlint` (via Lefthook). **Format:** ``` (): ``` **Allowed types:** `feat`, `fix`, `refactor`, `perf`, `test`, `docs`, `ci`, `build`, `chore`, `style`, `revert`. **Rules:** - Subject must be lower-case, no trailing period, max 100 characters. - Body lines max 100 characters. - Breaking changes: append `!` after the type/scope, or add `BREAKING CHANGE:` in the footer. ```sh # Good feat(core): add image alias resolution for azure resources fix: correct hmr port fallback on windows chore(deps): bump aspire.hosting to 9.2.0 # Bad — upper-case subject, trailing period Fix: Correct HMR port fallback on Windows. ``` ## Tests All tests in this repository **must use [TUnit](https://github.com/thomhurst/TUnit)**. Do not use xUnit, NUnit, or MSTest. Test projects declare just ``; the SDK (Purview.BuildSdk) wires TUnit, TUnit.Mocks, and Bogus into them automatically. ```csharp [Test] public async Task Something_Should_DoX() { // Arrange // Act // Assert await Assert.That(result).IsEqualTo(expected); } ``` Mocking uses TUnit.Mocks (`.Returns(...)` API); NSubstitute is not referenced. ### Project structure | Project | What to test here | |---|---| | `AspireC4.UnitTests` | `LikeC4ModelBuilder`, `LikeC4DslGenerator`, annotations, options — no Docker required | | `AspireC4.IntegrationTests` | Full Aspire lifecycle: container startup, file generation, endpoint availability | Integration tests require Docker to be running. They pull `ghcr.io/likec4/likec4` on first run. ## See also - [Release Flow](../release-flow/) # Customizing Resources > AspireC4 annotates resources in the Aspire app model, so each resource and relationship can be customized before the .c4 file is generated. # Customizing Resources AspireC4 annotates resources in the Aspire app model, so each resource and relationship can be customized before the `.c4` file is generated. ## Element details — `WithLikeC4Details` Customizes how a resource appears as a node in the diagram: ```csharp builder.AddProject("api") .WithLikeC4Details(details => details .WithLabel("Public API") .WithTechnology(".NET") .WithDescription("The public HTTP API surface.") .WithSummary("Handles client requests") .WithIcon("tech:dotnet") .WithKind("service") .WithTag("backend") .WithLink("https://example.com/docs", "API docs") .WithMetadata("Owner", "Platform Team") ); ``` ### Available node options | Method | Purpose | | --- | --- | | `WithLabel(string)` | Display label on the element node. | | `WithTechnology(string?)` | Technology string shown beneath the label (e.g. `.NET`, `Redis`). | | `WithDescription(string?)` | Longer description rendered in the detail panel (Markdown). | | `WithSummary(string?)` | One-line summary shown in tooltips/map view. | | `WithIcon(string?)` | Icon identifier (e.g. `tech:dotnet`, `azure:storage`); `null` reverts to automatic inference. | | `WithAutoIcon(bool?)` | Per-element override for auto-icon inference (`null` inherits the project setting). | | `WithKind(string?)` | Element kind override (e.g. `service`); must be a valid LikeC4 identifier. | | `WithTag(string)` | Adds a tag; a leading `#` is accepted and stripped. | | `WithLink(string \| Uri, string? title)` | Adds a hyperlink (absolute, or relative to the `.c4` file). | | `WithMetadata(string key, string value)` | Adds a metadata key/value pair. | ## Relationships — `WithLikeC4Reference` Customizes how the relationship from a resource to a target appears in the diagram. There are two overloads: ```csharp // Target is any resource builder. builder.AddNodeApp("app", ...) .WithLikeC4Reference(redis, opts => opts.WithLabel("Caches sessions").WithTechnology("Redis Protocol").WithKind("RESP")); // Target is a resource that exposes a connection string — also calls Aspire's WithReference. builder.AddProject("api") .WithLikeC4Reference(db, opts => opts.WithLabel("Persists data"), connectionName: "postgres"); ``` :::note The connection-string overload (`IResourceWithConnectionString`) additionally wires up Aspire's `WithReference`, so the connection string is available to the consumer. Pass `skipAspireReference: true` to opt out. ::: ### Available relationship options | Method | Purpose | | --- | --- | | `WithLabel(string)` | Short label on the relationship arrow. | | `WithTechnology(string?)` | Technology/protocol (e.g. `HTTP/2`, `gRPC`, `AMQP`). | | `WithDescription(string?)` | Longer relationship description. | | `WithKind(string?)` | Typed relationship kind (e.g. `async`, `sync`), declared in the `specification` block and emitted with the configured `RelationshipKindSyntax`. | | `WithTag(string)` | Adds a tag to the relationship. | | `WithLink(string \| Uri, string? title)` | Adds a hyperlink to the relationship. | | `WithMetadata(string key, string value)` | Adds a metadata key/value pair. | | `WithNavigateTo(string viewId)` | LikeC4 dynamic view to navigate to when the relationship is clicked. | Attach multiple annotations to the same source — one per target — to customize each relationship independently. ## Groups — `WithLikeC4Group` Assigns a resource to a named group. Resources sharing a group are emitted inside a `group 'label' { include ... }` block in the generated view: ```csharp builder.AddRedis("cache").WithLikeC4Group("Platform"); ``` ## Excluding resources — `ExcludeFromLikeC4` ```csharp builder.AddRedis("cache").ExcludeFromLikeC4(); ``` The AspireC4 sidecar itself is always excluded from the diagram. Set `WithIncludeAspireC4InternalResource(true)` in the options callback if you want to inspect it. ### Type-based exclusion By default `ParameterResource` (passwords/secrets added via `AddParameter()`/`WithParameter()`) is excluded. Add or remove types globally: ```csharp builder.AddAspireC4(options => options .WithExcludedResourceType() .WithoutExcludedResourceType()); ``` A resource is excluded when its runtime type is the same as, or a subclass of, any type in the set. ## Aspire metadata injection When `AutoIncludeAspireMetadata` is `All` (the default), each element automatically receives `aspire-name`/`aspire-type` metadata and links to its allocated HTTP/HTTPS endpoints. Endpoint URLs come from resource snapshots so they use the correct public ports. ## Next pages - [Generated Output](../generated-output/) - [Advanced Configuration](../advanced-configuration/) - [Source Generator Validation](../source-generator/) # Dashboard Integration > AspireC4 integrates with the Aspire dashboard so the diagram is easy to reach and links back to resource telemetry. # Dashboard Integration AspireC4 integrates with the Aspire dashboard so the diagram is easy to reach and links back to resource telemetry. ## The sidecar resource `AddAspireC4()` creates an `aspirec4` resource (name configurable via the `name` parameter) of container type. Its state, URLs, and properties are forwarded from the inner LikeC4 server resource, which is always kept hidden. The dashboard shows: - A **View LikeC4 Diagram** URL on the resource, opening `/view/index` (or the `DefaultViewId`). - The **LikeC4 Version** property, resolved from the pinned tag or the startup version check. - The server's console logs, relayed onto the outer resource's Console tab. ## Hiding the sidecar When `HideFromDashboard` is set (via `WithHideFromDashboard(displayName)`), the `aspirec4` resource is also hidden and the diagram is instead surfaced on every **project** resource as: - A **link** (`architecture-diagram`) injected into the project resource's URLs once the server is running. - A **command** (`likec4-architecture-diagram`) that opens the diagram. The command is disabled until the LikeC4 server is `Running`. `DashboardLinkDisplayName` controls the label shown (default `Architecture Diagram`). ## Dashboard links on diagram elements `IncludeAspireDashboardLinks` (default `true`) adds links from each LikeC4 element back to the Aspire dashboard's console logs and structured logs pages for that resource. This requires `AutoIncludeAspireMetadata` to include `Links` (the default `All` includes it). The links are constructed at runtime once the dashboard URL is discovered. ### Security: browser tokens in links `IncludeAspireTokenInDashboardLinks` (default `false`) embeds the Aspire browser token in those dashboard links so the browser is authenticated when the link is clicked (`/login?t=…&returnUrl=…`). :::caution The token grants the same access as a browser session and is written into the generated diagram file, which may be shared or stored in an insecure location/source control. Only enable it if you understand the implications. Consider excluding the generated `.c4` file from source control and sharing it only over secure channels. For normal development, keep it disabled and navigate to the dashboard manually. ::: ## View selection The dashboard link opens `/view/{DefaultViewId}` (`index` by default — the ID of the auto-generated view). Set `DefaultViewId` to `null`/empty to link to the server root instead, which is useful when you prefer the LikeC4 server's own landing page. If you change `GeneratedViewId` in the generated file, set `DefaultViewId` to the same value so the dashboard link still opens the correct diagram. ## Hot Module Replacement HMR keeps the diagram up to date in the browser as the file regenerates. The HMR endpoint is exposed in the dashboard (details view) and, on Windows/Docker Desktop, `CHOKIDAR_USEPOLLING`/`CHOKIDAR_INTERVAL` environment variables are set on the container so file changes are detected via polling. Disable HMR with `WithHMRDisabled()`. See [Configuration](../configuration/) for `HMRPort` behavior across LikeC4 versions. ## Next pages - [Generated Output](../generated-output/) - [Configuration](../configuration/) # Generated Output > AspireC4 writes a LikeC4 project into the output directory ./likec4/gen/ by default and serves it with the LikeC4 server. # Generated Output AspireC4 writes a LikeC4 project into the output directory (`./likec4/gen/` by default) and serves it with the LikeC4 server. ## Files | File | Purpose | | --- | --- | | `model.gen.c4` | The generated model — see below. | | `likec4.config.json` | LikeC4 project configuration (`name`, `title`, `include.paths`, `imageAliases`, `metadata`). Generated when `GenerateConfigFile` is `true`. | | Additional `.c4` files | Any files registered through `WithAdditionalDSLFile` are copied here; LikeC4 discovers all `.c4` files in the project directory automatically. | ## The generated `.c4` file The file starts with an auto-generated header and is split into `specification {}`, `model {}`, and `views {}` blocks: - **`specification {}`** declares every element kind, relationship kind, and tag used in the model, plus any specs from `ElementKindSpecs`/`RelationshipKindSpecs`. When `IncludeDefaultStateStyles` is enabled, all known state tags are declared up-front so the block is stable regardless of the current resource states. - **`model {}`** emits each element as ` = 'label' { ... }` with tags, technology, summary, description, icon, links, and metadata, plus relationships using the configured `RelationshipKindSyntax` (`SOURCE .KIND TARGET` or `SOURCE -[KIND]-> TARGET`). - **`views {}`** emits the `index` view (or the `GeneratedViewId`) with its title/description, `group 'label' { include ... }` blocks, `include *`, and `style element.tag = #aspire-run-state-* {}` rules for the built-in state styles. :::caution The file is auto-generated. Do not edit it manually — changes are overwritten on the next regeneration (at startup or on a resource state change). ::: ## Regeneration behavior - The file is written **before** the application starts. - At runtime, resource state changes trigger a **debounced** regeneration (300 ms), so the diagram reflects the current state of each resource. - Writes are skipped when the generated content is unchanged, avoiding needless file churn and git noise. - In publish mode (`aspire publish`), the file is generated once and the server is not started. ## Formatting When `FormatGeneratedFile` is `true`, AspireC4 runs `npx likec4 format --files ` against the generated file immediately after writing it. The formatter modifies the file in place so the on-disk copy is human-readable; the formatted content is also what is synced to the container workspace. Failures are silently ignored. `ExternalProcessTimeoutSeconds` caps how long this can block startup (default 30 s). ## Config file `likec4.config.json` uses the LikeC4 `$schema` and includes: - `name` — the LikeC4 project name (unique within the workspace). - `title` — when `Title` is set. - `include.paths` — for each `AdditionalDSLFolders` entry (LikeC4 recursively scans the folder for `.c4` files). - `imageAliases` — for each `ImageAliases` entry (keys start with `@`). - `metadata` — for each `ConfigFileMetadata` entry. Set `WithoutConfigFileGeneration()` (or `GenerateConfigFile = false`) to manage `likec4.config.json` manually, for example when the output directory is already part of a hand-curated LikeC4 project. ## Icons - **Auto icons** (`AutoIconsEnabled`, default `true`): icons are inferred from the resource type and name using the bundled icon manifest. - **Custom resolvers** (`IconResolvers`): evaluated in registration order before built-in inference; the first non-`null` result wins. Each resolver receives an `IconResolverContext` exposing the visible `Resource` and the `HiddenOriginal` Azure resource (when a local surrogate was created via `RunAsContainer()`). - **GraphViz `dot`** (`UseDotIfAvailable`, default `true`): when `dot` is on `PATH`, LikeC4 uses it for more accurate inference (e.g. database icons) and can infer icons from container/component relationships. ## Version detection When the container image tag resolves to `latest` (default), `CheckLatestImageVersion` (default `true`) runs a throwaway container at startup to call `likec4 --version` and resolve the actual version. The resolved version configures version-gated features (such as configurable HMR ports) and is surfaced as a **LikeC4 Version** property on the dashboard resource. Set it to `false` for faster startup if you pin an explicit tag or accept possible misconfiguration. ## Live state reflection Each element is tagged `aspire-run-state-` based on the live Aspire resource state, and the default view styles color it accordingly: | State tag | Default style | | --- | --- | | `aspire-run-state-starting` | sky | | `aspire-run-state-waiting` | sky | | `aspire-run-state-running` | green | | `aspire-run-state-stopping` | slate, 60% opacity | | `aspire-run-state-exited` | muted, 30% opacity | | `aspire-run-state-finished` | muted, 30% opacity | | `aspire-run-state-runtimeunhealthy` | amber | | `aspire-run-state-failedtostart` | red | Override per-state tags with `WithStateTag(state, tag)` (pass `null` to suppress), and disable the built-in style rules with `WithDefaultStateStyles(false)` if you prefer to style state tags in your own DSL. ## Next pages - [Dashboard Integration](../dashboard-integration/) - [Configuration](../configuration/) - [Advanced Configuration](../advanced-configuration/) # Getting Started > This guide walks through installing AspireC4 and generating your first live architecture diagram. # Getting Started This guide walks through installing AspireC4 and generating your first live architecture diagram. ## Prerequisites - **.NET 8 or later**. - **Aspire 13.5.3 or later**. Aspire AppHost projects must reference both the AppHost SDK and `Aspire.Hosting.AppHost`. - **Docker** is used by default to run the LikeC4 sidecar container. - Optional: a local Node.js CLI runtime (`npx`, `pnpm`, `yarn`, `bun`, or `deno`) if you call `.WithLocalCLI()`. ## Installation Add AspireC4 to the AppHost project: ```bash dotnet add package AspireC4.Hosting dotnet add package Aspire.Hosting.AppHost ``` An Aspire 13.5 AppHost project should contain the equivalent of: ```xml ``` :::note The generator, registry attributes, and enums are injected automatically by the `AspireC4.Hosting` package. Do not declare or reference a separate `AspireC4.SourceGenerators` package. ::: ## Quick start Add the visualization to your AppHost: ```csharp var builder = DistributedApplication.CreateBuilder(args); builder.AddAspireC4(); builder.Build().Run(); ``` That is all it takes. On startup AspireC4: 1. Writes the generated model to `./likec4/gen/model.gen.c4` (relative to the AppHost working directory). 2. Starts the official `ghcr.io/likec4/likec4` container as a sidecar resource named `aspirec4`. 3. Refreshes the diagram whenever the Aspire application changes — for example when a resource transitions to `Running` or `Exited`. Open the Aspire dashboard and select the `aspirec4` resource (or its **View LikeC4 Diagram** link) to see the live diagram. ## Customize the diagram Pass a configuration callback to `AddAspireC4`: ```csharp builder.AddAspireC4(options => options .WithTitle("My distributed application") .WithViewTitle("Architecture") .WithViewDescription("Generated from the Aspire resource graph") ); ``` See [Configuration](../configuration/) for the full options reference and [Customizing Resources](../customizing-resources/) for per-resource details. ## Use a local CLI instead of Docker When Docker is not available, or you prefer a local Node.js-based workflow: ```csharp builder.AddAspireC4().WithLocalCLI(); ``` The first runtime available on the system `PATH` is selected automatically (npx → pnpm → yarn → bun → deno). See [Local CLI Runtimes](../local-cli/). ## Run the TypeScript sample AspireC4 supports both C# and TypeScript AppHosts. See [TypeScript AppHosts](../typescript-apphost/) for the TypeScript API and a runnable sample. ## Next pages - [Configuration](../configuration/) - [Customizing Resources](../customizing-resources/) - [Generated Output](../generated-output/) - [Dashboard Integration](../dashboard-integration/) - [Source Generator Validation](../source-generator/) # Local CLI Runtimes > By default the LikeC4 server runs as the ghcr.io/likec4/likec4 Docker container. When Docker is not available — or you prefer a local Node.js-based workflow —… # Local CLI Runtimes By default the LikeC4 server runs as the `ghcr.io/likec4/likec4` Docker container. When Docker is not available — or you prefer a local Node.js-based workflow — switch to a local CLI with `.WithLocalCLI()`. ```csharp builder.AddAspireC4().WithLocalCLI(); ``` The selected runtime must be installed and accessible on the system `PATH`. ## Runtime selection ```csharp builder.AddAspireC4().WithLocalCLI(LocalCLIRuntime.Bun); ``` `LocalCLIRuntime` supports: | Runtime | Command | | --- | --- | | `Auto` | Detects the first available runtime in order: npx → pnpm → yarn → bun → deno. | | `Npx` | `npx likec4 serve --port ` | | `Pnpm` | `pnpm dlx --ignore-workspace likec4 serve --port ` | | `Yarn` | `yarn dlx --package likec4 --package react --package react-dom likec4 serve --port ` | | `Bun` | `bunx --bun likec4 serve --port ` | | `Deno` | `deno run --allow-all --node-modules-dir=none npm:likec4 serve --port ` | :::note `Auto` throws a `DistributedApplicationException` when no supported package manager is found. Install one of Node.js (`npx`), pnpm, yarn, bun, or Deno, or remove `WithLocalCLI()` to use the Docker container. ::: ## Behavior notes - The output directory is passed as an absolute path; the server process uses the system temp directory as its working directory so package managers do not walk up and treat the AppHost's parent `package.json` as a workspace root. - `ContainerImageTag` is ignored — it only applies to the Docker container. - Yarn's `dlx` does not install optional peer dependencies (`react`, `react-dom`) by default, so AspireC4 passes them explicitly as `--package` arguments. - HMR is enabled with `--hmr-port` when not disabled; `DisableHMR` still applies. ## ConfigureServer Use `ConfigureServer` to apply annotations directly to the inner server resource (e.g. `WithLikeC4Details`): ```csharp builder.AddAspireC4() .ConfigureServer(server => server.WithLikeC4Details(options => options.WithLabel("Architecture diagram"))); ``` :::tip Call `ConfigureServer` **after** `.WithLocalCLI()` if you want to configure the CLI resource. Calling it first configures the Docker container that is subsequently replaced. ::: ## Next pages - [Getting Started](../getting-started/) - [Configuration](../configuration/) # Migration Guide > This guide covers breaking changes introduced by the migration of the source generator to the current Purview.SourceGeneratorFramework incremental APIs.… # Migration Guide This guide covers breaking changes introduced by the migration of the source generator to the current `Purview.SourceGeneratorFramework` incremental APIs. Existing applications should review the following when upgrading: - **Aspire AppHost dependency is explicit.** Aspire 13.5 AppHosts must reference `Aspire.Hosting.AppHost`; relying on the AppHost SDK alone produces `ASPIRE002`. - **Only one registry class is supported per assembly.** Merge multiple `[LikeC4Registry]` classes into one class. - **Registry declaration styles cannot be mixed per type.** For example, choose either a `Tags` nested class or `[KnownType(LikeC4RegistryType.Tag)]` fields. Mixing both now produces `ASPIREC4005`. - **Strict settings are severity-based.** Replace older boolean-only assumptions with `suggestion`, `warning`, `error`, `all`, or `allincludingmetadata`. `true` remains accepted as an alias for error-level validation. - **Metadata validation is opt-in at the global level.** Use `allincludingmetadata`, or apply an explicit metadata severity through `[Severity]`/`[KnownType]`. Plain `all` does not validate metadata keys. - **Generated source files are split by type.** The generator now emits `LikeC4RegistryAttribute.g.cs`, `KnownTypeAttribute.g.cs`, `SeverityAttribute.g.cs`, `LikeC4RegistryType.g.cs`, and `LikeC4Severity.g.cs` instead of a combined `LikeC4RegistryAttributes.g.cs`. This affects generator snapshot tests and tooling that inspected hint names; normal application source code is unaffected. - **Do not define generated registry types manually.** Remove compatibility copies of `LikeC4RegistryAttribute`, `KnownTypeAttribute`, `SeverityAttribute`, `LikeC4RegistryType`, or `LikeC4Severity` to avoid duplicate-type errors. - **Generator packaging is automatic.** Consumers should reference only `AspireC4.Hosting`; remove direct references to `AspireC4.SourceGenerators` or `Purview.SourceGeneratorFramework` that were added solely to make the AspireC4 generator run. - **TypeScript APIs are generated by Aspire.** TypeScript AppHosts import from `.aspire/modules/aspire.mjs`; generated files must not be copied between projects or edited manually. Run `aspire restore` after upgrading AspireC4 so the exported API matches the installed integration version. ## See also - [Source Generator Validation](../source-generator/) - [TypeScript AppHosts](../typescript-apphost/) # Release Flow > The version in package.json is maintained manually and is the sole version source used by the release pipeline. Versions must use valid SemVer, including an… # Release Flow The version in `package.json` is maintained manually and is the sole version source used by the release pipeline. Versions must use valid SemVer, including an optional prerelease suffix when required. ## Preparing a release 1. Choose an unused version and update `package.json`. 2. Use conventional commit subjects for noteworthy changes: - `feat:` for features - `fix:` for bug fixes - `perf:`, `refactor:`, or `revert:` for other noteworthy changes 3. Commit the version update and merge or push it to `main`. Commits beginning with `chore:`, `build:`, `ci:`, `test:`, `docs:`, or `style:` are intentionally omitted from release notes. When no noteworthy commits exist, the release notes contain "Improvements ongoing." ## Running the release pipeline The pipeline is driven by the `Purview.Build` tool through `just`: ```sh just pipeline-release # restore, build, lint, tests, pack, publish, GitHub release just pipeline-local-release # Same but to a local NuGet feed (use forward slashes in paths) ``` ## CI release workflow A push to `main` triggers `.github/workflows/release.yml`, which delegates to the `purview-dev/build` reusable `purview-release.yml` with `release-mode: NuGet`. It: 1. Reads and validates the version from `package.json`. 2. Builds the solution and runs unit and integration tests. 3. Packs the NuGet package using the exact manual version. 4. Builds release notes from noteworthy commits since the previous release. 5. Creates a GitHub Release with the `.nupkg` and `.snupkg` files attached. The workflow does not publish to NuGet. Download the package from GitHub Releases and push it to the desired feed manually. ## See also - [Contributing](../contributing/) # Source Generator Validation > AspireC4 includes an incremental Roslyn source generator built on Purview.SourceGeneratorFramework that validates constant values passed to: # Source Generator Validation AspireC4 includes an incremental Roslyn source generator (built on `Purview.SourceGeneratorFramework`) that validates constant values passed to: - `.WithTag()` - `.WithKind()` - `.WithLikeC4Group()` - `.WithMetadata()` The generator injects the registry attributes and enums automatically — the `AspireC4.Hosting` package references it, so no separate generator package is needed. TypeScript AppHosts are not validated this way; they use the generated fluent API. ## Registry class Add one `[LikeC4Registry]` class to the AppHost assembly. Its accessibility and nesting do not matter. Values are declared as `const string` fields in conventionally named nested classes: ```csharp using Aspire.Hosting.AspireC4; [LikeC4Registry] internal static class ArchitectureRegistry { public static class Tags { public const string External = "external"; public const string LocalDevelopment = "local-dev"; } public static class ElementKinds { public const string Service = "service"; } public static class RelationshipKinds { public const string Async = "async"; } public static class Groups { public const string Platform = "Platform"; } public static class MetadataKeys { public const string AzureSku = "Azure_SKU"; } } ``` Supported nested-class names are: | Registry type | Accepted class names | | --- | --- | | Tag | `Tag`, `Tags` | | Element kind | `ElementKind`, `ElementKinds`, `Element`, `Elements` | | Relationship kind | `RelationshipKind`, `RelationshipKinds`, `Relationship`, `Relationships` | | Group | `Group`, `Groups` | | Metadata key | `MetadataKey`, `MetadataKeys` | Use the constants at call sites to make refactoring safe: ```csharp builder.AddProject("api") .WithLikeC4Details(details => details .WithTag(ArchitectureRegistry.Tags.External) .WithKind(ArchitectureRegistry.ElementKinds.Service) .WithMetadata(ArchitectureRegistry.MetadataKeys.AzureSku, "Standard_LRS") ) .WithLikeC4Group(ArchitectureRegistry.Groups.Platform); ``` ## Individual registry fields For a flat registry, annotate each constant with `[KnownType]`: ```csharp [LikeC4Registry] internal static class ArchitectureRegistry { [KnownType(LikeC4RegistryType.Tag)] public const string External = "external"; [KnownType(LikeC4RegistryType.Group, Strict = LikeC4Severity.Warning)] public const string Platform = "Platform"; } ``` Do not declare the same registry type using both a named nested class and `[KnownType]` fields. Doing so produces `ASPIREC4005`. ## Validation severity Without an explicit strict setting, a registry class enables suggestion-level validation. Severity can be configured at three levels, from broadest to most specific: 1. The `AspireC4Strict` MSBuild property. 2. `[LikeC4Registry(Strict = ...)]` for the registry. 3. `[Severity(...)]` on a named nested class, or `KnownType.Strict` on an individual field. ```csharp [LikeC4Registry(Strict = LikeC4Severity.Warning)] internal static class ArchitectureRegistry { [Severity(LikeC4Severity.Error)] public static class Tags { public const string External = "external"; } [KnownType(LikeC4RegistryType.Group, Strict = LikeC4Severity.Off)] public const string UnvalidatedGroup = "Temporary"; } ``` `LikeC4Severity` supports `Inherit`, `Off`, `Suggestion`, `Warning`, and `Error`. ## The `AspireC4Strict` MSBuild property The project-wide setting can be placed in the AppHost project or `Directory.Build.props`: ```xml warning ``` Accepted values: | Value | Behavior | | --- | --- | | `off` or an unset/unknown value | Disables DSL-file strict validation | | `suggestion` | Reports undeclared DSL values as suggestions | | `warning` | Reports undeclared DSL values as warnings | | `error`, `true`, `yes`, or `all` | Reports undeclared DSL values as errors | | `allincludingmetadata` | Error-level validation including metadata keys | Metadata-key comparison is case-insensitive and normalizes punctuation and whitespace to underscores. For example, `Azure SKU`, `azure sku`, and `Azure_SKU` identify the same key. To disable all AspireC4 source-generator diagnostics while retaining the injected registry types: ```xml true ``` ## Validate LikeC4 files Add `.c4` or `.likec4` specification files as compiler additional files, then set `AspireC4Strict`: ```xml warning ``` Tags, element kinds, and relationship kinds in `specification` blocks are merged with registry-class definitions. ## Diagnostics | ID | Meaning | | --- | --- | | `ASPIREC4001` | A tag passed to `.WithTag()` is undeclared | | `ASPIREC4002` | An element or relationship kind passed to `.WithKind()` is undeclared | | `ASPIREC4003` | More than one class in the assembly has `[LikeC4Registry]` | | `ASPIREC4004` | A group passed to `.WithLikeC4Group()` is undeclared | | `ASPIREC4005` | A registry type uses both a nested class and `[KnownType]` fields | | `ASPIREC4006` | A metadata key passed to `.WithMetadata()` is undeclared | | `ASPIREC4007` | A nested class in the registry uses a name that is not a recognized registry type | ## Injected types The generator emits `LikeC4RegistryAttribute.g.cs`, `KnownTypeAttribute.g.cs`, `SeverityAttribute.g.cs`, `LikeC4RegistryType.g.cs`, and `LikeC4Severity.g.cs`. Do not define these types manually in your own code. ## Next pages - [Migration Guide](../migration/) - [Customizing Resources](../customizing-resources/) # TypeScript AppHosts > AspireC4 supports both C# and TypeScript Aspire AppHosts. In a TypeScript AppHost, the Aspire integration exports the same diagram configuration, resource… # TypeScript AppHosts AspireC4 supports both C# and TypeScript Aspire AppHosts. In a TypeScript AppHost, the Aspire integration exports the same diagram configuration, resource metadata, grouping, and relationship features through camel-cased asynchronous APIs. The C# registry source generator is not applied to TypeScript; TypeScript applications configure tags, kinds, groups, and metadata through the generated fluent API. ## Generated modules The Aspire CLI generates the TypeScript API surface under `.aspire/modules/`. Import `createBuilder` from the generated Aspire module, then add AspireC4 to the builder: ```typescript import { createBuilder } from "./.aspire/modules/aspire.mjs"; const builder = await createBuilder(); await builder .addAspireC4({ configure: async (options) => { options .withTitle("My distributed application") .withViewTitle("Architecture") .withViewDescription("Generated from the Aspire resource graph"); }, }) .configureServer(async (resource) => { resource.withLikeC4Details({ configure: async (options) => { options.withLabel("Architecture diagram"); }, }); }); const api = await builder.addNodeApp("api", "../api", "index.ts"); await api.withLikeC4Details({ configure: async (options) => { options .withLabel("API") .withTechnology("Node.js") .withTag("backend"); }, }); const app = await builder.build(); await app.run(); ``` ## aspire.config.json Declare the integration and its Aspire dependencies in `aspire.config.json`: ```json { "appHost": { "path": "apphost.mts", "language": "typescript/nodejs" }, "sdk": { "version": "13.5.3" }, "packages": { "Aspire.Hosting.JavaScript": "13.5.3", "AspireC4.Hosting": "13.5.3" } } ``` Use the AspireC4 package version appropriate for the application. The repository sample points `AspireC4.Hosting` at `../../src/src/AspireC4/AspireC4.csproj` so it exercises the local source instead of a published package. ## Run the TypeScript sample The sample at `samples/typescript-app-host` demonstrates: - AspireC4 configuration from `apphost.mts`. - Azure Redis and PostgreSQL resources running as local containers. - Redis Commander and PgWeb dashboard resources. - A TypeScript Node.js service with Redis and PostgreSQL references. - LikeC4 labels, descriptions, links, icons, metadata, tags, groups, and relationships. - Additional LikeC4 DSL and image folders from the repository `assets` directory. Prerequisites are Docker, the Aspire CLI, and Bun. From the repository root: ```bash cd samples/typescript-app-host bun install aspire restore aspire start ``` `aspire restore` restores the integrations declared in `aspire.config.json` and regenerates `.aspire/modules/`. Once `aspire start` completes, open the Aspire dashboard URL printed by the CLI and select the LikeC4 resource or its architecture-diagram link. The sample also exposes the `node-app` `/health`, `/ping/redis`, and `/ping/postgres` endpoints through Aspire-assigned URLs. For an interactive foreground session, the sample's Bun script is equivalent to `aspire run`: ```bash bun run dev ``` Stop a background session with: ```bash aspire stop ``` ## Generated modules notes Do not edit files under `.aspire/modules/`; Aspire owns and regenerates them. If the folder is missing or stale after a pull, clean, or branch switch, run: ```bash aspire restore ``` When adding another Aspire integration, use `aspire add ` so Aspire updates `aspire.config.json` and regenerates the TypeScript API. Inspect `.aspire/modules/aspire.mts` to see the APIs currently available to `apphost.mts`. ## Next pages - [Configuration](../configuration/) - [Customizing Resources](../customizing-resources/) # Build > This wiki is the project documentation hub for Purview.Build — the shared build/test/release system for the purview-dev organisation. It is a Generalized… # Purview.Build Wiki This wiki is the project documentation hub for **Purview.Build** — the shared build/test/release system for the `purview-dev` organisation. It is a Generalized Modular Pipelines pipeline (based on the `PipelineCLI` originally developed in `sourcegeneratorframework`) packaged as a pinned .NET tool and exposed through a shared GitHub composite action and thin reusable workflows. Consuming repositories own configuration (`purview-build.json`); they do not own pipeline source code. Version, paths, feature switches, and release-mode selection are per-repository. ## Delivery surfaces The same implementation is available three ways: 1. **`Purview.Build` dotnet tool** — NuGet package published to nuget.org. Run anywhere a .NET SDK exists (locally via `just`, in GitHub Actions, or another CI service). 2. **Composite action** `purview-dev/build/.github/actions/purview-build` — for repositories that want the action embedded directly in one of their own jobs. 3. **Reusable workflows** `purview-dev/build/.github/workflows/purview-build.yml` and `.../purview-release.yml` — thin `workflow_call` wrappers with structured inputs/secrets. ## Start here - [Getting Started](getting-started/) - [Architecture](architecture/) - [Configuration Reference](configuration-reference/) - [Pipeline Modules](pipeline-modules/) - [Pack Validation](pack-validation/) - [Release Flow](release-flow/) - [Local Development](local-development/) - [Secrets and Environment Variables](secrets-and-environment-variables/) - [Repository CI/CD](repository-ci-cd/) - [Migration: aspire-resourcekit](migration-aspire-resourcekit/) ## Pipeline ```text Version ───────────────┐ Restore → Build → Test ├→ Pack → Validate → Publish → GitHub release └→ Lint │ Version ───────────────┘ ``` `Version` reads the SemVer `version` field from `package.json`. Lint restores local tools and runs CSharpier. Tests are discovered under `Build:TestRoot`/`Build:TestPatterns` and run with a TUnit tree-node filter (or an xUnit filter). Pack validation inspects each `.nupkg`/`.snupkg` against required/forbidden content rules (glob patterns) and can enforce source link, deterministic builds, and compiler flags on the packaged assemblies. Publication and GitHub release steps are controlled by `Release:Mode` (`None`, `LocalNuGet`, `NuGet`, `GitHubRelease`) and independently by the `Build__Run*` switches. `LocalNuGet` is only honoured when the tool runs locally; it is ignored in CI. ## Requirements - .NET SDK 10.0 or later. - `just` for local recipes (`just --list`). - A repository root `package.json` whose `version` field is the single release version source. ## Repository layout | Path | Purpose | | --- | --- | | `src/src/Build` | The packable `Purview.Build` tool: `Program.cs`, `Modules/`, `Settings/`, `Helpers/`, `appsettings.json` | | `.github/actions/purview-build` | The shared composite action | | `.github/workflows` | The reusable `purview-build.yml`/`purview-release.yml` and this repository's own `ci.yml`/`release.yml` | | `docs/wiki` | This wiki | | `purview-build.json` | This repository's own pipeline configuration (the tool dogfoods itself) | # Build SDK > This wiki is the project documentation hub for Purview.BuildSdk — a reusable MSBuild SDK NuGet package that delivers standardised .NET project defaults,… # Purview .NET Project SDK Wiki This wiki is the project documentation hub for **Purview.BuildSdk** — a reusable MSBuild SDK NuGet package that delivers standardised .NET project defaults, code-style enforcement, test-framework wiring, and Central Package Management integration. Install it once per repo; every project beneath the repo root inherits everything automatically. :::note The SDK imposes convention over configuration, enforcing certain styles and automations based on project file names and repository layout. ::: ## Start here - [Getting Started](getting-started/) - [Project Naming Conventions](project-naming-conventions/) - [Project Type Detection](project-type-detection/) - [Configuration Reference](configuration-reference/) - [Version Detection](version-detection/) - [Assembly Name Generation](assembly-name-generation/) - [InternalsVisibleTo](internalsvisibleto/) - [Testing Wiring](testing-wiring/) - [Packaging](packaging/) - [SDK-Shipped Analyzers](analyzers/) - [Code Fixes](code-fixes/) - [Repository Bootstrap](repository-bootstrap/) - [Agent Folder](agent-folder/) - [Release Flow](release-flow/) ## Feature highlights - **Project type detection** — `IsCSharpProject`, `IsTestProject`, `IsSharedTestingProject`, `IsContainerProject`, `IsWebSdkProject`, `IsAspireHostProject`, `IsCLIProject`, … driven by the `.csproj` filename, the `Sdk` attribute, and on-disk markers. - **C# defaults** — `net10.0` TFM (overridable, `netstandard2.0` for Roslyn components), `LangVersion=preview` (`latest` for Roslyn components), `Nullable=enable`, `ImplicitUsings=enable`, deterministic builds, `ManagePackageVersionsCentrally=true`. - **Code style** — an `.editorconfig` baked into the package, applied via `EditorConfigFilePath`, and auto-bootstrapped to the repo root if missing; `EnforceCodeStyleInBuild=true`, `EnableNETAnalyzers=true`, `AnalysisLevel=latest`, `AnalysisMode=All`. - **Assembly identity** — `RootNamespace` is the canonical public name; `AssemblyName` and `PackageId` default to the fully evaluated `RootNamespace` (or the full logical project name when suffix stripping removed a segment). - **Testing framework** — `TestingFramework`: **TUnit** (default), `Xunit`, or `None`; `SubstituteFramework`: **TUnitMocks** (default), `NSubstitute`, or `None`; `TestDataFramework`: **Bogus** (default) or `None`. - **Version detection** — the `version` field from the repo `package.json` is applied to `Version` and `PackageVersion` automatically, with local caching and a strict mode. - **Repository bootstrap** — missing repo-root `.editorconfig` and `global.json` are auto-copied or created by default (`DisableAutoCopySdkFiles=true` to opt out). - **Bundled analyzers and code fixes** — `PDS0001`–`PDS0005` plus IDE code fixes for naming and extensions-namespace conventions. - **Agent folder** — the package ships `.agents/**` content that is copied into the consuming repository so coding agents can discover repository-aware guidance automatically. ## Requirements - .NET SDK 10.0 or later (the default TFM for source projects is `net10.0`). - A `NamespacePrefix` set before the SDK import. - A `Directory.Packages.props` at the repo root when Central Package Management is used (the SDK sets `ManagePackageVersionsCentrally=true`). ## Repository layout - `src/src/BuildSdk` — the packable MSBuild SDK package (`Purview.BuildSdk`), with the SDK logic under `Sdk/`. - `src/src/Analyzers` — Roslyn analyzer/suppressor assembly (`Purview.BuildSdk.Analyzers`). - `src/src/CodeFixers` — Roslyn code-fix assembly (`Purview.BuildSdk.CodeFixers`). - `src/tests` — unit, integration, and SDK harness test projects. - `docs/wiki` — this wiki. - `templates/` — ready-to-copy starter files for consuming repositories. # Agent Folder > Purview.BuildSdk ships bundled agent content skills, prompts, agents under .agents/ in the NuGet package. During build, the SDK copies it into the consuming… # Agent Folder `Purview.BuildSdk` ships bundled agent content (skills, prompts, agents) under `.agents/**` in the NuGet package. During build, the SDK copies it into the consuming repository's `.agents/` folder by default so compatible coding agents can discover repository-aware guidance automatically. ## How the copy works - `EnableAgentFolderInPackage=true` (default) copies the bundled `.agents/**` folder from the SDK NuGet package into `$(AgentPackDestinationFolder)/` (default `.agents`) in the consuming repository **before build**. - The destination root is resolved from `RepoRoot` (auto-discovered repo root), an `AGENTS.md` walk-up, or SourceLink source roots. - The copy is also performed for bundled `.agents` folders shipped by **any** restored NuGet package that used `PurviewAutoSdkPack` (read from `project.assets.json`), not just this SDK. - `SkipUnchangedFiles=true` keeps repeated builds cheap. ## Opting out To disable bundled agent folder copying in a consuming repo, set the opt-out property before importing the SDK: ```xml false ``` ## Packaging the folder For packable projects, the SDK packs the `Sdk/.agents/**` folder into the package at `.agents/**` and injects a `.gitignore` file into each second-level folder under `Sdk/.agents` with the content `# Ignore all files\n*\n\n# Don't ignore directories, so Git can traverse them\n!*/\n\n# Keep this file\n!.gitignore`. This ensures the copied folder structure remains discoverable in consuming repositories while the content itself is ignored by Git. | Property | Default | Description | | -- | -- | -- | | `PurviewAutoSdkPack` | `true` | When `true`, automatically packs the `Sdk/` folder contents into the NuGet package with the correct root-level paths. Disable this for MSBuild SDK projects. | | `EnableAgentFolderInPackage` | `true` | Copies the bundled `.agents/**` folder from the SDK NuGet package into the consuming repository's `.agents/` folder (or `$(AgentPackDestinationFolder)/`) before build. | | `AgentPackDestinationFolder` | `.agents` | Repo-relative destination folder that receives the copied agent folder contents when `EnableAgentFolderInPackage` is `true`. | See [Packaging](../packaging/) for the full `Sdk/` folder packaging rules. # SDK-Shipped Analyzers > The package ships Purview.BuildSdk.Analyzers.dll plus a separate code-fix assembly for the IDE and adds the analyzer to every C# project as an … # SDK-Shipped Analyzers The package ships `Purview.BuildSdk.Analyzers.dll` (plus a separate code-fix assembly for the IDE) and adds the analyzer to every C# project as an `` item, so the rules surface in both command-line builds and Visual Studio. | Rule | Category | Severity | Description | | -- | -- | -- | -- | | `PDS0001` | (suppressor) | — | Suppresses `CS1591` for `EditorBrowsable(Never)` members | | `PDS0002` | Naming | Warning | Files under a project-root `Extensions/` folder reset their namespace | | `PDS0003` | Style | Warning | Prefer an explicit type with target-typed `new()` over `var` | | `PDS0004` | Naming | Warning | Use correct acronym capitalization (`Api` → `API`) | | `PDS0005` | (suppressor) | — | Suppresses `IDE0130` for files rooted under `Extensions/` | ## PDS0002 — Extensions namespace rule When a file is placed under a project-root `Extensions/` folder, the analyzer intentionally treats that folder as a namespace reset point. - Scope: only files where the first project-relative segment is exactly `Extensions` - Expected namespace: derived from subfolders under `Extensions/` (file name is ignored) - `RootNamespace` is deliberately ignored for these files Examples: | Project-relative file path | Expected namespace | | -- | -- | | `Extensions/System/StringExtensions.cs` | `System` | | `Extensions/Microsoft/Extensions/Configuration/ConfigurationExtensions.cs` | `Microsoft.Extensions.Configuration` | | `Extensions/TopLevel.cs` | *(global namespace)* | To avoid conflicting guidance, `IDE0130` is suppressed (PDS0005) for files in this root `Extensions/` scope. The shipped `.editorconfig` also suppresses the namespace-conflict diagnostics this convention can trigger (`CA1724`, `CS0436`, `CS1591`, `IDE0005`), so extension files never need `#pragma` suppressions. Outside this scope, normal `IDE0130` behaviour remains unchanged. ## PDS0003 — Prefer explicit type with target-typed `new()` Flags `var` declarations that use a target-typed object creation initializer, preferring: ```csharp // ❌ flagged var service = new Service(); // ✅ preferred Service service = new(); ``` ## PDS0004 — Acronym capitalization `PDS0004` follows .NET naming guidance for well-known framework spellings (`Sql`, `Guid`, `Uuid`, `Url`, `Dns`, `Http`, `Xml`, `DbContext`, ...) while still enforcing uppercase for acronyms such as `Api` → `API`, `Ai` → `AI`, `Cpu` → `CPU`, `Gpu` → `GPU`, `Cli` → `CLI`, `Gui` → `GUI`, `Ram` → `RAM`, and `Ssh` → `SSH`. By default the acronym-like segments `Http`, `Xml`, `Json`, `Id`, `Sdk`, `Sql`, `Uuid`, `Url`, `Dns`, `Tcp`, `Udp`, `Csv`, `Pdf`, `Html`, `Css`, `Ftp`, `Smtp`, `Imap`, and `Db` are exempt — `Db` is deliberately exempt so the prevalent EF Core/ADO.NET spellings (`DbContext`, `DbConnection`, `DbSet`, `CreateDbContext`) are never flagged; a repo that prefers `DB` can re-enable it via `acronym_map = Db:DB`. All options are customisable per repo, project, or folder via `.editorconfig` and **merge with the shipped defaults — config entries override them** (so you can opt into `Sql` → `SQL` or opt out of `Cli` → `CLI` without re-declaring every default): - `dotnet_analyzer_configuration.pds0004.allowed_words` — semicolon-separated segments that are never flagged. - `dotnet_analyzer_configuration.pds0004.acronym_map` — semicolon-separated `Key:Value` corrections. - `dotnet_analyzer_configuration.pds0004.allowed_identifiers` — semicolon-separated whole identifiers that are never flagged. Matched in the order listed (first match wins) by exact name or word-boundary prefix, so a brand name like `CosmosDb` also covers `CosmosDbServer`/`CosmosDbContext`. Members whose names are mandated by a contract — interface implementations (implicit or explicit) and base-class overrides — are never renamed, since doing so would break the contract. ```ini [*.cs] # Optional overrides; everything not mentioned keeps its shipped default. dotnet_analyzer_configuration.pds0004.allowed_words = Cli dotnet_analyzer_configuration.pds0004.acronym_map = Db:DB dotnet_analyzer_configuration.pds0004.allowed_identifiers = ICosmosDBService;CosmosDb;GitHub;YouTube ``` The code fix for `PDS0004` renames the identifier and all of its references across the solution; see [Code Fixes](../code-fixes/). # Assembly Name Generation > By default EnableAssemblyNameGeneration=true, the SDK treats RootNamespace as the canonical public name: AssemblyName and PackageId both default to the fully… # Assembly Name Generation By default (`EnableAssemblyNameGeneration=true`), the SDK treats `RootNamespace` as the canonical public name: `AssemblyName` and `PackageId` both default to the fully evaluated `RootNamespace` — or, when suffix-stripping removed a segment of the logical project name, to the full logical project name so assemblies stay distinct. The defaults are applied during `Sdk.props` evaluation — before the Microsoft SDK computes `TargetName` and before the project body — so compilation, output paths, project references, restore, and packing all agree on the same identities. Set `EnableAssemblyNameGeneration=false` **before the SDK import** to opt out and fall back to standard .NET behaviour (`$(MSBuildProjectName)`). ## Resolved identities | Project name | `NamespacePrefix` | `RootNamespace` | Resolved `AssemblyName` / `PackageId` | | -- | -- | -- | -- | | `Api` | `Acme` | `Acme.Api` | `Acme.Api` | | `Acme.Api` | `Acme` | `Acme.Api` | `Acme.Api` (no double-prefix) | | `Core.Infrastructure` | `Acme` | `Acme.Infrastructure` | `Acme.Core.Infrastructure` (`.Core` stripped from the namespace only) | | `Shared` | `Acme` | `Acme` | `Acme.Shared` (full logical name, so it stays distinct) | | `ServiceDefaults` | `Acme` | `Acme` | `Acme.ServiceDefaults` (full logical name, so it stays distinct) | | `Acme` | `Acme` | `Acme` | `Acme` | Explicitly setting `` before the SDK import always wins: `AssemblyName`/`PackageId` follow that override instead of re-appending a stripped suffix. Test projects keep their detected suffix: `Api.UnitTests` → `AssemblyName`/`PackageId` = `Acme.Api.UnitTests`, while `RootNamespace` remains `Acme.Api`. Explicit `` or `` in a `.csproj` (or `Directory.Build.props`) always takes precedence. Because the defaults run before the project body, project-authored values set in the body are evaluated later and win. > **Note:** set `EnableAssemblyNameGeneration=false` **before** the SDK import (for example in > `Directory.Build.props`) — it is consumed during `Sdk.props` evaluation. ## Namespace stripping Certain suffixes are automatically stripped from `RootNamespace` to avoid awkward namespace names like `Acme.MyProject.Core.Something`. Stripped suffixes: `Core`, `EF`, `Shared`, `ClientShared`, `ServiceDefaults`, and all shared and shared-testing project names. See [Project Naming Conventions](../project-naming-conventions/) and [InternalsVisibleTo](../internalsvisibleto/) for related naming behaviour. # Code Fixes > The package ships Purview.BuildSdk.CodeFixers.dll for the IDE. The code-fix assembly is added as an analyzer reference only when building inside Visual Studio… # Code Fixes The package ships `Purview.BuildSdk.CodeFixers.dll` for the IDE. The code-fix assembly is added as an analyzer reference only when building inside Visual Studio (Roslyn's `CodeFixService` keys on `Project.AnalyzerReferences`, and the command-line compiler cannot resolve `Microsoft.CodeAnalysis.Workspaces`). ## PDS0004 — Rename to correct acronym capitalization The code fix for `PDS0004` renames the identifier to its correct-English capitalization (for example `ApiClient` → `APIClient`) and updates all of its references across the solution. See [Analyzers](../analyzers/) for the full rules and configuration options. ## PDS0002 — Fix extensions namespace When a static extensions class is not at its conventional location (or is at that location but not conventionally named), the **Move extensions class to conventional location** refactoring: - Re-paths the file to `Extensions//Extensions.cs`. - Renames the class to the receiver-derived name (for example an `IServiceCollection` extension becomes `ServiceCollectionExtensions` and moves to `Microsoft.Extensions.DependencyInjection`). - When a `Extensions` class already exists in the receiver's namespace it is merged into that class instead of creating a duplicate — every member from both classes (constants, private helpers, XML docs) is preserved, only members with a matching signature are skipped. - The namespace is fixed, a `using` for the previous namespace is added to the moved file, and a `using` for the new namespace plus the renamed type name are applied to every other document that references the type, so the move compiles everywhere. ## Split extensions class When a static extensions class targets multiple receiver types, the **Split extensions class into one class per receiver type** refactoring: - Splits the class into one `Extensions` class per receiver — for a generic `this TBuilder where TBuilder : IHostApplicationBuilder` receiver that becomes `HostApplicationBuilderExtensions`. - Places each new file under `Extensions//` so the namespace convention stays satisfied. - When a `Extensions` class already exists in the receiver's namespace, the receiver's methods are merged into it instead of creating a duplicate file. ## Target-typed `new()` The code fix for `PDS0003` rewrites a `var` declaration with a target-typed object creation initializer to use an explicit type with `new()`: ```csharp // before var service = new Service(); // after Service service = new(); ``` # Configuration Reference > Set any of these properties before the in your Directory.Build.props most are consumed during Sdk.props evaluation. Explicit values set before the… # Configuration Reference Set any of these properties **before** the `` in your `Directory.Build.props` (most are consumed during `Sdk.props` evaluation). Explicit values set before the import are always preserved. ## Version detection | Property | Default | Description | | -- | -- | -- | | `UsePackageJsonVersion` | `true` | `true` enables version detection, `false` disables it, and `Strict` requires version detection to succeed (build fails if no version source can be resolved). | | `RootPackageJson` | *(auto-discovered)* | Explicit path to a `package.json`. Relative paths are resolved from the project directory. | | `EnableVersionDetectionCache` | `true` | Enables local caching of auto-discovered package.json version results. | | `VersionDetectionLogEnabled` | `false` | Emits a high-importance message showing the detected package version. | See [Version Detection](../version-detection/) for the full resolution rules. ## General | Property | Default | Description | | -- | -- | -- | | `NamespacePrefix` | *(required)* | Root namespace prefix, e.g. `Acme`. Results in `Acme.MyProject`. | | `DisableNamespacePrefixCheck` | `false` | Set to `true` to suppress the build error for missing `NamespacePrefix`. | | `TargetFramework` | `net10.0` | Override the default TFM per-project or globally. Defaults to `netstandard2.0` for projects declaring `IsRoslynComponent=true`. | | `IsRoslynComponent` | `false` | When explicitly `true`, applies source-generator defaults: a single `netstandard2.0` target, `LangVersion=latest`, `Nullable=enable`, `TreatWarningsAsErrors=true`, `Deterministic=true`, extended analyzer rules, SourceLink with `EmbedUntrackedSources=true`, compiler-generated output under the intermediate directory, no dependency file, symbol packaging (`IncludeSymbols=false` by default), telemetry exclusion, and package build output. Packable Roslyn components automatically pack the built analyzer assembly and its PDB into `analyzers/dotnet/cs/` (`PurviewPackAnalyzerPdb=true`; set `false` only when symbols are delivered another way — NuGet's `.snupkg` cannot host `analyzers/dotnet/cs` symbols). Pack-time validation (`ValidateRoslynComponentCompilerSettings`) fails the pack if the compiler defaults are missing unless `DisableRoslynCompilerDefaultsValidation=true`. Roslyn development dependencies (`Microsoft.CodeAnalysis.*`, `Microsoft.CodeAnalysis.Analyzers`) default to `PrivateAssets="all"`. | | `PackProjectReferencedSourceGenerators` | `true` | Automatically packs analyzer `ProjectReference` outputs and their runtime dependencies under `analyzers/dotnet/cs/`. Set to `false` to opt out; set `Pack="false"` on an individual reference to exclude only that generator. | | `SourceLinkPackageName` | `Microsoft.SourceLink.GitHub` | SourceLink provider. Set to `Microsoft.SourceLink.AzureDevOps.Git` for ADO repos. | | `DisableSourceLink` | `false` | Set to `true` to stop the SDK from adding the configured SourceLink package automatically. | | `EnableAssemblyNameGeneration` | `true` | When `true` (default), `AssemblyName` and `PackageId` derive from the fully evaluated `RootNamespace`. When explicitly `false`, standard .NET behaviour applies (`$(MSBuildProjectName)`). Explicit ``/`` in a `.csproj` always take precedence. | | `DisableProjectFileNamingConventionCheck` | `false` | Set to `true` to disable the validation that requires `MyProject\MyProject.csproj` naming alignment. | | `DisableGenerateAssemblyInfoClass` | `false` | Set to `true` to disable the generated `AssemblyInfo` helper source. | | `AutoIncludeUsings` | `true` | Controls SDK-added global usings for `NamespacePrefix` and `RootNamespace`. | ## Packable project defaults For projects where `IsPackable=true`, the SDK provides these defaults **only when the consuming project has not supplied a value**: | Property | Default | Description | | -- | -- | -- | | `GenerateDocumentationFile` | `true` | Emits XML documentation. | | `IncludeSymbols` | `true` | Produces a symbol package (`false` for Roslyn components — their PDB ships inside the `.nupkg` under `analyzers/dotnet/cs/` via `PurviewPackAnalyzerPdb=true`). | | `SymbolPackageFormat` | `snupkg` | Symbol package format. Always the modern `.snupkg`; the legacy `.symbols.nupkg` is never produced by default. | | `PublishRepositoryUrl` | `true` | Publishes the repository URL. | | `EmbedUntrackedSources` | `true` | Embeds untracked sources for SourceLink. | | `DebugType` | `portable` | Ensures portable PDBs for symbol-package delivery. | | `IncludeSource` | `true` | Includes source files in the package. | Portable PDBs are delivered through the `.snupkg`; the normal `.nupkg` does **not** receive PDB files unless the project explicitly opts in (for example by adding `.pdb` to `AllowedOutputExtensionsInPackageBuildOutputFolder`). The SDK never forces organization/package-specific metadata — `Authors`, `Company`, `PackageLicenseExpression`, `PackageLicenseFile`, `Description`, `PackageTags`, `PackageProjectUrl`, and repository URLs are left to the repository or individual package. `IsPackable` is not set blindly: it defaults to `false` and only becomes `true` when a project explicitly opts in. Non-packable projects default `WarnOnPackingNonPackableProject=false`, so solution-wide pack operations skip them silently. ## Repo bootstrap | Property | Default | Description | | -- | -- | -- | | `DisableAutoCopySdkFiles` | `false` | Master switch that disables repo-level SDK file bootstrapping. | | `BootstrapEditorConfigToRepoRoot` | `true` | Copies the SDK `.editorconfig` to the repository root when missing. | | `RepositoryEditorConfigFilePath` | *(auto-detected)* | Override the destination path for the bootstrapped `.editorconfig`. | | `BootstrapGlobalJsonToRepoRoot` | `true` | Creates a `global.json` at the repository root when missing. | | `RepositoryGlobalJsonFilePath` | *(auto-detected)* | Override the destination path for the bootstrapped `global.json`. | | `PurviewBuildSdkVersionForGlobalJson` | *(auto-detected or `1.0.0` fallback)* | Version written to the `msbuild-sdks.Purview.BuildSdk` entry in a bootstrapped `global.json`. | ## Agent folder | Property | Default | Description | | -- | -- | -- | | `PurviewAutoSdkPack` | `true` | When `true`, automatically packs the `Sdk/` folder contents into the NuGet package with the correct root-level paths. Disable this for MSBuild SDK projects. | | `EnableAgentFolderInPackage` | `true` | Copies the bundled `.agents/**` folder from the SDK NuGet package into the consuming repository's `.agents/` folder (or `$(AgentPackDestinationFolder)/`) before build. | | `AgentPackDestinationFolder` | `.agents` | Repo-relative destination folder that receives the copied agent folder contents when `EnableAgentFolderInPackage` is `true`. | To disable bundled agent folder copying in a consuming repo, set the opt-out property before importing the SDK: ```xml false ``` ## Telemetry | Property | Default | Description | | -- | -- | -- | | `ExcludePurviewTelemetry` | `false` | Set to `true` to exclude `Purview.Telemetry.SourceGenerator` from all projects. | | `ExcludeMSTelemetryExtension` | `false` | Set to `true` to exclude `Microsoft.Extensions.Telemetry.Abstractions`. Only relevant when `ExcludePurviewTelemetry` is also `false` — when `ExcludePurviewTelemetry=true` the whole telemetry group is skipped anyway. | ## Testing | Property | Default | Description | | -- | -- | -- | | `TestingFramework` | `TUnit` | Testing framework. Supported values: `TUnit`, `Xunit`, `None`. | | `SubstituteFramework` | `TUnitMocks` | Mocking provider. Supported values: `TUnitMocks`, `NSubstitute`, `None`. | | `TestDataFramework` | `Bogus` | Test data provider. Supported values: `Bogus`, `None`. | | `DisableAutoInternalsVisibleTo` | `false` | Set to `true` to disable automatic `InternalsVisibleTo` generation for test types and shared testing projects. | See [Testing Wiring](../testing-wiring/) for details. ## Compiler-visible SDK properties The SDK exports its properties via `CompilerVisibleProperty`, so analyzers and source generators can read them through `build_property.`: | Property | Description | | -- | -- | | `UsePackageJsonVersion` | Whether version detection from `package.json` is active. | | `RootPackageJson` | Resolved path to the `package.json` used for version detection. | | `RepoRoot` | Repo root directory found via `.git` auto-discovery. | | `Version` | Package/assembly version, sourced from `package.json` when detection is enabled. | | `PackageVersion` | NuGet package version, sourced from `package.json` when detection is enabled. | | `NamespacePrefix` | Required namespace prefix used to derive `RootNamespace`. | | `DisableNamespacePrefixCheck` | Disables the build error for missing `NamespacePrefix`. | | `TestingFramework` | Selected testing framework (`TUnit`, `Xunit`, or `None`). | | `SubstituteFramework` | Selected mocking provider (`TUnitMocks`, `NSubstitute`, or `None`). | | `TestDataFramework` | Selected test data provider (`Bogus` or `None`). | | `SourceLinkPackageName` | SourceLink package ID added by the SDK. | | `DisableSourceLink` | Disables automatic SourceLink integration. | | `ExcludePurviewTelemetry` | Opt-out for `Purview.Telemetry.SourceGenerator`. | | `ExcludeMSTelemetryExtension` | Opt-out for `Microsoft.Extensions.Telemetry.Abstractions`. | | `EnableAgentFolderInPackage` | When `true`, copies the bundled `.agents` folder into the consuming repository. | | `AgentPackDestinationFolder` | Repo-relative destination folder that receives copied `.agents` content. | | `PurviewAutoSdkPack` | When `true`, automatically packs the `Sdk/` folder contents into the NuGet package. | | `DisableGenerateAssemblyInfoClass` | Disables generated `AssemblyInfo` helper source. | | `EnableAssemblyNameGeneration` | When `true` (default), `AssemblyName` derives from `RootNamespace`. | | `DisableAutoInternalsVisibleTo` | Disables automatic `InternalsVisibleTo` generation. | | `AutoIncludeUsings` | Controls SDK-added global usings. | | `IsCSharpProject` | True when the project is a `.csproj`. | | `IsTestProject` | True when project name ends with a supported test suffix. | | `IsSharedTestingProject` | True for known shared testing helper project names. | | `TestingType` | Detected test category suffix from project name. | | `TargetProjectName` | Inferred target project name for test projects. | | `IsContainerProject` | True when Dockerfile markers indicate container defaults. | | `IsSdkProject` | True when an SDK value is detected from project/import declaration. | | `SdkProjectName` | Detected SDK name (e.g. `Microsoft.NET.Sdk.Web`). | | `IsWebProject` | Marker used in SDK web-project behaviour. | | `IsWebSdkProject` | True when `SdkProjectName` is `Microsoft.NET.Sdk.Web`. | | `IsWorkerSdkProject` | True when `SdkProjectName` is `Microsoft.NET.Sdk.Worker`. | | `IsAspireHostProject` | True when SDK starts with `Aspire.Sdk.Host`. | | `IsCLIProject` | True when the project is a CLI project. | | `IsSharedProject` | True when the project is a shared project. | | `EditorConfigFilePath` | Path to the SDK-provided `.editorconfig` injected into `@(EditorConfigFiles)`. | | `RepositoryEditorConfigFilePath` | Destination path for bootstrapping a physical repo-level `.editorconfig`. | | `BootstrapEditorConfigToRepoRoot` | When `true` (default), copies the SDK `.editorconfig` to `RepositoryEditorConfigFilePath` if missing. | | `RepositoryGlobalJsonFilePath` | Destination path for bootstrapping a physical repo-level `global.json`. | | `BootstrapGlobalJsonToRepoRoot` | When `true` (default), creates `global.json` at `RepositoryGlobalJsonFilePath` if missing. | | `PurviewBuildSdkVersionForGlobalJson` | Version used for `msbuild-sdks.Purview.BuildSdk` when bootstrapping `global.json`. | | `DisableAutoCopySdkFiles` | When `true`, disables SDK auto-copy/bootstrap for repo files (`.editorconfig`, `global.json`). | | `CurrentYear` | Current year used in generated assembly metadata. | | `AutoGeneratedAssemblyInfoFile` | Relative path to generated AssemblyInfo source file. | Roslyn components additionally expose `LangVersion`, `Nullable`, `TreatWarningsAsErrors`, `EnforceExtendedAnalyzerRules`, `Deterministic`, `ContinuousIntegrationBuild`, and `EmbedUntrackedSources` as compiler-visible properties so downstream tooling can confirm the shipped analyzer packages were built with the standard settings. ## Example: switch a repo to Xunit + NSubstitute and disable Bogus ```xml Acme Xunit NSubstitute None ``` # Getting Started > This guide walks through the minimal setup required to adopt Purview.BuildSdk in a repository. Install it once per repo — every project beneath the repo root… # Getting Started This guide walks through the minimal setup required to adopt `Purview.BuildSdk` in a repository. Install it once per repo — every project beneath the repo root inherits everything automatically. ## 1. Add the SDK to `global.json` Add `Purview.BuildSdk` to the `msbuild-sdks` section so MSBuild can resolve the SDK: ```json { "test": { "runner": "Microsoft.Testing.Platform" }, "msbuild-sdks": { "Purview.BuildSdk": "1.0.0" } } ``` :::note The SDK can also bootstrap a `global.json` for you at the repository root when one is missing; see [Repository Bootstrap](../repository-bootstrap/). ::: ## 2. Create `Directory.Build.props` at the repo root ```xml YourCompany ``` `NamespacePrefix` is mandatory — a build error is raised if it is missing (`ValidateRootNamespacePrefixTarget`), unless `DisableNamespacePrefixCheck=true`. ## 3. Create `Directory.Build.targets` at the repo root ```xml ``` ## 4. Copy `Directory.Packages.props` to the repo root Copy `templates/Directory.Packages.props` from this package to your repo root. All package versions default to `*` (latest at restore). Pin any package by replacing `*` with a specific version. > **Note:** `ManagePackageVersionsCentrally=true` is set by the SDK. You **must** have a > `Directory.Packages.props` at your repo root for CPM to work, even if it only contains the packages > the SDK adds automatically. ## Next steps - Understand how projects are detected and named in [Project Type Detection](../project-type-detection/) and [Project Naming Conventions](../project-naming-conventions/). - Browse every configurable property in the [Configuration Reference](../configuration-reference/). - Learn how the package version is sourced in [Version Detection](../version-detection/). - See how test projects are wired up in [Testing Wiring](../testing-wiring/). - Read how packages are produced in [Packaging](../packaging/). # InternalsVisibleTo > The SDK automatically generates assembly: InternalsVisibleTo"…" attributes for every non-test C# project. The friend assembly name is derived from the source… # InternalsVisibleTo The SDK automatically generates `[assembly: InternalsVisibleTo("…")]` attributes for every non-test C# project. The friend assembly name is derived from the source project's resolved `$(AssemblyName)`, so all naming modes are handled correctly: - **Explicit ``** — if a project sets `Custom.Assembly`, the generated attributes use `Custom.Assembly.UnitTests`, `Custom.Assembly.IntegrationTests`, etc. - **Default** — `AssemblyName` is `RootNamespace`-derived, so fully-qualified names are used (for example `Acme.MyProject.UnitTests`). - **`EnableAssemblyNameGeneration=false`** — standard .NET behaviour: `$(MSBuildProjectName)` (for example `MyProject.UnitTests`). ## What is generated Two categories of friend assemblies are generated: 1. **TestType variants** — for each defined `TestType` (`Unit`, `Integration`, `Architecture`, `Contract`, `Functional`, …) the SDK emits the `$(AssemblyName).{TestType}Tests` alias plus the `$(TargetProjectName).{TestType}Tests` and `$(MSBuildProjectName).{TestType}Tests` equivalents, so the attribute resolves regardless of whether naming comes from an explicit `AssemblyName`, the SDK default, or the raw project name. 2. **SharedTesting projects** — one per known shared testing project name (`SharedTestingFramework`, `SharedTestingInfrastructure`, etc.). By default (`EnableAssemblyNameGeneration=true`) with a `NamespacePrefix` set, these are prefixed (for example `Acme.SharedTestingFramework`); with `EnableAssemblyNameGeneration=false` the raw name is used. `InternalsVisibleTo` is also generated for `DynamicProxyGenAssembly2` so Moq/NSubstitute dynamic proxies can access internals. ## Disabling automatic InternalsVisibleTo To disable automatic InternalsVisibleTo generation, set `DisableAutoInternalsVisibleTo=true` in your project or `Directory.Build.props`: ```xml true ``` # Packaging > The SDK configures packable projects IsPackable=true for clean NuGet output and handles several packaging workflows automatically: symbol packages, Roslyn… # Packaging The SDK configures packable projects (`IsPackable=true`) for clean NuGet output and handles several packaging workflows automatically: symbol packages, Roslyn analyzer assets, source generators, the `Sdk/` folder, and the repository README. ## Packable project defaults For projects where `IsPackable=true`, the SDK provides these defaults **only when the consuming project has not supplied a value** — explicit values are always preserved: | Property | Default | Description | | -- | -- | -- | | `GenerateDocumentationFile` | `true` | Emits XML documentation. | | `IncludeSymbols` | `true` | Produces a symbol package (`false` for Roslyn components — their PDB ships inside the `.nupkg` under `analyzers/dotnet/cs/`). | | `SymbolPackageFormat` | `snupkg` | Symbol package format. Always the modern `.snupkg`; the legacy `.symbols.nupkg` is never produced by default. | | `PublishRepositoryUrl` | `true` | Publishes the repository URL. | | `EmbedUntrackedSources` | `true` | Embeds untracked sources for SourceLink. | | `DebugType` | `portable` | Ensures portable PDBs for symbol-package delivery. | | `IncludeSource` | `true` | Includes source files in the package. | Portable PDBs are delivered through the `.snupkg`; the normal `.nupkg` does **not** receive PDB files unless the project explicitly opts in (for example by adding `.pdb` to `AllowedOutputExtensionsInPackageBuildOutputFolder`). The SDK never forces organization/package-specific metadata — `Authors`, `Company`, `PackageLicenseExpression`, `PackageLicenseFile`, `Description`, `PackageTags`, `PackageProjectUrl`, and repository URLs are left to the repository or individual package. `IsPackable` is not set blindly: it defaults to `false` and only becomes `true` when a project explicitly opts in. ## Roslyn components (`IsRoslynComponent=true`) Packable Roslyn components automatically pack the built analyzer/generator assembly **and its PDB** into `analyzers/dotnet/cs/` (`PurviewPackAnalyzerPdb=true`). Set `PurviewPackAnalyzerPdb=false` only when symbols are delivered another way — NuGet's `.snupkg` cannot host `analyzers/dotnet/cs` symbols. `IncludeBuildOutput=false` keeps `lib/` empty. Pack-time validation (`ValidateRoslynComponentCompilerSettings`) fails the pack with `PRSGD0001`– `PRSGD0004` if the standard compiler defaults (`LangVersion`, `Nullable`, `TreatWarningsAsErrors`, `EnforceExtendedAnalyzerRules`) are missing. Opt out with `DisableRoslynCompilerDefaultsValidation=true`. Roslyn development dependencies (`Microsoft.CodeAnalysis.CSharp`, `Microsoft.CodeAnalysis.CSharp.Workspaces`, `Microsoft.CodeAnalysis.Analyzers`) default to `PrivateAssets="all"`, and `Microsoft.CodeAnalysis.Analyzers` also gets the analyzer `IncludeAssets`, so they never leak into the packed nuspec. ## Automatic source generator packaging `PackProjectReferencedSourceGenerators=true` (default) automatically packs analyzer `ProjectReference` outputs and their runtime dependencies under `analyzers/dotnet/cs/`. Analyzer project references use the `GetSourceGeneratorAnalyzerFiles` target (set automatically when a `ProjectReference` has `OutputItemType=Analyzer` and `ReferenceOutputAssembly=false`); runtime dependencies are declared as `SourceGeneratorRuntimeDependency` items and copied beside the generator. Duplicates across analyzer projects are deduplicated by package path so the pack never fails on colliding `analyzers/dotnet/cs` targets. Set `PackProjectReferencedSourceGenerators=false` to opt out, or `Pack="false"` on an individual `ProjectReference` to exclude only that generator. ## Automatic `Sdk/` folder packaging (`PurviewAutoSdkPack`) For packable projects, `PurviewAutoSdkPack` (default `true`) automatically adds `Sdk/**/*` as `None` items with `Pack="true"` and `Visible="true"`, mapping each file to the correct location in the package: | Source path | Package path | | -- | -- | | `Sdk/.agents/**` | `.agents/**` | | `Sdk/.github/**` | `.github/**` | | `Sdk/build/**` | `build/**` | | `Sdk/buildTransitive/**` | `buildTransitive/**` | | `Sdk/buildMultiTargeting/**` | `buildMultiTargeting/**` | | `Sdk/*.md`, `Sdk/*.png`, `Sdk/*.jpg`, etc. | package root | | everything else under `Sdk/` | `Sdk/` | The SDK automatically adds a `.gitignore` file into each second-level folder under `Sdk/.agents` with the content `# Ignore all files\n*\n\n# Don't ignore directories, so Git can traverse them\n!*/\n\n# Keep this file\n!.gitignore`. This ensures the copied folder structure remains discoverable in consuming repositories while the content itself is ignored by Git. MSBuild SDK packages (like `Purview.BuildSdk` itself) set `PurviewAutoSdkPack=false` and pack their `Sdk/` contents explicitly instead. External files linked beneath `Sdk/` (via ``) are packed with the same paths as physical `Sdk/` files. ## Repository README auto-inclusion When the repo root is discoverable (`.git` marker or CI workspace variable), the repository-root `README.md` is packed automatically for packable projects and registered via `PackageReadmeFile` — but only when the file exists and `PackageReadmeFile` has not been configured explicitly. The SDK skips the auto-inclusion if a README-named file is already being packed, so no duplicate readme items are produced. No README is required; if the file is absent the pack succeeds without readme metadata. ## Pack validation The shared `Purview.Build` pipeline runs package validation on pack (`purview-build.json` sets `PackValidation.RequireSymbolPackage=false` for this SDK repo). See [Release Flow](../release-flow/) for the full flow. # Project Naming Conventions > The SDK applies several conventions automatically based on the .csproj filename and NamespacePrefix. # Project Naming Conventions The SDK applies several conventions automatically based on the `.csproj` filename and `NamespacePrefix`. ## Defaults (no extra configuration) `RootNamespace` is always derived from `$(NamespacePrefix).$(ProjectName)` and is the canonical default public name. By default (`EnableAssemblyNameGeneration=true`), `AssemblyName` and `PackageId` both follow the fully evaluated `RootNamespace`. Test projects retain their detected suffix so test assemblies stay distinct from the source assembly. Set `EnableAssemblyNameGeneration=false` (before the SDK import) to opt out and use standard .NET behaviour (the `.csproj` filename): | `.csproj` filename | `AssemblyName` / `PackageId` | `RootNamespace` | Detected as | | -- | -- | -- | -- | | `Api.csproj` | `Acme.Api` | `Acme.Api` | Source project | | `Api.UnitTests.csproj` | `Acme.Api.UnitTests` | `Acme.Api` | `IsTestProject=true`, `TestingType=Unit` | | `Api.IntegrationTests.csproj` | `Acme.Api.IntegrationTests` | `Acme.Api` | `IsTestProject=true`, `TestingType=Integration` | | `SharedTestingFramework.csproj` | `Acme.SharedTestingFramework` | `Acme` | `IsSharedTestingProject=true` | > **Note:** `InternalsVisibleTo` follows `$(AssemblyName)` — so for `Api.csproj` the SDK generates > `Acme.Api.UnitTests`, `Acme.Api.IntegrationTests`, etc. Use short `.csproj` names — the SDK handles the prefixing: ```text ✅ Api.csproj → short name, SDK resolves the rest ❌ Acme.Api.csproj → redundant prefix, avoid ``` A build-time check (`PurviewProjectFileNameMismatch`) enforces that the `.csproj` filename matches its parent directory name, preventing inconsistent naming. Set `DisableProjectFileNamingConventionCheck=true` to opt out. ## Recommended structure: `src/` + `tests/` For larger repos, separate source and test projects into `src/` and `tests/` folders: ```text MyRepo/ ├── Directory.Build.props ← NamespacePrefix=Acme ├── Directory.Build.targets ├── Directory.Packages.props ├── global.json ├── src/ │ ├── Api/ │ │ └── Api.csproj │ ├── Core/ │ │ └── Core.csproj │ └── SourceGenerator/ │ └── SourceGenerator.csproj ├── tests/ │ ├── Api.UnitTests/ │ │ └── Api.UnitTests.csproj → IsTestProject=true, TestingType=Unit │ ├── Api.IntegrationTests/ │ │ └── Api.IntegrationTests.csproj │ └── SharedTestingFramework/ │ └── SharedTestingFramework.csproj → IsSharedTestingProject=true └── package.json ``` ## Flat structure: everything together For smaller repos, source and test projects can live side-by-side: ```text MyRepo/ ├── Directory.Build.props ├── Directory.Build.targets ├── Directory.Packages.props ├── global.json ├── Api/ │ └── Api.csproj ├── Api.UnitTests/ │ └── Api.UnitTests.csproj ├── Core/ │ └── Core.csproj ├── Core.IntegrationTests/ │ └── Core.IntegrationTests.csproj └── package.json ``` Both layouts work identically — the SDK detects test projects by name suffix, not folder location. ## Quick reference ```sh # Create a source project mkdir src/Api && cd src/Api dotnet new classlib -n Api # Create its unit tests mkdir ../../tests/Api.UnitTests && cd ../../tests/Api.UnitTests dotnet new classlib -n Api.UnitTests # SDK wires TUnit automatically # Or flat: mkdir Api.UnitTests && cd Api.UnitTests dotnet new classlib -n Api.UnitTests ``` ## Test project naming conventions Test projects are automatically detected by their suffix. Supported patterns: ```text MyProject.UnitTests → IsTestProject=true, TestingType=Unit MyProject.IntegrationTests→ IsTestProject=true, TestingType=Integration MyProject.E2ETests → IsTestProject=true, TestingType=E2E ``` Any suffix from the full list is recognised: `Unit`, `Integration`, `E2E`, `EndToEnd`, `Acceptance`, `Functional`, `Performance`, `Load`, `Smoke`, `Stress`, `Regression`, `Security`, `Chaos`, `Scenario`, `System`, `Threat`, `BlackBox`, `WhiteBox`, `Accessibility`, `Interactive`, `Environment`, `Architecture`, `Contract`. ## Shared testing projects Projects named `SharedTestingFramework`, `SharedTestingInfrastructure`, `SharedTestingInfra`, `SharedTestingUtilities`, `SharedTestingUtils`, `SharedTestingLibrary`, `SharedTestingLib`, or `SharedTestingHelpers` are treated as shared testing helpers — they get test package references but not the test runner or coverage settings. See [Project Type Detection](../project-type-detection/) for how these names are classified, and [Assembly Name Generation](../assembly-name-generation/) for how the identities are derived. # Project Type Detection > During Sdk.props evaluation the SDK classifies every .csproj by reading the project filename, the Sdk attribute, and on-disk markers. The resulting flags… # Project Type Detection During `Sdk.props` evaluation the SDK classifies every `.csproj` by reading the project filename, the `Sdk` attribute, and on-disk markers. The resulting flags drive the defaults described throughout this wiki. ## Detection flags | Flag | Condition | | -- | -- | | `IsCSharpProject` | Project file extension is `.csproj`. | | `IsTestProject` | Project name ends with `Test`/`Tests` and carries a recognised `TestingType` suffix. | | `IsSharedTestingProject` | Project name is one of `SharedTestingFramework`, `SharedTestingInfrastructure`, `SharedTestingInfra`, `SharedTestingUtilities`, `SharedTestingUtils`, `SharedTestingLibrary`, `SharedTestingLib`, `SharedTestingHelpers`. | | `IsSharedProject` | Project name is one of `Shared`, `SharedFramework`, `SharedInfrastructure`, `SharedInfra`, `SharedUtilities`, `SharedUtils`, `SharedLibrary`, `SharedLib`, `SharedHelpers`. | | `IsContainerProject` | A `Dockerfile`, `dockerfile`, or `Dockerfile.dev` exists in the project directory. | | `IsSdkProject` | An `Sdk` value is parsed from the ``/`` element. | | `IsWebSdkProject` | `SdkProjectName` is `Microsoft.NET.Sdk.Web`. | | `IsWorkerSdkProject` | `SdkProjectName` is `Microsoft.NET.Sdk.Worker`. | | `IsAspireHostProject` | `SdkProjectName` starts with `Aspire.Sdk.Host` or `Aspire.AppHost.Sdk`. | | `IsCLIProject` | Project name ends with `CLI`, `Console`, `CommandLine`, `QuickStart`, or `QuickStarts`. | | `IsRoslynComponent` | `true` is declared in the project file. | | `IsPackable` | `true` is declared in the project file. | | `IsWebProject` | Marker used in SDK web-project behaviour. | `TestingType` is the detected test category suffix from the project name (for example `Unit`, `Integration`, `E2E`); `TargetProjectName` is the inferred non-test project name that a test project targets. ## What each type gets ### Test projects (`IsTestProject=true`) - `OutputType=Exe` when the testing framework is not `None`. - `CollectCoverage=true` with coverage exclusions for framework and mocking packages. - `IsPackable=false`, `IsPublishable=false`, `MaxCpuCount=0`. - Disabled native instrumentation by default. - An `[assembly: ExcludeFromCodeCoverage]` attribute. - Automatic `ProjectReference` to the target project (resolved as `..//`, sibling `src/` paths, and the shared testing project alongside). - A `[Category: ]` assembly attribute (TUnit) or `[Trait("Category", ...)]` (Xunit). ### Shared testing projects (`IsSharedTestingProject=true`) - Test package references, but not the test runner or coverage settings. - A `[Skip]` attribute (TUnit) so the shared assembly is never executed directly. - `TUnit.Core` instead of the full `TUnit` package. ### Shared projects (`IsSharedProject=true`) - Automatically referenced by sibling non-test projects via wildcard (`../Shared*/Shared*.csproj`); test projects automatically reference `../SharedTesting*/SharedTesting*.csproj`. ### Container projects - `InvariantGlobalization=true`, `PublishAot=true`, `DockerDefaultTargetOS=Linux`, `DockerfileContext=..\..\`. - Adds `Microsoft.VisualStudio.Azure.Containers.Tools.Targets`. ### Web SDK projects (`IsWebSdkProject=true`) - Non-API web apps get `InterceptorsNamespaces` extended with `Microsoft.AspNetCore.OpenApi.Generated` for OpenAPI interceptors. ### CLI projects - `OutputType=Exe`, `appsettings*.json` copied to the output directory. - `CA1515` suppressed (nested settings classes on internal commands). ### Aspire host projects - `OutputType=Exe` (when not a test/shared-testing project), `CA1515` suppressed. ### Roslyn components (`IsRoslynComponent=true`) - Default `netstandard2.0` target, `LangVersion=latest`, `Nullable=enable`, `TreatWarningsAsErrors=true`, `EnforceExtendedAnalyzerRules=true`, `Deterministic=true`. - Compiler-generated output under the intermediate directory, no dependency file. - No symbol package by default; the PDB ships beside the analyzer in the package. - SourceLink with `EmbedUntrackedSources=true`, telemetry excluded. - See [Packaging](../packaging/) for how the analyzer assets are packed. ### Packable projects (`IsPackable=true`) - `GenerateDocumentationFile`, `IncludeSource`, `IncludeSymbols` (`.snupkg`), `PublishRepositoryUrl`, `EmbedUntrackedSources`, `DebugType=portable` defaults. - See [Packaging](../packaging/). ## Properties exposed to the compiler The detection flags (plus many SDK properties) are exposed to Roslyn analyzers and source generators via `CompilerVisibleProperty`, readable as `build_property.`. The full list is in the [Configuration Reference](../configuration-reference/). # Release Flow > This repository uses the shared Purview.Buildhttps://github.com/purview-dev/build pipeline for the full PR/release cycle, and plain dotnet/just commands for… # Release Flow This repository uses the shared [`Purview.Build`](https://github.com/purview-dev/build) pipeline for the full PR/release cycle, and plain `dotnet`/`just` commands for focused local work. ## Versioning - **`package.json` is the single source of truth for the version.** The `version` field is read by the SDK's [Version Detection](../version-detection/) logic and applied to `Version` and `PackageVersion` for every build and pack. - Current package version: read from `package.json` (`just current_version`). ## Local workflow ```text dotnet tool restore # install local tools (csharpier, etc.) just build # dotnet build src/BuildSdk.slnx --configuration Debug just test # dotnet test with a TUnit tree-node filter just lint-check # csharpier check just lint-fix # csharpier format . just pack # dotnet pack to ./artifacts just pipeline-pr # restore, build, lint, tests, pack, package validation just pipeline-local-release # restore, build, lint, tests, pack, local NuGet publish ``` ## Pipeline workflows | Recipe | Purpose | | -- | -- | | `just pipeline-pr` | PR pipeline — restore, build, lint, tests, pack, package validation. | | `just pipeline-build` | Build pipeline — restore, build, lint, pack, package validation (no tests). | | `just pipeline-tests` | Tests pipeline — restore, build, lint, tests (no pack). | | `just pipeline-release` | Release pipeline — restore, build, lint, tests, pack, publish to NuGet, GitHub release. | | `just pipeline-local-release` | Release pipeline with a local NuGet publish (`--Release:Mode=LocalNuGet`). | The pipeline configuration lives in `purview-build.json`: ```json { "Build": { "Solution": "src/BuildSdk.slnx", "TestRoot": "src/tests", "TestPatterns": "*Tests.csproj", "TestFilter": "/*/*/*/*[Category=Unit]/" }, "PackValidation": { "RequireSymbolPackage": false }, "Release": { "Mode": "None" } } ``` ## CI workflows - **PRs** — `.github/workflows/pr.yml` runs the PR pipeline on pull requests. - **Releases** — `.github/workflows/release.yml` triggers on pushes to `main` and runs the shared `purview-release.yml` workflow with `release-mode: NuGet`, which builds, packs, validates, and publishes the package to NuGet. - `continuousIntegrationBuild` is detected automatically from `CI`, `GITHUB_ACTIONS`, or `TF_BUILD` environment variables for deterministic SourceLink output. ## Testing - Tests use **Microsoft.Testing.Platform** (`global.json` sets `"runner": "Microsoft.Testing.Platform"`) and **TUnit** conventions. - CI runs the full suite on `ubuntu-latest`, so all tests and features must work identically on Windows, Linux, and macOS — never hardcode platform-specific paths in tests or fixtures. - The integration harness (`ProjectHarness`) creates throwaway projects under `Path.GetTempPath()`; see `src/tests/BuildSdk.IntegrationTests/Harness/ProjectHarness.cs`. - Linux agent-pack integration tests can be run locally via `just test-linux` (Docker-based). # Repository Bootstrap > The SDK bootstraps repo-level files so external tooling that does not read MSBuild item metadata for example CSharpier, IDE formatting tools, and SDK… # Repository Bootstrap The SDK bootstraps repo-level files so external tooling that does not read MSBuild item metadata (for example CSharpier, IDE formatting tools, and SDK resolution) works consistently out of the box. ## `.editorconfig` bootstrapping The package ships an `.editorconfig` in `Sdk/.editorconfig`. It is: 1. Registered on `@(EditorConfigFiles)` via `EditorConfigFilePath` for build-time code-style enforcement (`EnforceCodeStyleInBuild=true`, `EnableNETAnalyzers=true`, `AnalysisLevel=latest`, `AnalysisMode=All`). 2. Copied to the repository root (as a physical file) when a `.editorconfig` does not already exist there, so tools like CSharpier pick it up. Control it with: | Property | Default | Description | | -- | -- | -- | | `BootstrapEditorConfigToRepoRoot` | `true` | Copies the SDK `.editorconfig` to the repository root when missing. | | `RepositoryEditorConfigFilePath` | *(auto-detected)* | Override the destination path for the bootstrapped `.editorconfig`. | | `DisableAutoCopySdkFiles` | `false` | Master switch that disables repo-level SDK file bootstrapping. | ## `global.json` bootstrapping The SDK creates a `global.json` at the repository root when one is missing, registering `Purview.BuildSdk` in `msbuild-sdks` and setting the `Microsoft.Testing.Platform` test runner: ```json { "test": { "runner": "Microsoft.Testing.Platform" }, "msbuild-sdks": { "Purview.BuildSdk": "" } } ``` Control it with: | Property | Default | Description | | -- | -- | -- | | `BootstrapGlobalJsonToRepoRoot` | `true` | Creates a `global.json` at the repository root when missing. | | `RepositoryGlobalJsonFilePath` | *(auto-detected)* | Override the destination path for the bootstrapped `global.json`. | | `PurviewBuildSdkVersionForGlobalJson` | *(auto-detected or `1.0.0` fallback)* | Version written to the `msbuild-sdks.Purview.BuildSdk` entry. | | `DisableAutoCopySdkFiles` | `false` | Master switch that disables repo-level SDK file bootstrapping. | ## Repository root discovery Both bootstrap targets locate the repository root by: 1. Running `git rev-parse --show-toplevel` from the `Directory.Build.props` directory. 2. Falling back to probing upward for a `.git` marker. 3. Finally falling back to the `Directory.Build.props` directory itself. Set the `RepositoryEditorConfigFilePath` / `RepositoryGlobalJsonFilePath` properties explicitly to override discovery. # Testing Wiring > The SDK wires the testing stack for you based on three properties that must be set before the SDK import: # Testing Wiring The SDK wires the testing stack for you based on three properties that must be set **before** the SDK import: | Property | Default | Description | | -- | -- | -- | | `TestingFramework` | `TUnit` | Testing framework. Supported values: `TUnit`, `Xunit`, `None`. | | `SubstituteFramework` | `TUnitMocks` | Mocking provider. Supported values: `TUnitMocks`, `NSubstitute`, `None`. | | `TestDataFramework` | `Bogus` | Test data provider. Supported values: `Bogus`, `None`. | All three are validated at build/restore/pack time, and invalid values fail the build with a clear error. ## TUnit (default) - `OutputType=Exe`, `TestingPlatformDotnetTestSupport=true`, `UseMicrosoftTestingPlatformRunner=true`, `EnableMicrosoftTestingPlatform=true`. - The `TUnit` package is referenced (with the `Microsoft.Testing.Platform` runner configured via `global.json`). - A `[Category: ]` assembly attribute tags every test with its detected test category (for example `Unit`, `Integration`), which makes `--treenode-filter` filtering work. - `TUnit.Mocks` is referenced when `SubstituteFramework=TUnitMocks` (the default). ## Xunit (opt-in) Set `TestingFramework=Xunit`: - `xunit.v3` and `xunit.runner.visualstudio` (private assets) are referenced. - A `[Trait("Category", "")]` assembly attribute tags every test. - `OutputType=Exe` when the testing framework is not `None`. ## None Set `TestingFramework=None` to disable the test runner wiring entirely. The test project is still detected and gets coverage/IVT behaviour, but no framework packages or runner configuration are added. ## Shared test configuration All test and shared-testing projects get: - `CollectCoverage=true` with coverage exclusions for `[NSubstitute*]`, `[TUnit.*]`, `[xunit.*]`, `[Microsoft.Testing.*]`, `[Microsoft.NET.Test*]`, and `[Bogus*]`. - `ExcludeByAttribute` for `ExcludeFromCodeCoverageAttribute`. - `IsPackable=false`, `IsPublishable=false`, `MaxCpuCount=0`, `DisableGenerateAssemblyInfoClass=true`. - Disabled native instrumentation. - An `[assembly: ExcludeFromCodeCoverage]` attribute. ## Substitution frameworks - **TUnitMocks** (default) — references `TUnit.Mocks`. - **NSubstitute** — references `NSubstitute` plus `NSubstitute.Analyzers.CSharp` (analyzers, private assets) and a global `using NSubstitute`. - **None** — no mocking package. ## Test data frameworks - **Bogus** (default) — references `Bogus` with a global `using Bogus`. - **None** — no data package. ## Shared testing projects Projects named `SharedTestingFramework`, `SharedTestingInfrastructure`, `SharedTestingInfra`, `SharedTestingUtilities`, `SharedTestingUtils`, `SharedTestingLibrary`, `SharedTestingLib`, or `SharedTestingHelpers` are treated as shared testing helpers: - They get the test package references (`TUnit.Core` rather than the full `TUnit`) but **not** the test runner or coverage settings. - A `[Skip]` attribute (TUnit) keeps the shared assembly from being executed directly. - Test projects automatically reference the sibling shared testing project via `../SharedTesting*/SharedTesting*.csproj`. ## Automatic project references Test projects automatically reference their target project. The SDK probes `..//`, `../..//`, `../src//`, and `../../src//` (whichever exists), so both the `src/`+`tests/` and flat layouts are covered. ## Running tests Tests use **Microsoft.Testing.Platform** (`global.json` sets `"runner": "Microsoft.Testing.Platform"`) and are filtered with TUnit tree-node filters: ```sh dotnet test --treenode-filter "/*/*/*/*[Category=Unit]/" ``` See [Project Naming Conventions](../project-naming-conventions/) for the recognised test suffixes and [InternalsVisibleTo](../internalsvisibleto/) for how test assemblies access internals. # Version Detection > The SDK reads the version field from a repository-root package.json and applies it to both Version and PackageVersion automatically. When no version source… # Version Detection The SDK reads the `version` field from a repository-root `package.json` and applies it to both `Version` and `PackageVersion` automatically. When no version source can be resolved (non-strict mode), it silently falls back to `0.0.1`. ## Behaviour When `UsePackageJsonVersion=true` (the default) or `UsePackageJsonVersion=Strict`, the SDK: 1. **Explicit path** — if `RootPackageJson` is set, reads that file directly. 2. **Auto-discovery** — otherwise, locates the repo root from CI workspace variables (`GITHUB_WORKSPACE`, `BUILD_SOURCESDIRECTORY`, `BUILD_REPOSITORY_LOCALPATH`, `CI_PROJECT_DIR`), then by walking up from the project directory looking for a `.git` marker or a `package.json`, and reads `package.json` from there. The extracted `version` field is applied to both `Version` and `PackageVersion`. A build error is raised when a `package.json` was resolved but can't be read, or when it contains no `version` field. With `UsePackageJsonVersion=Strict`, the build also fails when no package.json source can be discovered at all (for example, no explicit `RootPackageJson` and no discoverable `.git` marker or CI workspace variable); in non-strict mode that case silently falls back to the `0.0.1` default. ## Caching Version detection results are cached locally under the user's temporary directory (`%TEMP%\Purview.BuildSdk\VersionDetection`, platform equivalent elsewhere) so repeated evaluations don't re-scan the filesystem. Enable or disable with `EnableVersionDetectionCache` (default `true`). ## Logging Version detection logging is disabled by default. Set `VersionDetectionLogEnabled=true` to emit a high-importance message showing the detected package version. ## Important — set before the import Both `UsePackageJsonVersion` and `RootPackageJson` must be set **before** the `` line in your `Directory.Build.props`. The version logic runs during that import and cannot see properties set afterwards (for example in individual `.csproj` files). ```xml Acme $(MSBuildThisFileDirectory)package.json ``` # Architecture > The shared artifact is a .NET tool NuGet package, not a reusable workflow and not an MSBuild SDK. Modular Pipelines is an executable orchestration system, so… # Architecture ## Decision The shared artifact is a .NET tool NuGet package, not a reusable workflow and not an MSBuild SDK. Modular Pipelines is an executable orchestration system, so a tool is its natural package boundary. A tool manifest gives each consumer deterministic version pinning and Renovate/Dependabot-compatible upgrades. It also keeps GitHub Actions as a thin host; the same command runs locally, in GitHub Actions, or in another CI service. The implementation is the generalized `PipelineCLI` that originated in `sourcegeneratorframework` (its most advanced version, including pack validation). It supersedes the earlier `Purview.Build` modules. The repository additionally exposes: - a **composite action** (`.github/actions/purview-build`) that installs a pinned `Purview.Build` version and runs it, for repositories embedding the build in their own jobs, and - two **reusable workflows** (`purview-build.yml`, `purview-release.yml`) that wrap that logic with structured inputs/secrets, reducing a consumer to one reusable-workflow job plus `purview-build.json`. An MSBuild SDK remains a possible future companion for shared compile-time properties, analyzers, or package metadata. It should not own CI orchestration. ## Ownership boundary The package owns module implementation, dependency ordering, safe defaults, secret lookup, NuGet/GitHub integration, and diagnostics. Each repository owns its tool-version pin, paths and discovery patterns, feature switches, and release-mode selection. A project needing truly custom behavior can invoke its own command before/after the shared tool; a generally useful variation should be added as a typed option here. ## Module ordering The pipeline is registered in `Program.cs` in this order, with explicit `[DependsOn]` edges defining the graph: ```text VersionModule ──────────────┐ RestoreModule → BuildModule ├→ RunTestsModule → PackModule → ValidatePackModule RestoreModule → LintModule │ VersionModule ──────────────┘ ``` Explicit `[DependsOn]` edges: - `BuildModule` depends on `RestoreModule`. - `LintModule` depends on `RestoreModule` (Web lint needs the dependencies installed by `bun install`; dotnet lint is unaffected beyond running after restore). - `RunTestsModule` depends on `BuildModule`. - `PackModule` depends on `RunTestsModule` and `VersionModule`. - `ValidatePackModule` depends on `PackModule`. - `PublishNuGetModule` depends on `PackModule`, `ValidatePackModule`, and `RunTestsModule`. - `PublishLocalNuGetModule` depends on `PackModule` and `ValidatePackModule`. - `CreateGitHubReleaseModule` depends on `PublishNuGetModule`, `ValidatePackModule`, and `VersionModule`. See [Pipeline Modules](../pipeline-modules/) for per-module behavior and skip conditions. ## Repository root resolution The tool locates the repository root by walking up from the current working directory to the nearest `package.json` (`PathHelpers.FindRepositoryRoot`); `Environment.CurrentDirectory` is set to that root before modules run. `MODULAR_PIPELINES_DIRECTORY` can override the directory containing `appsettings.json` when the defaults do not apply. Command-line overrides use configuration syntax, for example: ```shell dotnet purview-build --Build:TestPatterns=*IntegrationTests.csproj --Build:RunPack=false ``` ## Project, testing, and release support - **Project types**: the pipeline is dotnet-first (libraries, source generators, analyzers, MSBuild SDKs, Aspire hosting extensions), gated by `Build:ProjectType`. Non-dotnet project types are implemented as configuration-gated module branches: - `Web` (Bun/JS/TS sites such as the Astro/Starlight purview.dev portal) runs the repository's root `package.json` scripts: `bun install` (restore), `bun run build` (build, after an automatic `data:sync` when one is declared and the command is left at the default), `bun run format:check` + `bun run lint` (lint), and `bun run test` (tests). The Web pack step zips `Build:WebBuildOutput` into `Build:ArtifactsFolder` as `-.zip`. Every Web command is overridable via the `Web*` settings. - `WebExtension` (JS/Azure DevOps extensions) remains future work. - **Testing types**: TUnit on Microsoft.Testing.Platform (default) and xUnit, both configurable via `TestFramework`/`TestFilter`. Web projects run the `WebTestCommand` (default `bun run test`); other non-dotnet runners (Vitest, Playwright, Jest) can be targeted by overriding that command. - **Release types**: nuget.org (API key or Trusted Publishing), GitHub Packages internal feed, local NuGet feed, GitHub release (optionally with package/vsix assets), and future Aspire-deploy / Azure DevOps marketplace publishing. ## Release behavior - `None`: build/test/pack may run, but nothing publishes. - `LocalNuGet`: pushes packages to the resolved local feed for developer testing. Only honoured when the tool runs **locally**; it is ignored in CI, so it cannot be driven through the reusable workflows. - `NuGet`: pushes packages to the configured feed and, by default, creates a GitHub release. - `GitHubRelease`: creates a GitHub release (optionally uploading `ArtifactsFolder` assets) without publishing NuGet packages. The workflow decides whether a version is eligible to release (for example, only an untagged version on `main` or `release`) and sets `Release__Mode`. Credentials remain CI secrets. ## See also - [Configuration Reference](../configuration-reference/) - [Pipeline Modules](../pipeline-modules/) - [Release Flow](../release-flow/) # Configuration Reference > Configuration is optional in a consuming repository; defaults are baked into the tool. Add purview-build.json at the repository root to override them. # Configuration Reference Configuration is optional in a consuming repository; defaults are baked into the tool. Add `purview-build.json` at the repository root to override them. ## Precedence Command line > environment variables > `purview-build.json` > baked-in defaults (`appsettings.json`) > code-level defaults. - Environment variables use `__` for nesting, for example `Release__Mode=NuGet`. - Command-line overrides use configuration syntax, for example `--Build:RunPack=false`. - Secrets must not be committed; they are supplied at runtime through env vars / CI secrets. See [Secrets and Environment Variables](../secrets-and-environment-variables/). ## `Build` | Key | Default | Purpose | | --- | --- | --- | | `LogLevel` | `Warning` | `Trace`/`Debug`/`Information`/`Warning`/`Error`/`Critical`/`None`; used by the pipeline logger | | `ProjectType` | `DotNet` | `DotNet` (dotnet restore/build/test/pack) or `Web` (Bun commands from the root `package.json` scripts) | | `Solution` | `src/Product.slnx` | Solution, project, or directory passed to restore/build/pack (dotnet only) | | `Configuration` | `Release` | .NET configuration | | `ArtifactsFolder` | `artifacts` | Package output directory | | `RunTests` | `true` | Enable discovered tests | | `TestRoot` | `src/tests` | Test discovery root (relative to the repository root) | | `TestPatterns` | `*Tests.csproj` | Comma-separated project search patterns applied under `TestRoot` | | `TestProjects` | `*` | Comma-separated project names/globs to run; `*` runs all discovered | | `TestFramework` | `TUnit` | `TUnit` (tree-node filter) or `xUnit` (VSTest filter) | | `TestFilter` | `/*/*/*/*/` | TUnit tree-node filter or xUnit `--filter`; empty disables it | | `RunLint` | `true` | Restore local tools and run CSharpier check (dotnet) or `format:check` + `lint` scripts (Web) | | `RunPack` | `true` | Enable packing | | `ValidatePack` | `true` | Enable pack validation (dotnet only; always skipped for Web) | | `WebInstallCommand` | `bun install` | Install command for `ProjectType=Web` | | `WebBuildCommand` | `bun run build` | Build command for `ProjectType=Web`; when left at the default the module first runs a `data:sync` script if one is declared | | `WebLintCommand` | `bun run lint` | Lint command for `ProjectType=Web` | | `WebFormatCheckCommand` | `bun run format:check` | Format-check command for `ProjectType=Web` | | `WebTestCommand` | `bun run test` | Test command for `ProjectType=Web` | | `WebBuildOutput` | `src/dist` | Directory zipped into `ArtifactsFolder` by the Web pack step | ## `PackValidation` | Key | Default | Purpose | | --- | --- | --- | | `RequireSymbolPackage` | `true` | Every `.nupkg` must have a matching `.snupkg` and vice versa | | `RequireSymbolFiles` | `true` | Every `.snupkg` must contain at least one `.pdb` | | `RequireSourceLink` | `false` | Every `.dll`/`.exe` must have a matching portable PDB containing a Source Link record | | `RequireDeterministic` | `false` | Every `.dll`/`.exe` must be built deterministically (the PE carries the Reproducible debug directory entry) | | `RequiredCompilerFlags` | `[]` | Compiler-flag `key=value` entries that must appear in each assembly's PDB compiler-flags record (e.g. `optimization=release`) | | `RequiredContent` | `{}` | Package-id glob → entry-path globs that must be present in the `.nupkg` (`"*"` matches every package) | | `ForbiddenContent` | `{}` | Package-id glob → entry-path globs that must not be present in the `.nupkg` (`"*"` matches every package) | Content entry paths and package-id keys are matched as globs (case-insensitive), e.g. `tools/**/Foo.dll` or `**/*.pdb`. Required content is satisfied when any package entry matches; forbidden content fails when any entry matches. The assembly checks (`RequireSourceLink`, `RequireDeterministic`, `RequiredCompilerFlags`) inspect each `.dll`/`.exe` in the `.nupkg` (PE header) and its sibling portable PDB in the `.snupkg` (custom debug info records); they only apply to assemblies the package ships symbols for. Determinism is detected via the PE's Reproducible debug directory entry, source link via the PDB's Source Link record, and compiler flags via the PDB's key/value compiler-flags record (matched case-insensitively, e.g. `optimization=release`). > **Tool defaults vs code defaults.** The shipped `appsettings.json` sets `RequireSourceLink: false`, `RequireDeterministic: false`, and `RequiredCompilerFlags: []`. The C# property initializers in `PackValidationSettings` default those to `true`/`true`/`["optimization=release"]`, but because `appsettings.json` always loads and wins over code defaults, the effective shipped defaults are the `false`/`false`/`[]` values shown above. ## `NuGet` | Key | Default | Purpose | | --- | --- | --- | | `FeedUrl` | nuget.org v3 | Remote package source | | `TrustedPublishing` | `false` | Push without an API key (NuGet Trusted Publishing / OIDC) | | `APIKey` | unset | Secret; use `NUGET_APIKEY` or `NuGet__ApiKey` | | `EnvAPIKey` | unset | Binds `NuGet__NUGET_APIKEY`; also falls back to process env `NUGET_APIKEY`/`NUGET_API_KEY` | ## `PublishLocalNuGet` | Key | Default | Purpose | | --- | --- | --- | | `LocalFeedPath` | unset | Absolute local package source | | `EnvLocalFeedPath` | unset | Binds `PublishLocalNuGet__LOCAL_NUGET_FEED_PATH`; also falls back to process env `LOCAL_NUGET_FEED_PATH` | | `OverwriteExistingPackages` | `true` | Overwrite packages already in the local feed | | `ShutdownDotnetBuilderServer` | `true` | Shut down the dotnet build server after publishing | | `ClearPackageCache` | `true` | Clear the local NuGet package caches for the published packages | ## `GitHub` | Key | Default | Purpose | | --- | --- | --- | | `AccessToken` | unset | Secret; use `GITHUB_TOKEN` | | `EnvAccessToken` | unset | Binds `GitHub__GITHUB_TOKEN`; also falls back to process env `GITHUB_TOKEN` | | `ProductHeader` | `Purview.Build.Pipeline` | GitHub API product header | ## `Release` | Key | Default | Purpose | | --- | --- | --- | | `Mode` | `None` | `None`, `LocalNuGet`, `NuGet`, or `GitHubRelease` | | `UploadArtifacts` | `false` | Upload every file in `Build:ArtifactsFolder` as GitHub release assets | ## Example ```json { "Build": { "Solution": "src/MyProduct.slnx", "TestRoot": "src/tests", "TestPatterns": "*Tests.csproj", "TestFilter": "/*/*/*/*[Category=Unit]" }, "PackValidation": { "RequireSymbolPackage": true, "RequireSourceLink": true, "RequireDeterministic": true, "RequiredCompilerFlags": ["optimization=release"], "RequiredContent": { "my.product": ["lib/netstandard2.0/My.Product.dll"] } }, "Release": { "Mode": "None" } } ``` ## See also - [Pipeline Modules](../pipeline-modules/) - [Secrets and Environment Variables](../secrets-and-environment-variables/) # Getting Started > Purview.Build is consumed from a repository that owns its own configuration. The pipeline code lives here; consumers reference the composite action or one of… # Getting Started `Purview.Build` is consumed from a repository that owns its own configuration. The pipeline code lives here; consumers reference the composite action or one of the reusable workflows (or run the tool locally) and configure it with `purview-build.json`. ## Two independent version axes - The `@ref` suffix on a reusable-workflow or composite-action reference selects the workflow/action **code**: `@main` always runs the latest code, while a release tag (e.g. `@v0.2.0`) pins it for reproducibility. There is no `@latest`; the `@ref` is required for cross-repository references. - The `build-version` input selects the installed `Purview.Build` **tool**. Omit it to always install the latest stable tool from nuget.org, or pin an exact version (e.g. `build-version: 0.2.0`) for reproducibility. Mixing a pinned old `@ref` with a floating `build-version` runs newer tool code through older workflow inputs. ## Minimal repository setup (reusable workflow) ```yaml # .github/workflows/pr.yml name: PR on: pull_request: branches: [main] jobs: build: uses: purview-dev/build/.github/workflows/purview-build.yml@main # `build-version` is optional; when omitted, the latest stable Purview.Build # from nuget.org is installed. Pin it (e.g. `build-version: 0.2.0`) for # reproducible builds. secrets: inherit ``` ```yaml # .github/workflows/release.yml — release on main name: Release on: push: branches: [main] concurrency: # Serialize releases; callers own concurrency (see Release Flow). group: release-${{ github.ref }} cancel-in-progress: false jobs: release: uses: purview-dev/build/.github/workflows/purview-release.yml@main with: release-mode: NuGet secrets: inherit ``` For the **main-as-head / release-branch model**, point the release caller at the release branch instead: ```yaml on: push: branches: [release] ``` The reusable release workflow checks whether `v{version}` (read from `package.json`) is already tagged and skips if so, so merging `main` into `release` releases exactly once. The reusable workflows install the pinned CLI version (or the latest stable when `build-version` is omitted) from nuget.org; the consuming repository adds `purview-build.json` and a root `package.json` version. It does not need a copied pipeline project or package-source credentials. ## Minimal repository setup (composite action) ```yaml jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - uses: purview-dev/build/.github/actions/purview-build@main env: Build__TestFilter: "/*/*/*/*[Category=Unit]" ``` The action's `build-version` input is optional. When omitted, `dotnet tool install` resolves the latest stable `Purview.Build` from nuget.org; pass an exact version to pin the build. ## Local use ```shell dotnet tool install Purview.Build --tool-path ./.tools ./.tools/purview-build ``` Omit `--version` to install the latest stable release. ## Release a consuming repository Releasing consists of bumping the `version` field in the repository's root `package.json` and merging the validated pull request into the release head. The pipeline owns tagging (`v{version}`) and the GitHub release; maintainers must not push release tags manually. See [Release Flow](../release-flow/). ## See also - [Architecture](../architecture/) - [Configuration Reference](../configuration-reference/) - [Pipeline Modules](../pipeline-modules/) - [Secrets and Environment Variables](../secrets-and-environment-variables/) # Local Development > Local work uses the just recipes in the Justfile which delegate to plain dotnet/bun commands or the shared pipeline tool directly. # Local Development Local work uses the `just` recipes in the `Justfile` (which delegate to plain `dotnet`/`bun` commands) or the shared pipeline tool directly. ## Tool installation ```shell dotnet tool install Purview.Build --tool-path ./.tools ./.tools/purview-build ``` Omit `--version` to install the latest stable release. For a pinned local tool manifest, add it to `.config/dotnet-tools.json` and run `dotnet tool restore`. ## `just` recipes | Recipe | Purpose | | --- | --- | | `just build` | Build `src/Build.slnx` (Debug) | | `just test` / `just test-unit` | Run tests with a TUnit tree-node filter (unit filter: `/*/*/*/*[Category=Unit]`) | | `just lint-check` / `just lint-fix` | CSharpier check / format | | `just pack` | `dotnet pack` with the current `package.json` version | | `just pipeline-pr` | Run the shared tool (restore, build, lint, tests) | | `just pipeline-build` | Run the shared tool without tests | | `just pipeline-release` | Run the shared tool with `Release:Mode=NuGet` | | `just pipeline-tests` | Run the shared tool with tests enabled | | `just pipeline-local-release` | Lint-fix, then run the shared tool with `Release:Mode=LocalNuGet` | | `just clean-all` / `just scrub` | Clean build outputs | `just pipeline-*` installs the `Purview.Build` tool to `.tools/purview-build` from nuget.org when it is not already present. ## Local NuGet publishing `Release:Mode=LocalNuGet` pushes packages to a local feed and is only honoured when the tool runs **locally** — it is ignored in CI. ```shell LOCAL_NUGET_FEED_PATH=C:/local/nuget-feed/ ./.tools/purview-build --Release:Mode=LocalNuGet ``` The local feed path must be an absolute path. `just` runs recipes through the shell, which strips backslashes from unquoted arguments, so a backslash-based Windows path is mangled before the tool sees it and is rejected. Use the `LOCAL_NUGET_FEED_PATH` environment variable, or forward slashes: ```shell just pipeline-local-release --PublishLocalNuGet:LocalFeedPath=C:/local/nuget-feed/ ``` By default the module overwrites existing packages, clears the NuGet global-packages and HTTP caches for the published packages, and shuts down the dotnet build server afterwards (all configurable under `PublishLocalNuGet`). ## Repository root resolution The tool locates the repository root by walking up from the current working directory to the nearest `package.json`. Run `purview-build` from within the repository. `MODULAR_PIPELINES_DIRECTORY` can override the directory containing `appsettings.json`. ## See also - [Configuration Reference](../configuration-reference/) - [Pipeline Modules](../pipeline-modules/) - [Release Flow](../release-flow/) # Migration: aspire-resourcekit > This repository currently contains a vendored copy of build/PipelineCLI. Migrate it as follows. # Migration: aspire-resourcekit This repository currently contains a vendored copy of `build/PipelineCLI`. Migrate it as follows. 1. Replace the vendored `build/PipelineCLI` with the shared `Purview.Build` tool pinned to the chosen released version. 2. Add this `purview-build.json`: ```json { "Build": { "Solution": "src/ResourceKit.slnx", "TestRoot": "src/tests", "TestPatterns": "*Tests.csproj", "TestFilter": "/*/*/*/*[Category=Unit]" }, "Release": { "Mode": "None" } } ``` 3. Replace `pr.yml` and `release.yml` with thin callers of `purview-dev/build/.github/workflows/purview-build.yml` and `.../purview-release.yml`, passing `build-version`. Keep the `[Category=Unit]` filter in the release caller. 4. In the release caller set `release-mode: NuGet` and `secrets: inherit` (`NUGET_APIKEY` and `GITHUB_TOKEN` are read by the shared workflow). 5. Run the PR pipeline, then delete `build/PipelineCLI` and its pipeline-only central package declarations (`ModularPipelines*`, `NuGet.Packaging/Versioning`). The old solution path and unit-test filter are preserved exactly. Other repositories migrate by changing only the JSON paths/patterns; for example `dotnet-project-sdk` can list unit and integration project globs in `Build:TestPatterns`. # Pack Validation > ValidatePackModule inspects every .nupkg/.snupkg produced in Build:ArtifactsFolder and fails the pipeline when any package has validation errors. Each package… # Pack Validation `ValidatePackModule` inspects every `.nupkg`/`.snupkg` produced in `Build:ArtifactsFolder` and fails the pipeline when any package has validation errors. Each package is reported as valid/invalid in the summary. ## Symbol package pairing (`RequireSymbolPackage`) Every `.nupkg` must have a matching `.snupkg` (same id/version) and vice versa. A package without its symbol sibling is an error. ## Symbol package contents (`RequireSymbolFiles`) Every `.snupkg` must contain at least one `.pdb`. Symbol packages must not contain non-symbol files other than OPC metadata (`[Content_Types].xml`, `_rels/`, `package/services/metadata/`) and the `.nuspec`. ## PDB placement in `.nupkg` The `.nupkg` must not contain `.pdb` files outside `tools/` — PDBs are delivered through the `.snupkg`. The exception is tool packages (`PackAsTool`), whose runtime PDBs legitimately live under `tools/`; the `Purview.Build` csproj strips those PDBs from the `.nupkg` after `GenerateNuspec` so the `.snupkg` keeps them for source-link validation. ## Content rules (`RequiredContent` / `ForbiddenContent`) Both maps are keyed by package-id glob (case-insensitive; `"*"` matches every package) and contain entry-path glob lists (forward slashes, e.g. `tools/**/Purview.Build.dll` or `**/*.pdb`). - **Required**: the rule is satisfied when any package entry matches the glob; a missing match is an error. - **Forbidden**: any matching entry is an error. ## Assembly inspection When any of `RequireSourceLink`, `RequireDeterministic`, or `RequiredCompilerFlags` is enabled, each `.dll`/`.exe` in the `.nupkg` that the package ships symbols for (a sibling PDB exists in the `.snupkg` or `.nupkg`) is inspected: - **Deterministic (`RequireDeterministic`)**: the PE must carry the Reproducible debug directory entry (`PEReader.ReadDebugDirectory` with type `Reproducible`). Bundled third-party binaries without symbols are not judged. - **Source link (`RequireSourceLink`)**: the matching portable PDB must contain a Source Link record (custom debug info GUID `CC110556-A091-4D38-9FEC-25AB9A351A6A`). - **Compiler flags (`RequiredCompilerFlags`)**: the PDB's compiler-flags record (custom debug info GUID `B5FEEC05-8CD0-4A83-96DA-466284BB4BD8`, stored as NUL-separated `key=value` pairs) must contain each required flag, matched case-insensitively (e.g. `optimization=release`). Unreadable packages and invalid PE/PDB files are reported as errors. ## Filename checks Package file names must match the nuspec id/version, i.e. `..nupkg` / `..snupkg`. ## See also - [Pipeline Modules](../pipeline-modules/) - [Configuration Reference](../configuration-reference/) # Pipeline Modules > The pipeline is a Modular Pipelines orchestration. Modules are registered in Program.cs; explicit DependsOn edges define ordering, while ModuleConfiguration… # Pipeline Modules The pipeline is a Modular Pipelines orchestration. Modules are registered in `Program.cs`; explicit `[DependsOn]` edges define ordering, while `ModuleConfiguration` skip conditions gate opt-in behavior. Module categories are `Build` and `Release`. ```text Version ───────────────┐ Restore → Build → Test ├→ Pack → Validate → Publish → GitHub release └→ Lint │ Version ───────────────┘ ``` ## VersionModule Reads the SemVer `version` field from the repository root `package.json` and produces a `NuGetVersion`. Fails when the file is missing, the field is missing/empty, or the value is not valid SemVer. The version feeds `PackModule` (via `Version`/`PackageVersion`) and `CreateGitHubReleaseModule` (via the `v{version}` tag). ## RestoreModule Runs `Build:WebInstallCommand` (default `bun install`) when `Build:ProjectType` is `Web`, otherwise `dotnet restore` against `Build:Solution`. ## BuildModule Depends on `RestoreModule`. - **DotNet**: runs `dotnet build` against `Build:Solution` with `Build:Configuration` and `--no-restore`. - **Web**: when `Build:WebBuildCommand` is left at the default (`bun run build`) and the repository declares a `data:sync` script, first runs `bun run data:sync` (so a fresh checkout can build offline), then runs the build command. Overriding `WebBuildCommand` takes full control of the build (for example a chain of validation scripts); the automatic data sync is then skipped. ## LintModule Depends on `RestoreModule` (Web lint invokes tooling installed by the restore step, so it must wait for `bun install`; dotnet lint is unaffected beyond running after restore). Skip condition: skipped when `Build:RunLint` is false. - **DotNet**: restores the repository's local tools (`dotnet tool restore` against `.config/dotnet-tools.json`, retried up to 3 times with a 2-second backoff on failure) and then runs `dotnet tool run csharpier check `. The repository root is resolved by walking up to the nearest `package.json`. - **Web**: runs `Build:WebFormatCheckCommand` (default `bun run format:check`) then `Build:WebLintCommand` (default `bun run lint`). Each step is skipped when the corresponding script is not declared in the root `package.json`. ## RunTestsModule Depends on `BuildModule`. Skip condition: skipped when `Build:RunTests` is false. - **DotNet**: discovers test projects by enumerating `*.csproj` recursively under `Build:TestRoot`, matching file names against `Build:TestPatterns` (comma-separated glob/name patterns, case-insensitive), then restricting the run list with `Build:TestProjects` (default `*`). If no projects match, it logs a warning and returns an empty result. Runs each test project with `dotnet test --no-build --no-restore` in parallel, using `Build:Configuration`: - **TUnit** (default): passes `--ignore-exit-code 8` (Microsoft.Testing.Platform exits with code 8 when no tests are selected; treated as success) and, when `Build:TestFilter` is non-empty, `--treenode-filter `. - **xUnit**: when `Build:TestFilter` is non-empty, passes `--filter `. - **Web**: runs `Build:WebTestCommand` (default `bun run test`) from the repository root. Per-project timings are logged, ordered by elapsed time. ## PackModule Depends on `RunTestsModule` and `VersionModule`. Skip condition: skipped when `Build:RunPack` is false. - **DotNet**: creates `Build:ArtifactsFolder` and runs `dotnet pack` against `Build:Solution` with `Build:Configuration`, `--output `, and `-p:PackageVersion= -p:Version=` where the version comes from `VersionModule`. - **Web**: creates `Build:ArtifactsFolder` and zips `Build:WebBuildOutput` (default `src/dist`) into `-.zip` (name from the root `package.json` `name` field, version from `VersionModule`). Logs a warning and produces no artifact when the build output directory does not exist. ## ValidatePackModule Depends on `PackModule`. Skip condition: skipped when `Build:ValidatePack` is false **or** `Build:ProjectType` is `Web` (Web projects produce no `.nupkg`). Inspects every `.nupkg`/`.snupkg` in `Build:ArtifactsFolder`. Fails the run if any package has errors. Produces a summary of valid/invalid package counts. See [Pack Validation](../pack-validation/) for the full rule set. ## PublishNuGetModule Category `Release`. Depends on `PackModule`, `ValidatePackModule`, and `RunTestsModule`. Skip condition: skipped when `Build:ProjectType` is `Web` **or** `Release:Mode` is not `NuGet` **or** (`NuGet:TrustedPublishing` is false and no API key resolves via `NuGet:GetNuGetAPIKey()`). Pushes every `*.nupkg` in `Build:ArtifactsFolder` to `NuGet:FeedUrl` with `--skip-duplicate`. When `NuGet:TrustedPublishing` is true, pushes without an API key (NuGet Trusted Publishing / OIDC federation). ## PublishLocalNuGetModule Depends on `PackModule` and `ValidatePackModule`. Skip condition: skipped when `Build:ProjectType` is `Web` **or** the tool is not running **locally** (`ctx.IsRunningLocally()`) **or** `Release:Mode` is not `LocalNuGet`. This mode is intentionally ignored in CI. Validates `PublishLocalNuGet:LocalFeedPath` (resolved via `GetLocalFeedPath()`, falling back to `PublishLocalNuGet__LOCAL_NUGET_FEED_PATH` and then process env `LOCAL_NUGET_FEED_PATH`). The path must be absolute; drive-relative paths such as `p:foo` (backslashes stripped by a sh-style shell) are rejected with a remediation message. See [Local Development](../local-development/). Moves the `.nupkg`/`.snupkg` files from `Build:ArtifactsFolder` into the local feed (skipping existing files unless `OverwriteExistingPackages` is true), optionally clears the NuGet global-packages and HTTP caches for the published packages (`ClearPackageCache`), and optionally shuts down the dotnet build server (`ShutdownDotnetBuilderServer`). ## CreateGitHubReleaseModule Category `Release`. Depends on `PublishNuGetModule`, `ValidatePackModule`, and `VersionModule`. Skip condition: skipped unless `Release:Mode` is `NuGet` or `GitHubRelease` **and** a GitHub token resolves via `GitHub:GetGitHubToken()`. Creates a GitHub release with tag `v{version}` and `GenerateReleaseNotes = true`. When `Release:UploadArtifacts` is true, uploads every file in `Build:ArtifactsFolder` as a release asset — for Web projects this is the `-.zip` produced by `PackModule`. The tag must not already exist; callers gate release eligibility (the tool does not skip an existing tag itself). ## See also - [Architecture](../architecture/) - [Configuration Reference](../configuration-reference/) - [Pack Validation](../pack-validation/) - [Release Flow](../release-flow/) # Versioning and Release Flow > Purview.Build follows SemVer. The package version is the compatibility contract for configuration keys, defaults, module ordering, and tool behavior. # Versioning and Release Flow `Purview.Build` follows SemVer. The package version is the compatibility contract for configuration keys, defaults, module ordering, and tool behavior. - Patch: fixes that preserve configuration and pipeline behavior. - Minor: additive options or modules with backward-compatible defaults. - Major: renamed/removed keys, changed defaults with material effects, or a required runtime upgrade. The reusable workflows and composite action are referenced with an `@ref` suffix (required for cross-repository references), which selects the workflow/action *code*: `@main` always runs the latest code, while a release tag (e.g. `@v0.2.0`) pins it for reproducibility. `build-version` is an independent axis that selects the installed `Purview.Build` *tool*; it defaults to the latest stable release from nuget.org, and consumers that need reproducibility pin an exact version via the `build-version` input (and, for local use, `.config/dotnet-tools.json`). Automated dependency updates should open a pull request, where the consumer's normal build validates the new tool before merge. Keep the previous major supported while migrations are in progress. The version is declared by the `version` field in the repository's root `package.json`. Releasing consists of bumping that field and merging the validated pull request into the release head. ## Branch models Each repository is gated by a pull-request build. Two release trigger models are supported; the consuming repository's tiny caller workflow chooses: - **Release on `main`**: the release caller triggers on `push: branches: [main]`. - **Main-as-head / release branch**: development merges to `main`, and merging `main` into a `release` branch performs the release. The release caller triggers on `push: branches: [release]`. In both models the reusable `purview-release.yml` workflow reads `package.json`'s `version`, skips when the `v{version}` tag already exists, and otherwise runs the pipeline with `Release__Mode` set. Because publication is idempotent (`--skip-duplicate`) and the tag is created by the workflow, re-merging `main` into `release` after a failed release is safe. The reusable workflow does **not** define a concurrency group. GitHub Actions cancels a run as a deadlock when a caller workflow and the reusable workflow it calls share the same concurrency group (the caller's `purview-release-main` collided with the reusable workflow's `purview-release-${{ inputs.release-branch }}` resolving to the same value, producing *"Canceling since a deadlock was detected for concurrency group"*). Callers must own release serialization by defining their own `concurrency` block: ```yaml concurrency: group: release-${{ github.ref }} cancel-in-progress: false ``` The `release-branch` input is retained for backward compatibility only. ## This repository's CI/CD This repository dogfoods the shared tool. CI performs restore, warnings-as-errors compilation, packing, installation from the generated package, then runs `purview-build` against this repository so the project builds and packs itself. See [Repository CI/CD](../repository-ci-cd/). On a push to `main`, the release workflow reads and validates the `package.json` version, skips when `v{version}` already exists, then builds and installs the tool from the current source and runs it with `Release__Mode=NuGet`, `NuGet__FeedUrl` set to nuget.org, and `Release__UploadArtifacts=true`. The tool performs the release build/pack steps, publishes the immutable package to `https://api.nuget.org/v3/index.json` using the `NUGET_APIKEY` secret, and creates `v{version}` plus a generated-notes GitHub release with the package attached — tagging itself exactly like every other purview-dev repository. The tool therefore owns tagging; maintainers must not push release tags manually. ## GitHub package visibility GitHub creates NuGet packages as private on first publication. To make sure every package is **Internal** (visible to all Purview-Dev members), set both: 1. **Organization default (prevents future private packages)** — org owner: GitHub → purview-dev → Settings → Packages → **Package Creation** → select **Internal**. New NuGet packages published by organization members then default to Internal. 2. **Existing packages already published while private** — org owner, per package: `https://github.com/orgs/purview-dev/packages/nuget/package/` → **Package settings** → **Danger Zone** → **Change visibility** → **Internal**. Or via the CLI/API for every package on the registry: ```shell gh api --method PATCH "/orgs/purview-dev/packages/nuget/Purview.Build" -f visibility=internal ``` Public packages cannot be made private again; private → internal is safe. NuGet versions are immutable; `--skip-duplicate` makes recovery safe if publication succeeded but tagging was interrupted. ## For local validation ```shell dotnet pack src/src/Build/Build.csproj -c Release -o artifacts -p:Version=0.2.4 -p:PackageVersion=0.2.4 dotnet tool install Purview.Build --tool-path ./.tools --add-source ./artifacts ./.tools/purview-build ``` To publish packages built by a consumer to a local feed for development: ```shell LOCAL_NUGET_FEED_PATH=p:/_sync-projects/.local-nuget/ ./.tools/purview-build --Release:Mode=LocalNuGet ``` ## See also - [Getting Started](../getting-started/) - [Repository CI/CD](../repository-ci-cd/) # Repository CI/CD > This repository dogfoods the shared Purview.Build tool: it builds and packs the tool from source, installs the generated package, then runs purview-build… # Repository CI/CD This repository dogfoods the shared `Purview.Build` tool: it builds and packs the tool from source, installs the generated package, then runs `purview-build` against itself so the project builds and packs itself. ## CI (`ci.yml`) Runs on pull requests and pushes to `main`: 1. Check out the repository. 2. Restore `src/Build.slnx`. 3. **Build gate**: `dotnet build src/Build.slnx --configuration Release --no-restore --warnaserror`. 4. Read and SemVer-validate the `package.json` version. 5. Pack the tool from source (`dotnet pack src/Build.slnx --configuration Release --no-build --output artifacts -p:Version=… -p:PackageVersion=…`). 6. Install the packed tool from the `artifacts` source into a temp tool path. 7. **Dogfood**: run the freshly installed `purview-build` against this repository (with `GITHUB_TOKEN`). The tool restores, builds, lints, runs tests, packs, and validates itself. ## Release (`release.yml`) Runs on push to `main` and is serialized by its own `concurrency` group (`purview-build-release`, `cancel-in-progress: false`): 1. Read and SemVer-validate the `package.json` version; skip the whole job when `v{version}` is already tagged (the tag check makes re-merges safe). 2. Restore and build `src/Build.slnx` with `--warnaserror`. 3. Pack the tool from source with `-p:ContinuousIntegrationBuild=true`. 4. Verify the `NUGET__APIKEY` secret is set. 5. Install the packed tool. 6. **Run the release pipeline** with `Release__Mode=NuGet`, `NuGet__FeedUrl=https://api.nuget.org/v3/index.json`, `Release__UploadArtifacts=true`, `Build__RunTests=false`, `Build__RunLint=false`, and `Build__ValidatePack=true`, passing `GITHUB_TOKEN` and `NUGET_APIKEY`. The tool therefore publishes the immutable package to nuget.org and tags and releases itself (`v{version}` + generated-notes GitHub release with the package attached) — exactly like every other purview-dev repository. Maintainers bump the `package.json` version and merge; they do not create release tags manually. ## GitHub package visibility GitHub initially creates NuGet packages as private. An organization owner should set the org default to **Internal** (Purview-Dev → Settings → Packages → **Package Creation** → **Internal**) and change any already-published package's visibility in its **Package settings** → **Danger Zone**. See [Release Flow](../release-flow/) for the exact steps and the `gh api` alternative. ## See also - [Release Flow](../release-flow/) - [Getting Started](../getting-started/) - [Architecture](../architecture/) # Secrets and Environment Variables > Secrets must never be committed. They are supplied at runtime via environment variables / CI secrets and read by the pipeline through the settings' lookup… # Secrets and Environment Variables Secrets must never be committed. They are supplied at runtime via environment variables / CI secrets and read by the pipeline through the settings' lookup helpers. ## Precedence recap Command line > environment variables > `purview-build.json` > baked-in defaults. Nested environment keys use `__`, for example `Release__Mode=NuGet`. Because env vars take precedence over `purview-build.json`, an empty forwarded env var can silently override a configured value — the reusable workflows only forward optional test settings when the caller actually provides them. ## Secrets | Secret | Where it is used | Environment-var bound alias | | --- | --- | --- | | `NUGET_APIKEY` | NuGet push | `NuGet__NUGET_APIKEY` (binds `EnvAPIKey`); also read directly from process env `NUGET_APIKEY`/`NUGET_API_KEY` | | `NuGet__ApiKey` | NuGet push | `APIKey` | | `GITHUB_TOKEN` | GitHub release creation | `GitHub__GITHUB_TOKEN` (binds `EnvAccessToken`); also read directly from process env `GITHUB_TOKEN` | | `LOCAL_NUGET_FEED_PATH` | Local NuGet publishing | `PublishLocalNuGet__LOCAL_NUGET_FEED_PATH` (binds `EnvLocalFeedPath`); also read directly from process env `LOCAL_NUGET_FEED_PATH` | The config binder does not map plain `NUGET_APIKEY`/`GITHUB_TOKEN`/`LOCAL_NUGET_FEED_PATH` process env vars under their settings sections, so the settings classes fall back to reading the process environment directly. ## Test filter forwarding The reusable workflows (`purview-build.yml`, `purview-release.yml`) forward the caller's `test-filter` and `test-projects` inputs as `Build__TestFilter`/`Build__TestProjects` **only when they are non-empty**. An empty forwarded value would override a consuming repository's `purview-build.json` (env vars take precedence over JSON) and silently disable the filter — see commit `4d72bf7`. ## See also - [Configuration Reference](../configuration-reference/) - [Pipeline Modules](../pipeline-modules/) - [Release Flow](../release-flow/) # Event Sourcing > This wiki is the project documentation hub for framework features, provider capabilities, and release workflow. # Purview EventSourcing Wiki This wiki is the project documentation hub for framework features, provider capabilities, and release workflow. ## Start here - [Getting Started](getting-started/) - [Guarantees and Limitations](guarantees-and-limitations/) - [Solution Design Guide](solution-design-guide/) - [Solution Design Worksheet](solution-design-worksheet/) - [Provider Feature Matrix](provider-feature-matrix/) - [Provider Capabilities](provider-capabilities/) - [Transactional Outbox](transactional-outbox/) - [Transaction Guarantees](transaction-guarantees/) - [Event Contract Manifest](event-contract-manifest/) - [Event Versioning Strategy](event-versioning-strategy/) - [Event Versioning Examples](event-versioning-examples/) - [Source Generator Performance](source-generator-performance/) - [Runtime Performance](runtime-performance/) - [Dependency Guardrails](dependency-guardrails/) - [Source Generator Behaviors](source-generator-behaviors/) - [Source Generator Code Fixes](code-fixes/) - [SQL Server Guide](sql-server-guide/) - [Shared Testing Framework](shared-testing-framework/) - [Release Flow](release-flow/) ## Feature highlights - **Core framework (`Purview.EventSourcing`)** - `AggregateBase`, `IEventStore`, `IQueryableEventStore`, and `IEventStoreTransactionFactory`. - [Transaction guarantees](transaction-guarantees/): atomic requirements, best-effort fallback, and failure behavior. - [Snapshot schema versioning](snapshot-schema-versioning/): compatibility detection and event-replay rebuilds. - Source-generated aggregate events/command wiring from partial methods. - Provider-agnostic aggregate load/save/query APIs. - **Solution design** - Paper-first worksheets for aggregate boundaries, commands, events, relationships, and event streams. - Guidance for relational data, value objects, validation layers, and schema evolution. - **Storage providers** - SQL Server / Azure SQL: append-only event streams, internal replay snapshots, and optional SQL query snapshots with transaction coordination. - PostgreSQL: append-only event streams, internal replay snapshots, and optional PostgreSQL JSONB query snapshots. - Azure Storage: table-backed event streams with blob support for snapshots/large payloads. - MongoDB: event streams plus an optional MongoDB query snapshot store. - Cosmos DB: optional query snapshot store. - In-memory provider: non-persistent event/snapshot store for local/test scenarios. - Validation adapters: FluentValidation and Purview.ZodSharp adapters for `IAggregateValidator`. - SQL snapshot translation distinguishes between provider-converted scalar value objects and directly mapped complex snapshot graphs; see the provider matrix and SQL guide for details. - **Generator behavior** - `[Aggregate]` supports no base, direct `AggregateBase`, and transitive base-chain inheritance. - Property hooks are property-scoped across generated events that map that property. - `OnChanged` runs in `Apply(...)` (including replay); `OnChanging` runs on command/event-raise path only. - Event hooks (`OnRaising...`, `OnRaised...`, `OnApplied...`) are event-scoped. # Source Generator Code Fixes > The source-generator package ships IDE code fixes alongside its diagnostics. Fixes live in a separate analyzer assembly… # Source Generator Code Fixes The source-generator package ships IDE code fixes alongside its diagnostics. Fixes live in a **separate analyzer assembly** (`Purview.EventSourcing.SourceGenerator.CodeFixes`) so the core `Purview.EventSourcing.SourceGenerator` assembly never acquires a `Microsoft.CodeAnalysis.Workspaces` dependency. ## Assembly separation | Assembly | Contents | Workspaces dependency | | --- | --- | --- | | `Purview.EventSourcing.SourceGenerator` | Incremental generators, the analyzer, and all diagnostic descriptors | None | | `Purview.EventSourcing.SourceGenerator.CodeFixes` | `CodeFixProvider` implementations that use `DocumentEditor` / `SyntaxGenerator` | Yes (PrivateAssets) | Both assemblies are packed under `analyzers/dotnet/cs` of `Purview.EventSourcing`. No Workspaces assembly is shipped in the package and no Workspaces dependency flows into consumer projects. The compiler loads the code-fix assembly without instantiating its Workspaces-dependent types; the IDE activates them for code fixes. ## Available fixes | Diagnostic | Fix | Notes | | --- | --- | --- | | `EVENTSTORE001` (aggregate must be partial) | Adds `partial` to the aggregate declaration | Trivia, nesting, generic parameters, and accessibility preserved | | `EVENTSTORE101` (value object must be partial) | Adds `partial` to the value-object declaration | Works for record structs, structs, and classes | | `EVENTSTORE007` (event method must be partial) | Adds `partial` to the method | | | `EVENTSTORE021` (schema version must be positive) | Resets the version to `1` | Only when the version argument is explicit | | `EVENTSTORE022` (duplicate schema version) | Moves the version to the next unused version on the aggregate | Only when the version argument is explicit | Fixes use stable equivalence keys and support **Fix All** where safe. No fix is offered when a correct correction is ambiguous (for example a renamed event contract or an incompatible payload change); the diagnostic message provides guidance instead. ## Reference The fixes share the diagnostic descriptors defined in the source-generator assembly via `InternalsVisibleTo`; no diagnostic is defined in the code-fix assembly. # Dependency Guardrails > This page documents repository guardrails that prevent known dependency/runtime pitfalls. # Dependency Guardrails This page documents repository guardrails that prevent known dependency/runtime pitfalls. ## Purview.ZodSharp direct-reference guardrail ### Problem When a consumer project directly references the `Purview.EventSourcing.Validation.ZodSharp` project and uses `Purview.ZodSharp` types, relying on transitive package flow can lead to runtime assembly load failures (for example, `FileNotFoundException` for `Purview.ZodSharp`). ### Required fix in consuming project Add a direct package reference: ```xml ``` ### Automated enforcement `Purview.EventSourcing.Validation.ZodSharp` includes a build target in package `buildTransitive` assets (`buildTransitive/Purview.EventSourcing.Validation.ZodSharp.targets`): - Target name: `ValidateZodSharpDirectReference` - Runs: `BeforeTargets="ResolveReferences"` - Behavior: - Detects projects that reference `Purview.EventSourcing.Validation.ZodSharp` via `ProjectReference` - Fails the build if `PackageReference Include="Purview.ZodSharp"` is missing - Emits a remediation message with the exact package reference to add This shifts failure left from runtime to build-time. ### CI verification The reusable pack workflow also validates the generated `.nupkg` and fails if `buildTransitive/Purview.EventSourcing.Validation.ZodSharp.targets` is missing from the package contents. ## Validation adapters overview - `Purview.EventSourcing.Validation.FluentValidation`: adapter for `FluentValidation.IValidator` to `IAggregateValidator`. - `Purview.EventSourcing.Validation.ZodSharp`: adapter for `Purview.ZodSharp` schema validation to `IAggregateValidator`. When using either adapter package directly from source projects, keep direct package references explicit for external runtime dependencies used by the adapter. ## Admin API validation and OpenAPI dependencies `Purview.EventSourcing.Admin.API` validates its request contracts and options with Purview.ZodSharp source-generated schemas and ships the Admin API OpenAPI document (`/openapi/admin.json`) used to generate `Purview.EventSourcing.Admin.Client`. As a result `Purview.ZodSharp`, `Purview.ZodSharp.AspNetCore`, and `Purview.ZodSharp.SystemTextJson` are direct dependencies of the Admin API package. ### OpenAPI XML-comment source generator is disabled in Admin.API The `Microsoft.AspNetCore.OpenApi` package ships a source generator that builds a runtime cache of XML doc IDs across the compilation and its referenced assemblies. Purview's telemetry scaffolding (`Purview.Telemetry.SourceGenerator`) re-declares the same attribute types in every assembly, which makes that cache throw at runtime with a duplicate key when an OpenAPI document is generated. The Admin.API project therefore removes the `Microsoft.AspNetCore.OpenApi.SourceGenerators` analyzer from its compilation (see `Admin.API.csproj`), and the spec-export tool (`src/tools/AdminAPI.OpenAPI`) does not feed referenced assembly XML docs to the generator. The generated Admin API document and typed client remain complete; XML-comment-derived schema descriptions are omitted. # Event Contract Manifest > The source generator produces a deterministic, machine-readable schema manifest of every generated event contract in a compilation. The manifest is the… # Event Contract Manifest The source generator produces a **deterministic, machine-readable schema manifest** of every generated event contract in a compilation. The manifest is the machine-readable contract that must stay compatible with previously persisted event payloads, and it is the input to baseline-based compatibility validation. ## What is captured For every `[Aggregate]` with at least one valid event method, the manifest records: | Entry | Meaning | | --- | --- | | Aggregate name / namespace | The aggregate type identity | | Event name / namespace / method | The generated event identity and the source method | | Schema version | `[Event(Version = N)]`, defaulting to 1 | | Fields | Each persisted event property: name, fully-qualified type, element type (arrays), array flag, nullability, requiredness (`[Required]`), and string flag | The manifest is deliberately **location-free**: it captures only what affects persisted JSON compatibility, so comments, formatting, and unrelated source edits never change it. ## Determinism guarantees - Stable ordinal ordering for aggregates, events, and fields — reordering source declarations does not change the output. - No timestamps, absolute paths, machine information, random values, reflection-order dependencies, or culture-sensitive formatting. - Identical input always produces byte-identical output. ## Emitting the manifest Emission is opt-in via the MSBuild property: ```xml true ``` With the property set, the generator emits `EventContractManifest.g.cs` (a generated source constant) and the packaged build targets materialize the compact JSON to `EventContractManifest.json` in the project directory after `CoreCompile`. ## Supplying a baseline Add the approved manifest as an additional file so the generator can compare current contracts against it: ```xml ``` The default baseline file name is `EventContractManifest.json`. Override it with: ```xml event-contracts.json ``` Comparison runs whenever a matching additional file is present; without a baseline no compatibility diagnostics are emitted. ## Generate, commit, update, and validate in CI 1. **Generate** — enable `PurviewEventContractManifestEnabled` and build; the target writes `EventContractManifest.json`. 2. **Commit** — commit the generated file as the approved baseline. 3. **Validate** — every build (CI included) compares current contracts against the committed baseline and fails on breaking changes. 4. **Update** — for an intentional, documented schema evolution, bump the schema version (and add an upcaster), then regenerate and commit the updated baseline in the same change. ## Compatible additions versus breaking changes **Silent (compatible):** - Adding a new aggregate or a new event. - Bumping an event's schema version (the sanctioned evolution path). - Adding an optional (nullable, non-`[Required]`) field to an existing event. - Relaxing a field from non-nullable to nullable. **Diagnostics (breaking), reported as errors:** | ID | Condition | | --- | --- | | `EVENTSTORE030` | An aggregate contract was removed or renamed | | `EVENTSTORE031` | An event was removed or renamed | | `EVENTSTORE032` | A persisted field was removed or renamed | | `EVENTSTORE033` | A persisted field type changed incompatibly | | `EVENTSTORE034` | A field became required/non-nullable, or a `[Required]` field was added on an unchanged version | | `EVENTSTORE035` | An event's schema version decreased below the baseline | | `EVENTSTORE036` | The baseline manifest is malformed or uses an unsupported format version | Each diagnostic points at the current method or aggregate declaration and explains the remediation: retain compatibility, bump the schema version and add an upcaster, or introduce a new event type. ## Runtime access and Admin inspection The generated `EventContractManifest` class is public, so applications can register it for runtime inspection: ```csharp builder.Services.AddEventContractManifest( EventContractManifest.FormatVersion, EventContractManifest.Json, baselineJson: /* the committed baseline, when available */); ``` `IEventContractManifestProvider` then reports the manifest and a compatibility status (`Compatible` when the current manifest matches the supplied baseline, `Incompatible` when it differs, `NotConfigured` when no baseline was supplied). The Admin portal exposes it at `GET /admin/api/manifest` when the `ViewManifest` feature and permission are enabled (opt-in, separately authorized, audited). ## Format version The manifest carries a `formatVersion` field. When the generator supports a different format, the baseline is rejected with `EVENTSTORE036` and must be regenerated with the current package. # Event Versioning: Practical Examples > This guide provides practical examples of implementing event versioning in Purview EventSourcing. # Event Versioning: Practical Examples This guide provides practical examples of implementing event versioning in Purview EventSourcing. ## Table of Contents 1. [Additive Changes (No Versioning Needed)](https://github.com/purview-dev/event-sourcing/blob/main/docs/wiki#additive-changes) 2. [Versioning with SchemaVersion](https://github.com/purview-dev/event-sourcing/blob/main/docs/wiki#versioning-with-schemaversion) 3. [Single-Hop Upcasting](https://github.com/purview-dev/event-sourcing/blob/main/docs/wiki#single-hop-upcasting) 4. [Multi-Hop Upcasting Chains](https://github.com/purview-dev/event-sourcing/blob/main/docs/wiki#multi-hop-upcasting-chains) 5. [Common Mistakes & How to Avoid Them](https://github.com/purview-dev/event-sourcing/blob/main/docs/wiki#common-mistakes) 6. [Testing Versioned Events](https://github.com/purview-dev/event-sourcing/blob/main/docs/wiki#testing-versioned-events) ## Additive Changes When you add a new optional field to an event, no versioning is needed. Old events will deserialize successfully with the new field set to its default value. ### Example: Adding an Optional Phone Number **Initial event (v1, implicit `SchemaVersion = 1`):** ```csharp [EventContract] public sealed record CustomerRegisteredEvent { public string CustomerId { get; set; } = default!; public string Email { get; set; } = default!; } ``` **After adding an optional field (still v1, no SchemaVersion bump needed):** ```csharp [EventContract] public sealed record CustomerRegisteredEvent { public string CustomerId { get; set; } = default!; public string Email { get; set; } = default!; public string? PhoneNumber { get; set; } // Optional, new field } ``` **Aggregate apply logic:** ```csharp protected override void RegisterEvents() { Register(cr => { CustomerId = cr.CustomerId; Email = cr.Email; PhoneNumber = cr.PhoneNumber ?? "N/A"; }); } ``` Old events will deserialize with `PhoneNumber = null`, and the aggregate handles it gracefully. --- ## Versioning with SchemaVersion When you make a **breaking change** to an event's payload (required field added, meaning changed, property removed), bump the `SchemaVersion`. ### Example: Making Phone Number Required **Old event (v1):** ```csharp [EventContract] public sealed record CustomerRegisteredEvent { public string CustomerId { get; set; } = default!; public string Email { get; set; } = default!; public string? PhoneNumber { get; set; } // Was optional } ``` **New event (v2, breaking change):** ```csharp [EventContract] public sealed record CustomerRegisteredEvent { public string CustomerId { get; set; } = default!; public string Email { get; set; } = default!; public string PhoneNumber { get; set; } = default!; // Now required public static int SchemaVersion => 2; } ``` ### Defining the Upcaster A same-type upcaster transforms the payload in place. The store re-attaches `EventMetadata` after upcasting, so the upcaster only maps payload fields. ```csharp public sealed class CustomerRegisteredV1ToV2Upcaster : IEventUpcaster { public CustomerRegisteredEvent Upcast(CustomerRegisteredEvent source) => new() { CustomerId = source.CustomerId, Email = source.Email, PhoneNumber = source.PhoneNumber ?? "UNKNOWN", // Default for old events }; } ``` --- ## Single-Hop Upcasting Single-hop upcasting converts v1 events directly to v2 during replay. ### Full Example: Order Events **Step 1: Define the events** ```csharp // Order event v1 (no currency) [EventContract] public sealed record OrderCreatedEventV1 { public string OrderId { get; set; } = default!; public decimal Amount { get; set; } } // Order event v2 (with currency, breaking change) [EventContract] public sealed record OrderCreatedEvent { public string OrderId { get; set; } = default!; public decimal Amount { get; set; } public string Currency { get; set; } = default!; public static int SchemaVersion => 2; } ``` **Step 2: Define the upcaster** ```csharp public sealed class OrderCreatedV1ToV2Upcaster : IEventUpcaster { public OrderCreatedEvent Upcast(OrderCreatedEventV1 source) => new() { OrderId = source.OrderId, Amount = source.Amount, Currency = "USD", // Default currency for old events }; } ``` **Step 3: Register the upcaster in DI** ```csharp services.AddEventUpcaster(); ``` **Step 4: Use in the aggregate** ```csharp public sealed class OrderAggregate : AggregateBase { public string OrderId { get; private set; } = default!; public decimal Amount { get; private set; } public string Currency { get; private set; } = default!; protected override void RegisterEvents() { // Old event type (will be upcast to OrderCreatedEvent) Register(v1 => { OrderId = v1.OrderId; Amount = v1.Amount; Currency = "USD"; }); // New event type (v2) Register(oc => { OrderId = oc.OrderId; Amount = oc.Amount; Currency = oc.Currency; }); } } ``` --- ## Multi-Hop Upcasting Chains Multi-hop chains (v1 → v2 → v3) are automatically applied during replay. ### Full Example: Three Event Versions **Step 1: Define the events** ```csharp // v1: OrderCreatedEventV1 [EventContract] public sealed record OrderCreatedEventV1 { public string OrderId { get; set; } = default!; public decimal Amount { get; set; } } // v2: OrderCreatedEventV2 (added currency) [EventContract] public sealed record OrderCreatedEventV2 { public string OrderId { get; set; } = default!; public decimal Amount { get; set; } public string Currency { get; set; } = default!; public static int SchemaVersion => 2; } // v3: OrderCreatedEvent (added tax info) [EventContract] public sealed record OrderCreatedEvent { public string OrderId { get; set; } = default!; public decimal Amount { get; set; } public string Currency { get; set; } = default!; public decimal TaxAmount { get; set; } public static int SchemaVersion => 3; } ``` **Step 2: Define the upcasters** ```csharp public sealed class OrderCreatedV1ToV2Upcaster : IEventUpcaster { public OrderCreatedEventV2 Upcast(OrderCreatedEventV1 source) => new() { OrderId = source.OrderId, Amount = source.Amount, Currency = "USD", }; } public sealed class OrderCreatedV2ToV3Upcaster : IEventUpcaster { public OrderCreatedEvent Upcast(OrderCreatedEventV2 source) => new() { OrderId = source.OrderId, Amount = source.Amount, Currency = source.Currency, TaxAmount = source.Amount * 0.1m, // 10% tax on amount }; } ``` **Step 3: Register both upcasters** ```csharp // Order matters: register from earliest to latest version services.AddEventUpcaster(); services.AddEventUpcaster(); ``` **Step 4: Aggregate receives the final upcast event** ```csharp public sealed class OrderAggregate : AggregateBase { public string OrderId { get; private set; } = default!; public decimal Amount { get; private set; } public string Currency { get; private set; } = default!; public decimal TaxAmount { get; private set; } protected override void RegisterEvents() { // The upcaster chain is applied before the aggregate applies the event. // Old v1 and v2 events arrive as OrderCreatedEvent (v3) after upcasting. Register(oc => { OrderId = oc.OrderId; Amount = oc.Amount; Currency = oc.Currency; TaxAmount = oc.TaxAmount; }); } } ``` During replay: - V1 events → upcast by `OrderCreatedV1ToV2Upcaster` → upcast by `OrderCreatedV2ToV3Upcaster` → arrive as `OrderCreatedEvent` - V2 events → upcast by `OrderCreatedV2ToV3Upcaster` → arrive as `OrderCreatedEvent` - V3 events → arrive as-is (no upcasting needed) --- ## Common Mistakes ### ❌ Mistake 1: Copying Metadata in the Upcaster Metadata (`EventMetadata`: idempotency, correlation, user, timestamp, schema version) is carried by the framework and re-attached by the store, so it should **not** be copied by the upcaster. **Wrong:** ```csharp public OrderCreatedEvent Upcast(OrderCreatedEventV1 source) { return new() { Metadata = source.Metadata, // Metadata is framework-managed; do not copy it OrderId = source.OrderId, Amount = source.Amount, Currency = "USD", }; } ``` **Correct:** ```csharp public OrderCreatedEvent Upcast(OrderCreatedEventV1 source) { return new() { OrderId = source.OrderId, Amount = source.Amount, Currency = "USD", }; } ``` The store attaches the source event's `EventMetadata` (idempotency, correlation, user) to the upcast event automatically. ### ❌ Mistake 2: Creating a New Event Type Instead of Versioning If the semantic meaning changes (e.g., "registration" → "registration with email verification"), create a **new event type**, not a new version. **Wrong (semantic change, not a versioning scenario):** ```csharp [EventContract] public sealed record UserRegisteredEvent { public string Email { get; set; } = default!; public static int SchemaVersion => 2; public bool EmailVerified { get; set; } // Required, breaking change } ``` This conflates two different processes. **Correct (introduce a new event type):** ```csharp [EventContract] public sealed record UserRegisteredEvent { public string Email { get; set; } = default!; } [EventContract] public sealed record UserRegisteredWithEmailVerificationEvent { public string Email { get; set; } = default!; public bool EmailVerified { get; set; } } ``` ```csharp protected override void RegisterEvents() { Register(ur => { Email = ur.Email; EmailVerified = false; }); Register(urwv => { Email = urwv.Email; EmailVerified = urwv.EmailVerified; }); } ``` ### ❌ Mistake 3: Forgetting a Safe Default for Legacy Values When a breaking change adds a field, the upcaster must provide a deterministic default for old events. Otherwise the aggregate applies a meaningless value. **Wrong:** ```csharp public OrderCreatedEvent Upcast(OrderCreatedEventV1 source) { return new() { OrderId = source.OrderId, Amount = source.Amount, // Currency omitted — old events would hydrate Currency = null }; } ``` **Correct:** ```csharp public OrderCreatedEvent Upcast(OrderCreatedEventV1 source) { return new() { OrderId = source.OrderId, Amount = source.Amount, Currency = "USD", // Deterministic default for legacy events }; } ``` ### ❌ Mistake 4: Circular Upcaster Chains The registry detects circular chains and throws an exception when it is constructed, but you can prevent this by registering upcasters in order (v1 → v2 → v3). **Wrong:** ```csharp // This will throw at runtime services.AddEventUpcaster(); services.AddEventUpcaster(); // Creates a cycle! ``` **Correct:** ```csharp // Always register from earlier to later versions services.AddEventUpcaster(); services.AddEventUpcaster(); ``` --- ## Testing Versioned Events ### Unit Test: Single-Hop Upcasting ```csharp [Test] public async Task Upcast_V1ToV2_PreservesDataAndDefaults() { var upcaster = new OrderCreatedV1ToV2Upcaster(); var v1Event = new OrderCreatedEventV1 { OrderId = "123", Amount = 99.99m, }; var v2Event = upcaster.Upcast(v1Event); await Assert.That(v2Event.OrderId).IsEqualTo("123"); await Assert.That(v2Event.Amount).IsEqualTo(99.99m); await Assert.That(v2Event.Currency).IsEqualTo("USD"); } ``` ### Integration Test: Replay with Upcasting Legacy v1 rows are produced by an older deployment; the test seeds one directly in storage, then loads the aggregate so the upcaster runs during replay. ```csharp [Test] public async Task Replay_WithV1Events_UpcastsToV2AndAppliesCorrectly() { // 1. Register upcaster services.AddEventUpcaster(); // 2. Seed a V1 event row directly in storage (provider-specific), for example // write an OrderCreatedEventV1 payload under the aggregate's stream. // 3. Load the aggregate (triggers replay with upcasting) var aggregate = await eventStore.GetAsync("123", cancellationToken); // 4. Verify the aggregate state matches the upcast event await Assert.That(aggregate.OrderId).IsEqualTo("123"); await Assert.That(aggregate.Amount).IsEqualTo(99.99m); await Assert.That(aggregate.Currency).IsEqualTo("USD"); // Upcast default } ``` ### Testing Unknown Events ```csharp [Test] public async Task Replay_WithUnknownEventType_ReturnsUnknownEventAndContinues() { // 1. Seed an event whose persisted type name does not resolve to a registered event type // 2. Load the aggregate var aggregate = await eventStore.GetAsync("123", cancellationToken); // 3. Verify replay continues without throwing await Assert.That(aggregate).IsNotNull(); await Assert.That(aggregate.SkippedEvents).IsNotEmpty(); // 4. In a real test, you'd have a mixture of known and unknown events // to verify partial replay works correctly } ``` --- ## Summary - **Additive changes (optional fields)** → No versioning needed - **Breaking changes (required fields, removed fields, semantic changes)** → Increment SchemaVersion - **Semantic meaning changes** → Create a new event type - **Do not copy metadata in upcasters** — the store re-attaches `EventMetadata` - **Register upcasters in order** (v1 → v2 → v3 → …) - **Test multi-hop chains** and unknown event handling - **Stream-backed providers apply upcasting during replay** (SQL Server, PostgreSQL, Azure Storage, MongoDB) For more information, see [Event-Versioning-Strategy.md](../event-versioning-strategy/). # Event Versioning Strategy > Each persisted event row/document records SchemaVersion, CorrelationId, CausationId, UserId, IdempotencyId, aggregate version, timestamp, and event name… # Event Versioning Strategy Each persisted event row/document records `SchemaVersion`, `CorrelationId`, `CausationId`, `UserId`, `IdempotencyId`, aggregate version, timestamp, and event name separately from its payload. This allows Admin and history consumers to inspect an event envelope without deserializing sensitive or obsolete payload JSON. Legacy SQL rows are assigned schema version 1 by the metadata migration. Document and table providers also treat a missing schema-version field as version 1. Correlation, causation, and user identifiers remain null when they were not recorded by the original write; they are never inferred during a migration. This document codifies the product-wide approach to event versioning and schema evolution across all Purview EventSourcing providers. ## Core Principles 1. **Events are append-only immutable facts.** Never change the meaning of persisted event data. 2. **SchemaVersion is the versioning contract.** Track breaking payload changes through the `SchemaVersion` on the event contract (a static property on generated events). 3. **Upcasting bridges payload versions.** When old events must hydrate into new event shapes, implement `IEventUpcaster`. 4. **Unknown events fail safely.** Providers return `UnknownEvent` when event types cannot be resolved or deserialized. 5. **Providers implement consistent replay semantics.** Replay-time upcasting is applied uniformly by the stream-backed providers — SQL Server, PostgreSQL, Azure Storage, and MongoDB. The in-memory provider does not apply upcasting. ## Event Contract Shape Events are `[EventContract]` sealed records with no base class or interface. Payload properties are stored directly on the record; framework-managed metadata lives in a `[JsonIgnore]`d `Metadata` property of type `EventMetadata` and is persisted by providers to row columns / document fields and rehydrated on replay. ```csharp [EventContract] public sealed record OrderCreatedEvent { public string OrderId { get; set; } = default!; public string Currency { get; set; } = default!; [JsonIgnore] public EventMetadata Metadata { get; set; } public static int SchemaVersion => 2; } ``` See [Source Generator Behaviors](../source-generator-behaviors/) for the generated shape and naming rules. ## When to Version vs. When to Rename ### Add a new property without versioning (additive change) - Property is **optional** (nullable or has a default). - Backward compatibility is preserved: old events deserialize successfully without the new field. - **Example:** `CustomerRegisteredEvent` gains an optional `PhoneNumber` field; old events hydrate with `null` or `string.Empty`. - **Action:** No `SchemaVersion` bump needed; no upcaster required. ### Increment SchemaVersion (breaking payload change) - Property is **required** and has no safe default (e.g., changes meaning or becomes non-nullable). - Property is **removed or renamed** without a clear mapping. - **Example:** `OrderCreatedEvent` v1 has optional `Currency`; v2 makes it required. Or `Price` → `UnitPrice` with different semantics. - **Action:** Use `[Event(Version = 2)]` and implement an upcaster. ### Create a new event type (semantic change) - The event's **meaning fundamentally changes** (e.g., `UserRegisteredEvent` → `UserRegisteredWithEmailVerificationEvent`). - The domain concept is distinct and should have its own event contract. - **Example:** A new workflow requires user email verification at registration; instead of changing `UserRegisteredEvent`, define `UserRegisteredAndVerificationSentEvent`. - **Action:** Define a new event class. Optionally define an upcaster if the new event should apply the old event's data. ## SchemaVersion Details ### Scope - `SchemaVersion` is per-event-class, not per-aggregate. - Multiple events on one aggregate can have different versions. ### Numbering - Starts at 1 (default). - Increment by 1 for each breaking change. - Never decrease; version numbers are immutable markers. ### Declaration **Via the source generator:** ```csharp [Aggregate] public partial class OrderAggregate : AggregateBase { public string OrderId { get; private set; } = default!; public string Currency { get; private set; } = "USD"; // Added in v2 // Version 2: Currency is now part of the event [Event(Version = 2)] public partial void CreateOrder(string orderId, string currency); } ``` The generator emits a `[EventContract]` record named `OrderCreatedEvent` with `public static int SchemaVersion => 2;`. **Manually (hand-written event contract):** ```csharp [EventContract] public sealed record OrderCreatedEvent { public string OrderId { get; set; } = default!; public string Currency { get; set; } = default!; public static int SchemaVersion => 2; } ``` Hand-written events must be marked `[EventContract]` (the analyzer enforces this with `EVENTSTORE037`) and are registered with `Register(...)` or `RegisterGenerated()`. ## Upcasting Chains ### Purpose Upcasters convert old event payloads (deserialized from storage) into current event shapes so aggregates can apply them during replay. ### Implementation **Single-hop upcaster (v1 → v2):** ```csharp public sealed class OrderCreatedV1ToV2Upcaster : IEventUpcaster { public OrderCreatedEvent Upcast(OrderCreatedEventV1 source) => new() { OrderId = source.OrderId, Currency = "USD", // Default for legacy events }; } ``` Metadata is **not** copied by the upcaster: `EventMetadata` is carried by the framework and re-attached by the store, so the source's metadata flows to the upcast event automatically. **Multi-hop chain (v1 → v2 → v3):** ```csharp // Register both upcasters; the registry applies them in sequence. services.AddEventUpcaster(); services.AddEventUpcaster(); // On replay, events automatically: v1 → v2 → v3 (final) → aggregate.ApplyEvent() ``` ### Upcaster Rules - **Direction:** Forward only (v1 → v2 → v3 → …). Downgrading events is not supported. - **Metadata:** Do **not** copy metadata in an upcaster. The store re-attaches `EventMetadata` (idempotency, correlation, user, timestamp, schema version) to the upcast event. - **Legacy type resolution:** Legacy (source) event types are registered automatically from the upcaster registry when an aggregate is initialized, so stored legacy event names resolve back to CLR types during replay. No extra registration is required. - **Same-type upcasters:** An upcaster whose source and target types are the same (an in-place transform) is applied exactly once; it is not treated as a cycle. - **Cycle detection:** The registry detects and rejects circular upcaster chains (for example v1 → v2 → v1) when it is constructed. - **Unknown target:** If an old event has no upcaster path to a known type, it remains `UnknownEvent`. ### Detecting Partial Replay Because old consumers reading newer events skip what they cannot apply, a replayed aggregate can be **partially stale** without an error being thrown. Replay records every skipped event on the aggregate instance: - `aggregate.SkippedEvents` (`IReadOnlyList`) lists the versions, persisted event names, and whether each was unresolvable (`UnknownEvent`) or simply not applicable. - Callers that must not act on stale state should check `SkippedEvents` after a load and fail closed or rehydrate through a different path when it is non-empty. `SkippedEvents` is populated only while an aggregate is rehydrated from an event stream. It is not persisted in SQL Server/PostgreSQL EF-backed snapshot payloads, so always check it on the aggregate returned by an event-stream load. Downgrading (downcasting newer events into older shapes) remains unsupported; this signal exists so applications can detect and react to the mixed-version-fleet case explicitly. ## Replay Semantics (Stream-Backed Providers) When replaying an aggregate from the event stream: 1. **Deserialize** the event from JSON. If the event type cannot be resolved, return `UnknownEvent`. 2. **Apply upcasting chain** (if a registry is present). Follow all registered upcasters in sequence until no further upcaster is found. 3. **Call `aggregate.ApplyEvent()`** with the (possibly upcast) event. 4. **Handle unknown events** gracefully. The aggregate's `CanApplyEvent()` should return false for `UnknownEvent`; the store logs and continues replay. ### Provider Implementation Checklist - [ ] `GetEventRangeAsync()` applies the upcaster registry after deserializing (SQL Server, PostgreSQL, Azure Storage, MongoDB). - [ ] `GetAsync()` (single aggregate load) applies the upcaster registry during replay. - [ ] Unknown event types return `UnknownEvent` with metadata populated. - [ ] Upcasting errors are logged and surfaced (not silently swallowed). - [ ] Multi-hop upcasting chains are tested end-to-end. ## Documentation and Contracts ### EventMetadata Preservation `EventMetadata` is framework-managed and is **not** copied by upcasters. Providers persist these fields to row columns / document fields and re-attach them on replay: `IdempotencyId`, `AggregateVersion`, `When`, `UserId`, `CausationId`, `CorrelationId`, `SchemaVersion`. ### Event Type Naming - Event type names are persisted as `{aggregate-type}.{event-name-without-event-suffix}` (for example `order.order-created`). Renaming an event type breaks deserialization without a migration step. - If renaming is necessary, define the old event type alongside the new one and create an upcaster. ### Version Boundaries - `SchemaVersion` is persisted as event metadata (a row column / document field), not inside the payload, and is rehydrated into `EventMetadata` on replay. - Consumers can inspect `@event.Metadata.SchemaVersion` to make conditional decisions during replay (fallback values, feature flags, etc.). ## Test Coverage All stream-backed providers must verify: 1. **Additive changes** – Old events deserialize and apply without upcasters. 2. **Versioned events** – New events with `SchemaVersion > 1` deserialize correctly. 3. **Single-hop upcasting** – V1 events are upcast to V2 during replay. 4. **Multi-hop upcasting** – V1 → V2 → V3 chains work end-to-end. 5. **Unknown events** – Missing event types produce `UnknownEvent` and replay continues. 6. **Metadata preservation** – The store re-attaches `EventMetadata` (idempotency, correlation, user) after upcasting. 7. **Cycle detection** – Circular upcaster chains are rejected at registry construction. ## Related Files - **Core abstractions:** `src/src/EventSourcing/Aggregates/Events/EventContractAttribute.cs`, `EventMetadata.cs`, `src/src/EventSourcing/Aggregates/Events/Upcasting/IEventUpcaster.cs`, `EventUpcasterRegistry.cs` - **SQL Server replay:** `src/src/SqlServer/Events/SqlServerEventStore.GetEventRangeAsync.cs` (reference implementation) - **Sample:** [Event-Versioning-Examples.md](../event-versioning-examples/) - **Tests:** Provider-specific replay tests (to be harmonized) # Getting Started > dotnet add package Purview.EventSourcing # Getting Started ## Install ```bash dotnet add package Purview.EventSourcing ``` Add one or more provider packages based on your persistence target: ```bash dotnet add package Purview.EventSourcing.SqlServer dotnet add package Purview.EventSourcing.Postgres dotnet add package Purview.EventSourcing.AzureStorage dotnet add package Purview.EventSourcing.MongoDB dotnet add package Purview.EventSourcing.CosmosDb ``` Optional packages: ```bash # In-memory provider (local/test scenarios) dotnet add package Purview.EventSourcing.InMemory # Validation adapters dotnet add package Purview.EventSourcing.Validation.FluentValidation dotnet add package Purview.EventSourcing.Validation.ZodSharp ``` ## Dependency guardrail for Purview.ZodSharp If your project references the `Purview.EventSourcing.Validation.ZodSharp` project directly and uses `Purview.ZodSharp` types, you must add: ```xml ``` `Purview.EventSourcing.Validation.ZodSharp` ships a build-time check (`ValidateZodSharpDirectReference`) in package `buildTransitive` assets so consumer projects fail fast with remediation guidance when this direct package reference is missing. ## Define an aggregate (source generator) ```csharp using Purview.EventSourcing.Aggregates; [Aggregate] public partial class OrderAggregate : AggregateBase { public string CustomerId { get; private set; } = default!; public decimal Total { get; private set; } [Event] public partial void CreateOrder(string customerId); [Event] public partial void AddLineItem(string productId, string productName, int quantity, decimal unitPrice); } ``` ## Register storage ```csharp // SQL Server / Azure SQL (events + queryable snapshots) builder.Services.AddSqlServerEventStore(); builder.Services.AddSqlServerSnapshotQueryableEventStore(); ``` Other provider registrations: ```csharp // Azure Storage (event store with blob support) builder.Services.AddAzureStorageEventStore(); // PostgreSQL (events + queryable snapshots) builder.Services.AddPostgresEventStore(); builder.Services.AddPostgresSnapshotQueryableEventStore(); // MongoDB (events + queryable snapshots) builder.Services.AddMongoDBEventStore(); builder.Services.AddMongoDBSnapshotQueryableEventStore(); // Cosmos DB (queryable snapshots) builder.Services.AddCosmosDbSnapshotQueryableEventStore(); // Core-only fallback for projects without persistent query snapshots builder.Services.AddNullQueryableEventStore(); ``` ## Use the provider-agnostic facade ```csharp public sealed class OrderService(IEventStore store) { public async Task PlaceOrderAsync(string orderId, string customerId, CancellationToken cancellationToken) { var order = await store.GetAsync(orderId, cancellationToken) ?? await store.CreateAsync(orderId, cancellationToken: cancellationToken); order.CreateOrder(customerId); await store.SaveAsync(order, cancellationToken); } } ``` ## Query aggregate event history (time/range filters) ```csharp var history = await store.GetEventHistoryAsync( aggregateId: orderId, request: new AggregateEventHistoryRequest { FromVersion = 10, ToVersion = 50, FromUtc = DateTimeOffset.UtcNow.AddDays(-7), MaxRecords = 100 }, cancellationToken: cancellationToken); foreach (var item in history.Results) { Console.WriteLine($"{item.AggregateVersion} {item.When:u} {item.EventType}"); } ``` ## Next pages - [Guarantees and Limitations](../guarantees-and-limitations/) - [Provider Feature Matrix](../provider-feature-matrix/) - [Provider Capabilities](../provider-capabilities/) - [Transaction Guarantees](../transaction-guarantees/) - [Event Contract Manifest](../event-contract-manifest/) - [Dependency Guardrails](../dependency-guardrails/) - [Source Generator Behaviors](../source-generator-behaviors/) - [Source Generator Code Fixes](../code-fixes/) - [SQL Server Guide](../sql-server-guide/) - [Release Flow](../release-flow/) If you plan to query snapshot JSON deeply in SQL providers, read the SQL Server guide and provider matrix before relying on nested predicates through scalar value object `.Value` members. # Guarantees and Limitations > This page is the single authoritative summary of what the framework guarantees and where it does not. Individual topics link to their detailed pages; do not… # Guarantees and Limitations This page is the single authoritative summary of what the framework guarantees and where it does not. Individual topics link to their detailed pages; do not duplicate conflicting claims elsewhere. ## Event ordering and optimistic concurrency - Event streams are the canonical source of aggregate truth. Snapshots are replaceable optimizations or read models. - Events within a stream are persisted in aggregate-version order. Writes to different aggregates never contend. - Providers detect conflicting writes (optimistic concurrency) and surface them as `ConcurrencyException`/`IConcurrencyConflict`; see `ConcurrencyRetry` and `AggregateWriteLock` for retry and in-process serialization. `EventStoreCapabilities.Concurrency` reports which providers are optimistic versus last-writer-wins. ## Transaction guarantees and failure modes See [Transaction Guarantees](../transaction-guarantees/) for the full contract. - `EventStoreTransactionGuarantee.BestEffort`: aggregates are saved sequentially; earlier saves are not rolled back on failure. - `EventStoreTransactionGuarantee.Atomic`: all enlisted aggregates commit or roll back in one provider-native transaction (SQL Server and PostgreSQL within one database boundary). - A transaction that requires a stronger guarantee than the enlisted stores can provide fails before any save (`EventStoreTransactionGuaranteeException`). Capability discovery reports the actual guarantee per provider. ## Idempotency scope - Saves deduplicate on an idempotency marker where the provider supports it (`EventStoreCapabilities.SupportsIdempotencyMarkers`). Idempotency is scoped to a save operation under a correlation/idempotency identifier; it is not a delivery guarantee for downstream consumers (which must be idempotent themselves). ## Metadata persistence Providers persist and expose event metadata where supported (`EventStoreCapabilities.PreservedMetadata`). The metadata fields are `SchemaVersion`, `CorrelationId`, `CausationId`, `UserId`, `IdempotencyId`, `AggregateVersion`, and `When`. A field that is not preserved by a provider is exposed as `null`/default. ## Schema evolution, manifests, and upcasters - [Event-Versioning-Strategy.md](../event-versioning-strategy/) describes how to evolve event schemas. - [Event-Contract-Manifest.md](../event-contract-manifest/) describes the deterministic contract manifest and baseline validation that fails a build on breaking changes. - Upcasters translate legacy payloads during replay. Treat emitted event names, serialized payloads, schema versions, and generated method signatures as compatibility-sensitive contracts. ## Snapshot compatibility and safe rebuild See [Snapshot-Schema-Versioning.md](../snapshot-schema-versioning/). - Snapshots must always be reconstructible from the event stream. - `[SnapshotSchemaVersion]` and `AggregateSnapshotSchema` drive version-aware snapshot storage. Incompatible snapshots are ignored before deserialization and canonical event replay is used instead; a later snapshot-eligible save writes a compatible replacement. ## Provider capability discovery See [Provider-Capabilities.md](../provider-capabilities/). Resolve `IEventStoreCapabilitiesProvider` from DI to query transaction guarantee, snapshot behavior, preserved metadata, query support, idempotency, concurrency, and operational limitations for the registered stores. The [Provider Feature Matrix](../provider-feature-matrix/) summarizes the same facts for package selection. ## Admin security, metadata/payload separation, and deny-by-default - Admin endpoints are denied by default and authorized per feature (`AdminFeature`, `AdminPortalPolicies`). `AdminEndpointOptions` lets a host map a feature to its own named authorization policy. - `ViewEvents` grants metadata access; event **payloads** are only returned with `ViewEventPayloads` permission. Without it, payloads are `null`. - Event export requires both export and payload permissions. Read permissions never imply mutation authority. Export is capped at `AdminProjectionOptions.MaxVersionsPerQuery`; a truncated stream is signaled with the `Purview-Event-Export-Truncated` response header so callers can detect partial exports. - Operational endpoints (`GET /admin/api/capabilities`, `GET /admin/api/health`, `GET /admin/api/manifest`, `GET /admin/api/outbox/poisoned`, `GET /admin/api/aggregates/{aggregateType}/{aggregateId}/events/unknown`, `GET /admin/api/aggregates/{aggregateType}/{aggregateId}/snapshot`, and the opt-in mutation `POST /admin/api/aggregates/{aggregateType}/{aggregateId}/snapshot/rebuild`) are opt-in, separately authorized, and audited through `IAdminAuditLogger` (default in-memory; replace with a durable implementation in production). Health reflects whether the capability contract resolves; it does not probe live storage. The manifest endpoint reports the runtime event-contract manifest and its compatibility status against a supplied baseline. A snapshot rebuild reconstructs the aggregate from its canonical event stream and persists a fresh snapshot; it is idempotent and requires both an event-backed `IEventStore` and a registered `IQueryableEventStore`. ## Query consistency and provider-specific translation limitations - Queryable stores are snapshot-backed read models; consistency is as-of-replay, not transactional. - SQL snapshot translation supports deep predicates over directly mapped JSON graphs, but provider-converted scalar value objects may not translate deep members through `.Value`; see the [Provider Feature Matrix](../provider-feature-matrix/) and [SQL Server Guide](../sql-server-guide/) for exact limits. ## Unknown-event handling and recovery - Replay of an unknown event type does not corrupt the stream: the aggregate reports `AggregateBase.SkippedEvents` so callers can detect partial reconstruction. - Event-schema versioning and upcasters are the recovery path for payload evolution; the contract manifest prevents accidental breaking changes. - The Admin portal can report stored event type names the runtime cannot resolve to a registered event type (`GET /admin/api/aggregates/{aggregateType}/{aggregateId}/events/unknown`, opt-in via `ViewUnknownEvents`). Legacy event types handled only by an upcaster may appear in this report because they are not registered current event types. # Provider Capabilities > Event-store capabilities are exposed as a provider-neutral, queryable contract so applications and Admin tooling can determine actual guarantees instead of… # Provider Capabilities Event-store capabilities are exposed as a provider-neutral, queryable contract so applications and Admin tooling can determine actual guarantees instead of inferring them from a provider name. ## Discovery Resolve `IEventStoreCapabilitiesProvider` from dependency injection: ```csharp public sealed class StoreHealth(IEventStoreCapabilitiesProvider capabilitiesProvider) { public void Report() { var capabilities = capabilitiesProvider.GetCapabilities(); var guarantee = capabilities.TransactionGuarantee; var preservesMetadata = capabilities.PreservedMetadata; } } ``` Capability discovery never constructs a store or probes live storage; it only reads what was registered. `IEventStoreCapabilitiesProvider` is always resolvable after `AddEventSourcing()` and reports the conservative `EventStoreCapabilities.Default` until a provider registers its capabilities. ## What is exposed | Member | Meaning | | --- | --- | | `TransactionGuarantee` | `EventStoreTransactionGuarantee.Atomic` or `.BestEffort` (the same abstraction used by transaction options). | | `SupportsEventStreams` | Whether the provider persists an append-only event stream. | | `SupportsSnapshots` | Whether the provider stores aggregate snapshots (replay cache or query store). | | `SnapshotSchemaVersioning` | `None`, `SingleVersion` (legacy single-shape layout), or `Versioned` (honors `[SnapshotSchemaVersion]`). | | `PreservedMetadata` | Flags for which event metadata fields are persisted: `SchemaVersion`, `CorrelationId`, `CausationId`, `UserId`, `IdempotencyId`, `AggregateVersion`, `When`. | | `SupportsQueries` | Whether a queryable snapshot store is available through `IQueryableEventStore`. | | `SupportsIdempotencyMarkers` | Whether saves deduplicate on an idempotency marker. | | `Concurrency` | `Optimistic` (conflicts rejected) or `LastWriterWins`. | | `OperationalLimitations` | Stable limitation identifiers, for example `non-persistent` (InMemory) and `no-event-stream` (Cosmos DB). | ## Registration Built-in providers register their truthful capabilities from their `Add*EventStore` extension methods. Multiple registrations for the same provider are merged into the union of what is actually available (for example SQL Server event store + snapshot query store report atomic transactions, event streams, snapshots, and queries). Custom providers register their own capabilities explicitly: ```csharp services.AddEventStoreCapabilities(new EventStoreCapabilities( EventStoreTransactionGuarantee.BestEffort, SupportsEventStreams: true, SupportsSnapshots: false, SnapshotSchemaVersioning: SnapshotSchemaSupport.None, PreservedMetadata: PreservedEventMetadata.All, SupportsQueries: false, SupportsIdempotencyMarkers: false, Concurrency: ConcurrencyGuarantee.Optimistic, OperationalLimitations: [] )); ``` Providers that register nothing are reported with `EventStoreCapabilities.Default`: best-effort transactions, no streams, no snapshots, no queries, no idempotency, and `LastWriterWins` concurrency. A provider is never assumed to offer stronger behavior than it implements. ## Built-in capabilities The values below are asserted by the `Capabilities.UnitTests` contract suite so documentation and implementation cannot drift apart. | Provider | Transactions | Event streams | Snapshots | Snapshot versions | Metadata | Queries | Idempotency | Concurrency | Transactional outbox | | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | | InMemory event store | BestEffort | Yes | No | None | All | No | Yes | Optimistic | No | | InMemory snapshot store | BestEffort | Yes | Yes | SingleVersion | All | Yes | Yes | Optimistic | No | | SQL Server event store | Atomic | Yes | Yes | Versioned | All | No | Yes | Optimistic | Yes | | SQL Server snapshot query store | Atomic | No | Yes | SingleVersion | None | Yes | No | Optimistic | No | | PostgreSQL event store | Atomic | Yes | Yes | Versioned | All | No | Yes | Optimistic | Yes | | PostgreSQL snapshot query store | Atomic | No | Yes | SingleVersion | None | Yes | No | Optimistic | No | | Azure Storage | BestEffort | Yes | Yes | Versioned | All | No | Yes | Optimistic | No | | MongoDB event store | BestEffort | Yes | Yes | Versioned | All | No | Yes | Optimistic | No | | MongoDB snapshot query store | BestEffort | No | Yes | SingleVersion | None | Yes | No | Optimistic | No | | Cosmos DB snapshot query store | BestEffort | No | Yes | SingleVersion | None | Yes | No | Optimistic | No | The [Provider Feature Matrix](../provider-feature-matrix/) summarizes the same facts for package selection. # Provider Feature Matrix > This page summarizes feature availability by package so provider selection is explicit and accurate. The capability values below are backed by executable… # Provider Feature Matrix This page summarizes feature availability by package so provider selection is explicit and accurate. The capability values below are backed by executable capability definitions registered by each provider and asserted by the `Capabilities.UnitTests` contract suite — see [Provider Capabilities](../provider-capabilities/) for the runtime-queryable contract and the exact per-registration values. | Capability | `Purview.EventSourcing` (core) | `Purview.EventSourcing.SqlServer` | `Purview.EventSourcing.Postgres` | `Purview.EventSourcing.AzureStorage` | `Purview.EventSourcing.MongoDB` | `Purview.EventSourcing.CosmosDb` | | --- | --- | --- | --- | --- | --- | --- | | Aggregate/event abstractions (`AggregateBase`, `[EventContract]` event records) | Yes | Uses core | Uses core | Uses core | Uses core | Uses core | | Provider-agnostic event facade (`IEventStore`) | Yes | SQL event store | PostgreSQL event store | Azure Table event store | MongoDB event store | Optional registration via snapshot provider | | Provider-agnostic query facade (`IQueryableEventStore`) | Yes (interface + null implementation) | Optional SQL snapshot store | Optional PostgreSQL snapshot store | Not provided | Optional MongoDB snapshot store | Optional Cosmos snapshot store | | Event-stream persistence | Not persistent by itself | Yes | Yes | Yes | Yes | No | | Snapshot-backed query/list/count | Null provider only | Optional | Optional | No | Optional | Optional | | Blob-backed snapshots / large payloads | No | No | No | Yes | No | No | | Provider-neutral transaction guarantee | Explicit `BestEffort` or required `Atomic` | Atomic within one database boundary | Atomic within one database boundary | Best effort | Best effort | Best effort | | Transactional outbox (atomic with events) | No | Yes (`AddSqlServerOutbox`) | Yes (`AddPostgresOutbox`) | No | No | No | | Provider-specific native transaction factory | No | `ISqlServerEventStoreTransactionFactory` | `IPostgresEventStoreTransactionFactory` | No | No | No | | Runtime-configured JSON payload indexes | No | Yes (event + snapshot stores, auto-create path) | Yes (GIN + expression indexes on event + snapshot stores) | No | No | No | | DI registration helpers | `AddNullQueryableEventStore()` | `AddSqlServerEventStore()`, `AddSqlServerSnapshotQueryableEventStore()` | `AddPostgresEventStore()`, `AddPostgresSnapshotQueryableEventStore()` | `AddAzureStorageEventStore()` | `AddMongoDBEventStore()`, `AddMongoDBSnapshotQueryableEventStore()` | `AddCosmosDbSnapshotQueryableEventStore()` | ## Selection guidance - Choose **SQL Server** when you need event streams and optional SQL query snapshots with SQL-native transaction coordination. - Choose **PostgreSQL** when you need append-only PostgreSQL event streams, replay snapshots for strategy-driven rehydration, and optionally a PostgreSQL-backed query store. - Choose **Azure Storage** when you want Azure Table event persistence with Blob support for large payloads/snapshots. - Choose **MongoDB** when you want both event and snapshot stores on MongoDB. - Choose **Cosmos DB** when you only need a queryable snapshot store. ## High-scale / global-production readiness The framework is designed for **single-region, single-authoritative-store** deployment with per-aggregate-stream concurrency. This is a sound model for horizontal scale: writes to different aggregates never contend, and ordering is guaranteed per stream. The following capabilities are built in: - **Keyset event-history paging.** `GetEventHistoryAsync` continuation tokens record the last returned aggregate version, so each page scans only the events it needs (O(page) rather than O(stream)). Legacy integer tokens remain supported. - **Snapshot cache single-flight.** Concurrent first-reads of a cold aggregate serialize rehydration per stream and double-check the cache, preventing replay and cache-write stampedes. Optional `EventStoreOperationContext.ValidateCachedSnapshot` rejects stale cache entries against the stream version (adds one storage read per cache hit). - **Strategy-gated snapshots.** The SQL Server/PostgreSQL same-table snapshot honors `ISnapshotStrategy` (defaults to every save, preserving historical behavior) and per-operation overrides via `SetSnapshotStrategy`, so write amplification can be tuned. - **Concurrency retry + in-process serialization.** `ConcurrencyRetry.ExecuteAsync` retries conflicts (all provider `ConcurrencyException` types implement `IConcurrencyConflict`) with exponential backoff; `AggregateWriteLock` serializes read-modify-write work per stream within a process. - **Conflict recognition across providers.** MongoDB duplicate-key writes now surface as `ConcurrencyException` (not `CommitException`), and the in-memory store throws on conflicting versions instead of silently dropping them. - **Partial-replay detection.** `AggregateBase.SkippedEvents` reports events skipped during replay so callers can detect a partially reconstructed aggregate in a mixed-version fleet. `SkippedEvents` is replay-transient metadata and is **not** persisted in SQL Server/PostgreSQL EF-backed snapshot payloads; snapshot reads reconstruct stored aggregate state without replay and therefore never report skips. Check `SkippedEvents` after event-stream loads rather than relying on snapshot persistence. ### Remaining gaps for global scale (not implemented) - **Geo-replication / multi-region writes.** There is no framework-level replication, multi-region write path, or conflict resolution. Active-active writes across regions are unsupported; route all writes for a stream to one region or use provider-native replication. - **Hot-partition mitigation / sharding.** A stream is a single aggregate instance; a hot aggregate concentrates onto one partition in every provider. SQL Server per-aggregate-type table/schema overrides and provider-native partitioning are the available levers. - **Snapshot query listing paging is offset-based.** Queryable store `ListAsync`/`QueryAsync` continuation is an integer skip; deep pages are O(n). Keyset conversion for arbitrary `orderBy` clauses is not implemented. - **Event-stream aggregate listing is an unbounded scan.** `GetAggregateIdsAsync` streams ids in deterministic (ordered) form but does not support keyset resumption through the API. See the provider guides for provider-specific scaling configuration (for example SQL Server data compression, JSON indexes, and per-type schema overrides). ## Snapshot model reminder - Event-store snapshots are replay/rehydration optimizations for append-only streams. - Query snapshots are explicit read/query stores used through `IQueryableEventStore`. - Applications may use the event store without any query snapshot store at all. - Applications may also pair one event-store provider with a different query-store provider when that better fits read requirements. ### SQL Server query translation notes - SQL snapshot queries support predicates over JSON-mapped primitive members and supported value-object shapes. - SQL Server can additionally create runtime-managed indexes over supported JSON scalar paths when `JsonIndexOptions.Enabled = true` and `AutoCreateTable = true`. - For provider-converted members (for example, a `[Scalar]` value object whose inner `Value` is a complex type), deep predicates on inner members are **not SQL-translatable** (for example: `a.ReportSummary.Value.ParserDetails.FailedLines > 0`). - The same conceptual data **can** be queried deeply when exposed as a directly mapped complex property in the snapshot graph (for example: `a.ReportSummaryScalar.ParserDetails.FailedLines > 0`, where `ReportSummaryScalar` is a `ParserReportSummary`). - `EventStoreList` / `EventStoreSet` members with `[ValueObject]` struct elements are persisted via JSON conversion for compatibility; treat nested element member filtering as non-translatable unless explicitly covered by tests. - Nested dictionary/interface-collection members cannot be structurally mapped by the SQL Server or PostgreSQL EF snapshot model. Mark non-queryable values `[EfOpaque]` to persist them as a converted JSON scalar, or remodel them as complex entry collections when their contents must be queried. - Opaque JSON currently uses EF's supported string conversion inside the outer JSON document. This preserves round-trip values but stores the nested value as JSON text rather than a raw nested JSON token. - Recommended pattern: query by SQL-translatable fields first, or expose a directly mapped complex mirror property when deep SQL filtering is a real requirement. ## Related docs - [Getting Started](../getting-started/) - [Guarantees and Limitations](../guarantees-and-limitations/) - [Provider Capabilities](../provider-capabilities/) - [Dependency Guardrails](../dependency-guardrails/) - [SQL Server Guide](../sql-server-guide/) - Postgres package README: `src/src/Postgres/Sdk/README.md` - Core package README: `src/src/EventSourcing/Sdk/README.md` - SQL Server package README: `src/src/SqlServer/Sdk/README.md` - Azure Storage package README: `src/src/AzureStorage/Sdk/README.md` - MongoDB package README: `src/src/MongoDB/Sdk/README.md` - Cosmos DB package README: `src/src/CosmosDb/Sdk/README.md` # Release flow > This repository uses the shared Purview.Buildhttps://github.com/purview-dev/build pipeline for both PR validation and releases. Consuming repositories own… # Release flow This repository uses the shared [Purview.Build](https://github.com/purview-dev/build) pipeline for both PR validation and releases. Consuming repositories own configuration (through `purview-build.json`) but not pipeline source code. - `.github/workflows/pr.yml` — PR validation - `.github/workflows/release.yml` — release on push to `main` - `purview-build.json` — pipeline configuration ## PR validation `pr.yml` runs on pull requests targeting `main` and delegates to the shared `purview-build.yml` workflow. It runs: 1. `dotnet restore` of `src/EventSourcing.slnx` 2. `dotnet build --no-restore --configuration Release` 3. CSharpier lint across the repository 4. Unit tests (discovered under `src/tests` matching `*Tests.csproj`, run with the `/*/*/*/*[Category=Unit]` TUnit tree-node filter) 5. `dotnet pack` and package-content validation Integration tests are never discovered in CI: `purview-build.json` sets `Build:TestPatterns` to `*Tests.csproj`, `Build:TestProjects` to `*UnitTests.csproj`, and `Build:TestFilter` to `/*/*/*/*[Category=Unit]`, so only unit-test projects (tagged `[Category=Unit]` by the `Purview.BuildSdk`) are executed; provider integration tests (which require Docker/Testcontainers) run only locally via `just test`. The performance harnesses live under `src/src/Benchmarks` (a single non-test `Benchmarks.csproj`) and run locally via `just perf-source-generator` / `just perf-runtime` / `just perf-sql-server`. The PR workflow does not tag, release, or publish packages. ## Versioning model `package.json` is the authoritative release version source. The release workflow reads: ```bash bun -p "require('./package.json').version" ``` This flow assumes version prep already happened before release (for example with `@changesets/cli` versioning and changelog updates merged to `main`). The release pipeline does not invent or auto-bump versions. ## Release on push to main `release.yml` triggers on push to `main` and delegates to the shared `purview-release.yml` workflow with `release-mode: NuGet`. The shared workflow: 1. Reads `package.json` `version` and computes the `v` tag. 2. Skips the entire release if `v` already exists (so re-merging to `main`, or merging `main` into a `release` branch, releases exactly once). 3. Restores, builds, lints, runs unit tests, packs, and validates packages. 4. Pushes every `.nupkg` to nuget.org (`--skip-duplicate`). 5. Creates the `v` GitHub release with generated release notes and attaches the package artifacts. A release is therefore produced simply by bumping `package.json` (via changesets) and merging to `main`. Do not create release tags manually. ## Prerelease support Prerelease versions (any SemVer containing a hyphen, for example `2.0.0-prerelease.29`) release through the same push-to-`main` flow. The `v` tag and GitHub release are still created and packages published; the shared pipeline does not mark the GitHub release with the prerelease flag. ## NuGet publishing NuGet publishing uses the shared workflow's API-key path with the organization `NUGET__APIKEY` secret (available through `secrets: inherit`). The pipeline also accepts `NUGET_APIKEY`. No long-lived repository-level API key secrets are required. To use NuGet Trusted Publishing (OIDC) instead, the consuming repository would need to mint the federated credential before the shared pipeline runs; the shared workflow itself does not perform the `NuGet/login` step. ## Shared pipeline configuration `purview-build.json` at the repository root drives the pipeline: | Key | Value | Purpose | | --- | --- | --- | | `Build:Solution` | `src/EventSourcing.slnx` | Solution passed to restore/build/pack | | `Build:TestRoot` | `src/tests` | Test project discovery root | | `Build:TestPatterns` | `*Tests.csproj` | Test projects discovered for the test step | | `Build:TestProjects` | `*UnitTests.csproj` | Restricts the run list to unit-test projects (excludes integration tests and the `src/src/Benchmarks` harnesses) | | `Build:TestFilter` | `/*/*/*/*[Category=Unit]` | TUnit tree-node filter (unit-only) | | `PackValidation:RequireSymbolPackage` | `true` | Every `.nupkg` needs a matching `.snupkg` | | `PackValidation:RequireSymbolFiles` | `true` | Every `.snupkg` must contain PDBs | | `PackValidation:RequiredContent` | Expected package contents | Asserts each package ships its expected output — README/logo, `buildTransitive/Purview.EventSourcing.targets` in the core package, `buildTransitive/Purview.EventSourcing.Validation.ZodSharp.targets` in the Purview.ZodSharp package, the analyzer assemblies in the core/EF-Core-enabled packages, and the provider/admin `lib` assemblies | | `Release:Mode` | `None` | Publishing is enabled only by the release workflow | Configuration precedence is command line, environment variables, `purview-build.json`, then the tool's built-in defaults. Nested environment keys use `__`, for example `Release__Mode=NuGet`. # Runtime Performance > The runtime performance suite just perf-runtime measures the runtime hot paths of the code the source generator emits, using the generated aggregates and… # Runtime Performance The runtime performance suite (`just perf-runtime`) measures the **runtime hot paths of the code the source generator emits**, using the generated aggregates and value objects in `src/src/Samples` (`OrderAggregate`, `CustomerAggregate`, `EmailAddress`, `Money`, `OrderStatus`, `UserDetails`, ...). It runs under BenchmarkDotNet with `[MemoryDiagnoser]`, so every case reports both wall time and allocated bytes per operation. ## Running ```text just perf-runtime # quick run (1 warmup, 3 iterations) just perf-runtime --benchmark # benchmark run (3 warmup, 12 iterations) ``` Equivalent: `dotnet run --project src/src/Benchmarks/Benchmarks.csproj --configuration Release -- runtime`. Always use Release; in Debug the JIT produces meaningless numbers. ## What is measured | Category | Cases | | --- | --- | | Aggregate | `new CustomerAggregate()`, `new OrderAggregate()` (per-instance registration cost) | | Command | generated partial methods: `CreateOrder`, `ConfirmOrder`, `ShipOrder`, `CompleteOrder`, `AddLineItem`, `RegisterCustomer`, `ChangeEmail` | | Replay | single event application, 100-event stream replay, event `GetHashCode` | | ValueObject | scalar (`EmailAddress`, `CurrencyCode`, `OrderStatus`) and complex (`Money`, `UserDetails`) `Create`/`Hydrate`/equality/hash/compare/`ToString`/implicit conversion | | Serialization | event payload round-trip (the reflection-based provider path), snapshot round-trip through the generated `OrderAggregateJsonConverter`, value-object round-trip through generated converters | | Collections | `EventStoreList` add/enumerate, `EventStoreSet` add/contains/remove | | Mapping | `IAggregateEventNameMapper.GetName` per event type | Command and replay cases construct a fresh aggregate per invocation (measured together with the command), which reflects the realistic hot path: an aggregate is loaded or created, then mutated. The delta between `Command_CreateOrder` and `AggregateConstruction_Order` isolates the command cost. ## Interpreting results Each run writes `artifacts/runtime-performance/{history,latest.json}` and compares against the previous run. The suite fails when: - allocated bytes per operation regress by more than 10% for any case, or - mean wall time regresses by more than 40% for any case. Allocations are the most reliable regression signal in a micro-benchmark: an extra event allocation, delegate, or closure per operation shows up immediately as a byte jump. When reporting a regression, record the machine, framework, mode, and the history file (the previous-run comparison is only meaningful on the same machine). ## Known hotspots and findings The suite exists to surface and track these; the numbers below are from a representative run and should be re-measured locally. - **Generated command methods** construct a **single** event record per invocation: the property `OnChanging` hooks run, `OnShouldApply` is evaluated before `OnRaising`, the payload is re-synchronized from post-hook values, and the event is recorded via `RecordAndApply`. A re-introduction of a second event construction shows up as a byte jump. - **Event application/replay is allocation-free**: `Replay_100EventStream` allocates the same bytes as constructing the aggregate, because applying an event is a shared-applier lookup plus a delegate call plus property assignments. - **Aggregate construction is near-zero allocation**: `AggregateBase` builds a per-type static applier map once (open delegates shared across instances), stores unsaved events in a `List<(object, EventMetadata)>`, and derives `AggregateType` from a cached name. With events as `sealed record` payloads carrying a struct `EventMetadata` (no per-event metadata heap object), `new OrderAggregate()` dropped from ~2.1 KB to **~0.4 KB** and `Command_CreateOrder` from ~2.6 KB to **~0.8 KB**. - **Event-name mapping is allocation-free on the hot path**: `AggregateEventNameMapper` caches names by CLR type, so the per-event `Type.AssemblyQualifiedName` string is no longer built on every save (`EventNameMapper_GetName` measures 0 bytes/op). - **Event/snapshot payloads serialize through reflection-based System.Text.Json** (no source-generated `JsonSerializerContext` for events); the generated aggregate/value-object converters are thin wrappers over DTOs and are already reflection-free. Event `Metadata` is `[JsonIgnore]`d, so payloads carry no per-event metadata and provider row columns are the source of truth on replay. - **SQL Server save (SQL suite)**: skipping the guaranteed-miss existence `SELECT` for brand-new stream rows, caching `DbContextOptions`, gating cache-key allocations on `CacheMode`, and folding the snapshot write into the events batch/transaction (atomic by default via `RequireSnapshotWrite`, with a best-effort opt-out) reduced `EventStore_Save` from ~16 ms to **~12 ms**. The `EventStore_Save_NoSnapshot` case isolates the default per-save snapshot (Interval=1) cost; operators can raise the cadence with `operationContext.SetSnapshotStrategy(new IntervalSnapshotStrategy(N))` with no code change. - **In-memory store suite**: `just perf-inmemory` measures the allocation-free reference implementation — save ~10 µs, cached get ~0.3 µs, and a 101-event replay ~35 µs — so provider overhead can be compared against a zero-I/O baseline. The source-generator suite now short-circuits identical reruns via a pre-compilation marker (warm ≈ 6–12% of cold); see `Source-Generator-Performance.md`. # Shared Testing Framework > This page describes the shared provider-agnostic test framework used by the storage-provider integration suites. # Shared Testing Framework This page describes the shared provider-agnostic test framework used by the storage-provider integration suites. ## Overview Each storage provider (`AzureStorage`, `CosmosDb`, `MongoDB`, `Postgres`, `SqlServer`) has its own integration test project. Instead of duplicating the same behavioural tests for every provider, the repository defines two shared contract suites that run against every provider that advertises the relevant capability: - **Event-store contract suite** — runs against providers with an event store: Azure Storage, MongoDB, Postgres, SQL Server. (Cosmos DB has no event stream.) - **Snapshot-store contract suite** — runs against providers with a query snapshot store: Cosmos DB, MongoDB, Postgres, SQL Server. (Azure Storage has no query snapshot store.) Provider-specific behaviour (batch limits, index creation, JSON operators, query-translation boundaries, telemetry, storage layout) lives in per-provider guard tests in each integration project. ## Layout | Path | Purpose | | --- | --- | | `src/tests/SharedTestingFramework/Contracts/` | The shared contract suites. These are **not** compiled into the `SharedTestingFramework` assembly; each provider integration test project links them into its own compilation (see below). | | `src/tests/SharedTestingFramework/Fixtures/` | Provider Testcontainers fixtures. | | `src/tests/.IntegrationTests/Events/` | The per-provider event-store wiring + guard tests. | | `src/tests/.IntegrationTests/Snapshots/` | The per-provider snapshot-store wiring + guard tests. | | `src/tests/.IntegrationTests/Guards/` | Provider-specific event-store guard tests. | ## How the shared suites are wired TUnit uses compile-time discovery, so the `[Test]` methods must be discoverable from the test assembly. The shared suites achieve this with three TUnit features: 1. `[GenerateGenericTest(typeof(PersistenceAggregate))]` on a generic test class makes TUnit generate a concrete test class for the supplied aggregate type. 2. `[ClassDataSource(Shared = SharedType.PerTestSession)]` injects the provider fixture (one container per test session). 3. `[InheritsTests]` picks up the `[Test]` methods declared on the shared generic base class (`EventStoreContractTestsBase` / `SnapshotStoreContractTestsBase`). The contract sources under `Contracts/` are linked directly into each provider test project's compilation (via `` links) rather than consumed cross-assembly. This is deliberate: TUnit's `TestMetadataGenerator` reports error diagnostic `TUNIT0999` at the inherited method's source location when an internal generation error occurs, and Roslyn's `SourceProductionContext.ReportDiagnostic` rejects diagnostics whose location is not part of the compilation being analyzed (surfacing as `CS8785`). With a cross-assembly base class the location points into `SharedTestingFramework`'s sources, outside the provider compilation, so the warning is unavoidable. Linking the sources keeps every inherited `[Test]` method inside the provider compilation and eliminates the failure. `SharedTestingFramework` therefore no longer carries the base `[Test]` methods at all. Each provider test project therefore adds a small derived class such as: ```csharp [GenerateGenericTest(typeof(PersistenceAggregate))] [ClassDataSource(Shared = SharedType.PerTestSession)] [InheritsTests] public sealed class EventStoreContractTests(SqlServerEventStoreFixture fixture) : EventStoreContractTestsBase where TAggregate : class, IAggregateTest, new() { protected override IEventStoreCore CreateEventStore() => fixture.CreateEventStore(); protected override IEventStoreCore CreateEventStore(IAggregateChangeFeedNotifier? notifier) => fixture.CreateEventStore(aggregateChangeNotifier: notifier); protected override Task MarkEventTypesAsUnknownAsync(...) => /* provider-specific event rewrite */; } ``` The shared base classes only exercise the public contracts (`IEventStoreCore` and `IQueryableEventStoreCore`), observable state (save results, rehydrated aggregates, change-feed notifications, event ranges, query results) and shared aggregates (`PersistenceAggregate`, `ComplexTestType`). Provider internals are deliberately out of scope for the shared suites. ## Where each suite runs | Suite | AzureStorage | CosmosDb | MongoDB | Postgres | SqlServer | | --- | --- | --- | --- | --- | --- | | Event-store contract suite | ✓ | — | ✓ | ✓ | ✓ | | Snapshot-store contract suite | — | ✓ | ✓ | ✓ | ✓ | | Provider guard / feature tests | ✓ | ✓ | ✓ | ✓ | ✓ | ## Adding a new provider 1. Create the integration test project (see `project-placement-defaults`), referencing `SharedTestingFramework` and `Samples`, and link the contract sources that the provider needs (the `EventStore*` and/or `SnapshotStore*` files under `Contracts/`) — see the `` links in the existing provider test projects. 2. Add an `EventStoreContractTests` (and/or `SnapshotStoreContractTests`) derived class wired to the provider fixture. 3. Implement the provider-specific seams (`MarkEventTypesAsUnknownAsync` for the unknown-event test, `SnapshotAsync` for the snapshot suite). 4. Add guard tests for capabilities that are not part of the shared contract. ## Adding a shared test 1. Add the `[Test]` method to the relevant shared base class under `Contracts/`. 2. Add any data sources to the matching `*ContractTestData` static class. 3. The test runs automatically for every provider whose integration project links the contract sources and derives from the relevant base class. ## Environment notes - Integration suites require Docker and the provider images (Testcontainers). - The Azure/Cosmos/Mongo snapshot fixtures rely on Azurite; the SQL fixtures on a SQL Server image. - The CI pipeline runs only the unit-test tree filter; integration suites are exercised locally or via an opt-in run. # Snapshot Schema Versioning > Snapshots are replaceable optimizations. Event streams remain the source of truth. # Snapshot Schema Versioning Snapshots are replaceable optimizations. Event streams remain the source of truth. When an aggregate change makes older serialized snapshots unsafe to read, declare a new snapshot schema version: ```csharp [SnapshotSchemaVersion(2)] [Aggregate] public sealed partial class Order : AggregateBase { } ``` The default version is 1, so existing aggregates and storage names remain compatible. Versions must be positive and are inherited by derived aggregate types. SQL Server and PostgreSQL store the version on the snapshot row. MongoDB stores it on the snapshot document. Azure Storage uses a versioned blob name. Distributed-cache keys are also versioned. A mismatch is detected before payload deserialization; the store ignores the snapshot and rebuilds the aggregate from its complete event stream. The next snapshot-eligible save writes the current schema and replaces or supersedes the incompatible snapshot. This fallback is safe because it never mutates the event stream and never treats a snapshot as canonical state. A version bump may temporarily increase replay work, so deploy it before removing runtime types or converters needed by old snapshot payloads if a rolling deployment must support both application versions. ## Administrative inspection and rebuild The Admin portal reports whether a snapshot is materialized for an aggregate (`GET /admin/api/aggregates/{aggregateType}/{aggregateId}/snapshot`, opt-in via `ViewSnapshot`) and can reconstruct a snapshot from the canonical event stream (`POST /admin/api/aggregates/{aggregateType}/{aggregateId}/snapshot/rebuild`, opt-in, separately authorized, and audited via `RebuildSnapshot`). Rebuild is idempotent and requires both an event-backed `IEventStore` and a registered `IQueryableEventStore`; it never mutates the event stream. # Solution Design Guide > This guide helps application developers design an event-sourced solution before writing aggregate code. It is written for Purview EventSourcing projects that… # Solution Design Guide This guide helps application developers design an event-sourced solution before writing aggregate code. It is written for Purview EventSourcing projects that use `AggregateBase`, source-generated aggregate events, provider event stores, and optional queryable snapshots. Use it in this order: 1. Model the business process on paper. 2. Choose aggregate boundaries and relationships. 3. Name aggregates, commands, events, and value objects using the repository rules. 4. Decide where validation belongs. 5. Sketch the event stream and read/query model. 6. Only then create aggregate code and tests. For a printable template, use [Solution Design Worksheet](../solution-design-worksheet/). ## Design Principles - An aggregate is a consistency boundary, not a database table. - An event is a fact that has happened, not an instruction to do something. - Aggregate state is derived from its ordered event stream. - Snapshots and query stores are optimizations/read models. The event stream remains the source of truth. - Cross-aggregate workflows should be coordinated by services/process managers, not by loading other aggregates inside an aggregate method. - Relational data belongs in query models, snapshots, projections, or referenced IDs, not as live joins inside aggregate invariants. - Value objects carry reusable meaning and validation across aggregates. - Validation should be explicit about when it runs: command-time, event creation, replay/hydration, save-time, or projection-time. - Correlation IDs, idempotency markers, and transaction boundaries are part of the design, not just infrastructure details. ## Repository Rules To Design Against ### Aggregate Naming Aggregate classes should end with `Aggregate`. ```csharp public sealed partial class OrderAggregate : AggregateBase { } ``` `AggregateBase` derives the persisted aggregate type by trimming the `Aggregate` suffix and converting the remaining type name to lower kebab case: | Class name | Aggregate type | | --- | --- | | `OrderAggregate` | `order` | | `CustomerAggregate` | `customer` | | `LearningHTMLTestAggregate` | `learning-html-test` | The aggregate type is used by store implementations for stream grouping and lookup. Treat it as persisted data. Renaming an aggregate class or overriding the aggregate type after data exists is a migration decision. The source generator supports aggregates that: - are `partial` - have no declared base class, where the generator adds `AggregateBase` - directly inherit `AggregateBase` - transitively inherit through a custom base class If an aggregate uses a custom base class, confirm the chosen base class still inherits `AggregateBase` and does not hide event-sourcing behavior from developers. Aggregates with no declared base class get `AggregateBase` added by the generator. ### Event Naming Generated event type names come from `[Event]` method names unless overridden with `EventName`. Generated events normally end with `Event`. The event store name mapper trims that suffix and stores the event name as: ```text {aggregate-type}.{event-name-without-event-suffix} ``` Example: | Aggregate | Generated event type | Persisted event name | | --- | --- | --- | | `OrderAggregate` | `OrderCreatedEvent` | `order.order-created` | | `CustomerAggregate` | `EmailChangedEvent` | `customer.email-changed` | | `InventoryAggregate` | `StockReservedEvent` | `inventory.stock-reserved` | Prefer past-tense event names: - `OrderCreated` - `CustomerRegistered` - `EmailChanged` - `StockReserved` - `ReservationReleased` - `OrderCancelled` Avoid command-like event names: - `CreateOrder` - `ChangeEmail` - `ReserveStock` - `ValidateCustomer` If the generated name is not the business language you want to persist, set it explicitly: ```csharp [Event(EventName = "CustomerRegistered")] public partial CustomerAggregate RegisterCustomer(string name, string email); ``` Use explicit `EventName` sparingly. It is useful for compatibility, integration contracts, or a domain term the generator cannot infer. Once persisted, event names are contracts. ### Event Namespace By default, generated event classes are placed under: ```text {AggregateNamespace}.{AggregateNameWithoutAggregateSuffix}Events ``` For example, `Purview.EventSourcing.Samples.Domain.OrderAggregate` generates events in an `OrderEvents` namespace. You can override the namespace at aggregate or method level with `EventNamespace`, but use that only when you need stable compatibility or a shared event namespace. ### Generated Method Shapes Use generated methods for state changes that should become events: ```csharp [Event] public partial OrderAggregate CreateOrder(CustomerId customerId); ``` Keep a public wrapper when the business intent needs guard clauses, calculations, or multiple lower-level event methods: ```csharp public OrderAggregate ConfirmOrder() => SetStatusCode(OrderStatusCode.Confirmed); [Event] private partial OrderAggregate SetStatusCode(OrderStatusCode status); ``` Use collection events for `EventStoreList` and `EventStoreSet` properties: ```csharp public EventStoreSet RelatedProjects { get; private set; } = []; [CollectionEvent(nameof(RelatedProjects))] public partial ReportUploadAggregate AddRelatedProject(ProjectId projectId); ``` Use `[Computed]` for deterministic values that callers must not supply directly and that generated hooks finalize before recording the event. Use `Manual = true` when generated property mapping is not expressive enough and you will write the `Apply(...)` method yourself. ## Paper-First Worksheet Copy these tables into a design note or pull request before building a new feature. ### Business Capability | Question | Answer | | --- | --- | | What business process is this? | | | Who initiates it? | | | What decisions must be consistent immediately? | | | What can be eventually consistent? | | | What external systems or UI screens need to know? | | | What audit questions must be answerable later? | | ### Aggregate Candidates | Candidate aggregate | Owns these decisions | Does not own | Lifecycle start | Lifecycle end | | --- | --- | --- | --- | --- | | | | | | | Choose an aggregate when it owns rules that must be consistent in one event stream. Do not create one aggregate per relational table by default. ### Command And Event Sketch | User/system intent | Aggregate method | Event fact | State changed | Validation needed | | --- | --- | --- | --- | --- | | Place order | `CreateOrder(...)` | `OrderCreated` | `CustomerId`, `Status` | Customer ID present | | Add item | `AddLineItem(...)` | `LineItemAdded` or `LineItemsChanged` | `LineItems`, `TotalAmount` | Quantity, price, status | | Confirm order | `ConfirmOrder()` | `OrderConfirmed` | `Status` | Has line items | Keep method names intention-focused. Keep event names factual and past tense. ### Event Stream Sketch | Version | Event | Important payload | Why this event exists | | --- | --- | --- | --- | | 1 | `OrderCreated` | `customerId` | Starts the order lifecycle | | 2 | `LineItemAdded` | `productId`, `quantity`, `unitPrice` | Audits basket change | | 3 | `OrderConfirmed` | `status` | Locks in the order | Check that replaying the events in order recreates the aggregate state without calling external services. ### Relationship Sketch | Relationship | Store on event/aggregate as | Enforce where | Query shape | | --- | --- | --- | --- | | Order belongs to customer | `CustomerId` value object/string | Application service or command validator | Projection joins customer snapshot | | Order reserves inventory | `OrderId`, `ProductId`, `LocationId` | Workflow service across aggregates | Stock reservation read model | | Report belongs to project | `ProjectId` value object | Command-time check or policy | Project report projection | ## Relational Data Event-sourced aggregates should model relationships by identity, not by live object references. Use this pattern inside aggregates: ```csharp public CustomerId CustomerId { get; private set; } public EventStoreList LineItems { get; private set; } = new(); ``` Avoid this inside aggregates: ```csharp public CustomerAggregate Customer { get; private set; } public List Inventory { get; private set; } ``` ### When You Need Relational Views Use query-side models for relational questions: - customer profile with recent orders - inventory by location and product - order details with customer and shipment data - audit pages across aggregate types The project supports queryable snapshot stores for providers such as SQL Server, MongoDB, and Cosmos DB, and a null queryable store for core-only scenarios. Design relational views as projections/snapshots that can be rebuilt from event streams when possible. When designing snapshot-backed SQL queries, distinguish between: - provider-converted scalar value objects, which are ideal for invariants and serialization but may not support deep translation through `.Value`, and - directly mapped complex snapshot members, which can support deep JSON-path predicates when the payload shape is explicitly supported and covered by integration tests. If deep snapshot filtering is a hard requirement for a complex concept, model that query-facing shape deliberately instead of assuming a scalar wrapper will remain queryable. The current repository also includes an in-memory provider and quick-start sample. Treat in-memory storage as a development/testing convenience unless a production use case has explicitly accepted its durability limits. ### Cross-Aggregate Rules If a rule needs more than one aggregate, do not hide that rule inside one aggregate. Use an application service or process manager: ```csharp public sealed class CartCheckoutService(IEventStore eventStore) { public async Task CheckoutAsync(string customerId, CartItem[] items, CancellationToken cancellationToken) { var order = await eventStore.CreateAsync( aggregateId: Guid.NewGuid().ToString(), cancellationToken: cancellationToken); order.CreateOrder(customerId); foreach (var item in items) order.AddLineItem(item.ProductId, item.ProductName, item.Quantity, item.UnitPrice); await eventStore.SaveAsync(order, cancellationToken); } } ``` If several aggregates must be saved together, use the transaction support provided by the selected store where available. Still design each aggregate as if it can replay independently. `EventStoreTransaction` chooses the strongest compatible coordinator available: - If all enlisted stores share a native transaction boundary, commits are atomic. - If no shared native boundary exists, commits are sequential under a shared correlation ID. - Sequential fallback does not roll back aggregates that were already persisted. Design cross-aggregate workflows with this distinction in mind. For mixed stores or unsupported transaction boundaries, use idempotent commands, compensating events, and retry-safe process managers. ## Value Objects Use value objects for concepts that are more meaningful than primitive strings, integers, or decimals: - `EmailAddress` - `Name` - `CustomerId` - `ProjectId` - `Money` - `OrderStatus` - `CurrencyCode` Value objects are a good place for normalization and validation that should apply everywhere. ```csharp [Scalar] public readonly partial record struct EmailAddress { public string Value { get; } static partial void OnNormalize(ref string value) => value = value.Trim().ToLowerInvariant(); static partial void OnValidate(string value) { if (string.IsNullOrWhiteSpace(value)) throw new ArgumentException("Email address cannot be empty.", nameof(value)); } } ``` Use scalar value objects when one primitive value carries the meaning. Use full value objects when the concept has multiple fields, such as `Money` with `Amount` and `Currency`. Generated value objects distinguish strict creation from hydration: - `Create(...)` normalizes and validates command-time input. - `Hydrate(...)` rebuilds persisted state and should be replay-safe. - `[Scalar]` and `[ValueObject]` default to hydration-oriented deserialization. - `GenerateEmpty`, implicit primitive conversion, comparison operators, JSON converters, and constructor generation are configurable. ### Contextual Value Objects Use contextual value objects when validity depends on the current aggregate state. The sample `OrderStatus` validates allowed transitions against the current `OrderAggregate`. This is useful for: - state machines - date ranges relative to aggregate state - limits that depend on current totals - transitions that must not be checked again during replay Design rule: command-time creation can be strict; replay/hydration must be able to rebuild historical state. ## Validation Across The Board Use the smallest validation scope that correctly owns the rule. | Rule type | Best location | Runs during replay? | Example | | --- | --- | --- | --- | | Primitive shape | Value object | Usually hydrate-safe | Email format, non-empty name | | Command input | Public aggregate method | No | Quantity must be positive | | Property normalization | `OnChanging` hook | No | Trim/lowercase email | | State transition | Contextual value object or aggregate method | No for strict command path | Draft to Confirmed only | | State mutation | Generated/manual `Apply(...)` | Yes | Set `Status`, update totals | | Whole aggregate validity | DataAnnotations default validator or FluentValidation `IValidator` at save | Save-time | Snapshot must be internally valid | | Cross-aggregate rule | Application service/process manager | No | Customer must exist before order | | Read model rule | Projection/query model | Projection-time | Denormalized search fields | ### Aggregate Method Guards Public aggregate methods should protect business intent before raising events: ```csharp public InventoryAggregate ReserveStock(int quantity, string? orderId) { ArgumentOutOfRangeException.ThrowIfNegativeOrZero(quantity); if (quantity > AvailableQuantity) throw new InvalidOperationException( $"Cannot reserve {quantity} units. Only {AvailableQuantity} available."); return ReserveStock( quantityOnHand: QuantityOnHand, reservedQuantity: ReservedQuantity + quantity, orderId); } ``` ### Generator Hooks Use generated hooks for local normalization and event-specific extension points: - `OnChanging(ref value)` runs before event creation on the command path. - `OnChanged(previous, current)` runs in `Apply(...)`, including replay. - `OnRaisingEvent(ref ...)` runs before the generated event is recorded. - `OnRaisedEvent(@event)` runs after event creation. - `OnAppliedEvent(@event)` runs after application. - `OnShouldApplyEvent(@event, ref bool shouldApply)` can skip generated application. Because `OnChanged` runs during replay, keep it deterministic and free of external side effects. ### Save-Time Validation Stores run aggregate validation before persistence. With no custom validator, the current implementation uses `DefaultAggregateValidator`, which validates standard DataAnnotations such as `[Range]`. Store constructors accept `IAggregateValidator?` — when null, the default DataAnnotations validator is used. FluentValidation integration is available in the separate `Purview.EventSourcing.Validation.FluentValidation` package, which provides `FluentValidationAggregateValidator` to adapt `FluentValidation.IValidator` to `IAggregateValidator`. Register it via `AddFluentValidationAdapter()` or `AddFluentValidationAdapter()` DI extensions. Use save-time validation for aggregate-wide consistency checks that should pass before persistence: ```csharp public sealed class OrderAggregate : AggregateBase { [Range(0, double.MaxValue)] public decimal TotalAmount { get; private set; } } ``` Do not rely only on save-time validation for user-facing command errors. Put business guard clauses near the command method as well so invalid operations fail before an event is created. `SaveResult` carries `Saved`, `Skipped`, and `ValidationResult`. Check `IsValid` or call `EnsureValid()` when callers need validation failures surfaced as exceptions. ## Implementation Pattern Prefer this aggregate shape: ```csharp [Aggregate] public sealed partial class OrderAggregate : AggregateBase { public CustomerId CustomerId { get; private set; } public OrderStatus Status { get; private set; } = OrderStatus.Draft; public EventStoreList LineItems { get; private set; } = new(); public decimal TotalAmount { get; private set; } public OrderAggregate ConfirmOrder() => SetStatusCode(OrderStatusCode.Confirmed); public OrderAggregate AddLineItem(string productId, string productName, int quantity, decimal unitPrice) { ArgumentException.ThrowIfNullOrWhiteSpace(productId); ArgumentOutOfRangeException.ThrowIfNegativeOrZero(quantity); ArgumentOutOfRangeException.ThrowIfNegative(unitPrice); var updated = LineItems.Append(new OrderLineItem(productId, productName, quantity, unitPrice)).ToList(); return AddLineItem( new EventStoreList(updated), totalAmount: updated.Sum(m => m.Quantity * m.UnitPrice)); } [Event(EventName = "OrderCreated")] public partial OrderAggregate CreateOrder(CustomerId customerId); [Event(EventName = "OrderLineItemsChanged")] private partial OrderAggregate AddLineItem(EventStoreList lineItems, decimal totalAmount); [Event(EventName = "OrderStatusChanged")] private partial OrderAggregate SetStatusCode(OrderStatusCode status); } ``` The pattern is: 1. Public methods express business intent and validate inputs. 2. Private generated methods record the factual state change when callers should not raise it directly. 3. Events carry enough data to replay state. 4. Value objects normalize and validate reusable concepts. 5. Services coordinate multiple aggregates. When a state change should only be recorded if a property actually changes, a manual aggregate can use `CompareRecordAndApply(...)`; generated aggregate methods already provide the higher-level convention for most cases. ## Event Payload Design Put enough information on the event to replay the aggregate without external reads. Good payloads: - stable IDs - normalized value objects - values needed to update aggregate state - metadata that explains the operation, such as `orderId` on stock reservations - timestamps when the business time differs from event commit time Be careful with: - personally identifiable information - large documents or binary payloads - fields copied from another aggregate that may become stale - values that can be calculated deterministically from event payload For metadata parameters that should be stored on generated events but not mapped to aggregate properties, use `[Metadata]`. ```csharp [Event] public partial InventoryAggregate ReserveStock( int quantityOnHand, int reservedQuantity, [Metadata] string? orderId); ``` Use `[Property(nameof(Property))]` when the parameter name does not match the aggregate property. ```csharp [Event] public partial InventoryAggregate Create( string productId, [Property(nameof(QuantityOnHand))] int initialQuantity = 0); ``` ## Schema Evolution Events are persisted facts. Changing them is a compatibility decision. - Add optional event properties when possible. - Avoid changing the meaning of an existing property. - Use `[Event(Version = N)]` for breaking schema versions. - Add upcasters when old events need to hydrate into newer event shapes. - Do not change generated event/aggregate naming conventions after data exists unless you plan a migration. (`EventSuffixLength` is a provider option that controls the zero-padded numeric suffix on event row IDs, not a naming convention.) - Treat aggregate type names and event names as persisted contracts. ## Operation Semantics Document these choices for each workflow: | Concern | Design decision | | --- | --- | | Aggregate ID | Who creates it: caller, `IAggregateIdFactory`, or store default? | | Correlation ID | How is it propagated across service calls and transactions? | | Idempotency | Should `UseIdempotencyMarker` be enabled for retries? | | Principal ID | Will saves require a `ClaimsPrincipal` identifier? | | Delete behavior | Soft delete, restore, or permanent delete? | | Snapshot behavior | Use snapshots, skip snapshots for replay, or apply operation-specific snapshot strategy? | | Notifications | Which change feed notifications should fire? | These details affect reliability and auditability as much as aggregate code does. ## Testing Checklist For each aggregate: - Can version 1 of the stream be created with one clear lifecycle-start event? - Does every public method either raise an event or intentionally no-op? - Do invalid commands fail before an event is recorded? - Does replaying the event stream rebuild the same state? - Are value object rules tested independently? - Are state-machine transitions tested for allowed and rejected paths? - Are cross-aggregate workflows tested at service level? - Are query/projection shapes tested separately from aggregate behavior? ## Design Review Checklist Before coding, reviewers should be able to answer yes to these: - Aggregate names end in `Aggregate` and produce stable aggregate type names. - Event names are past-tense business facts. - Event payloads can replay state without external services. - Relationships use IDs/value objects inside events and aggregates. - Relational screens are designed as query models, snapshots, or projections. - Validation is assigned to the right layer. - Replay-safe code has no external side effects. - Schema evolution and event versioning have been considered. - Tests cover command guards, replay, value objects, and workflows. # Solution Design Worksheet > Use this worksheet before implementing a new aggregate, workflow, or read model. Keep the first version short. The goal is to expose modelling decisions… # Solution Design Worksheet Use this worksheet before implementing a new aggregate, workflow, or read model. Keep the first version short. The goal is to expose modelling decisions early, not to produce perfect documentation. ## Business Capability | Question | Answer | | --- | --- | | Capability or workflow name | | | Primary actor or system | | | Business outcome | | | Immediate consistency decisions | | | Eventually consistent decisions | | | External systems involved | | | Audit questions to answer later | | ## Aggregate Boundaries | Candidate aggregate | Owns these rules | Must not own | Lifecycle starts when | Lifecycle ends when | | --- | --- | --- | --- | --- | | | | | | | | | | | | | Boundary checks: - Can this aggregate make its decision using only its own current state? - Does it need one ordered event stream to stay correct? - Are other aggregate IDs enough, or are you trying to join live state? - Would splitting this aggregate allow invalid business states? ## Names | Concept | Name | Persisted name or note | | --- | --- | --- | | Aggregate class | `ExampleAggregate` | Aggregate type becomes `example` | | Lifecycle-start event | | | | Important transition event | | | | Important correction event | | | | Value object | | | | Query model/projection | | | Naming checks: - Aggregate class ends in `Aggregate`. - Generated event names should read as past-tense facts. - Explicit `EventName` overrides are reserved for compatibility, integration contracts, or deliberate domain language. - Events avoid command-style phrasing unless the business fact genuinely uses that language. - Persisted names are stable enough to keep after release. ## Commands And Events | Intent | Aggregate method | Event fact | Payload | Validation | | --- | --- | --- | --- | --- | | | | | | | | | | | | | | | | | | | Checks: - Invalid commands fail before an event is recorded. - Event payload can replay state without external services. - Metadata is marked with `[Metadata]` when it should not map to aggregate state. - Parameter aliases use `[Property(nameof(Property))]`. - Deterministic generated values use `[Computed]`. - Collection changes use `[CollectionEvent]` with `EventStoreList` or `EventStoreSet`. - Manual events identify who owns the `Apply(...)` method. ## Event Stream Example | Version | Event | Payload summary | Resulting aggregate state | | --- | --- | --- | --- | | 1 | | | | | 2 | | | | | 3 | | | | | 4 | | | | Replay checks: - Replaying these events in order recreates the expected state. - Replay does not call external systems. - Replay does not depend on current time, random values, or database lookups. ## Relationships | Relationship | Stored identity/value object | Enforced by | Read/query model | | --- | --- | --- | --- | | | | | | | | | | | Relationship checks: - Aggregates store IDs/value objects, not other aggregate instances. - Cross-aggregate rules live in an application service, process manager, or transaction boundary. - Relational screens are supplied by snapshots, projections, or query models. - If a workflow spans stores or transaction boundaries, compensating behavior is designed. ## Value Objects | Value object | Primitive fields | Normalization | Validation | Context needed? | | --- | --- | --- | --- | --- | | | | | | | | | | | | | Checks: - Reusable primitive rules are not duplicated across aggregates. - Contextual value objects separate strict command-time creation from hydration/replay. - Value object names use business language. ## Validation Map | Rule | Layer | Failure message/user impact | Test case | | --- | --- | --- | --- | | | Value object | | | | | Public aggregate method | | | | | Generator hook | | | | | DataAnnotations/save-time | | | | | FluentValidation/save-time | | | | | Application service/process manager | | | | | Projection/query model | | | Validation checks: - Command errors fail before events are recorded. - Save-time validation covers whole-aggregate consistency. - Replay/hydration paths do not reject historical facts that were valid when written. - Validation failures are surfaced from `SaveResult` where needed. ## Operation Semantics | Concern | Decision | | --- | --- | | Aggregate ID source | | | Correlation ID propagation | | | Idempotency marker usage | | | Principal/claim requirement | | | Delete/restore/permanent-delete behavior | | | Snapshot strategy | | | Change feed notifications | | | Transaction boundary | | Transaction checks: - Shared native transaction boundary is identified where atomicity is required. - Sequential fallback is acceptable or mitigated. - Retry and compensation behavior is documented. ## Query And Reporting | Question/screen/API | Source events or snapshots | Shape | Rebuild strategy | | --- | --- | --- | --- | | | | | | | | | | | Checks: - The aggregate is not shaped around a single UI screen. - Query models can be rebuilt or corrected from event history where practical. - Sensitive data and retention concerns are noted. - Snapshots are treated as read/performance models, not as the canonical source of truth. ## Schema Evolution | Event | Expected future change | Compatibility approach | | --- | --- | --- | | | | | | | | | Checks: - Event versioning is considered for breaking payload changes. - Old event names and aggregate type names are treated as persisted contracts. - Upcasting or migration notes exist for released event shapes. ## Ready To Build - Aggregate boundary is clear. - Event names and payloads are agreed. - Relationship modelling uses identities and query models. - Validation has a named owner at each layer. - Operation semantics are documented. - Replay path is deterministic. - Tests are identified for commands, replay, value objects, workflows, and projections. # Source Generator Behaviors > This page documents framework-level source-generator behavior not storage-provider behavior. # Source Generator Behaviors This page documents framework-level source-generator behavior (not storage-provider behavior). ## Aggregate eligibility and inheritance `[Aggregate]` supports three inheritance paths: 1. No declared base class: generated partial type automatically inherits `AggregateBase`. 2. Direct inheritance from `AggregateBase`. 3. Transitive inheritance through one or more intermediate base classes. Other eligibility rules: - Aggregate type must be `partial`. - Nested and generic aggregate types are not supported. - `RegisterEvents()` is generated and cannot be manually declared. ### Inheritance examples ```csharp // 1) No declared base class (generator adds AggregateBase on generated partial) [Aggregate] public partial class ProductAggregate { [Event] public partial void Create(string name); } // 2) Direct inheritance [Aggregate] public partial class OrderAggregate : AggregateBase { [Event] public partial void CreateOrder(string customerId); } // 3) Transitive inheritance public abstract class DomainAggregateBase : AggregateBase { } public abstract class BillingAggregateBase : DomainAggregateBase { } [Aggregate] public partial class InvoiceAggregate : BillingAggregateBase { [Event] public partial void CreateInvoice(string invoiceNumber); } ``` ## Generated event naming and namespace Default event namespace: - `.Events` - Example: `Testing.OrderAggregate` -> `Testing.OrderEvents` Default event type naming: - Event names are inferred from method names (or overridden with `EventName = ...`). - Event type suffix defaults to `Event` (configurable with `EventSuffix` defaults/overrides). - Typical generated type: `Testing.OrderEvents.OrderCreatedEvent`. Namespace can be overridden per method (`EventNamespace`) or by aggregate defaults. ### Event naming examples ```csharp namespace Testing; [Aggregate] public partial class OrderAggregate : AggregateBase { [Event] public partial void CreateOrder(string customerId); [Event(EventName = "OrderRegistered", EventNamespace = "Testing.Custom.Events")] public partial void RegisterOrder(string customerId); } ``` Typical generated types: - `Testing.OrderEvents.OrderCreatedEvent` (default namespace/name) - `Testing.Custom.Events.OrderRegistered` (explicit namespace/name) :::note An explicit `EventName` is used verbatim: the generator does **not** append the `Event` suffix when a name is provided. Include the suffix in the explicit name (for example `EventName = "OrderRegisteredEvent"`) if you want the generated type to end in `Event`. The `Event` suffix is only appended to inferred names. ::: ## Hook behavior semantics Property hooks are property-scoped: - `OnChanging(ref value)` runs on generated command methods before event creation. - `OnChanged(previous, current)` runs in generated `Apply(...)` after assignment. - If different events update the same property, the same property hooks run for each. - Hooks run only when the event method maps that property. Replay behavior: - Replay executes generated `Apply(...)`. - `OnChanged` runs on replay. - `OnChanging` does not run on replay. Event hooks are event-scoped: - `OnRaisingEvent(ref ...)` - `OnRaisedEvent(@event)` - `OnAppliedEvent(@event)` - `OnShouldApplyEvent(@event, ref bool shouldApply)` Manual behavior: - `Manual = true` does not auto-wire property hooks unless manual code invokes them. ### Property hook example ```csharp [Aggregate] public partial class CustomerAggregate : AggregateBase { public string Email { get; private set; } = string.Empty; [Event(EventName = "CustomerRegistered")] public partial void Register(string email); [Event(EventName = "CustomerEmailChanged")] public partial void ChangeEmail(string email); partial void OnEmailChanging(ref string email) => email = email.Trim().ToLowerInvariant(); partial void OnEmailChanged(string previous, string current) { /* audit */ } } ``` `OnEmailChanging/Changed` run for both `Register` and `ChangeEmail` because both map to `Email`. ## Event method mapping and validation - `[Event]` methods must be `partial` declarations without bodies. - Return types must be `void`, `bool`, or the containing aggregate type. - Parameters must map to writable aggregate properties unless explicitly handled as metadata/manual payload. - Collection event methods (`[CollectionEvent]`) require `EventStoreList` / `EventStoreSet` target properties. ### Event contracts Events are emitted as `[EventContract]` `sealed record` types — pure payload data with no base class or interface: ```csharp [EventContract] public sealed record OrderCreatedEvent { public static int SchemaVersion => 1; [JsonIgnore] public EventMetadata Metadata { get; set; } public string CustomerId { get; set; } } ``` - `[EventContract]` marks the type as an event contract (the generator, analyzer, and upcasting registry use it to recognise event types; hand-written events registered via `Register`/`RegisterGenerated` must be marked with it too — `EVENTSTORE037`). - `Metadata` (`EventMetadata`, a readonly record struct) carries framework-managed metadata (aggregate version, timestamp, schema version, idempotency/correlation/causation/user ids). It is `[JsonIgnore]`d, so event payloads no longer embed metadata; providers persist metadata to row columns and rehydrate it on replay. - `GetHashCode` is content-based for payload properties and metadata, preserving stable event hashing (used by the Azure idempotency compound key). - The generated `RegisterEvents` registers appliers with `RegisterGenerated()`, which resolves the generated `Apply(TEvent)` method once per aggregate/event type and shares it statically, so aggregate construction allocates no per-instance applier delegates. ### Generated command method shape A generated command method constructs **one** event instance, runs the property `OnChanging` hooks, evaluates `OnShouldApply` before `OnRaising`, runs `OnRaising`/`OnComputing` hooks (which may mutate parameters via `ref`), re-synchronizes the event's payload properties from the post-hook values, re-evaluates `OnShouldApply`, then records the event via `RecordAndApply`. The single allocation keeps command invocation allocation-light; the post-hook re-synchronization preserves the exact payload values that a second construction would have produced. ### Example ```csharp [Aggregate] public partial class ReportAggregate : AggregateBase { public EventStoreSet Tags { get; private set; } = []; [CollectionEvent(nameof(Tags))] public partial void AddTag(string tag); } ``` ### Parameter nullability and required guards The generator honors two standard attributes on event parameters to tighten command-time validation and the shape of the generated event class: - `[NotNull]` (`System.Diagnostics.CodeAnalysis`) on a nullable parameter generates an `ArgumentNullException` guard and emits the event property as non-nullable. - `[Required]` (`System.ComponentModel.DataAnnotations`) on a nullable `string` parameter generates an `ArgumentException` guard for null or whitespace and emits the event property as non-nullable. Both attributes also cause the generator to use a local copy of the parameter value when calling `On...Changing` hooks and when creating the event. This keeps the original parameter unmodified so the compiler does not require it to be assigned after a `throw` path. ```csharp [Aggregate] public partial class ProfileAggregate : AggregateBase { public string? Bio { get; private set; } [Event] public partial void UpdateBio([NotNull] string? bio); } ``` For the event above, the generator produces a property typed as `string` rather than `string?`. The generated record carries the `[EventContract]` attribute and the usual `Metadata`/`SchemaVersion` members (see the event-contract shape above); it has **no base class**: ```csharp [EventContract] public sealed record BioUpdatedEvent { public string Bio { get; set; } = default!; } ``` ## Value-object conversion behavior > The `[Scalar]` / `[ValueObject]` generator and analyzer are provided by the `Purview.ValueObjects` package, > referenced transitively by `Purview.EventSourcing`. Value objects live in the `Purview.ValueObjects` and > `Purview.ValueObjects.Serialization` namespaces (previously `Purview.EventSourcing.ValueObjects` and > `Purview.EventSourcing.Serialization`). - Generated mapping paths use `Create(...)` semantics for strict command-time conversion/validation. - Contextual `Create(TValue, in ValueObjectContext)` is used when available. - Replay/hydration paths apply event payloads through generated `Apply(...)` logic. - Snapshot-query translation depends on how the provider maps the resulting property graph, not only on the value-object generator behavior. - Projects compiled with the SQL Server or PostgreSQL EF analyzer can mark a property `[EfOpaque]`. The EF-only generator emits this internal marker into the consuming compilation; it does not add a runtime attribute API. - `EVENTSTOREEF001` reports dictionary-like members reachable from an aggregate unless they are explicitly opaque. Prefer a collection of domain entry objects when structural querying is required; the generator does not synthesize those domain types. - `EVENTSTOREEF002` reports uses of an opaque member in recognized snapshot query expressions. Opaque values round-trip through JSON but their contents are not part of EF's queryable complex model. - A `[Scalar]` value object that wraps a complex CLR type may serialize correctly while still requiring a separate directly mapped complex mirror property for deep SQL predicates. ### Value-object conversion examples ```csharp // Scalar conversion [Scalar] public readonly partial record struct EmailAddress { public string Value { get; } static partial void OnNormalize(ref string value) => value = value.Trim().ToLowerInvariant(); static partial void OnValidate(string value) { /* format checks */ } } ``` ```csharp // Contextual conversion [Scalar] public readonly partial record struct OrderStatus : IContextualValueObject { public OrderStatusCode Value { get; } public static OrderStatus Create(OrderStatusCode value, in ValueObjectContext context) => IsValidTransition(context.Aggregate.Status.Value, value) ? new(value) : throw new InvalidOperationException(); } ``` ## Diagnostics to expect Model-validation diagnostics are produced by `Purview.EventSourcing.SourceGenerator` analyzers (`AggregateDiagnosticAnalyzer`, `ValueObjectDiagnosticAnalyzer`, and `EventStoreAnalyzer`), not by the source generators themselves. The generators consume the same validation internally to decide whether to emit source, but they do not report these validation diagnostics. Analyzer diagnostics can be suppressed or configured through the usual `#pragma warning` / `.editorconfig` mechanisms. The exception is the event-contract manifest baseline comparison: the generator itself reports `EVENTSTORE030`–`EVENTSTORE036` when the current contracts differ from the committed baseline (see [Event Contract Manifest](../event-contract-manifest/)). Those diagnostics are emitted from the generator's output stage, so they always surface on a build regardless of analyzer configuration. Common aggregate diagnostic IDs: - `EVENTSTORE001` aggregate must be partial - `EVENTSTORE002` aggregate must inherit `AggregateBase` (or have no base so generator can add it) - `EVENTSTORE003` nested aggregates unsupported - `EVENTSTORE004` generic aggregates unsupported - `EVENTSTORE005` manual `RegisterEvents` unsupported - `EVENTSTORE007` generated event method must be partial - `EVENTSTORE009` duplicate generated event names - `EVENTSTORE010` parameter must map to writable property - `EVENTSTORE018` unsupported aggregate collection property type - `EVENTSTORE021` event schema version must be positive - `EVENTSTORE022` duplicate event schema version on aggregate Value-object diagnostics are provided by the `Purview.ValueObjects` package (the `[Scalar]`/`[ValueObject]` generator and analyzer moved out of this repository). They use the `VO1001`–`VO1008` ID range; see the `Purview.ValueObjects` package documentation for the full list. The analyzer and the generator share the same validation rules (the model builders are the single source of truth). When validation fails, the generator skips generation entirely — it never emits an invalid partial type — while the analyzer reports the diagnostic. A generator-only run therefore produces no output and no exception for invalid input; the validation diagnostics are always surfaced by the analyzer assets that ship in the same package. The manifest-compatibility diagnostics (`EVENTSTORE030`–`036`), by contrast, are reported by the generator itself. ## Testing generated output Generator unit tests assert on the generated structure with the `CodeQuery` API from `Purview.SourceGeneratorFramework.Testing` rather than whole-file string matching: - `result.Generated()` returns a `CodeQuery` over the generated trees (backed by the output compilation). - Prefer `GetClass`/`GetRecord`/`GetStruct`/`GetEnum`/`HasNamespace`, `HasMethod`, `HasProperty`, `HasConstructor`, and `TypeReference`-based parameter matching for member signatures. - Keep string assertions only for method-body statements that `CodeQuery` does not model (for example `RecordAndApply(@event);`), scoped to the returned syntax node's body. - Operator declarations are `OperatorDeclarationSyntax`, not methods; assert them via `CodeQuery.GetOperator`/`HasOperator`/`TryGetOperator` (optionally scoped with `CodeQuery.In(type)`), or `GetConversionOperator` for `implicit`/`explicit` conversions. Incremental caching is tested with the framework's `GenerateIncrementalAsync`/`RunIncrementalAsync`, which reuse one driver and compilation across identical runs. The framework-named stages (`GetGenerationConfiguration`, `GetGenerationContext_{Capabilities}`, and the per-target `ForAttribute`/target stage) must stay `Cached`/`Unchanged` on identical reruns, and only the stage whose input actually changed reports `Modified`. # Source Generator Performance > The source-generator performance harness measures how fast the aggregate and value-object generators are and how their incremental pipeline behaves. It runs… # Source Generator Performance The source-generator performance harness measures how fast the aggregate and value-object generators are and how their incremental pipeline behaves. It runs under BenchmarkDotNet (in-process toolchain) in the same `Benchmarks` console project as the runtime and SQL Server suites. ## Running ```text just perf-source-generator # quick run (1 warmup, 3 iterations) just perf-source-generator --benchmark # benchmark run (3 warmup, 12 iterations) ``` Equivalent: `dotnet run --project src/src/Benchmarks/Benchmarks.csproj --configuration Release -- source-generator`. Always use Release; in Debug the JIT produces meaningless numbers. Each run writes a JSON snapshot to `artifacts/source-generator-performance/history/` and the latest to `artifacts/source-generator-performance/latest.json`, then prints a summary compared against the previous run. `artifacts/` is not committed. ## What is measured For every scenario (`AggregateSimple`, `AggregateWithValueObjects`, `AggregateMulti`, `ScalarValueObject`, `ComplexValueObject`) the harness records: | Measurement | Meaning | | --- | --- | | `ColdGeneration` | Fresh compilation and driver, generate once (framework cost floor) | | `WarmRerun` | Incremental rerun of the same driver + compilation | | `SingleAggregateEdit` | Rerun after one aggregate changed in the five-aggregate `AggregateMulti` compilation | Ratios are computed against cold generation and enforced as regression guards, and every case is also compared against the previous run (40% mean regression threshold). ## Known incremental-caching hotspot ## Incremental caching The generator emits an inert **pre-compilation marker** (`RegisterPreCompilationSourceOutput`, experimental `RSEXPERIMENTAL007`) so Roslyn's `CompilationCache` reuses the previous run's compilation reference on an identical rerun. This short-circuits the per-candidate `ForAttributeWithMetadataName` transforms: a **warm rerun measures ~6–12% of cold generation** (the aggregate/value-object targets report exactly `Cached`), so identical incremental builds are effectively free. The harness also captures per-run **step run-reasons** (`Cached`/`Unchanged`/`Modified`/`New` per pipeline stage) to `artifacts/source-generator-performance/steps.txt` and prints them, so a regression that silently regenerates work on warm reruns is visible before the threshold trips. The ratio thresholds are regression guards: warm-rerun must stay at or below **40%** of cold generation (guarding the marker against silently regressing), and single-aggregate-edit at or below **150%** (an edit inherently re-executes the changed aggregate's transform). ## Interpreting history The summary compares each scenario against `latest.json` from the previous run. When reporting a regression, record the machine (`Machine`), framework (`Framework`), mode, and the history file so the comparison conditions are reproducible. Compare runs on the same machine and mode. ## Comparison conditions - All measurements run in-process on the machine where the harness is executed. - The quick mode is for local iteration; the benchmark mode is for recorded comparisons. - Correctness is enforced separately by `SourceGenerator.UnitTests` (step-reason caching tests and byte-identical determinism tests); the performance harness is not a correctness substitute. # SQL Server Event and Snapshot Stores > Purview Event Sourcing ships separate SQL Server-backed event and snapshot implementations in a single NuGet package: # SQL Server Event and Snapshot Stores Purview Event Sourcing ships separate SQL Server-backed event and snapshot implementations in a single NuGet package: - `Purview.EventSourcing.SqlServer` + `SqlServerEventStore`: pure event-sourced store where events remain the source of truth. - `Purview.EventSourcing.SqlServer` + `SqlServerSnapshotEventStore`: queryable snapshot store optimized for query/list/count over snapshots. These two concepts are related but **not interchangeable**: - the SQL Server **event store** keeps internal stream snapshots in its event table to speed aggregate rehydration and event-based operations, - the **queryable snapshot store** is an optional LINQ/query-optimized store that can be omitted entirely or implemented by a different provider. Both stores create their tables automatically on first use (configurable) and use a **single shared table** for all aggregate types. --- ## Table of Contents 1. [Installation](https://github.com/purview-dev/event-sourcing/blob/main/docs/wiki#installation) 2. [Quick Start](https://github.com/purview-dev/event-sourcing/blob/main/docs/wiki#quick-start) 3. [Configuration Reference](https://github.com/purview-dev/event-sourcing/blob/main/docs/wiki#configuration-reference) 4. [Required SQL Server Permissions](https://github.com/purview-dev/event-sourcing/blob/main/docs/wiki#required-sql-server-permissions) 5. [Single-Table Design](https://github.com/purview-dev/event-sourcing/blob/main/docs/wiki#single-table-design) 6. [Per-Aggregate Schema and Table Routing](https://github.com/purview-dev/event-sourcing/blob/main/docs/wiki#per-aggregate-schema-and-table-routing) 7. [Event Schema Versioning](https://github.com/purview-dev/event-sourcing/blob/main/docs/wiki#event-schema-versioning) 8. [SQL Transaction Coordination](https://github.com/purview-dev/event-sourcing/blob/main/docs/wiki#sql-transaction-coordination) 9. [JSON Index Configuration](https://github.com/purview-dev/event-sourcing/blob/main/docs/wiki#json-index-configuration) 10. [Snapshot Payload Shape](https://github.com/purview-dev/event-sourcing/blob/main/docs/wiki#snapshot-payload-shape) 11. [Behavior Notes and Caveats](https://github.com/purview-dev/event-sourcing/blob/main/docs/wiki#behavior-notes-and-caveats) 12. [Connection String Examples](https://github.com/purview-dev/event-sourcing/blob/main/docs/wiki#connection-string-examples) --- ## Installation ```xml ``` --- ## Quick Start ### Events Store ```csharp // Program.cs builder.Services.AddSqlServerEventStore(); // appsettings.json { "EventStore:SqlServer": { "ConnectionString": "Server=.;Database=MyApp;Trusted_Connection=True;", "SchemaName": "dbo", "TableName": "EventStore" } } ``` Inject `IEventStore` for the provider-agnostic facade, or `ISqlServerEventStore` when you need the typed SQL Server implementation directly: ```csharp public class OrderService(IEventStore store) { public async Task PlaceOrderAsync(string orderId, string customerId) { var order = await store.GetOrCreateAsync(orderId); order.CreateOrder(customerId, 0m); await store.SaveAsync(order); } } ``` ### Event-history API (version and time filters) The provider-agnostic facade exposes aggregate history reads for audit/review use-cases: ```csharp var response = await store.GetEventHistoryAsync( orderId, new AggregateEventHistoryRequest { FromVersion = 1, ToVersion = 200, FromUtc = DateTimeOffset.UtcNow.AddDays(-30), ToUtc = DateTimeOffset.UtcNow, MaxRecords = 100 }, cancellationToken); ``` The response is a `ContinuationResponse` so callers can page using the returned `ContinuationToken`. ### Snapshot Store ```csharp // Program.cs builder.Services.AddSqlServerEventStore(); builder.Services.AddSqlServerSnapshotQueryableEventStore(); // appsettings.json { "EventStore:SqlServerSnapshot": { "ConnectionString": "Server=.;Database=MyApp;Trusted_Connection=True;", "SchemaName": "dbo", "TableName": "EventStoreSnapshots" } } ``` --- ## Configuration Reference ### Events Store (`SqlServerEventStoreOptions`) | Property | Type | Default | Description | | --- | --- | --- | --- | | `ConnectionString` | `string` | *(required)* | ADO.NET connection string | | `SchemaName` | `string` | `"dbo"` | Default schema for the events table | | `TableName` | `string` | `"EventStoreEvents"` | Default table name for events | | `AutoCreateTable` | `bool` | `true` | Create table and indices on first use | | `UseDataCompression` | `bool` | `true` | Apply `PAGE` compression (Enterprise / Azure SQL) | | `TimeoutInSeconds` | `int?` | `60` | Command timeout (1–120 000 s) | | `MaxEventCountOnSave` | `int` | `1000` | Maximum events per save operation | | `EventSuffixLength` | `int` | `30` | Zero-padded version suffix on event row IDs | | `RemoveDeletedFromCache` | `bool` | `true` | Evict deleted aggregates from distributed cache | | `CacheMode` | `SnapshotCachingOptions` | `GetAndStore` | Distributed-cache interaction policy | | `DefaultCacheSlidingDuration` | `TimeSpan` | `60 min` | Sliding cache expiry | | `RequiresValidPrincipalIdentifier` | `bool` | `true` | Require a `ClaimsPrincipal` identifier on save | | `AggregateTableOverrides` | `Dictionary` | `{}` | Per-aggregate schema/table overrides | | `JsonIndexOptions` | `SqlServerJsonIndexOptions` | disabled / empty | Runtime-managed JSON computed columns and indexes for `Payload` | ### Snapshot Store (`SqlServerSnapshotEventStoreOptions`) | Property | Type | Default | Description | | --- | --- | --- | --- | | `ConnectionString` | `string` | *(required)* | ADO.NET connection string | | `SchemaName` | `string` | `"dbo"` | Default schema for the snapshots table | | `TableName` | `string` | `"EventStoreSnapshots"` | Default table name | | `AutoCreateTable` | `bool` | `true` | Create table on first use | | `UseDataCompression` | `bool` | `true` | Apply `PAGE` compression | | `AggregateTableOverrides` | `Dictionary` | `{}` | Per-aggregate schema/table overrides | | `JsonIndexOptions` | `SqlServerJsonIndexOptions` | disabled / empty | Runtime-managed JSON computed columns and indexes for `Payload` | --- ## Required SQL Server Permissions ### Minimum Runtime Permissions Grant the application's login (or contained-database user) the following on every schema/table it uses: ```sql -- On the schema GRANT SELECT, INSERT, UPDATE, DELETE ON SCHEMA::[dbo] TO [app_login]; -- Or more targeted, per table: GRANT SELECT, INSERT, UPDATE, DELETE ON [dbo].[EventStore] TO [app_login]; GRANT SELECT, INSERT, UPDATE, DELETE ON [dbo].[Snapshots] TO [app_login]; ``` ### Auto-Create Permissions (`AutoCreateTable = true`) When `AutoCreateTable` is enabled (the default), the application also needs DDL rights at startup to create the table, computed columns, and indices: ```sql -- Required to create tables and indices in the schema: GRANT CREATE TABLE TO [app_login]; GRANT ALTER ON SCHEMA::[dbo] TO [app_login]; ``` :::tip Use a separate migration user or initialisation step in CI/CD with elevated permissions, then set `AutoCreateTable = false` in production to avoid granting DDL rights to the runtime user. ::: ### Minimal Role-Based Setup (SQL Server) ```sql -- Create a dedicated role for the event store CREATE ROLE [event_store_rw]; GRANT SELECT, INSERT, UPDATE, DELETE ON SCHEMA::[dbo] TO [event_store_rw]; ALTER ROLE [event_store_rw] ADD MEMBER [app_login]; -- Additionally for auto-create: CREATE ROLE [event_store_ddl]; GRANT CREATE TABLE TO [event_store_ddl]; GRANT ALTER ON SCHEMA::[dbo] TO [event_store_ddl]; ALTER ROLE [event_store_ddl] ADD MEMBER [migration_login]; ``` ### Azure SQL (Managed Identity) ```sql -- Create contained user for Managed Identity CREATE USER [my-app-service] FROM EXTERNAL PROVIDER; ALTER ROLE db_datawriter ADD MEMBER [my-app-service]; ALTER ROLE db_datareader ADD MEMBER [my-app-service]; -- For auto-create only: GRANT CREATE TABLE TO [my-app-service]; GRANT ALTER ON SCHEMA::[dbo] TO [my-app-service]; ``` --- ## Single-Table Design Both stores use a **single shared table** by default. All aggregate types are stored in the same table and distinguished by the `AggregateType` column. For clarity: - `SqlServerEventStore` stores stream metadata, events, idempotency markers, and **internal replay snapshots** in its event table. - `SqlServerSnapshotEventStore` stores **queryable snapshots** in a separate snapshot table for LINQ-based reads. The internal replay snapshots in the event table are part of the event-store implementation and should not be treated as redundant copies of the optional queryable snapshot store. ### Events table schema ```text [Id] NVARCHAR(450) PK [EntityType] INT 0=StreamVersion, 1=Event, 2=IdempotencyMarker, 3=Snapshot [AggregateId] NVARCHAR(450) The aggregate's id [AggregateType] NVARCHAR(450) Kebab-case aggregate type (e.g. "order") [Version] INT Aggregate version at time of event [IsDeleted] BIT Soft-delete flag on the stream-version row [Payload] JSON / NVARCHAR(MAX) JSON payload (events and snapshots) [EventType] NVARCHAR(450) Mapped event type name (e.g. "order.order-created") [IdempotencyId] NVARCHAR(450) Idempotency marker id [Timestamp] DATETIMEOFFSET UTC timestamp of the operation ``` Three covering indices are created automatically (named with the configured table name, so `IX_EventStoreEvents_*` with the default table): | Index | Columns | Purpose | | --- | --- | --- | | `IX_{table}_AggregateId_EntityType` | `(AggregateId, AggregateType, EntityType)` INCLUDE `(Version, IsDeleted)` | Stream lookups, idempotency markers, and deletes | | `IX_{table}_EventRange` | `(AggregateId, AggregateType, Version)` WHERE EntityType=1 INCLUDE `(Payload, EventType, IdempotencyId, SchemaVersion, CorrelationId, CausationId, UserId, Timestamp)` | Event replay | | `IX_{table}_AggregateType_EntityType` | `(AggregateType, EntityType, IsDeleted)` INCLUDE `AggregateId` | Aggregate ID enumeration | :::note The single-table design minimises DDL surface area and allows aggregates from different bounded contexts to share a connection pool and database. **Aggregate ID vs type scoping:** when multiple aggregate types share the same schema/table, event-stream read/delete queries scope by both `AggregateId` and `AggregateType`. If you isolate aggregate types by schema/table via `AggregateTableOverrides`, that physical separation provides the same isolation boundary. ::: --- ## Per-Aggregate Schema and Table Routing Use `AggregateTableOverrides` to route specific aggregate types to a dedicated schema or table. This is useful when you want bounded-context isolation at the database level while still sharing a connection string. The dictionary key is the aggregate's **`AggregateType`** value — the kebab-case type name derived from the class (e.g. `"order"` for `OrderAggregate`). Keys are **case-insensitive**, so `"Order"` also matches. ### Code-based configuration ```csharp builder.Services.AddSqlServerEventStore(); builder.Services.Configure(options => { options.ConnectionString = "Server=.;Database=MyApp;Trusted_Connection=True;"; // Orders aggregate uses the "orders" schema options.AggregateTableOverrides["order"] = new SqlServerAggregateTableOverride { SchemaName = "orders", TableName = "EventStore", // optional — falls back to global TableName }; // Inventory uses a completely separate table options.AggregateTableOverrides["inventory"] = new SqlServerAggregateTableOverride { SchemaName = "inventory", TableName = "DomainEvents", }; }); ``` ### appsettings.json configuration ```json { "EventStore:SqlServer": { "ConnectionString": "Server=.;Database=MyApp;Trusted_Connection=True;", "SchemaName": "dbo", "TableName": "EventStore", "AggregateTableOverrides": { "order": { "SchemaName": "orders" }, "inventory": { "SchemaName": "inventory", "TableName": "DomainEvents" } } } } ``` ### How it works When `SqlServerEventStore` is constructed it looks up `T`'s `AggregateType` name in `AggregateTableOverrides`. If a match is found: - `SchemaName` override (if set) replaces the global `SchemaName` - `TableName` override (if set) replaces the global `TableName` - All other options (compression, timeouts, caching…) are inherited from the global options Each overridden aggregate type gets its own table with its own set of automatically-created indices. :::note **Permissions note:** If you use per-aggregate schema routing you must grant the runtime user `SELECT/INSERT/UPDATE/DELETE` on **each** schema/table used. ::: --- ## Event Schema Versioning Event classes can declare a **schema version** to track breaking changes to their properties. This allows consumers to perform version-aware deserialization or apply up-casting when replaying old events. ### With the source generator Set `Version` on `[Event]`: ```csharp [Aggregate] public partial class OrderAggregate : AggregateBase { public string CustomerId { get; private set; } = default!; public string Currency { get; private set; } = default!; // Version 1: original event (no currency) // [Event] ← implicitly Version = 1 // public partial void CreateOrder(string customerId); // Version 2: added Currency field [Event(Version = 2)] public partial void CreateOrder(string customerId, string currency); } ``` The generator emits a `[EventContract]` record named `OrderCreatedEvent` with `public static int SchemaVersion => 2;`. ### Manually Mark a hand-written event contract with `[EventContract]` and declare a static `SchemaVersion`: ```csharp [EventContract] public sealed record OrderCreatedEvent { public string CustomerId { get; set; } = default!; public string Currency { get; set; } = default!; public static int SchemaVersion => 2; } ``` The `SchemaVersion` is persisted as event metadata (a row column), not inside the payload. When the event is replayed from the store the version is rehydrated into `Metadata` and available via `@event.Metadata.SchemaVersion`, enabling conditional up-casting: ```csharp void Apply(OrderCreatedEvent e) { CustomerId = e.CustomerId; // Up-cast: v1 events did not have Currency; default to "GBP" Currency = e.Metadata.SchemaVersion >= 2 ? e.Currency : "GBP"; } ``` --- ## SQL Transaction Coordination Use `ISqlServerEventStoreTransactionFactory` when you need one SQL Server transaction that includes: - multiple enlisted aggregates saved through SQL Server event stores, - extra ad-hoc SQL commands (for example audit/outbox inserts), - EF Core operations against the same SQL connection boundary. ### Registration and usage `AddSqlServerEventStore()` registers `ISqlServerEventStoreTransactionFactory`. ```csharp public sealed class CheckoutService( ISqlServerEventStoreTransactionFactory sqlTransactionFactory, IEventStore store) { public async Task CheckoutAsync(OrderAggregate order, CancellationToken cancellationToken) { await using var tx = sqlTransactionFactory.CreateSqlServerTransaction(); tx.Enlist(order, store); tx.Enlist(async (connection, sqlTransaction, token) => { await using var cmd = new SqlCommand( "INSERT INTO dbo.TransactionAudit(CorrelationId, Value) VALUES (@c, @v)", connection, sqlTransaction); cmd.Parameters.AddWithValue("@c", tx.CorrelationId); cmd.Parameters.AddWithValue("@v", "checkout"); await cmd.ExecuteNonQueryAsync(token); }); var result = await tx.CommitAsync(cancellationToken); if (!result.Success) throw new InvalidOperationException("Transaction failed."); } } ``` ### Notes and limits - SQL-native atomic commit is available when enlisted stores share the same SQL transaction boundary. - Enlisting stores with different SQL transaction boundaries is rejected up front. - If you need cross-database/distributed coordination, implement a custom transaction coordinator strategy. - The SQL-specific coordinator requires at least one enlisted aggregate (the aggregate store establishes the connection/transaction boundary). - `IEventStoreTransactionFactory` remains available and unchanged for provider-agnostic transaction orchestration. ### Integration coverage `src/tests/SqlServer.IntegrationTests/Events/SqlServerEventStoreTransactionIntegrationTests.cs` verifies: - aggregate + raw SQL operation commit in one transaction, - aggregate + EF operation commit in one transaction, - rollback of both aggregate and enlisted SQL when an enlisted operation throws, - cross-implementation enlistment (event store + SQL snapshot event store) with additional SQL operations in one transaction. --- ## JSON Index Configuration SQL Server stores event and snapshot payloads in a JSON column named `Payload`. You can optionally configure additional runtime-managed indexes over scalar JSON paths. The provider creates these indexes only when: - `AutoCreateTable = true`, and - `JsonIndexOptions.Enabled = true`. The current implementation materializes each configured path as a computed column and then creates an index over that column. ### Supported configuration shape ```csharp public sealed class SqlServerJsonIndexOptions { public bool Enabled { get; set; } public SqlServerJsonIndexDefinition[] Indexes { get; set; } = []; } public sealed class SqlServerJsonIndexDefinition { public string JsonPath { get; set; } = default!; public string? IndexName { get; set; } public string? ComputedColumnName { get; set; } public string SqlType { get; set; } = "nvarchar(450)"; public bool Unique { get; set; } public SqlServerJsonComputedColumnMode ComputedColumnMode { get; set; } = SqlServerJsonComputedColumnMode.Persisted; public string[] IncludeColumns { get; set; } = []; public string? Filter { get; set; } } ``` ### Snapshot store example ```csharp builder.Services.AddSqlServerSnapshotQueryableEventStore(); builder.Services.Configure(options => { options.ConnectionString = "Server=.;Database=MyApp;Trusted_Connection=True;"; options.JsonIndexOptions.Enabled = true; options.JsonIndexOptions.Indexes = [ new SqlServerJsonIndexDefinition { JsonPath = "$.StringProperty", SqlType = "nvarchar(450)", IncludeColumns = ["Id"], }, new SqlServerJsonIndexDefinition { JsonPath = "$.IncrementInt32", SqlType = "int", }, ]; }); ``` ### Event store example ```csharp builder.Services.AddSqlServerEventStore(); builder.Services.Configure(options => { options.ConnectionString = "Server=.;Database=MyApp;Trusted_Connection=True;"; options.JsonIndexOptions.Enabled = true; options.JsonIndexOptions.Indexes = [ new SqlServerJsonIndexDefinition { JsonPath = "$.Value", SqlType = "nvarchar(450)", IncludeColumns = ["AggregateId", "Version"], Filter = "[EntityType] = 1", }, ]; }); ``` ### appsettings.json example ```json { "EventStore:SqlServerSnapshot": { "ConnectionString": "Server=.;Database=MyApp;Trusted_Connection=True;", "SchemaName": "dbo", "TableName": "EventStoreSnapshots", "JsonIndexOptions": { "Enabled": true, "Indexes": [ { "JsonPath": "$.StringProperty", "SqlType": "nvarchar(450)", "IncludeColumns": ["Id"] } ] } } } ``` ### Rules and limitations - `JsonPath` must start with `$`. - `SqlType` must be a supported scalar SQL type expression such as `nvarchar(450)` or `int`. - Filter expressions are intentionally restricted; unsafe SQL text is rejected during startup. - Include columns are validated against the store schema. - Indexes are **created**, but not dropped or reconciled, by the runtime. - When `AutoCreateTable = false`, configured JSON indexes are not created automatically. ### Query-shape guidance Indexes help only when the predicate path is SQL-translatable. - Good candidates: - `a => a.StringProperty == value` - `a => a.ReportSummaryScalar!.ParserDetails.FailedLines > 0` - Poor candidates: - `a => a.ReportSummary!.Value.ParserDetails.FailedLines > 0` when `ReportSummary` is a `[Scalar]` wrapping a complex inner type If deep filtering matters, prefer directly mapped complex mirror properties on the aggregate snapshot model and test the exact predicate path. --- ## Snapshot Payload Shape Snapshot payload is the fully serialized aggregate graph stored in a JSON payload column. Supported members include: - writable primitive members, - `[Scalar]` value objects, - complex objects composed of supported members, - `EventStoreList` / `EventStoreSet` collections of supported primitive/complex members. Important distinction for SQL translation: - A `[Scalar]` value object with a **primitive** inner value behaves like a scalar in queries. - A `[Scalar]` value object with a **complex** inner value is persisted correctly, but deep predicates through `.Value` are not guaranteed to translate in SQL snapshot queries. - If you need deep SQL predicates for a complex concept, expose the underlying complex type directly on the aggregate/query snapshot model (for example, a `ParserReportSummary` mirror property) and test the exact nested predicate you expect to support. - Directly mapped complex snapshot members can support deep predicates such as `ParserDetails.FailedLines > 0`, subject to the provider's supported payload-shape rules. Unsupported members fail during model creation, including: - arrays, - collection types other than `EventStoreList` / `EventStoreSet` (for example `List`, `IReadOnlyList`, `IEnumerable`, `HashSet`, `ImmutableArray`), - unsupported object types that are not explicitly mapped for JSON conversion. Read-only and `[JsonIgnore]` members are excluded from snapshot payload mapping. Some nested collection/dictionary members inside directly mapped complex graphs may be supported through provider JSON conversion rather than direct relational collection mapping. Treat those shapes as provider-specific and verify them with integration tests. ### Examples ```csharp // Supported snapshot shape public sealed class CustomerSnapshot { public string Name { get; set; } = string.Empty; public EmailAddress Email { get; set; } = EmailAddress.Hydrate("demo@example.com"); // [Scalar] public EventStoreList Items { get; set; } = []; public EventStoreSet Tags { get; set; } = []; } ``` ```csharp // Unsupported snapshot shape (model validation fails) public sealed class CustomerSnapshot { public List Items { get; set; } = []; // use EventStoreList public string[] Labels { get; set; } = []; // arrays unsupported public Dictionary Metadata { get; set; } = []; // dictionaries unsupported } ``` For generator/framework behavior (aggregate inheritance paths, hooks, event naming/namespace, manual mode), see [Source Generator Behaviors](../source-generator-behaviors/). --- ## Behavior Notes and Caveats - `IsDeletedAsync` throws when the aggregate does not exist (it does not return `false` for missing aggregates). - Event replay is tolerant by default: unknown or unappliable events are skipped, and stream version continues to advance. - Integration coverage includes replay compatibility scenarios for: - **Unknown events** (event type name no longer resolvable): replay skips affected records and continues. - **Schema-change style evolution** (event type still deserializes but is no longer applied/registered): replay logs `CannotApplyEvent` and continues. - See: `src/tests/SqlServer.IntegrationTests/Guards/SqlServerEventStoreGuardTests.cs` and the shared contract suites in `src/tests/SharedTestingFramework/Contracts/EventStoreContractTestsBase.cs`. - Principal enforcement is enabled by default (`RequiresValidPrincipalIdentifier = true`), so save operations require the configured claim identifier to be present on the current principal. --- ## Connection String Examples ### Local development (Windows auth) ```text Server=(localdb)\MSSQLLocalDB;Database=MyApp;Trusted_Connection=True; ``` ### SQL Server with SQL auth ```text Server=my-server.database.windows.net;Database=MyApp;User Id=app_login;Password=…; ``` ### Azure SQL with Managed Identity ```text Server=my-server.database.windows.net;Database=MyApp;Authentication=Active Directory Default; ``` ### Azure SQL with connection string from Key Vault ```json { "EventStore:SqlServer": { "ConnectionString": "@Microsoft.KeyVault(SecretUri=https://my-vault.vault.azure.net/secrets/SqlConnection)" } } ``` # Transaction Guarantees > IEventStoreTransaction coordinates aggregate saves, but the exact guarantee depends on the enlisted stores. Callers can inspect AvailableGuarantee after… # Transaction Guarantees `IEventStoreTransaction` coordinates aggregate saves, but the exact guarantee depends on the enlisted stores. Callers can inspect `AvailableGuarantee` after enlistment and can require atomicity when creating a transaction. ```csharp await using var transaction = transactionFactory.Create( new EventStoreTransactionOptions { CorrelationId = command.CorrelationId, RequiredGuarantee = EventStoreTransactionGuarantee.Atomic, }); transaction.Enlist(order, eventStore); transaction.Enlist(inventory, eventStore); await transaction.CommitAsync(cancellationToken); ``` When `Atomic` is required, `CommitAsync` validates every enlisted store and its native transaction boundary before performing any write. Incompatible providers, different databases, or stores without a native coordinator cause an `EventStoreTransactionGuaranteeException`; no enlisted aggregate is saved. When `BestEffort` is accepted (the backward-compatible default), the coordinator uses a native atomic transaction when every store shares a supported boundary. Otherwise, it saves sequentially under one correlation ID, stops on the first failure, and does not roll back earlier saves. A correlation ID and idempotency marker aid recovery but do not make sequential saves atomic. | Scenario | Available guarantee | | --- | --- | | SQL Server stores sharing one configured database boundary | `Atomic` | | PostgreSQL stores sharing one configured database boundary | `Atomic` | | Stores using different database boundaries | `BestEffort` | | Any provider without native transaction coordination | `BestEffort` | | Mixed providers | `BestEffort` | Provider-specific SQL transaction factories always require and provide `Atomic`; they reject unsupported stores during enlistment. Provider-neutral transactions make the requirement explicit at creation and verify it again at commit, after all stores have been enlisted. # Transactional Outbox > The transactional outbox persists messages atomically with event saves and dispatches them reliably to downstream consumers. It is an explicit capability, not… # Transactional Outbox The transactional outbox persists messages atomically with event saves and dispatches them reliably to downstream consumers. It is an **explicit capability**, not a universal guarantee: only providers with a relational transaction boundary (SQL Server and PostgreSQL) can write the outbox in the same native transaction as events. ## Honest semantics An outbox provides **atomic persistence plus at-least-once delivery**: - Atomic persistence means a message committed with its events cannot be lost — if the event save rolls back, the outbox write rolls back too. - Delivery is at-least-once: after a crash or lease expiry a message can be dispatched again. **Consumers must be idempotent.** `EventStoreCapabilities.SupportsTransactionalOutbox` reports which registered providers support the atomic write path; see [Provider Capabilities](../provider-capabilities/). ## Registering ```csharp builder.Services.AddSqlServerOutbox(options => { options.MaxAttempts = 5; options.RetryBackoffBase = TimeSpan.FromSeconds(5); }); ``` PostgreSQL uses `AddPostgresOutbox`. The hosted dispatch loop is registered automatically. Outbox table options are bound from `EventStore:SqlServer:Outbox` (or `EventStore:Postgres:Outbox`) and fall back to the event-store connection string. ## Writing messages atomically with events Use the provider-native transaction coordinator and enlist the outbox write alongside the aggregate save: ```csharp public sealed class OrderService( ISqlServerEventStoreTransactionFactory transactionFactory, ISqlServerEventStore orderStore, SqlServerOutboxStore outboxStore) { public async Task PlaceOrderAsync(OrderAggregate order, CancellationToken cancellationToken) { var envelope = new OutboxEnvelope( Id: Guid.NewGuid().ToString("N"), AggregateType: nameof(OrderAggregate), AggregateId: order.Id(), EventType: "OrderPlaced", PayloadJson: "{\"orderId\":\"" + order.Id() + "\"}", IdempotencyKey: order.Id(), CorrelationId: null, CreatedUtc: DateTimeOffset.UtcNow); await using var transaction = transactionFactory.CreateSqlServerTransaction(); transaction.Enlist(order, orderStore); transaction.Enlist((connection, sqlTransaction, token) => outboxStore.EnqueueInTransactionAsync(connection, sqlTransaction, envelope, token)); var result = await transaction.CommitAsync(cancellationToken); } } ``` Enqueuing with the same `IdempotencyKey` again is a no-op (deduplicated at the store). ## Dispatch behavior - **Leasing/claiming:** a batch is claimed atomically (`UPDATE ... OUTPUT`/`RETURNING`) by a lease owner until a lease duration; another dispatcher reclaims only expired leases. - **Ordering:** messages are claimed oldest-first by `CreatedUtc`, then `Id`. - **Retry and backoff:** failures increment the attempt count and schedule a retry with exponential backoff (`RetryBackoffBase` doubled per attempt, capped at 64x). - **Poison messages:** after `MaxAttempts` the message moves to the poisoned (dead-letter) state with the last error recorded. Poisoned and dispatched messages older than `Retention` are removed by `CleanupAsync`. - **Observability:** every failure and dispatch cycle is logged; the last error is stored on the message. - **Cancellation:** dispatch honors the cancellation token; stopping the host interrupts the loop. ## Concurrent dispatchers Multiple dispatchers (or hosts) may run concurrently. The lease claim is atomic, so each message is claimed by exactly one dispatcher at a time. See `SqlServerOutboxIntegrationTests` and `PostgresOutboxIntegrationTests` for real-provider coverage of atomic commit/rollback, retry/poison, deduplication, and concurrent claim disjointness. ## Dead-letter visibility The Admin portal exposes poisoned (dead-letter) messages at `GET /admin/api/outbox/poisoned` when the `ViewPoisonedOutbox` feature and permission are enabled (opt-in, separately authorized, and audited). `IOutboxStore.GetPoisonedAsync` returns a page of poisoned messages ordered most-recently-poisoned first; providers without dead-letter inspection return an empty page. # SourceGenerator Framework > This wiki is the project documentation hub for Purview.SourceGeneratorFramework — a strongly typed framework for building, testing, and maintaining… # SourceGenerator Framework Wiki This wiki is the project documentation hub for **Purview.SourceGeneratorFramework** — a strongly typed framework for building, testing, and maintaining incremental C# source generators with Roslyn. It includes structured code generation (`CodeWriter`), incremental pipeline helpers, attribute data models, type libraries, a step-cache test runner for verifying incremental behaviour, and bundled analysers that guide generators back to best practice. ## Start here - [Getting Started](getting-started/) - [Source Generator & Analyser Best Practices](guide/) - [CodeWriter structured API reference](code-writer/) - [TypeLibraryGenerator](type-library/) - [Attribute Data Models](attribute-data-models/) - [Incremental Pipeline](incremental-pipeline/) - [Analyzers](analyzers/) - [Testing](testing/) - [Testing with TUnit](testing-tunit/) - [Step-Cache Tests](step-cache-tests/) - [Packaging](packaging/) - [Performance](performance/) - [Release Flow](release-flow/) ## Packages | Package | Description | Packable | | --- | --- | --- | | [`Purview.SourceGeneratorFramework`](https://github.com/purview-dev/sourcegenerator-framework/blob/main/src/src/SourceGeneratorFramework) | Core helpers, models, and MSBuild integration for writing incremental source generators. | Yes | | [`Purview.SourceGeneratorFramework.Testing`](https://github.com/purview-dev/sourcegenerator-framework/blob/main/src/src/SourceGeneratorFramework.Testing) | Framework-agnostic test runner and assertions for source generator unit tests. | Yes | | [`Purview.SourceGeneratorFramework.Testing.TUnit`](https://github.com/purview-dev/sourcegenerator-framework/blob/main/src/src/SourceGeneratorFramework.Testing.TUnit) | TUnit-specific test base classes and assertions for source generator tests. | Yes | | [`Purview.SourceGeneratorFramework.Generators`](https://github.com/purview-dev/sourcegenerator-framework/blob/main/src/src/SourceGeneratorFramework.Generators) | Internal Roslyn source generator used by the framework package. | No | | [`Purview.SourceGeneratorFramework.ExampleGenerator`](https://github.com/purview-dev/sourcegenerator-framework/blob/main/src/src/SourceGeneratorFramework.ExampleGenerator) | Reference implementation showing how to build a generator with the framework. | No | ## Feature highlights - **`CodeWriter`** — an allocation-conscious writer for generated C# source with indentation, namespace/type declarations, structured statements, XML documentation, conditional compilation blocks, and deterministic output. Declarations and statements are structured values rather than raw text; see [Code-Writer.md](code-writer/). - **`IncrementalPipeline`** — extension methods for composing `IncrementalValueProvider` / `IncrementalValuesProvider` pipelines, including attribute-based discovery, generation-context creation, and disable-property checks; see [Incremental-Pipeline.md](incremental-pipeline/). - **`GenerationContext`** — a base execution-services context carrying the Roslyn `Compilation`, immutable generator settings, optional logging, and a factory for independently owned `CodeWriter` instances. - **`GeneratorResult`** — a value-or-diagnostics result type for incremental transforms, with explicit per-diagnostic `IsBlocking` control over whether generation continues. - **`AttributeDataModelGenerator`** — bundled generator that emits `readonly record struct` attribute parser models from `[Generate]` declarations; see [Attribute-Data-Models.md](attribute-data-models/). - **`TypeLibraryGenerator`** — generates a self-contained `public static partial` type library from a small declarative spec; see [Type-Library.md](type-library/). - **Bundled analysers and code fixes** — `PSGFR11`–`PSGFR38` diagnostics for Roslyn best practice, plus code fixes; see [Analyzers.md](analyzers/). - **Testing framework** — `SourceGeneratorTestRunner`, `CodeQuery` syntax-node assertions, refactoring tests, and incremental cache tests; see [Testing.md](testing/) and [Testing-TUnit.md](testing-tunit/). - **Step-cache tests** — prove a generator caches correctly stage-by-stage; see [Step-Cache-Tests.md](step-cache-tests/). ## Requirements - .NET SDK 10.0 or later to build the framework. - The framework is built against Roslyn 5.0 (`Microsoft.CodeAnalysis` 5.x), so compiler hosts that load the generator, analyser, and testing assemblies must be Roslyn 5.0 or later (`.NET 10` SDK / Visual Studio 2026 18.0). - Source generators target `netstandard2.0`; test projects target `net8.0`, `net9.0`, and `net10.0`. ## Repository layout - `src/src/SourceGeneratorFramework` — core framework package. - `src/src/SourceGeneratorFramework.Generators` — bundled generators (attribute data models, type library), shipped inside the core package. - `src/src/SourceGeneratorFramework.Analyzers` — bundled analysers. - `src/src/SourceGeneratorFramework.CodeFixers` — bundled code fix providers. - `src/src/SourceGeneratorFramework.Testing` — framework-agnostic testing package. - `src/src/SourceGeneratorFramework.Testing.TUnit` — TUnit testing integration. - `src/src/SourceGeneratorFramework.ExampleGenerator` — reference generator implementation. - `src/src/SourceGeneratorFramework.Benchmarks` — BenchmarkDotNet benchmarks (see [Performance.md](performance/)). # Analyzers > The Purview.SourceGeneratorFramework package includes the Purview.SourceGeneratorFramework.Analyzers assembly as an analyzer asset, together with the… # Analyzers The `Purview.SourceGeneratorFramework` package includes the `Purview.SourceGeneratorFramework.Analyzers` assembly as an analyzer asset, together with the `Purview.SourceGeneratorFramework.CodeFixers` code fix providers. The diagnostics are enabled automatically when you reference `Purview.SourceGeneratorFramework` from a source generator project. ## How the analyzers are shipped The analyzer and code-fix assemblies are built as Roslyn components (`IsRoslynComponent = true`) and packed into the `Purview.SourceGeneratorFramework` package under `analyzers/dotnet/cs/`. Because they are not separately packable NuGet packages, they are documented here rather than in a standalone README. The analyzers enforce two families of rules: - **Incremental generator best practice** — `PSGFR11`–`PSGFR33`, covering pipeline design, `CodeWriter` usage, Roslyn component discovery, and extension-class conventions. - **C# 14 extension-member conventions** — `PSGFR34`–`PSGFR38`, plus the associated `ReorganizeExtensionClassCodeFixProvider` and `ConvertToExtensionBlockCodeFixProvider`. ## Rule reference | Rule | Summary | |------|---------| | `PSGFR11` | Prefer `SyntaxProvider.ForAttributeWithMetadataName` over `CreateSyntaxProvider` for attribute-based detection. | | `PSGFR12` | Use `IIncrementalGenerator` / `RegisterSourceOutput` instead of `ISourceGenerator`. | | `PSGFR14` | Avoid `RegisterImplementationSourceOutput` unless implementation-only output is required. | | `PSGFR15` | Pipeline model collection members should use sequence equality (e.g. `EquatableArray`). | | `PSGFR16` | Prefer the nullable-context `Nullable()`/`MakeNullable()` overload so annotations honour the target compilation. | | `PSGFR17` | Consume `CodeWriter` scope-returning methods (`...Scope`, `IndentedScope`) with `using`. | | `PSGFR18` | Prefer structured declaration APIs (`Class`, `Method`, `Property`, `Field`) over raw declaration text. | | `PSGFR19` | Prefer structured statement APIs (`Return`, `MethodCall`, `Throw`, `Assignment`, `Using`, `Comment`) over raw statement text. | | `PSGFR20` | Prefer the minimal `CodeWriter` overloads over constructing `*DeclarationOptions` values manually. | | `PSGFR21` | Prefer `HashDefines`/`HashDefinesScope` for `#if`/`#endif` conditional-compilation directives. | | `PSGFR22` | Prefer `PragmaDisable`/`OpenPragmasScope` for `#pragma warning` directives. | | `PSGFR23` | Prefer structured `IfBlock`/`ElseIf`/`Else` over raw `if` block text. | | `PSGFR24` | `CodeFixProvider` is not marked `[ExportCodeFixProvider]`; Visual Studio will never discover it. | | `PSGFR25` | `DiagnosticAnalyzer` is not marked `[DiagnosticAnalyzer]`; it will never run. | | `PSGFR26` | A generator type is not marked `[Generator]`; it will never run. | | `PSGFR27` | A Roslyn component type is not public; the compiler host cannot instantiate it. | | `PSGFR28` | `FixableDiagnosticIds` references a diagnostic ID no analyzer in the compilation produces; the fix will never be shown. | | `PSGFR29` | Do not embed a `CodeWriter` in a string; use `XmlCommentWriter.XmlInlineCode` instead. | | `PSGFR30` | Prefer `static` lambdas in incremental pipeline methods so the compiler never allocates a closure on the per-item hot path. | | `PSGFR31` | Prefer `GeneratorAttributeSyntaxContext.TargetSymbol` over `SemanticModel.GetDeclaredSymbol(ctx.TargetNode)`. | | `PSGFR32` | Avoid `NormalizeWhitespace` when generating source; use an indented text writer such as `CodeWriter`. | | `PSGFR33` | Pipeline models must not retain Roslyn objects (`ISymbol`, `SyntaxNode`, `Location`, ...); extract the information into value types. | | `PSGFR34` | Prefer C# 14 `extension(Receiver)` blocks over classic static `this`-parameter extension methods. | | `PSGFR35` | Extension class name must match the extended type (`{Receiver}Extensions`). | | `PSGFR36` | Extension classes must be placed in the extended type's namespace under an `Extensions` folder. | | `PSGFR37` | One extension class per receiver type; split classes that extend multiple types. | | `PSGFR38` | Extension classes should carry `[EditorBrowsable(EditorBrowsableState.Never)]`. | ## Type-library and attribute-model diagnostics The bundled generators carry their own diagnostic families, reported by the `TypeLibraryValidationAnalyzer` (`TLB0001`–`TLB0019`) and the attribute-data-model validation analyzers. These are documented on their feature pages: - [Type-Library.md](../type-library/#validation) - [Attribute-Data-Models.md](../attribute-data-models/) ## Code fixes Code fix providers ship in the `Purview.SourceGeneratorFramework.CodeFixers` assembly and cover the analyzer rules above, including: - `AddGeneratorAttributeCodeFixProvider` — adds the missing `[Generator]` attribute (`PSGFR26`). - `AddDiagnosticAnalyzerAttributeCodeFixProvider` — adds `[DiagnosticAnalyzer]` (`PSGFR25`). - `AddExportCodeFixProviderAttributeCodeFixProvider` — adds `[ExportCodeFixProvider]` (`PSGFR24`). - `MakeRoslynComponentPublicCodeFixProvider` — makes the component type public (`PSGFR27`). - `RemoveOrphanedFixableDiagnosticIdCodeFixProvider` — removes unused fixable diagnostic IDs (`PSGFR28`). - `PreferTargetSymbolCodeFixProvider` — switches to `TargetSymbol` (`PSGFR31`). - `PreferStaticLambdaCodeFixProvider` — makes pipeline lambdas `static` (`PSGFR30`). - `PreferNullableContextOverloadCodeFixProvider` — adds the generation context to `Nullable()` / `MakeNullable()` calls, including project-wide "Fix all" support (`PSGFR16`). - `PipelineModelReferenceEqualityCollectionCodeFixProvider` — wraps collection members for sequence equality (`PSGFR15`). - `PreferStructuredCodeWriterIfBlockCodeFixProvider` — rewrites raw `if`/`else if`/`else` block text to the structured `IfBlock`/`ElseIf`/`Else` APIs (`PSGFR23`). - `CodeWriterToStringCodeFixProvider` — replaces embedded `CodeWriter` string interpolation (`PSGFR29`). - `AttributeDataModelSymbolPropertyCodeFixProvider` — fixes attribute-data-model symbol properties. - `ReorganizeExtensionClassCodeFixProvider` — renames (`PSGFR35`), splits multi-receiver classes (`PSGFR37`), moves the class under `Extensions/{ReceiverNamespace}/`, and updates referencing files (`PSGFR36`). - `ConvertToExtensionBlockCodeFixProvider` — converts classic methods to C# 14 `extension` blocks (`PSGFR34`). - `AddExtensionClassMetadataCodeFixProvider` — adds `[EditorBrowsable(EditorBrowsableState.Never)]` to extension classes (`PSGFR38`). - Type-library fixes — `TypeLibraryMemberAccessibilityCodeFixProvider`, `TypeLibraryMarkerDefaultInitializerCodeFixProvider`, `MakeTypeLibrarySpecPartialCodeFixProvider`, `RenameTypeLibrarySpecCodeFixProvider`, and `TypeLibraryMemberTypeCodeFixProvider`. See [Guide.md](../guide/#19-extension-class-conventions) for the extension-class conventions the `PSGFR34`–`PSGFR38` rules enforce. ## Roslyn component discovery The compiler host only loads a source generator, diagnostic analyzer, or code fix provider when three conditions hold. Missing any one means the component is **silently ignored**: 1. **The type is public** (`PSGFR27`). 2. **The type is decorated** — `[Generator]` (`PSGFR26`), `[DiagnosticAnalyzer]` (`PSGFR25`), or `[ExportCodeFixProvider]` (`PSGFR24`). 3. **The assembly is loaded as an analyzer** — packed under `analyzers/dotnet/cs/` in a package, or referenced with `OutputItemType="Analyzer"` in a project reference. A code fix provider also only appears when the diagnostic ID in `FixableDiagnosticIds` is actually produced by an analyzer loaded alongside it (`PSGFR28`). Visual Studio MEF-composes fix providers when the analyzer set loads, so after adding or updating a fixer assembly you must restart Visual Studio or reload the project for the fixes to appear. ## License This documentation is part of the MIT-licensed `Purview.SourceGeneratorFramework` project. # Attribute Data Models > AttributeDataModelGenerator generates readonly record struct parser models for .NET attributes. Instead of hand-writing FromAttributeData methods for every… # Attribute Data Models `AttributeDataModelGenerator` generates `readonly record struct` parser models for .NET attributes. Instead of hand-writing `FromAttributeData` methods for every attribute you inspect in a source generator, declare a `readonly partial record struct` with `[Generate]` and the generator fills in the `Empty` sentinel, `FromAttributeData` overloads, and property extraction logic. The generator is implemented in `Purview.SourceGeneratorFramework.Generators` and ships inside the `Purview.SourceGeneratorFramework` package under `analyzers/dotnet/cs/`, so it runs automatically when you reference the package. ## Marker attributes The generator emits marker attributes into your compilation: | Attribute | Purpose | | --- | --- | | `[Generate(Type targetAttribute)]` | Placed on a `readonly partial record struct` to opt into generation. | | `[Generate(string targetAttribute)]` | Resolves the attribute by fully-qualified name. Use when the attribute type is not available in the generator's compilation (e.g. `LengthAttribute` in .NET 8+ or a self-generated attribute). | | `[Property]` | A record parameter is populated from a named attribute property (the property name is inferred from the parameter name unless overridden). | | `[Property(string name)]` | Explicit named property source. | | `[Property(..., DefaultValue = ...)]` | Fallback value when the named property is not present. | | `[Argument]` | Populated from a constructor argument by parameter name. | | `[Argument(int index)]` | Constructor argument by parameter index. | | `[Argument(string name)]` | Constructor argument by parameter name. | | `[Argument(..., DefaultValue = ...)]` | Fallback when the constructor argument is not present. | | `[NestedModel]` | Populated by recursively calling `FromAttributeData` on a nested generated model. | | `[Exclude]` | Skips auto-discovery for this parameter. | | `[GenericTypeArgument]` | Populated from a generic type argument of the attribute class. | | `[GenericTypeArgument(int index)]` | Generic type argument by position. | | `[GenericTypeArgument(string name)]` | Generic type argument by type parameter name. | ## Manual mapping ```csharp using Microsoft.CodeAnalysis; using Purview.SourceGeneratorFramework.Generators; using System.ComponentModel.DataAnnotations; namespace MySourceGenerator.Models; [Generate(typeof(RequiredAttribute))] public readonly partial record struct RequiredAttributeData( bool AllowEmptyStrings ); ``` Generated output: ```csharp readonly record struct RequiredAttributeData(bool Exists, bool AllowEmptyStrings) { public static readonly RequiredAttributeData Empty = new(false, default(bool)); public static RequiredAttributeData FromAttributeData(ImmutableArray attributes) { // ... } public static RequiredAttributeData FromAttributeData(AttributeData attributeData) { if (!TargetAttribute.Equals(attributeData.AttributeClass)) return Empty; attributeData.TryGetNamedArgument("AllowEmptyStrings", out var allowEmptyStrings); return new(true, allowEmptyStrings); } } ``` ## String target names When the attribute type is not referenced in the generator project, pass the fully-qualified name as a string. This is useful for attributes newer than the generator's target framework (e.g. `LengthAttribute` in .NET 8+) or attributes that are generated by the same generator: ```csharp [Generate("System.ComponentModel.DataAnnotations.RequiredAttribute")] public readonly partial record struct RequiredAttributeData( bool AllowEmptyStrings ); ``` A plain type name (`"RequiredAttribute"`) can also be used, which matches an attribute in the global namespace or in any namespace. `AutoDiscover` requires the real `Type` overload because it must inspect the attribute's constructors and properties. ## Constructor arguments ```csharp [Generate(typeof(LengthAttribute))] public readonly partial record struct LengthAttributeData( [Argument(0)] int MinimumLength, [Argument(1)] int MaximumLength ); ``` Or by constructor parameter name: ```csharp [Generate(typeof(StringLengthAttribute))] public readonly partial record struct StringLengthAttributeData( [Argument("maximumLength", DefaultValue = 2147483647)] int MaximumLength, int MinimumLength ); ``` ## Nested models Any property whose type is itself annotated with `[Generate]` can be populated as a nested model. This is useful for shared base attribute data, such as `ValidationAttribute` in `System.ComponentModel.DataAnnotations`: ```csharp [Generate(typeof(ValidationAttribute), MatchByInheritance = true)] public readonly partial record struct ValidationAttributeData( [Property] string? ErrorMessage, [Property] string? ErrorMessageResourceName, [Property] ITypeSymbol? ErrorMessageResourceType ); [Generate(typeof(RequiredAttribute))] public readonly partial record struct RequiredAttributeData( bool AllowEmptyStrings, [NestedModel] ValidationAttributeData ValidationAttribute ); ``` Because `ValidationAttributeData` uses `MatchByInheritance = true`, it matches any attribute that derives from `ValidationAttribute`, including `RequiredAttribute`. ## Generic type arguments If the attribute class is generic, a record parameter can be populated from the attribute's type argument: ```csharp [Generate(typeof(MyGenericAttribute<>))] public readonly partial record struct MyGenericAttributeData( [GenericTypeArgument] T Value ); ``` Use `[GenericTypeArgument(0)]` or `[GenericTypeArgument("TValue")]` to disambiguate when the attribute has multiple type parameters. ## Auto-discovery For simple attributes you can let the generator discover all constructor parameters and public named properties automatically: ```csharp [Generate(typeof(RequiredAttribute), AutoDiscover = true)] public readonly partial record struct RequiredAttributeData; ``` This generates the same `RequiredAttributeData` as the manual example above. Nested models are not auto-discovered; declare them explicitly if needed. ## Default values `DefaultValue` provides a runtime fallback when the attribute does not contain the requested property or argument. The `Empty` sentinel always uses `default(T)` for every property (including an `Exists` field set to `false`): ```csharp [Generate(typeof(HostKitAttribute))] public readonly partial record struct HostKitAttributeData( [Argument("name", DefaultValue = "MyApp")] string Name, [Argument("generateOptions", DefaultValue = true)] bool GenerateOptions ); ``` ## Type library integration A `[TypeRef]` member declared with `GenerateFullNameConst` produces a `public const string {Member}FullName`, which can be used as the `[Generate]` target of an attribute-data model instead of a `typeof(...)` value — see [Type-Library.md](../type-library/#using-full-name-constants-as-attribute-data-model-targets) for the full example. Because the `TypeLibrary` class is emitted through `TypeLibraryGenerator`'s main pipeline, its constants are **not** present in the compilation that `AttributeDataModelGenerator`'s `ForAttributeWithMetadataName` pipeline sees (only post-initialization output is shared between generators in a single pass). `AttributeDataModelGenerator` therefore reassembles the target from the argument's member-access expression — guarded so the root identifier must match a `[GenerateTypeLibrary]` spec's `ClassName` — and resolves it against the compilation. For `[Argument]`/`[Property]` members marked `IsEnum = true`, a `DefaultValue` supplied as a **bare member name** (for example `"Inherit"`) is expanded to the fully-qualified `"{EnumFullName}.{Member}"` form using the enum type of the target attribute's matching constructor parameter (for `[Argument]`) or property (for `[Property]`). Fully-qualified defaults and defaults whose enum type cannot be resolved are emitted unchanged. ## License This documentation is part of the MIT-licensed `Purview.SourceGeneratorFramework` project. # CodeWriter > CodeWriter is the structured, allocation-conscious writer used to build generated C# source. Instead of concatenating strings or writing raw text, generators… # CodeWriter `CodeWriter` is the structured, allocation-conscious writer used to build generated C# source. Instead of concatenating strings or writing raw text, generators describe *what* to emit — declarations, statements, scopes — and the writer handles indentation, blank-line separation, generated attributes, and deterministic layout. This page uses the current best-practice API: bare semantic names (`Class`, `Method`, `Property`), the minimal-parameter overloads with an optional `configure` callback, and structured statements (`Return`, `MethodCall`, `Assignment`) instead of raw text. ## Construction and scope validation A `CodeWriter` is created with `GenerationSettings` and defaults to the **production** configuration: scope validation is off, so no opening stack traces are captured and `ToString()` materializes partial output even while a scope is open. `GenerationContext.CreateCodeWriter()` inherits this via `GenerationSettings.ValidateCodeWriterScopes`, which defaults to `false`. Testing flips the default so a generator that forgets a `using` or leaves a block open fails fast instead of silently emitting malformed code. With validation enabled, `ToString()` throws `CodeWriterScopeValidationException` listing every open scope, its header, and the stack trace captured when it was opened: - `CodeWriterFactory.ForTests()` and `CodeWriter.CreateTestWriter()` enable validation by default (`throwOnUnclosedScopes: true`). - `SourceGeneratorTestOptions.ValidateCodeWriterScopes` defaults to `true`, so the test runner enables it for the generators under test. - Pass `throwOnUnclosedScopes: false` explicitly when a test intentionally materializes partial output. Scope tracking has a real cost — every scope open captures a `StackTrace` and allocates a per-scope record — which is why production leaves it off (see [Performance.md](../performance/)). ## Primitives Use the raw primitives for low-level text that has no structured equivalent: ```csharp writer.Write("partial"); // no trailing line feed writer.Line("// generated"); // line feed appended writer.Append("text"); // Write alias writer.AppendLine("text"); // Line alias writer.Comment("Explains the next member."); writer.Indent(); // increase indentation writer.NewLine(); ``` `Write`/`Line`/`Append`/`AppendLine` are the only methods that retain a verb prefix: everything semantic drops it because the receiver is already a writer. ## Declarations Each declaration writer has: - a **minimal overload** taking name/type/accessibility plus an optional `configure` callback (`options => options with { ... }`); and - a **scope form** (`...Scope`) returning a `BlockScope` for `using` when you need fine-grained control. Type declarations can also be written **without a body**, terminated with a semicolon instead of an empty block — useful for marker types, primary-constructor records, and host-kit stubs: ```csharp // public sealed partial class TestingHostKit; writer.Class( new TypeDeclarationOptions("TestingHostKit", TypeDeclarationAccessibility.Public) { IsPartial = true, Attributes = [new(HostKitAttribute) { Arguments = [new(true, "GenerateOptions", true)] }], } ); // public record class Point(int X, int Y); writer.RecordClass( new TypeDeclarationOptions("Point") { PrimaryConstructorParameters = [new("X", intType), new("Y", intType)] } ); // writer.Class("C"); // public sealed partial class C; ``` The semicolon-terminated form is valid for `Class`, `Struct`, `RecordClass`, `RecordStruct`, and `Interface` (primary-constructor parameters, base types, and `where` constraints are still written). Enums and delegates always require a body / self-terminate. ```csharp writer.Class( "OrderService", TypeDeclarationAccessibility.Public, options => options with { IsSealed = true, IsPartial = false }, body => { body.Field("_total", TypeIdentity.Create().AsTypeReference(), TypeDeclarationAccessibility.Private); body.Constructor( "OrderService", TypeDeclarationAccessibility.Public, options => options with { Parameters = [new("total", TypeIdentity.Create().AsTypeReference())], }, constructorBody => constructorBody.Assignment("_total", "total") ); body.Property( "Total", TypeIdentity.Create().AsTypeReference(), TypeDeclarationAccessibility.Public ); } ); ``` The same pattern applies to `Struct`, `RecordClass`, `RecordStruct`, `Interface`, `Enum` (+ `EnumField`), `Type` (kind-driven), `Delegate`, `AttributeClass`, `Method`/`PartialMethod`/`MethodExpression`, `Property`/`PropertyExpression`, `Indexer`, `Field`, and `Operator`. ### Scope forms ```csharp using (writer.ClassScope("OrderService", TypeDeclarationAccessibility.Public)) using (writer.MethodScope("Apply", TypeLibrary.System.Void, TypeDeclarationAccessibility.Public)) { writer.MethodCall("Validate"); } ``` Scope forms are ideal when a declaration spans multiple calls, loops, or conditional content. The `using` statement is mandatory — the closing token and indentation are written on dispose, and the `DiscardedCodeWriterScopeAnalyzer` (PSGFR17) flags scope returns that are dropped. ### C# 14 extension-member blocks `ExtensionBlockScope`/`ExtensionBlock` emit C# 14 `extension(...)` blocks (Roslyn 5.0 or later), for generators that need to attach members to a receiver type (note that extension members compile to static accessor methods such as `get_X`, not CLR properties): ```csharp using (writer.ExtensionBlockScope( new TypeIdentity("PurviewTypeLibrary", "Purview.SourceGeneratorFramework") .Nested("System") .Nested("Diagnostics") .AsTypeReference())) { writer.Property( "Activity", TypeReference.Create(), TypeDeclarationAccessibility.Public, options => options with { IsStatic = true, ExpressionBody = "Activity" }); } // extension(global::Purview.SourceGeneratorFramework.PurviewTypeLibrary.System.Diagnostics) // { // public static global::Purview.SourceGeneratorFramework.TypeIdentity Activity => Activity; // } ``` The receiver must be a plain named type; composed references (arrays, pointers, nullable annotations, type parameters and `dynamic`) and the null literal are rejected. Use the callback form `writer.ExtensionBlock(receiver, body => ...)` for a complete block in one call. ### Enums Generated enums are emitted with the `[Embedded]` marker attribute by default — the same default as `AttributeClass` — so the enum is embedded into each consuming assembly rather than leaking as a reference to the generator's type surface. Opt a specific enum out with `IncludeEmbeddedAttribute = false` in the `configure` callback: ```csharp writer.Enum("ServiceLifetime", TypeDeclarationAccessibility.Public, options => options with { IncludeEmbeddedAttribute = false }); ``` Fields are separated by a blank line, matching the spacing applied to other members, so XML summaries and attributes stay readable: ```csharp writer.Enum("Status", TypeDeclarationAccessibility.Public, fields: [ new("None", 0), new("Ready", 1) { XmlSummary = ["The service is ready."] }, ]); // [global::Microsoft.CodeAnalysis.Embedded] // [global::System.Runtime.CompilerServices.CompilerGenerated] // [global::System.CodeDom.Compiler.GeneratedCode("TestGenerator", "1.0.0")] // public enum Status // { // None = 0, // // /// // /// The service is ready. // /// // Ready = 1, // } ``` `XmlSummary` is always written over multiple lines, so single-line summaries are emitted as a `` block: ```csharp writer.XmlSummary("Gets the value."); // /// // /// Gets the value. // /// ``` ### Field spacing Consecutive `Field` declarations are emitted without a blank line between them when they carry no decoration. A blank line is inserted before a field when it (or the preceding field) has a generated or user attribute, an XML summary, or a comment — so generated attributes keep consecutive fields readable: ```csharp writer.Field("_first", TypeReference.Create()); writer.Field("_second", TypeReference.Create()); // [global::System.Runtime.CompilerServices.CompilerGenerated] // [global::System.CodeDom.Compiler.GeneratedCode("TestGenerator", "1.0.0")] // private int _first; // // [global::System.Runtime.CompilerServices.CompilerGenerated] // [global::System.CodeDom.Compiler.GeneratedCode("TestGenerator", "1.0.0")] // private int _second; ``` ## Statements Emit executable statements through the structured statement methods rather than raw `Line`: ```csharp writer.MethodCall("Process", "item"); // Process(item); writer.AwaitedMethodCall("SaveAsync", "cancellationToken"); // await SaveAsync(cancellationToken); writer.MethodCallOn("variable", "Process", "item"); // variable.Process(item); writer.AwaitedMethodCallOn("service", "LoadAsync", "token"); // await service.LoadAsync(token); writer.Return("value"); // return value; writer.Throw(TypeIdentity.Create(), "Failed."); // throw new ...; writer.Assignment("_total", "value"); // _total = value; writer.Assignment("var hostKit", expression => expression.New("HostKit", "onBuilt")); // var hostKit = new HostKit(onBuilt); writer.IfBlock("value is null", body => body.Return("null")); writer.IfBlock("value is null", body => body.Return("null")) .ElseIf("value is 0", body => body.Return("zero")) .Else(body => body.Return("value")); writer.Foreach("var item in items", body => body.MethodCallOn("item", "Process")); ``` `MethodCall`/`AwaitedMethodCall` write a call without a receiver — `Process(item);` or `await SaveAsync(token);`. Use `MethodCallOn`/`AwaitedMethodCallOn` (or the `receiver` parameter on the `IEnumerable` overloads) for a call on a variable, including generic arguments: ```csharp writer.MethodCall("Create", ["x"], receiver: "factory", genericArguments: [TypeReference.Create()]); // factory.Create(x); ``` When a statement or declaration must embed a runtime or user-supplied string — for example a regular-expression pattern or error message — emit it through the `StringLiteral()` extension rather than wrapping it in quotes by hand. It returns a quoted, escaped C# string literal: ```csharp body.Field("regex", regexType, TypeDeclarationAccessibility.Private, options => options with { IsStatic = true, Initializer = $"new({pattern.StringLiteral()})" }); // pattern = ^[\w\-.]+$ => new("^[\\w\\-.]+$") ``` A **chained** invocation — where the result of each call is the receiver of the next, and a postfix is applied to the final result — is expressed with `MethodCallChain`/`AwaitedMethodCallChain`. The chain is written as an expression (no terminating semicolon), so it composes as the value of an `Assignment`/`Return` expression callback: ```csharp writer.Assignment( "var hostKitOptions", expression => expression.MethodCallChain( "builder.Configuration.GetSection", [$"{name}.SectionName"], chain => chain.Method("Get", genericArguments: [optionsType]).Postfix(" ?? new()"))); // var hostKitOptions = builder.Configuration.GetSection("x.SectionName").Get() ?? new(); ``` - `rootMethod` may include the receiver (e.g. `builder.Configuration.GetSection`); each subsequent `.Method(...)` call implicitly uses the previous result as its receiver. - `genericArguments` provides the `<...>` type arguments for a segment. A chain that starts on a receiver with generic arguments uses `genericArguments` on the root: ```csharp writer.Assignment("var optionsBuilder", expression => expression.MethodCallChain( "builder.Services.AddOptions", [], chain => chain.Method("BindConfiguration", ["options.SectionName"]), genericArguments: [optionsType])); // var optionsBuilder = builder.Services.AddOptions().BindConfiguration("options.SectionName"); ``` - `Postfix(expression)` appends a trailing expression such as `?? new()` or `!`. ### Object creation `New` writes an object-creation expression — `new Type(...)` or a target-typed `new(...)` — without a trailing semicolon, so it composes as the value of an `Assignment`/`Return` expression callback: ```csharp writer.Assignment("var hostKit", expression => expression.New("HostKit", "onBuilt", "onConfigured")); // var hostKit = new HostKit(onBuilt, onConfigured); writer.Assignment("HostKit hostKit", expression => expression.New(["onBuilt", "onConfigured"])); // HostKit hostKit = new(onBuilt, onConfigured); ``` `New` accepts a verbatim type name, a `TypeReference`, structured `MethodCallArgumentOptions` (preserving `ref`/`out`/`in` modifiers and named arguments), or no type at all. Use `expression.New()` for `new()`. The no-type form emits a target-typed `new(...)` expression, which is valid only where the target type is known (an assignment to a typed local, field, property, parameter, or a `return` statement). `ObjectCreationOptions` supports the same no-type construction at statement level via its argument-only constructor: ```csharp writer.Assignment( context.HostKit.HostKitType, "hostKit", new ObjectCreationOptions("onBuilt", "onConfigured")); // HostKitType hostKit = new(onBuilt, onConfigured); ``` A null-conditional receiver — `onBuilt?.Invoke(this, builder);` — is written with the `nullConditional` argument on the structured `MethodCallOn`/`AwaitedMethodCallOn` overloads, which also accept `genericArguments`: ```csharp writer.MethodCallOn("onBuilt", "Invoke", ["this", "builder"], nullConditional: true); // onBuilt?.Invoke(this, builder); writer.MethodCallOn("builder.Services", "AddOptions", genericArguments: [optionsType]); // builder.Services.AddOptions(); ``` ### Conditional statements `IfBlock`/`IfBlockScope` write an `if` block. `ElseIf`/`ElseIfScope` chain an `else if` block after an `if` or another `else if`, and `Else`/`ElseScope` close the chain with an `else` block. The methods return the writer, so branches can be chained fluently: ```csharp writer .IfBlock("value is null", body => body.Return("null")) .ElseIf("value is 0", body => body.Return("zero")) .Else(body => body.Return("value")); ``` Emits: ```csharp if (value is null) { return null; } else if (value is 0) { return zero; } else { return value; } ``` `IfElse(condition, ifBody, elseBody)` is the compact two-branch form. The scope forms `IfBlockScope`, `ElseIfScope`, and `ElseScope` write the header and return the body scope for content that spans multiple calls. ### Conditional compilation blocks `HashDefines`/`HashDefinesScope` write a `#if`/`#endif` block with both directives at **column zero**. The body keeps the surrounding indentation — file-level directives and their content stay at column zero, while class members inside the block stay at the same indent as their siblings: ```csharp using (writer.HashDefinesScope("!EXCLUDE_PURVIEW_TELEMETRY_LOGGING")) { writer.FileScopedNamespace("Example"); writer.Enum("Mode", TypeDeclarationAccessibility.Public, fields: [new("Default", 0)]); } // Equivalent action form: writer.HashDefines("NET", body => body.Line("// NET only")); ``` Emits: ```csharp #if !EXCLUDE_PURVIEW_TELEMETRY_LOGGING namespace Example; ... #endif ``` At file level these blocks are self-spacing: a blank line is ensured before the `#if` and after the `#endif`, so directive sections remain separated without explicit `NewLine()` calls. `HashElse()` writes the `#else` directive at column zero between the two bodies: ```csharp using (writer.HashDefinesScope("NET48_OR_GREATER || PURVIEW_TELEMETRY_NON_NULLABLE")) { writer.Property("name", TypeIdentity.Create().AsTypeReference(), TypeDeclarationAccessibility.Public, options => options with { HasSetter = true, IncludeGeneratedAttributes = false }); writer.HashElse(); writer.Property("name", TypeIdentity.Create().MakeNullable(writer), TypeDeclarationAccessibility.Public, options => options with { HasSetter = true, IncludeGeneratedAttributes = false }); } ``` Emits: ```csharp #if NET48_OR_GREATER || PURVIEW_TELEMETRY_NON_NULLABLE public string name { get; set; } #else public string? name { get; set; } #endif ``` `EmptyScope()` returns a no-op scope so a block can be wrapped only when a guard requires it: ```csharp using var scope = wrapInExcludeLoggingGuard ? writer.EmptyScope() : writer.HashDefinesScope("EXCLUDE_PURVIEW_TELEMETRY_LOGGING"); ``` ### Pragma warning suppression `PragmaDisable` writes a single `#pragma warning disable` directive at column zero for one or more warning codes. At file level it is self-spacing (blank lines are ensured around the directive): ```csharp writer.PragmaDisable("CS8625", "CS0618"); // #pragma warning disable CS8625 CS0618 ``` For a scoped disable that restores the warnings when the scope is disposed, use `OpenPragmasScope`: ```csharp using (writer.OpenPragmasScope("CS0618")) { writer.Line("ObsoleteCall();"); } // #pragma warning disable CS0618 // ObsoleteCall(); // #pragma warning restore CS0618 ``` The full header pattern — nullable directive, conditional `#nullable enable`, and a disabled warning — can be expressed entirely through the structured APIs (the file-level directives are self-spacing, so no explicit `NewLine()` calls are needed): ```csharp writer.AutoGeneratedHeader(nullableDirective: NullableDirectiveMode.Disable); writer.HashDefines("!NET48_OR_GREATER && !PURVIEW_TELEMETRY_NON_NULLABLE", hashWriter => hashWriter.Line("#nullable enable")); writer.PragmaDisable("CS8625"); writer.FileScopedNamespace("Purview.Telemetry"); ``` Emits: ```csharp // // This code was generated by ExampleGenerator (version 1.0.0). // Changes to this file will be lost when the source generator runs again. #if !NET48_OR_GREATER && !PURVIEW_TELEMETRY_NON_NULLABLE #nullable enable #endif #pragma warning disable CS8625 namespace Purview.Telemetry; ``` The generator version in the header and the `GeneratedCode` attribute comes from the `GenerationSettings` used to create the writer. When settings are created via `GenerationSettings.Create()`, the full assembly informational version is used, so any pre-release suffix (such as `-alpha`) and build metadata (such as `+commit-hash`) are preserved rather than being reduced to the numeric assembly version. ### Conditional compilation returns `NetConditionalReturn` writes a `return` for an interpolated string using the best invariant-culture API on each target framework, guarded by `#if NET`: ```csharp writer.Method( "Format", TypeIdentity.Create().AsTypeReference(), TypeDeclarationAccessibility.Public, null, body => body.NetConditionalReturn("Value: {_value}") ); ``` Emits: ```csharp #if NET return string.Create(global::System.Globalization.CultureInfo.InvariantCulture, $"Value: {_value}"); #else return global::System.FormattableString.Invariant($"Value: {_value}"); #endif ``` ## Default accessibility `CodeWriter` applies a default accessibility for each member kind when a declaration does not specify one. Set the defaults on `GenerationSettings` (to apply across a generation) or on the writer itself (to override per writer). Each value is `null`-able, so setting a kind back to `null` omits the modifier entirely. | Setting | Default | |---|---| | `DefaultTypeAccessibility` | `Public` | | `DefaultPropertyAccessibility` | `Public` | | `DefaultPropertyGetterAccessibility` | `Public` | | `DefaultPropertySetterAccessibility` | `Public` | | `DefaultFieldAccessibility` | `Private` | | `DefaultMethodAccessibility` | `Public` | | `DefaultConstructorAccessibility` | `Public` | | `DefaultIndexerAccessibility` | `Public` | | `DefaultOperatorAccessibility` | `Public` | ```csharp var writer = generationContext.CreateCodeWriter(); writer.Field("_total", TypeReference.Create()); // private int _total; (DefaultFieldAccessibility) writer.Property("Total", TypeReference.Create()); // public decimal Total { get; } ``` An explicit accessibility always wins over the default: ```csharp writer.Property("Total", TypeReference.Create(), TypeDeclarationAccessibility.Internal); // internal decimal Total { get; } ``` Accessor (getter/setter) defaults are emitted only when they are **more restrictive** than the property's own accessibility — C# forbids an accessor modifier that is equal to or more permissive than the property (CS0273). With the public defaults, a public property keeps bare `{ get; set; }`: ```csharp writer.DefaultPropertySetterAccessibility = TypeDeclarationAccessibility.Private; writer.Property("Name", TypeReference.Create(), TypeDeclarationAccessibility.Public, options => options with { HasSetter = true }); // public string Name { get; private set; } ``` ## Guidance - Prefer the minimal overloads with a `configure` callback over constructing `*DeclarationOptions` values manually — the `PreferMinimalCodeWriterOverloadAnalyzer` (PSGFR20) flags the verbose form. - Prefer structured declarations and statements over raw text — `PreferStructuredCodeWriterApiAnalyzer` (PSGFR18) and `PreferStructuredCodeWriterStatementAnalyzer` (PSGFR19) flag raw emission. - Prefer `IfBlock`/`ElseIf`/`Else` over generic block methods for conditional content — the `PreferStructuredCodeWriterIfBlockAnalyzer` (PSGFR23) flags `OpenBlockScope`/`OpenBlock` headers that write an `if`, `else if`, or `else` statement, and its code fix rewrites them. - Always consume scope-returning methods with `using` (PSGFR17). - Never embed a `CodeWriter` in a string. The XML block-writing methods (`XmlCode`, `XmlSummary`, `XmlCodeBlock`, ...) return the `CodeWriter`, so interpolating or concatenating them implicitly calls `ToString()` and dumps the writer's possibly-incomplete buffer — `CodeWriterInStringContextAnalyzer` (PSGFR29) flags it. For inline XML tags in documentation text, use the static helpers instead: `XmlCommentWriter.XmlInlineCode("value")` → `value`, or `XmlInlineCodeBlock(...)` for a `` block; `writer.XmlCode(...)` writes to the buffer and returns the writer, it does not produce a string. When the writer is genuinely complete, call `ToString()` explicitly. - Never write an open generic as a type. A `TypeIdentity` with a generic arity but no type arguments renders a placeholder such as `List<>` or `List<,>`, which is invalid C# in a type position (base type, return type, parameter, property, ...). `CodeWriter` rejects it when it is emitted as a type — construct it with `MakeGeneric(...)` first. An arity mismatch (`MakeGeneric` supplying the wrong number of arguments) is also rejected, so the mismatch surfaces as a clear exception rather than corrupted generated code. - Keep every value emitted through the structured API so layout stays deterministic and the analyzers can guide callers back to the best practice. ## Samples The [`SourceGeneratorFramework.ExampleGenerator`](https://github.com/purview-dev/sourcegenerator-framework/blob/main/src/src/SourceGeneratorFramework.ExampleGenerator) reference implementation demonstrates these APIs end-to-end, including the `CodeWriterSampleGenerator`, which compiles a best-practice sample class for every `[GenerateCodeWriterSample]` target. # Getting Started > dotnet add package Purview.SourceGeneratorFramework # Getting Started ## Install ```bash dotnet add package Purview.SourceGeneratorFramework ``` Reference the package from a Roslyn source generator project: ```xml netstandard2.0 true true ``` ## Referencing a generator project Roslyn must receive both a source-generator assembly and its framework runtime dependency as analyzer inputs. Use an analyzer project reference: ```xml ``` The Purview SDK automatically invokes `GetSourceGeneratorAnalyzerFiles`, which returns both the generator and its framework dependency without adding either file to the consuming application's runtime references. Specifying `Targets="GetSourceGeneratorAnalyzerFiles"` explicitly remains supported but is not required. ### Referencing a generator from its test project A test project can need the source-generator project in two different roles at the same time: - as an analyzer, so the generator runs against the test project and its generated attributes and other types can be used directly by test source files; and - as a normal assembly reference, so the test code can name and instantiate the generator type through `Purview.SourceGeneratorFramework.Testing`. Add two project references with deliberately different metadata: ```xml ``` Do not put `OutputItemType="Analyzer"` on the normal reference. The Purview SDK automatically uses `GetSourceGeneratorAnalyzerFiles` for the analyzer reference and supplies the generator's runtime dependencies to Roslyn. Because the second reference is a normal assembly reference, the generator's Roslyn dependencies also become visible to the test compilation. For a multi-target test project, build the generator against the Roslyn version that supports its API usage and is compatible with the oldest test target. This framework is built against Roslyn 5.0 (C# 14 / .NET 10 generation), which ships `net8.0` and `net9.0` package assets, so a `.NET 8`–`.NET 10` test matrix still loads it. Compiler hosts must be Roslyn 5.0 or later (`.NET 10` SDK / Visual Studio 2026). Do not centrally pin `System.Collections.Immutable` to a newer runtime version merely to make the generator load. ## Write a generator Implement `IIncrementalGenerator` and use the framework helpers to build a pipeline: ```csharp using Microsoft.CodeAnalysis; using Purview.SourceGeneratorFramework.Helpers; using Purview.SourceGeneratorFramework.Models; [Generator] public sealed class MyGenerator : IIncrementalGenerator { static readonly TypeIdentity AttributeType = new("MyAttribute", "MyNamespace"); public void Initialize(IncrementalGeneratorInitializationContext context) { var contextProvider = IncrementalPipeline.DefaultGenerationContextValueProvider(context); var targets = IncrementalPipeline.ForAttributeWithMetadataName( context, AttributeType, static (ctx, ct) => ctx.TargetSymbol.Name ); context.RegisterSourceOutput( targets.CombineWithContext(contextProvider), static (spc, pair) => { var (name, generationContext) = pair; var writer = generationContext.CreateCodeWriter(); writer.AutoGeneratedHeader(); writer.FileScopedNamespace("MyNamespace"); writer.Class( name, TypeDeclarationAccessibility.Public, options => options with { IsStatic = true }, body => body.Comment("generated content") ); spc.AddSource($"{name}.g.cs", writer.ToString()); } ); } } ``` See the [`SourceGeneratorFramework.ExampleGenerator`](https://github.com/purview-dev/sourcegenerator-framework/blob/main/src/src/SourceGeneratorFramework.ExampleGenerator) reference implementation for a complete end-to-end sample, and [`SourceGeneratorFramework.ExampleGenerator.CodeFixers`](https://github.com/purview-dev/sourcegenerator-framework/blob/main/src/src/SourceGeneratorFramework.ExampleGenerator.CodeFixers) for a companion code-fix sample. ## Test the generator Reference the testing package and run the generator against a snippet of C#: ```bash dotnet add package Purview.SourceGeneratorFramework.Testing ``` ```csharp using Purview.SourceGeneratorFramework.Testing; public class MyGeneratorTests { [Test] public async Task GeneratesExpectedSource() { var source = """ [MyNamespace.MyAttribute] public partial class MyClass { } """; var runner = new SourceGeneratorTestRunner(); var result = await runner.RunAsync(source); result.AssertNoCompilationErrors(); var generated = result.AssertSingleGeneratedSource(); } } ``` Use the TUnit integration for ready-made test base classes and fluent assertions: ```bash dotnet add package Purview.SourceGeneratorFramework.Testing.TUnit ``` ## Next pages - [Source Generator & Analyser Best Practices](../guide/) - [CodeWriter structured API reference](../code-writer/) - [Incremental Pipeline](../incremental-pipeline/) - [Testing](../testing/) - [Testing with TUnit](../testing-tunit/) - [Step-Cache Tests](../step-cache-tests/) - [Packaging](../packaging/) # Source Generator & Analyser Best Practices > --- # Source Generator & Analyser Best Practices > Practical guidance for writing Roslyn analysers and incremental source generators that remain fast, deterministic, cache-friendly, IDE-compatible, and safe to distribute. --- ## Contents - [1. Core Principles](https://github.com/purview-dev/sourcegenerator-framework/blob/main/docs/wiki#1-core-principles) - [2. Analyser or Source Generator?](https://github.com/purview-dev/sourcegenerator-framework/blob/main/docs/wiki#2-analyser-or-source-generator) - [3. Choosing an Analyser Action](https://github.com/purview-dev/sourcegenerator-framework/blob/main/docs/wiki#3-choosing-an-analyser-action) - [4. Syntax vs Symbol vs Operation](https://github.com/purview-dev/sourcegenerator-framework/blob/main/docs/wiki#4-syntax-vs-symbol-vs-operation) - [5. Analyser Best Practices](https://github.com/purview-dev/sourcegenerator-framework/blob/main/docs/wiki#5-analyser-best-practices) - [6. Incremental Generator Golden Rules](https://github.com/purview-dev/sourcegenerator-framework/blob/main/docs/wiki#6-incremental-generator-golden-rules) - [7. Pipeline Value Equality](https://github.com/purview-dev/sourcegenerator-framework/blob/main/docs/wiki#7-pipeline-value-equality) - [8. Designing the Incremental Pipeline](https://github.com/purview-dev/sourcegenerator-framework/blob/main/docs/wiki#8-designing-the-incremental-pipeline) - [9. Syntax Discovery](https://github.com/purview-dev/sourcegenerator-framework/blob/main/docs/wiki#9-syntax-discovery) - [10. `Collect`, `Combine`, and Invalidation](https://github.com/purview-dev/sourcegenerator-framework/blob/main/docs/wiki#10-collect-combine-and-invalidation) - [11. Diagnostics](https://github.com/purview-dev/sourcegenerator-framework/blob/main/docs/wiki#11-diagnostics) - [12. Output Generation](https://github.com/purview-dev/sourcegenerator-framework/blob/main/docs/wiki#12-output-generation) - [13. Testing Incrementally](https://github.com/purview-dev/sourcegenerator-framework/blob/main/docs/wiki#13-testing-incrementally) - [14. Roslyn Version Compatibility](https://github.com/purview-dev/sourcegenerator-framework/blob/main/docs/wiki#14-roslyn-version-compatibility) - [15. Visual Studio, .NET SDK, and Rider](https://github.com/purview-dev/sourcegenerator-framework/blob/main/docs/wiki#15-visual-studio-net-sdk-and-rider) - [16. Multi-Version Roslyn Packaging](https://github.com/purview-dev/sourcegenerator-framework/blob/main/docs/wiki#16-multi-version-roslyn-packaging) - [17. `Microsoft.CodeAnalysis.Analysers`](https://github.com/purview-dev/sourcegenerator-framework/blob/main/docs/wiki#17-microsoftcodeanalysisanalysers) - [18. Recommended Project Configuration](https://github.com/purview-dev/sourcegenerator-framework/blob/main/docs/wiki#18-recommended-project-configuration) - [19. Extension Class Conventions](https://github.com/purview-dev/sourcegenerator-framework/blob/main/docs/wiki#19-extension-class-conventions) - [20. Review Checklist](https://github.com/purview-dev/sourcegenerator-framework/blob/main/docs/wiki#20-review-checklist) --- # 1. Core Principles The most important rules are: 1. **Use an analyser to validate user code.** 2. **Use an incremental generator to generate code.** 3. **Use `ForAttributeWithMetadataName` for attribute-driven generators.** 4. **Remove Roslyn objects from the incremental pipeline as early as possible.** 5. **Every value crossing a pipeline boundary should have meaningful value equality.** 6. **Prefer many small incremental stages over one large transform.** 7. **Keep broad inputs such as `Compilation` away from downstream generation.** 8. **Generate deterministic output.** 9. **Compile against the oldest Roslyn API version you actually need.** 10. **Test caching, not just generated text.** The guiding principle for an incremental generator is: > **Extract semantic information once, convert it into a small value model, and make everything downstream operate only on that value model.** --- # 2. Analyser or Source Generator? Analysers and generators have different responsibilities. A `DiagnosticAnalyser` answers: > Is the source code valid according to this library's rules? An `IIncrementalGenerator` answers: > Given valid source code, what source should be generated? ## Decision Table | Requirement | Prefer | Reason | | --- | --- | --- | | Require a class to be `partial` | Analyser | User-code contract violation | | Require an attribute on a declaration | Analyser | User-code contract violation | | Validate a method signature | Analyser | Semantic validation | | Reject unsupported property types | Analyser | Better IDE feedback | | Detect invalid attribute arguments | Analyser | Natural diagnostic | | Detect unsupported API usage | Analyser | Operation analysis | | Offer an automatic fix | Analyser + `CodeFixProvider` | Code fixes operate on diagnostics | | Generate members for a marked class | Incremental generator | Generation | | Generate serializers/validators/mappers | Incremental generator | Generation | | Generate a registry from discovered types | Incremental generator | Generation | | Read an additional schema file and generate C# | Incremental generator | Generation | | Report an unexpected internal generation failure | Generator diagnostic | Generation-specific failure | | Validate generator-only external input | Generator diagnostic may be appropriate | Analyser may not see equivalent input | Prefer: ```text User Source │ ├── Analyser │ ├── discovers relevant source │ ├── validates the contract │ ├── reports diagnostics │ └── optionally provides code fixes │ └── Incremental Generator ├── discovers relevant source ├── extracts semantic values ├── creates equatable models └── generates deterministic source ``` A useful shorthand is: > **Analysers protect the contract. Generators implement the contract.** --- # 3. Choosing an Analyser Action Use the narrowest analyser API that directly represents the thing being analysed. Do not start with a broad syntax scan if Roslyn already exposes the concept as a symbol or operation. ## Analyser Action Decision Matrix | API | Use when | Examples | Recommendation | | --- | --- | --- | --- | | `RegisterSyntaxNodeAction` | Exact source syntax matters | Modifier presence, declaration form | Use for syntax rules | | `RegisterSymbolAction` | A declaration's semantic meaning matters | Accessibility, attributes, implemented interfaces | Preferred for declaration rules | | `RegisterOperationAction` | Executable behaviour/API usage matters | Invocation, assignment, conversion, object creation | Preferred for semantic usage rules | | `RegisterOperationBlockStartAction` | Multiple operations inside one body need shared state | Track resource use throughout a method | Use for stateful method analysis | | `RegisterOperationBlockAction` | Whole executable body must be analysed | Final body-level validation | Prefer narrower actions where possible | | `RegisterCodeBlockStartAction` | Syntax-oriented body analysis needs state | Stateful syntax rules | Less common than operation-block analysis | | `RegisterCodeBlockAction` | Entire syntax code block matters | Body-level syntax rules | Use sparingly | | `RegisterSymbolStartAction` | A symbol and its members must be analysed together | Type-wide analysis across members | Powerful but relatively expensive | | Symbol end action | Result depends on all child/member analysis | Report once for entire type | Register from symbol-start | | `RegisterCompilationStartAction` | Expensive semantic setup should happen once | Resolve known framework/library symbols | Good initialization boundary | | Compilation end action | A result genuinely depends on the entire compilation | Global collision/aggregate rule | Avoid unless necessary | | `RegisterAdditionalFileAction` | Analyze `AdditionalFiles` | Config/schema validation | Correct abstraction | | `RegisterSemanticModelAction` | Analysis genuinely applies to an entire semantic model | Rare tree-wide semantic rule | Usually too broad | | `RegisterSyntaxTreeAction` | Entire raw syntax tree matters | File header / file-level syntax | Prefer node actions when possible | --- # 4. Syntax vs Symbol vs Operation The most common analyser design decision is choosing between: - syntax; - symbols; - operations. ## Quick Decision ```text Does exact source spelling/structure matter? │ ├── Yes ──► Syntax │ └── No │ ├── Is this a declaration? │ └── Yes ──► Symbol │ └── Is this executable behaviour? └── Yes ──► Operation ``` --- ## Syntax Use syntax when the literal structure of the user's source matters. Examples: - is `partial` explicitly present? - did the user write a primary constructor? - is the namespace file-scoped? - was an explicit modifier specified? - is a declaration syntactically structured in a particular way? Example: ```csharp context.RegisterSyntaxNodeAction( AnalyzeClass, SyntaxKind.ClassDeclaration ); ``` Syntax is often the cheapest solution when no semantic information is required. Do not ask the semantic model a question that can be answered directly from syntax. --- ## Symbols Use symbols when analysing declarations semantically. Examples: - does this type implement `IDisposable`? - does this member have a particular attribute? - what is the method return type? - what is the property's accessibility? - what generic type arguments are present? - is this type abstract? - which containing namespace owns this type? Example: ```csharp context.RegisterSymbolAction( AnalyzeNamedType, SymbolKind.NamedType ); ``` When comparing symbols: ```csharp SymbolEqualityComparer.Default.Equals(left, right) ``` should normally be used rather than reference equality. --- ## Operations Use `IOperation` when analysing executable semantics. Examples: - method invocation; - constructor invocation; - assignment; - conversion; - property access; - field access; - argument passing; - return values; - `await`; - binary/unary operations. For example: ```csharp context.RegisterOperationAction( AnalyzeInvocation, OperationKind.Invocation ); ``` Then: ```csharp static void AnalyzeInvocation(OperationAnalysisContext context) { var invocation = (IInvocationOperation)context.Operation; var method = invocation.TargetMethod; // Semantic method information is already available. } ``` This is normally preferable to: ```csharp context.RegisterSyntaxNodeAction( AnalyzeInvocation, SyntaxKind.InvocationExpression ); ``` followed by: ```csharp context.SemanticModel.GetSymbolInfo(...) ``` for every invocation. --- ## Common Operation Kinds | Requirement | Operation | | --- | --- | | Method invocation | `OperationKind.Invocation` | | Constructor invocation | `OperationKind.ObjectCreation` | | Assignment | `OperationKind.SimpleAssignment` | | Compound assignment | compound assignment operation kinds | | Argument validation | `OperationKind.Argument` | | Property access | `OperationKind.PropertyReference` | | Field access | `OperationKind.FieldReference` | | Conversion | `OperationKind.Conversion` | | Return expression | `OperationKind.Return` | | Await | `OperationKind.Await` | | Binary expression | `OperationKind.Binary` | | Unary expression | `OperationKind.Unary` | Use the semantic operation rather than reconstructing equivalent information from syntax whenever possible. --- # 5. Analyser Best Practices ## Enable Concurrent Execution Analysers should normally enable concurrent execution: ```csharp public override void Initialize(AnalysisContext context) { context.EnableConcurrentExecution(); context.ConfigureGeneratedCodeAnalysis( GeneratedCodeAnalysisFlags.None ); // Register actions. } ``` Analyser callbacks can execute concurrently. Avoid shared mutable state. --- ## Configure Generated Code Explicitly Do not leave generated-code handling implicit. For most library contract analysers: ```csharp context.ConfigureGeneratedCodeAnalysis( GeneratedCodeAnalysisFlags.None ); ``` is appropriate. Only inspect generated code if the analyser explicitly needs to. --- ## Resolve Known Types Once If an analyser needs to repeatedly compare against known framework or library types, resolve them during compilation start. ```csharp context.RegisterCompilationStartAction(static context => { var targetType = context.Compilation.GetTypeByMetadataName( "MyLibrary.SomeType" ); if (targetType is null) return; context.RegisterOperationAction( c => AnalyzeInvocation(c, targetType), OperationKind.Invocation ); }); ``` This is a good reason to use `CompilationStart`. Do not use `CompilationStart` merely to enumerate the entire compilation. --- ## Prefer Narrow Registration Prefer: ```csharp context.RegisterOperationAction( AnalyzeInvocation, OperationKind.Invocation ); ``` over an action that sees every operation. Prefer: ```csharp context.RegisterSyntaxNodeAction( AnalyzeClass, SyntaxKind.ClassDeclaration ); ``` over scanning an entire `SyntaxTree`. --- # 6. Incremental Generator Golden Rules Implement: ```csharp IIncrementalGenerator ``` rather than the legacy: ```csharp ISourceGenerator ``` But simply implementing `IIncrementalGenerator` does **not** make a generator meaningfully incremental. Incrementally depends on the equality behaviour of the values flowing through the pipeline. ## The Golden Rule > **Pipeline values must be immutable and value-equatable.** Roslyn needs to determine: ```text Did this pipeline stage produce the same logical value as last time? ``` If the answer is yes, Roslyn can stop executing downstream stages and reuse cached results. --- # 7. Pipeline Value Equality ## Pipeline Red List These should not normally survive into your generator model. | Type | Verdict | Why | | --- | --- | --- | | `ISymbol` | ❌ Never retain | Not suitable for pipeline equality; can retain old compilations | | `INamedTypeSymbol` | ❌ Never retain | Same problem as `ISymbol` | | `IMethodSymbol` | ❌ Never retain | Same problem as `ISymbol` | | `IPropertySymbol` | ❌ Never retain | Same problem as `ISymbol` | | `Compilation` | ❌ Do not propagate | Huge semantic graph and broad invalidation source | | `SemanticModel` | ❌ Do not propagate | Bound to compilation/tree | | `IOperation` | ❌ Do not propagate | Compiler semantic graph object | | `SyntaxTree` | ❌ Do not propagate | Changes with source edits | | `SyntaxNode` | ⚠ Remove ASAP | Usually loses equality after edits to its tree | | `Location` | ⚠ Remove ASAP | Same incrementally problem as syntax | | `AdditionalText` | ⚠ Project immediately | Host/compiler input object | | `T[]` | ❌ Avoid in models | Reference equality | | `List` | ❌ Avoid in models | Mutable and reference equality | | `ImmutableArray` | ⚠ Wrap/compare explicitly | Immutable but not sequence-value-equatable for model equality | | Mutable class | ❌ Avoid | Reference equality unless explicitly implemented | --- ## Good Model ```csharp internal sealed record TypeModel( string Namespace, string Name, string FullyQualifiedName, Accessibility Accessibility, EquatableArray Properties ); internal sealed record PropertyModel( string Name, string FullyQualifiedTypeName, bool IsNullable ); ``` --- ## Bad Model ```csharp internal sealed record TypeModel( INamedTypeSymbol Symbol, Compilation Compilation, Location Location, ImmutableArray Properties ); ``` Making the outer object a `record` does not magically make its members suitable for incremental equality. --- ## `ImmutableArray` Is Not Enough `ImmutableArray` solves: > Can this collection be mutated? It does not automatically solve: > Do two separately-created collections containing equivalent elements compare as the same sequence for my pipeline model? These are different problems. For incremental models, prefer something such as: ```csharp EquatableArray ``` with sequence-based equality. Conceptually: ```csharp internal readonly struct EquatableArray : IEquatable> { private readonly ImmutableArray _items; public bool Equals(EquatableArray other) { if (_items.Length != other._items.Length) return false; return _items .AsSpan() .SequenceEqual(other._items.AsSpan()); } public override bool Equals(object? obj) => obj is EquatableArray other && Equals(other); public override int GetHashCode() { var hash = new HashCode(); foreach (var item in _items) hash.Add(item); return hash.ToHashCode(); } } ``` The precise implementation can vary. The important requirement is: ```text same contents => equal pipeline value ``` --- # 8. Designing the Incremental Pipeline Think of every transformation as a cache checkpoint. Prefer: ```text Roslyn Input │ ▼ Cheap discovery │ ▼ Semantic extraction │ ▼ Small equatable model │ ▼ Validation/transformation │ ▼ Generation model │ ▼ Source output ``` Do not do: ```text Roslyn Input │ ▼ Giant transform containing symbols + syntax + compilation │ ▼ Generate everything ``` --- ## Project Early The semantic transform should usually be the boundary where Roslyn objects disappear. Example: ```csharp static TypeModel CreateModel( GeneratorAttributeSyntaxContext context, CancellationToken cancellationToken ) { var symbol = (INamedTypeSymbol)context.TargetSymbol; return new TypeModel( Namespace: symbol.ContainingNamespace.ToDisplayString(), Name: symbol.Name, FullyQualifiedName: symbol.ToDisplayString( SymbolDisplayFormat.FullyQualifiedFormat ), Accessibility: symbol.DeclaredAccessibility ); } ``` Everything downstream should receive `TypeModel`, not `INamedTypeSymbol`. --- ## Prefer Static Lambdas Prefer: ```csharp .Select(static (value, cancellationToken) => { return Transform(value, cancellationToken); }); ``` Static callbacks prevent accidental capture of generator instance state. Generator instances should not be treated as application services or state containers. --- ## Honour Cancellation For non-trivial transformations: ```csharp .Select(static (value, cancellationToken) => { cancellationToken.ThrowIfCancellationRequested(); return Transform(value, cancellationToken); }); ``` Pass cancellation tokens into Roslyn APIs that accept them. --- ## Split Transformations Prefer: ```text Syntax ↓ Symbol projection ↓ Type model ↓ Property models ↓ Generation model ↓ Output ``` over: ```text Syntax ↓ Do absolutely everything ↓ Output ``` More meaningful boundaries give Roslyn more opportunities to short-circuit downstream processing. --- # 9. Syntax Discovery ## Prefer `ForAttributeWithMetadataName` Attribute-driven generation should normally start with: ```csharp context.SyntaxProvider.ForAttributeWithMetadataName( fullyQualifiedMetadataName: "MyLibrary.GenerateAttribute", predicate: static (node, _) => node is TypeDeclarationSyntax, transform: static (context, cancellationToken) => CreateModel(context, cancellationToken) ); ``` Advantages include: - highly optimized discovery; - alias support; - direct `TargetSymbol`; - matching `AttributeData`; - obvious user intent; - easier analyser integration. For marker-attribute generators, this should be the default. --- ## Use `CreateSyntaxProvider` When Syntax Is Actually the Trigger Use: ```csharp context.SyntaxProvider.CreateSyntaxProvider(...) ``` when there is no appropriate marker attribute. Examples: - syntax-driven DSL; - a generator intentionally driven by a language construct; - a pattern that cannot reasonably use an attribute. The predicate must be cheap. Good: ```csharp predicate: static (node, _) => node is ClassDeclarationSyntax { AttributeLists.Count: > 0 } ``` Bad: ```csharp predicate: static (node, _) => { // expensive walking // semantic work // allocations // string construction return true; } ``` The predicate runs extremely frequently. Semantic work belongs in the transformation callback. --- ## Avoid Indirect Discovery Avoid designs that require discovering: - every indirect implementation of an interface; - every indirect subclass; - inherited marker attributes through arbitrary hierarchies; - every type in a compilation followed by manual filtering. A change high in a type hierarchy can invalidate a large arbitrary portion of the compilation. Prefer explicit intent: ```csharp [GenerateSchema] partial class Customer { } ``` over: ```text Generate everything somewhere downstream of IBaseSchemaThing ``` --- # 10. `Collect`, `Combine`, and Invalidation ## `Collect()` `Collect()` transforms: ```csharp IncrementalValuesProvider ``` into roughly: ```csharp IncrementalValueProvider> ``` This changes invalidation scope. Before: ```text A ──► output A B ──► output B C ──► output C ``` After collection: ```text A ─┐ B ─┼──► [A,B,C] ──► output C ─┘ ``` Changing `B` changes the aggregate `[A,B,C]`. --- ## Prefer Per-Item Output Prefer: ```csharp context.RegisterSourceOutput( models, static (context, model) => Emit(context, model) ); ``` instead of: ```csharp context.RegisterSourceOutput( models.Collect(), static (context, models) => { foreach (var model in models) Emit(context, model); } ); ``` unless the generation genuinely requires the complete set. --- ## Good Uses of `Collect()` Use `Collect()` when generating something intrinsically global: - one registry containing every handler; - one lookup containing every generated type; - duplicate-name detection across all targets; - one aggregate switch; - one generated dependency map. A useful design is: ```text ┌──► Per-type source Type Models ────────┤ │ └──► Collect() │ ▼ Global registry ``` Only the registry should pay the global invalidation cost. --- ## `Combine()` Use `Combine()` when one output logically depends on two providers. Example: ```csharp var generationInput = typeModels.Combine(generatorOptions); ``` That means: ```text type changed ───────┐ ├──► generation invalidated option changed ─────┘ ``` This is correct if either input should regenerate the output. --- ## Be Very Careful Combining `CompilationProvider` This: ```csharp models.Combine(context.CompilationProvider) ``` is often an incremental performance smell. Almost any semantic change can replace the compilation. If possible, project the compilation into the tiny fact you actually need: ```csharp var capabilities = context.CompilationProvider .Select(static (compilation, _) => new CompilationCapabilities( HasRequiredType: compilation.GetTypeByMetadataName( "MyLibrary.RequiredType" ) is not null ) ); ``` Then: ```csharp models.Combine(capabilities) ``` At least downstream equality can now short-circuit when the relevant capability did not change. --- ## `WithComparer()` Roslyn provides: ```csharp .WithComparer(...) ``` when default equality is insufficient. Example: ```csharp provider.WithComparer( MyModelComparer.Instance ); ``` Use this when your logical equality differs from the default implementation. Do not use it as a way to justify retaining large compiler objects inside the model. This is suspicious: ```csharp record Model( INamedTypeSymbol Symbol, Compilation Compilation ); ``` followed by an elaborate comparer. The better solution is usually to redesign `Model`. --- # 11. Diagnostics ## Prefer a Separate Analyser Normal user validation belongs in a `DiagnosticAnalyser`. Benefits include: - immediate IDE feedback; - independent execution from generation; - easier testing; - code-fix support; - simpler incremental generator pipelines. --- ## Generator Diagnostics Are Still Valid Generator diagnostics make sense for things such as: - malformed additional files; - invalid generator-only configuration; - conflicting generated output discovered only during generation; - failures that cannot naturally be expressed by a separate analyser. Do not turn the generator pipeline into an analyser pipeline by default. --- ## Blocking vs Non-Blocking Generator Diagnostics `GeneratorResult.ShouldProcess` decides whether the output stage runs for a target. It is `true` when the result carries a value and none of its carried `ReportableDiagnostic` diagnostics are blocking. Whether a diagnostic blocks is an explicit, per-diagnostic decision (`ReportableDiagnostic.IsBlocking`), independent of its severity. An `Error`-severity diagnostic can still allow generation to continue when the generated code helps the developer fix the problem — for example, emitting an abstract base class alongside an error for a missing override, so the user can see what to implement: ```csharp var diagnostic = ReportableDiagnostic.Create( MissingOverride, isBlocking: false, // report the error, but keep generating symbol, symbol.Name, "Execute" ); return GeneratorResult.Create(model, diagnostic); ``` A blocking diagnostic (`isBlocking: true`) stops generation for that target while still being reported. Prefer blocking diagnostics for genuine contract violations that would produce misleading output; prefer non-blocking diagnostics when the partial output is still useful. --- ## Location Handling Analysers should report diagnostics on the most useful user-authored `Location`. Generators should avoid keeping `Location` inside long-lived pipeline models. If a generator absolutely requires source position information, convert it into a value model: ```csharp internal readonly record struct SourceLocationModel( string FilePath, int Start, int Length ); ``` But even this should only be carried downstream if generation actually depends on the location. --- # 12. Output Generation ## Output Must Be Deterministic For the same generator model: ```text input model ↓ identical generated source ``` Avoid: - current timestamps; - random GUIDs; - process IDs; - machine-specific paths; - unordered dictionary output; - machine environment variables; - current culture affecting generation. --- ## Deterministic Hint Names Good: ```csharp context.AddSource( $"{model.HintName}.g.cs", source ); ``` Bad: ```csharp context.AddSource( $"{Guid.NewGuid():N}.g.cs", source ); ``` Hint names must be: - deterministic; - unique within the generator; - stable when irrelevant source changes. --- ## Prefer Text Generation Do not build a complete Roslyn syntax tree merely to generate source unless there is a strong reason. For generator output, a small code writer or structured string builder is generally easier and faster. Avoid repeatedly doing: ```csharp syntax.NormalizeWhitespace().ToFullString() ``` for large generated trees. Generated output may use C# 14 features — extension-member blocks (`extension(...)`, via `CodeWriter.ExtensionBlockScope`), the `field` keyword, collection expressions — when the target compilation supports them. The framework is built against Roslyn 5.x, so its generators may emit C# 14 output; consumers need a matching compiler (`.NET 10` SDK / Roslyn 5.0 or later) to compile it. Gate any newer-than-baseline features on `GenerationSettings.LanguageVersion` when a generator must also serve older hosts. --- ## Post-Initialization Output Use: ```csharp RegisterPostInitializationOutput ``` for source that is constant regardless of the user's compilation. Examples: - marker attributes; - fixed helper attributes; - static support types. Example: ```csharp context.RegisterPostInitializationOutput( static context => { context.AddSource( "GenerateAttribute.g.cs", SourceText.From( """ // namespace MyLibrary; [global::System.AttributeUsage( global::System.AttributeTargets.Class, AllowMultiple = false, Inherited = false)] internal sealed class GenerateAttribute : global::System.Attribute { } """, Encoding.UTF8 ) ); } ); ``` --- # 13. Testing Incrementally Snapshot-testing generated source is not sufficient. A generator can generate perfectly correct code while defeating almost all incremental caching. Test both: ```text Correctness + Incrementally ``` --- ## Test Cases At minimum test: - first execution produces expected output; - identical second execution is cached; - unrelated source changes remain cached; - changing one target only invalidates that target; - changing one property only invalidates dependent stages; - deleting a target removes its output; - renaming a target changes the expected hint/source; - changing global generator options invalidates appropriate output; - changing an additional file invalidates only dependent output; - global registry generation invalidates when expected. --- ## Track Incremental Generator Steps Create the generator driver with tracking enabled. For example: ```csharp var driverOptions = new GeneratorDriverOptions( disabledOutputs: IncrementalGeneratorOutputKind.None, trackIncrementalGeneratorSteps: true ); ``` Inspect tracked output reasons such as: ```text New Modified Unchanged Cached Removed ``` The exact reason expected depends on the stage and test scenario. The important point is that tests should prove: > An unrelated edit does not rerun expensive downstream generation. ### Framework support The framework's testing packages make step-cache tests first-class. `SourceGeneratorTestRunner.RunIncrementalAsync` runs one shared driver over a sequence of source sets with step tracking enabled, and every pipeline helper assigns a tracking name so tests can reference individual stages. ```csharp var result = await new SourceGeneratorTestRunner().RunIncrementalAsync( [ new IncrementalRunInput([firstSources]), new IncrementalRunInput([changedSources]), ], options, cancellationToken ); await Assert.That(result.Runs[0]).AllStepsNew(); await Assert.That(result.Runs[1]).StepIsCached("ForAttribute_GenerateServiceAttribute"); await Assert.That(result.Runs[1]).StepIsModified("GetGenerationConfiguration"); ``` Assertions on `IncrementalCacheRun` (`AllStepsNew`, `AllStepsCachedOrUnchanged`, `StepIsCached`, `StepIsModified`, `HasStepReason`) plus `GetStepReasons()` cover the golden matrix. See [Step-Cache-Tests.md](../step-cache-tests/) for the full walkthrough and the canonical `StepCacheTests.cs` sample in the ExampleGenerator unit tests. --- # 14. Roslyn Version Compatibility The most important packaging rule is: > **The version of `Microsoft.CodeAnalysis.*` used to compile your analyser/generator establishes a minimum compiler-host API requirement.** The consumer's: ```xml ... ``` does not determine analyser compatibility. Analyser/generator code executes inside a compiler/IDE host. --- ## Roslyn / Visual Studio Compatibility Microsoft's published compatibility baseline is: | Roslyn package | Minimum Visual Studio | Language / .NET generation | | ---: | --- | --- | | 4.0.1 | VS 2022 17.0 | C# 10 / .NET 6 | | 4.1 | VS 2022 17.1 | C# 10 / .NET 6 | | 4.2 | VS 2022 17.2 | C# 10 / .NET 6 | | 4.3.1 | VS 2022 17.3 | C# 10 / .NET 6 | | 4.4 | VS 2022 17.4 | C# 11 / .NET 7 | | 4.5 | VS 2022 17.5 | C# 11 / .NET 7 | | 4.6 | VS 2022 17.6 | C# 11 / .NET 7 | | 4.7 | VS 2022 17.7 | C# 11 / .NET 7 | | 4.8 | VS 2022 17.8 | C# 12 / .NET 8 | | 4.9.2 | VS 2022 17.9 | C# 12 / .NET 8 | | 4.10 | VS 2022 17.10 | C# 12 / .NET 8 | | 4.11 | VS 2022 17.11 | C# 12 / .NET 8 | | 4.12 | VS 2022 17.12 | C# 13 / .NET 9 | | 4.13 | VS 2022 17.13 | C# 13 / .NET 9 | | 4.14 | VS 2022 17.14 | C# 13 / .NET 9 | | 5.0 | VS 2026 18.0 | C# 14 / .NET 10 | This table gives the **minimum documented Visual Studio host**. > **This framework is built against Roslyn 5.0.** The generator, analyzer, and testing assemblies in > `Purview.SourceGeneratorFramework*` are compiled against `Microsoft.CodeAnalysis` 5.x, so compiler > hosts that load them must be Roslyn 5.0 or later (`.NET 10` SDK / Visual Studio 2026 18.0). The > testing packages multi-target `net8.0`–`net10.0`; Roslyn 5.x ships `net8.0`/`net9.0` package assets, > so those test targets still load the test runner. Do not interpret it as: ```text net8.0 application = Roslyn 4.8 analyser ``` That is incorrect. --- ## Example A project may target: ```xml net8.0 ``` while being compiled by: ```text Visual Studio 2026 / Roslyn 5.x ``` An analyser compiled against Roslyn 5.0 may therefore work. The same `net8.0` project opened in: ```text Visual Studio 2022 17.8 / Roslyn 4.8 ``` cannot be assumed to load that Roslyn-5.0-based analyser. The application TFM did not change. The compiler host did. --- # 15. Visual Studio, .NET SDK, and Rider ## Safe Roslyn Baselines Choose the oldest Roslyn version containing the APIs you require. Typical baseline choices are: | Minimum tooling you intend to support | Maximum baseline you should normally compile against | | --- | ---: | | VS 2022 17.8 / initial .NET 8 generation | Roslyn 4.8 | | VS 2022 17.10 | Roslyn 4.10 | | VS 2022 17.12 / initial .NET 9 generation | Roslyn 4.12 | | VS 2022 17.14 | Roslyn 4.14 | | VS 2026 18.0 / initial .NET 10 generation | Roslyn 5.0 | If you compile against a later package, you have deliberately raised your minimum host requirement unless you have proven otherwise. --- ## .NET SDK The .NET SDK contains a compiler toolchain. Broad release alignment is: ```text .NET 8 / C# 12 ──► Roslyn 4.8 generation .NET 9 / C# 13 ──► Roslyn 4.12 generation .NET 10 / C# 14 ──► Roslyn 5.0 generation ``` However, SDK servicing and feature bands can contain later compiler versions. Therefore do not use: ```text TargetFramework == net10.0 ``` as proof that a particular Roslyn API is available to your analyser. Likewise: ```xml $(TargetFramework) ``` should not be used to choose the analyser binary. The relevant variable is the compiler host. --- ## Rider Rider supports: - Roslyn analysers; - source generators; - generated-source navigation; - analyser diagnostics; - analyser quick fixes; - source generator execution. However, JetBrains does not publish the same simple: ```text Rider Version => Maximum Microsoft.CodeAnalysis Version ``` matrix that Microsoft publishes for Visual Studio. Therefore: > **Do not invent a Rider/Roslyn version mapping.** If Rider support is part of your package contract: 1. choose a conservative Roslyn baseline; 2. test the oldest Rider version you support; 3. test `dotnet build`; 4. test Rider design-time generation; 5. test generated-source navigation; 6. test analysers and code fixes where applicable. Build-time compiler compatibility and Rider IDE integration should be tested independently. --- # 16. Multi-Version Roslyn Packaging This area is frequently misunderstood. ## NuGet Analyser Assets Are Not Normal TFM Assets Normal runtime/library assets support selection such as: ```text lib/net8.0/ lib/net9.0/ lib/net10.0/ ``` Analyser assets conventionally live under: ```text analysers/ dotnet/ cs/ MyGenerator.dll ``` This is not a general-purpose: ```text Roslyn 4.8 Roslyn 4.14 Roslyn 5.0 ``` selection mechanism. Do not place multiple Roslyn-targeted implementations into the ordinary analyser folder and expect NuGet to automatically choose the correct one. --- ## Strategy 1 — One Conservative Binary ### Recommended Default Compile against the oldest Roslyn version required by your implementation. For example: ```xml ``` Package: ```text analysers/ dotnet/ cs/ MyGenerator.dll ``` Advantages: - simplest; - predictable; - broadest compatibility; - works naturally with IDEs; - minimal packaging logic. Disadvantage: - cannot statically call newer Roslyn APIs. For most public generators, this is the correct approach. --- ## Strategy 2 — Raise the Package Baseline If a newer Roslyn feature materially improves the generator, it may be better to explicitly raise the minimum compiler version. For example: ```text MyGenerator 2.x Roslyn >= 4.8 MyGenerator 3.x Roslyn >= 5.0 ``` Document the minimum IDE/compiler requirement. This is much easier for users to reason about than hidden runtime selection. --- ## Strategy 3 — Separate Packages For significantly different implementations: ```text MyGenerator MyGenerator.Roslyn5 ``` can be reasonable. Advantages: - explicit; - predictable; - simple runtime behaviour. Disadvantages: - more packages; - more maintenance; - users must select correctly. --- ## Strategy 4 — MSBuild-Selected Binary Advanced packages can store binaries outside the automatically discovered analyser directory: ```text analysers/ roslyn4.8/ MyGenerator.dll roslyn5.0/ MyGenerator.dll buildTransitive/ MyGenerator.targets ``` Then a targets file can explicitly add exactly one: ```xml ``` depending on an intentionally selected compatibility band. Conceptually: ```xml ``` The difficult question is: > How is `MyGeneratorRoslynBand` determined reliably? There is no general NuGet analyser-asset negotiation equivalent to normal TFM selection. Do **not** use: ```xml $(TargetFramework) ``` for this. It identifies the application runtime target, not the compiler host. Using: ```xml $(NETCoreSdkVersion) ``` may work for a deliberately SDK-bound support model but must not be treated as universally equivalent to the active Roslyn host. Design-time builds, Visual Studio, Rider, CI, and explicit compiler toolsets all need testing. --- ## Multi-Targeting the Generator Is Not Automatic Selection This: ```xml netstandard2.0;net8.0 ``` may produce two generator assemblies. It does **not** mean NuGet will choose: ```text netstandard2.0 analyser for old compiler net8.0 analyser for new compiler ``` for you. Building multiple binaries and selecting analyser assets are separate problems. --- ## Recommended Rule Unless there is a compelling requirement: > **Ship one `netstandard2.0` analyser/generator binary compiled against the oldest Roslyn API version you need.** This remains the most robust distribution strategy. --- # 17. `Microsoft.CodeAnalysis.Analysers` Do not confuse: ```text Microsoft.CodeAnalysis.CSharp ``` with: ```text Microsoft.CodeAnalysis.Analysers ``` They serve different purposes. --- ## `Microsoft.CodeAnalysis.CSharp` Provides Roslyn compiler APIs used to implement your analyser/generator. Examples: ```csharp IIncrementalGenerator DiagnosticAnalyser SyntaxNode Compilation ISymbol IOperation ``` --- ## `Microsoft.CodeAnalysis.Analysers` This is a **meta-analyser package**. It analyses your analyser or source generator. Its purpose is to detect incorrect or unsafe usage of Roslyn/compiler APIs. It does not define your source-generator API baseline. --- ## Current Package Version As of August 2026, the current stable package is: ```text Microsoft.CodeAnalysis.Analysers 5.9.0 ``` Do not assume its version must match: ```text Microsoft.CodeAnalysis.CSharp ``` For example, it is perfectly reasonable to have: ```xml ``` provided the meta-analyser version itself works with your build tooling. These represent separate concerns: ```text Microsoft.CodeAnalysis.CSharp │ └── minimum Roslyn API used by your generator Microsoft.CodeAnalysis.Analysers │ └── rules used while developing the generator ``` --- ## Transitive Availability Roslyn compiler packages already bring `Microsoft.CodeAnalysis.Analysers` into the dependency graph as development tooling. You may nevertheless explicitly reference it when: - using Central Package Management; - deliberately pinning meta-analyser behaviour; - keeping analyser tooling versions consistent across a repository; - making the analyser-project configuration obvious. --- ## `PrivateAssets="all"` Roslyn development dependencies should normally use: ```xml PrivateAssets="all" ``` Example: ```xml ``` Your consumer should not gain ordinary runtime Roslyn package dependencies simply because it installed your source generator. --- ## `EnforceExtendedAnalyserRules` Analyser and generator projects should normally enable: ```xml true ``` These rules detect implementation patterns that are particularly dangerous inside compiler-hosted code. Do not immediately suppress an `RSxxxx` diagnostic. First determine what compiler-host invariant the rule is protecting. --- ## RS1035 `RS1035` bans APIs considered inappropriate for analysers. A common example is direct access to environment-dependent state. The underlying principle is: > Analyser/generator execution should not silently depend on machine-global environment state. Configuration should normally arrive through explicit compiler inputs such as: - analyser config options; - additional files; - MSBuild properties exposed through analyser config; - source code; - metadata references. --- ## RS2008 `RS2008` relates to analyser diagnostic release tracking. If your analyser publishes public diagnostic IDs, maintain release tracking files such as: ```text AnalyserReleases.Shipped.md AnalyserReleases.Unshipped.md ``` This helps detect accidental changes to diagnostic contracts. Diagnostic IDs are effectively part of your public API. --- ## Treat Diagnostic Descriptors as Public Contracts Changing: ```text ZS0001 ``` to: ```text ZS0017 ``` may break: - `.editorconfig`; - suppressions; - CI configuration; - documentation; - consumer tooling. Similarly, changing: - default severity; - category; - diagnostic semantics; should be treated as a compatibility decision. --- # 18. Recommended Project Configuration A broadly-compatible generator project might start with: ```xml netstandard2.0 latest enable true false true true ``` Then centrally define: ```xml 4.8.0 5.9.0 ``` The exact Roslyn baseline is a product-support decision. --- ## ProjectReference During Development A consuming project can reference the generator as: ```xml ``` --- ## Separate Runtime Contracts From Compiler Tooling Prefer: ```text MyLibrary.Abstractions │ ├── public attributes ├── runtime contracts └── shared public APIs MyLibrary.SourceGenerators │ └── IIncrementalGenerator MyLibrary.Analysers │ └── DiagnosticAnalyser MyLibrary.CodeFixes │ └── CodeFixProvider ``` over mixing runtime APIs and compiler tooling into one assembly. This prevents Roslyn dependencies leaking into runtime package assets. --- ## Roslyn Component Discovery The compiler host only loads a source generator, diagnostic analyser, or code fix provider when three conditions hold. Missing any one means the component is **silently ignored**, which is why "nothing shows up in Visual Studio" is usually a setup problem, not a code problem: 1. **The type is public.** Non-public component types cannot be instantiated by Roslyn (`PSGFR27`). 2. **The type is decorated.** A generator needs `[Generator]` (`PSGFR26`), an analyser needs `[DiagnosticAnalyzer]` (`PSGFR25`), and a code fix provider needs `[ExportCodeFixProvider]` (`PSGFR24`). 3. **The assembly is loaded as an analyser.** In a package the component assembly must be packed under `analyzers/dotnet/cs/`; in a project reference it must be referenced with `OutputItemType="Analyser"`. A normal library reference never surfaces a component to Roslyn. A code fix provider also only appears when the diagnostic ID in `FixableDiagnosticIds` is actually produced by an analyser that is loaded alongside it (`PSGFR28`). Visual Studio MEF-composes fix providers when the analyser set loads, so after adding or updating a fixer assembly you must restart Visual Studio or reload the project for the fixes to appear. --- # 19. Extension Class Conventions Extension classes should form a coherent, discoverable shape so that "which type does this extend?" and "where does it live?" are answerable from the file path alone. - **Placement**: under an `Extensions` folder whose path mirrors the extended type's namespace, e.g. `Extensions/Microsoft/CodeAnalysis/...`, `Extensions/System/...`. - **Namespace**: the extended type's namespace, so the folder, namespace, and receiver all align (`IDE0130` enforces the folder↔namespace pairing). - **Name**: `{Receiver}Extensions` (plural suffix), one receiver type per class. - **Style**: prefer C# 14 `extension(Receiver receiver)` blocks over classic `public static T Method(this Receiver receiver, ...)` methods. - **Metadata**: `[EditorBrowsable(EditorBrowsableState.Never)]` on the class, so IntelliSense and documentation tooling treat them as framework plumbing rather than public API. The framework's analyzers enforce these rules: | Rule | What it enforces | |------|------------------| | `PSGFR34` | Prefer `extension(...)` blocks over classic `this`-parameter methods (gated on the language version supporting extension blocks). | | `PSGFR35` | Class name matches the extended type (`{Receiver}Extensions`). | | `PSGFR36` | Class lives in the extended type's namespace. | | `PSGFR37` | One receiver type per class. | | `PSGFR38` | `[EditorBrowsable(EditorBrowsableState.Never)]` is present. | The `ReorganizeExtensionClassCodeFixProvider` converts a class with many disparate extensions into the coherent shape: it renames (`PSGFR35`), splits multi-receiver classes into per-type files (`PSGFR37`), moves the class under `Extensions/{ReceiverNamespace}/` and updates referencing files (`PSGFR36`), and the `ConvertToExtensionBlockCodeFixProvider` converts classic methods to `extension` blocks (`PSGFR34`). --- # 20. Review Checklist ## Analyser - [ ] Is this rule actually validation rather than generation? - [ ] Am I using the narrowest appropriate analyser action? - [ ] Does exact syntax matter? - [ ] If not, should this use a symbol? - [ ] If this is executable semantics, should this use `IOperation`? - [ ] Is `EnableConcurrentExecution()` enabled? - [ ] Is generated-code analysis explicitly configured? - [ ] Are known framework/library symbols resolved once where appropriate? - [ ] Are symbols compared semantically rather than by reference? - [ ] Is whole-compilation analysis genuinely necessary? - [ ] Could the diagnostic reasonably have a code fix? - [ ] Are diagnostic IDs release-tracked? - [ ] Is the analyser type `public` and decorated with `[DiagnosticAnalyzer]`? - [ ] Do the code fix's `FixableDiagnosticIds` match an ID the analyser actually produces? --- ## Incremental Generator - [ ] Uses `IIncrementalGenerator`. - [ ] Uses `ForAttributeWithMetadataName` where appropriate. - [ ] Syntax predicates are extremely cheap. - [ ] Semantic extraction happens once. - [ ] `ISymbol` never enters persistent model state. - [ ] `Compilation` does not propagate downstream. - [ ] `SemanticModel` does not propagate downstream. - [ ] `IOperation` does not propagate downstream. - [ ] `SyntaxTree` does not propagate downstream. - [ ] `SyntaxNode` is removed as early as possible. - [ ] `Location` is removed as early as possible. - [ ] Pipeline models are immutable. - [ ] Pipeline models have value equality. - [ ] Collection members have sequence equality. - [ ] Arrays are not relied upon for model equality. - [ ] `ImmutableArray` is wrapped or explicitly compared where equality matters. - [ ] Transform callbacks are static where practical. - [ ] Cancellation is honoured. - [ ] `Collect()` is only used where global knowledge is necessary. - [ ] Per-target output remains per-target. - [ ] `Combine()` does not unnecessarily broaden invalidation. - [ ] `CompilationProvider` is not casually combined into output. - [ ] `WithComparer()` represents real logical equality. - [ ] Hint names are deterministic. - [ ] Generated text is deterministic. - [ ] Constant source uses post-initialization output. - [ ] Normal source validation lives in an analyser. - [ ] Incremental caching behaviour has tests. --- ## Packaging - [ ] Generator/analyser binaries are packed as analyser assets. - [ ] Compiler tooling is not accidentally shipped as runtime `lib` output. - [ ] Roslyn package dependencies are private. - [ ] The Roslyn API baseline is intentional. - [ ] The minimum supported Visual Studio version is documented. - [ ] The minimum supported SDK/compiler environment is tested. - [ ] Rider support is tested rather than inferred. - [ ] Multi-targeting is not being mistaken for analyser asset selection. - [ ] Multiple Roslyn binaries are not placed in the normal analyser folder expecting automatic selection. - [ ] Any custom MSBuild analyser selection works during design-time builds. - [ ] `Microsoft.CodeAnalysis.Analysers` is enabled. - [ ] `EnforceExtendedAnalyserRules` is enabled. - [ ] `RSxxxx` diagnostics are investigated rather than reflexively suppressed. --- # Summary The shortest version of this guide is: > **Analyser for validation; generator for generation.** > **Syntax for syntax, symbols for declarations, operations for executable semantics.** > **Use `ForAttributeWithMetadataName` whenever possible.** > **`ISymbol`, `Compilation`, `SemanticModel`, and `IOperation` do not belong in incremental pipeline models.** > **Remove `SyntaxNode` and `Location` as soon as possible.** > **Immutable does not mean equatable: arrays, lists, and `ImmutableArray` require deliberate sequence equality.** > **Use `EquatableArray` or an equivalent value-equatable collection abstraction.** > **Avoid `Collect()` until global knowledge is genuinely required.** > **Never combine `CompilationProvider` into the pipeline merely because it is convenient.** > **Compile against the oldest Roslyn API version containing the functionality you need.** > **The consumer TFM does not determine analyser compatibility—the compiler host does.** > **NuGet does not automatically choose between Roslyn-version-specific analyser binaries.** > **Test incrementally and compatibility, not just generated source.** # Incremental Pipeline > IncrementalPipeline provides extension methods for composing IncrementalValueProvider and IncrementalValuesProvider pipelines — attribute-based… # Incremental Pipeline `IncrementalPipeline` provides extension methods for composing `IncrementalValueProvider` and `IncrementalValuesProvider` pipelines — attribute-based discovery, generation-context creation, disable-property checks, and thin source-output registration. It is designed around the golden rule from the [best-practices guide](../guide/): **pipeline values must be immutable and value-equatable.** ## GenerationContext `GenerationContext` is a base execution-services context that carries: - the Roslyn `Compilation`; - immutable generator `GenerationSettings`; - an optional `ISourceGenLogger`; and - a factory for independently owned `CodeWriter` instances. ```csharp var contextProvider = IncrementalPipeline.DefaultGenerationContextValueProvider(context); ``` Create a fresh writer through the generation context so it inherits the configuration: ```csharp var writer = generationContext.CreateCodeWriter(); ``` `CreateCodeWriter()` returns a new, independently owned instance on every call. The writer is not stored on `GenerationContext`; keep it scoped to the source-output operation that owns the generated source. ### Custom generation contexts Custom contexts do not need to accept or read build properties themselves: ```csharp public sealed class MyGenerationContext : GenerationContext { public MyGenerationContext( Compilation compilation, GenerationSettings settings, ISourceGenLogger? logger) : base(compilation, settings, logger) { } } ``` Use the ordinary context-provider overload. The framework combines the compiler-visible property with the compilation and supplies the resulting immutable settings to the custom context factory: ```csharp var contextProvider = IncrementalPipeline.GenerationContextValueProvider( context, nameof(MyGenerator), "1.0.0", factory: static (compilation, settings, logger, cancellationToken) => { cancellationToken.ThrowIfCancellationRequested(); return new MyGenerationContext(compilation, settings, logger); }, disablePropertyName: "MyGenerator_Disable" ); ``` The provider resolves scope validation, generator disabling, and test logging from analyzer-config properties before invoking the factory. The supplied logger is created internally only when logging is enabled and a sink is registered for that run. ## Keep CodeWriter out of incremental contexts Treat `GenerationContext` values as cached incremental-pipeline state and each `CodeWriter` as mutable, output-scoped execution state. Create the writer inside the registered source-output callback, after the incremental cache boundary. Creating it in the callback and passing it to emitter/helper methods called from that same callback is the intended pattern; the only thing that is forbidden is persisting the writer in pipeline state, where Roslyn caches it: ```csharp IncrementalPipeline.RegisterSourceOutput( context, targets, contextProvider, static (spc, target, generationContext) => { var writer = generationContext.CreateCodeWriter(); EmitTarget(generationContext, writer, target); spc.AddSource($"{target.Name}.g.cs", writer.ToString()); } ); ``` This separation is intentional: - Roslyn caches the complete value published by an incremental provider. It does not provide a way to exclude one property of that value from caching. - `CodeWriter` is mutable. Caching one can retain previously written source when the context is reused for another output or generator run. - Source-output callbacks may process independent targets concurrently. Sharing a writer can mix their output and introduce data races. - A fresh writer gives each generated source independent scope tracking and deterministic ownership. These rules also apply to custom contexts: **never add or assign a `CodeWriter` property or field on a class derived from `GenerationContext`**. A custom context is still produced by an incremental provider and cached as one complete value. Store only compilation-derived services and immutable configuration there, and call `CreateCodeWriter()` in the output callback. When emitter methods need both logging/context services and writing, either pass the context and output-scoped writer separately, or compose them into a short-lived output wrapper created inside the callback. Such a wrapper must never be returned from an incremental provider: ```csharp public sealed class GenerationOutputContext : ISourceGenLogger where TContext : GenerationContext { public GenerationOutputContext(TContext generation) { Generation = generation; Writer = generation.CreateCodeWriter(); } public TContext Generation { get; } public CodeWriter Writer { get; } public void Log( SourceGenLogLevel level, int indentation, string message, params object[] args) => Generation.Log(level, indentation, message, args); } ``` The wrapper reduces emitter parameter noise without extending the writer's lifetime into Roslyn's incremental cache. ## GeneratorResult and diagnostics that don't stop generation `IncrementalPipeline.RegisterSourceOutput` combines targets with the generation context, reports diagnostics, and runs the generator callback only for successful results: ```csharp var targets = IncrementalPipeline.ForAttributeWithMetadataName( context, AttributeType, static (ctx, ct) => { var symbol = ctx.TargetSymbol; return symbol is null ? GeneratorResult.Empty : GeneratorResult.Create(symbol.Name); } ); var contextProvider = IncrementalPipeline.DefaultGenerationContextValueProvider(context); IncrementalPipeline.RegisterSourceOutput( context, targets, contextProvider, static (spc, name, generationContext) => { var writer = generationContext.CreateCodeWriter(); writer.Comment($"generated {name}"); spc.AddSource($"{name}.g.cs", writer.ToString()); } ); ``` The registered callback runs only when `GeneratorResult.ShouldProcess` is `true` — the result carries a value and none of its carried diagnostics are blocking. `ReportableDiagnostic.IsBlocking` is an explicit, per-diagnostic decision, independent of the diagnostic's severity. `GeneratorResult.ShouldProcess` is `true` when the result carries a value and none of its diagnostics are blocking, so an `Error`-severity diagnostic can still allow generation to continue. This is useful when the generated code helps the developer fix the problem — for example, a generator that emits an abstract base class with methods the user must override can report an error for each missing override while still emitting the base class, so the user can see exactly what to implement: ```csharp static readonly DiagnosticDescriptor MissingOverride = new( "MYGEN001", "Missing override", "Type '{0}' must override '{1}'", "Usage", DiagnosticSeverity.Error, isEnabledByDefault: true ); var targets = IncrementalPipeline.ForAttributeWithMetadataName( context, AttributeType, static (ctx, ct) => { var symbol = ctx.TargetSymbol; var model = new BaseModel(symbol.Name); // An error-severity diagnostic that explicitly allows generation to continue: // IsBlocking is false, so ShouldProcess stays true and the base class is emitted. var diagnostic = ReportableDiagnostic.Create( MissingOverride, isBlocking: false, symbol, symbol.Name, "Execute" ); return GeneratorResult.Create(model, diagnostic); } ); ``` Blocking diagnostics (`isBlocking: true`) stop generation for that target while still being reported. `GeneratorResult.HasBlockingDiagnostics` reports whether any carried diagnostic blocked processing; `HasErrorDiagnostics` reports the severity-based view (whether any diagnostic has an `Error` `DefaultSeverity`). ## Disabling a generator at build time Pass the generator's compiler-visible disable property to the context provider. Its resolved value is included in `GenerationSettings` automatically: ```xml true ``` ```csharp var contextProvider = IncrementalPipeline.DefaultGenerationContextValueProvider( context, nameof(MyGenerator), "1.0.0", disablePropertyName: "MyGenerator_Disable" ); // In the output stage: if (generationContext.Settings.IsSourceGeneratorDisabled) return; ``` `IsDisabledValueProvider` remains available when expensive upstream transforms must be filtered before they are combined with the generation context. ## Scope validation The default generation-context provider reads the `PurviewSourceGeneratorFrameworkValidateCodeWriterScopes` MSBuild property and threads it into `GenerationSettings.ValidateCodeWriterScopes`. When enabled, `ToString()` throws `CodeWriterScopeValidationException` if `OpenScopeCount` is not zero. See [Code-Writer.md](../code-writer/#construction-and-scope-validation). ## Test logging Framework logging is disabled in ordinary compiler runs. The testing integration enables it by registering an isolated sink and supplying a per-run session ID through analyzer config. Context providers create the internal logger automatically; generators do not implement a logging interface and no logging-support source is generated. The sink registry stores callbacks only. It never buffers log entries. If logging is disabled, the session ID is missing, or no matching sink is registered, the provider supplies no logger and log calls are discarded without storing entries. Test sinks own any entries they choose to capture and are removed when the test run completes. ## Tracking names and step-cache tests The framework's pipeline helpers assign a tracking name to every stage so cache tests can assert which stages were recomputed. See [Step-Cache-Tests.md](../step-cache-tests/) for the tracking-name table and the golden test matrix. # Packaging > This page covers how to package a source generator that references Purview.SourceGeneratorFramework, and how the framework package itself is assembled and… # Packaging This page covers how to package a source generator that references `Purview.SourceGeneratorFramework`, and how the framework package itself is assembled and validated. ## How the framework packages are assembled `Purview.SourceGeneratorFramework` is dual-role: the built framework assembly ships in `lib/` so consumers can compile generators against it, and the `analyzers/` folder carries the generator + analyzer assemblies and their runtime dependencies. The bundled projects are: - `SourceGeneratorFramework.Generators` — `AttributeDataModelGenerator`, `TypeLibraryGenerator`; - `SourceGeneratorFramework.Analyzers` — the `PSGFR*` and `TLB*` analyzers; - `SourceGeneratorFramework.CodeFixers` — the code fix providers; - `SourceGeneratorShared` — shared models and helpers, packed into the package as `Purview.SourceGeneratorFramework.Shared.dll`. These projects are `IsRoslynComponent = true` and are **not** packable on their own; they are packed into the main package by the `SourceGeneratorFramework` project via analyzer project references (`OutputItemType="Analyzer"`). The repo's pack validation (`purview-build.json`) requires the `purview.sourcegeneratorframework` package to contain, at minimum: - `lib/netstandard2.0/Purview.SourceGeneratorFramework.dll` and `lib/netstandard2.0/Purview.SourceGeneratorFramework.Shared.dll`; - `analyzers/dotnet/cs/` versions of the framework, generators, analyzers, code fixers, and shared assemblies; - `build/Purview.SourceGeneratorFramework.props` and `build/Purview.SourceGeneratorFramework.targets`; - `README.md`, `LICENSE.md`, and `purview-logo-light.png`. PDBs are delivered only through the `.snupkg`; `*.pdb` files are forbidden inside the `.nupkg`. ## Referencing a generator from a consuming project Use an analyzer project reference so Roslyn receives both the generator assembly and its framework runtime dependency: ```xml ``` The Purview SDK automatically invokes `GetSourceGeneratorAnalyzerFiles`, which returns both the generator and its framework dependency without adding either file to the consuming application's runtime references. Specifying `Targets="GetSourceGeneratorAnalyzerFiles"` explicitly remains supported but is not required. ### Generators embedded in another package If the generator assembly is embedded in a different NuGet package, the outer package must make the framework's compiler-visible properties visible to its consumers. Build assets from `Purview.SourceGeneratorFramework` are not automatically copied into the outer package. Include a `.props` file imported by the outer package that declares the property and its `CompilerVisibleProperty` entry (see [Code-Writer.md](../code-writer/)), and pack it using the outer package's ID so NuGet imports it automatically: ```xml ``` ## Roslyn version compatibility The most important packaging rule is: > **The version of `Microsoft.CodeAnalysis.*` used to compile your analyzer/generator establishes a > minimum compiler-host API requirement.** The consumer's `` does not determine analyzer compatibility. Analyzer/generator code executes inside a compiler/IDE host. Microsoft's published baseline for the framework's Roslyn generation is: | Roslyn package | Minimum Visual Studio | Language / .NET generation | | ---: | --- | --- | | 4.8 | VS 2022 17.8 | C# 12 / .NET 8 | | 4.12 | VS 2022 17.12 | C# 13 / .NET 9 | | 5.0 | VS 2026 18.0 | C# 14 / .NET 10 | > **This framework is built against Roslyn 5.0.** The generator, analyzer, and testing assemblies in > `Purview.SourceGeneratorFramework*` are compiled against `Microsoft.CodeAnalysis` 5.x, so compiler > hosts that load them must be Roslyn 5.0 or later (`.NET 10` SDK / Visual Studio 2026 18.0). The > testing packages multi-target `net8.0`–`net10.0`; Roslyn 5.x ships `net8.0`/`net9.0` package assets, > so those test targets still load the test runner. See [Guide.md](../guide/) sections 14–18 for the full discussion of Roslyn versioning, multi-version packaging strategies, and the recommended generator project configuration. ## Recommended generator project configuration A broadly-compatible generator project might start with: ```xml netstandard2.0 latest enable true false true true ``` Then centrally define: ```xml 4.8.0 5.9.0 ``` The exact Roslyn baseline is a product-support decision. ## License This documentation is part of the MIT-licensed `Purview.SourceGeneratorFramework` project. # Performance > Benchmark results are produced by the benchmarks project SourceGeneratorFramework.Benchmarks../../src/src/SourceGeneratorFramework.Benchmarks using… # Performance Benchmark results are produced by the benchmarks project ([`SourceGeneratorFramework.Benchmarks`](https://github.com/purview-dev/sourcegenerator-framework/blob/main/src/src/SourceGeneratorFramework.Benchmarks)) using [BenchmarkDotNet](https://benchmarkdotnet.org) and folded here for reference. ## What is measured All benchmarks measure the **production** code path: generator runs configure `ValidateCodeWriterScopes = false` and the writer is constructed with `throwOnUnclosedScopes: false`. Scope tracking is a testing/debug feature and is excluded here because capturing an opening `StackTrace` per scope dominates both time and allocation (for 1000 small classes it inflates the writer benchmark from ~1.6 ms/2.3 MB to ~19 ms/22 MB). Tests opt into it so an unclosed `using` or block fails fast; see [Code-Writer.md](../code-writer/#construction-and-scope-validation). ## Environment - BenchmarkDotNet v0.15.8 - Windows 11 (10.0.28020.2991) - 13th Gen Intel Core i9-13900KF 3.00GHz, 1 CPU, 32 logical and 24 physical cores - .NET SDK 10.0.401 - .NET 10.0.12 (10.0.12, 10.0.1226.42308), X64 RyuJIT x86-64-v3 - Toolchain: InProcessEmitToolchain ## CodeWriter | Method | Mean | Error | StdDev | Gen0 | Gen1 | Gen2 | Allocated | | ---------- |---------:|---------:|---------:|----------:|---------:|--------:|----------:| | ManyClasses | 1.612 ms | 0.0139 ms | 0.0130 ms | 142.5781 | 142.5781 | 142.5781 | 2.34 MB | ## AttributeDataModelGenerator | Method | Mean | Error | StdDev | Gen0 | Gen1 | Allocated | | -------- |---------:|----------:|----------:|--------:|--------:|----------:| | RunAsync | 1.133 ms | 0.0182 ms | 0.0171 ms | 54.6875 | 11.7188 | 1.03 MB | ## EquatableArray | Method | Count | Mean | Error | StdDev | Median | Ratio | RatioSD | Allocated | Alloc Ratio | | ------------------------------ |------ |------------:|----------:|----------:|------------:|------:|--------:|----------:|------------:| | **EquatableArrayEquals** | **10** | **4.1603 ns** | **0.1068 ns** | **0.1271 ns** | **4.1864 ns** | **1.001** | **0.04** | **-** | **NA** | | EquatableArrayGetHashCode | 10 | 0.1677 ns | 0.0091 ns | 0.0085 ns | 0.1688 ns | 0.040 | 0.00 | - | NA | | ImmutableArrayReferenceEquals | 10 | 0.0032 ns | 0.0069 ns | 0.0061 ns | 0.0000 ns | 0.001 | 0.00 | - | NA | | ImmutableArraySequenceEqual | 10 | 3.7109 ns | 0.0374 ns | 0.0350 ns | 3.7080 ns | 0.893 | 0.03 | - | NA | | | | | | | | | | | | | **EquatableArrayEquals** | **100** | **29.9631 ns** | **0.2494 ns** | **0.2333 ns** | **29.9944 ns** | **1.000** | **0.01** | **-** | **NA** | | EquatableArrayGetHashCode | 100 | 0.1705 ns | 0.0086 ns | 0.0081 ns | 0.1685 ns | 0.006 | 0.00 | - | NA | | ImmutableArrayReferenceEquals | 100 | 0.0029 ns | 0.0041 ns | 0.0038 ns | 0.0004 ns | 0.000 | 0.00 | - | NA | | ImmutableArraySequenceEqual | 100 | 29.3123 ns | 0.2250 ns | 0.2105 ns | 29.3094 ns | 0.978 | 0.01 | - | NA | | | | | | | | | | | | | **EquatableArrayEquals** | **1000** | **189.2828 ns** | **1.3004 ns** | **1.2164 ns** | **189.2343 ns** | **1.000** | **0.01** | **-** | **NA** | | EquatableArrayGetHashCode | 1000 | 0.1855 ns | 0.0109 ns | 0.0102 ns | 0.1817 ns | 0.001 | 0.00 | - | NA | | ImmutableArrayReferenceEquals | 1000 | 0.0275 ns | 0.0178 ns | 0.0167 ns | 0.0251 ns | 0.000 | 0.00 | - | NA | | ImmutableArraySequenceEqual | 1000 | 191.8212 ns | 2.4799 ns | 2.3197 ns | 191.5713 ns | 1.013 | 0.01 | - | NA | ## ForAttributeTransform | Method | Mean | Error | StdDev | Ratio | Gen0 | Allocated | Alloc Ratio | | -------------------------- |------------:|----------:|----------:|------:|-------:|----------:|------------:| | GetDeclaredSymbolFromNode | 115.5924 ns | 0.8672 ns | 0.7688 ns | 1.000 | 0.0017 | 32 B | 1.00 | | PreResolvedTargetSymbol | 0.5658 ns | 0.0182 ns | 0.0170 ns | 0.005 | - | - | 0.00 | ## SourceGeneratorTestRunner | Method | ClassCount | CompileToAssembly | Mean | Error | StdDev | Gen0 | Gen1 | Allocated | | -------- |----------- |------------------ |--------------:|------------:|------------:|--------:|--------:|-----------:| | **RunAsync** | **1** | **False** | **5.725 μs** | **0.0552 μs** | **0.0489 μs** | **0.8545** | **0.2136** | **15.79 KB** | | **RunAsync** | **1** | **True** | **7,078.346 μs** | **133.0041 μs** | **124.4121 μs** | **23.4375** | **7.8125** | **458.51 KB** | | **RunAsync** | **10** | **False** | **7.579 μs** | **0.0873 μs** | **0.0729 μs** | **1.0147** | **0.2518** | **18.73 KB** | | **RunAsync** | **10** | **True** | **7,746.954 μs** | **150.9553 μs** | **239.4311 μs** | **23.4375** | **7.8125** | **555.61 KB** | | **RunAsync** | **100** | **False** | **25.144 μs** | **0.5020 μs** | **0.5580 μs** | **2.7161** | **0.4272** | **50.19 KB** | | **RunAsync** | **100** | **True** | **10,294.152 μs** | **203.6992 μs** | **382.5964 μs** | **78.1250** | **15.6250** | **1552.67 KB** | ## TypeIdentity | Method | Mean | Error | StdDev | Allocated | | ---------------------- |---------:|---------:|---------:|----------:| | Int32Identity | 15.19 ns | 0.207 ns | 0.193 ns | - | | StringIdentity | 14.85 ns | 0.266 ns | 0.236 ns | - | | ListOfStringIdentity | 16.72 ns | 0.348 ns | 0.386 ns | - | | DictionaryIdentity | 16.61 ns | 0.289 ns | 0.271 ns | - | | NestedGenericIdentity | 16.43 ns | 0.323 ns | 0.303 ns | - | ## TypeLibraryGenerator | Method | SpecCount | Mean | Error | StdDev | Gen0 | Gen1 | Allocated | | -------- |---------- |---------:|----------:|----------:|---------:|--------:|----------:| | **RunAsync** | **1** | **1.958 ms** | **0.0297 ms** | **0.0278 ms** | **62.5000** | **11.7188** | **1.16 MB** | | **RunAsync** | **5** | **2.555 ms** | **0.0368 ms** | **0.0344 ms** | **97.6563** | **23.4375** | **1.81 MB** | | **RunAsync** | **20** | **4.913 ms** | **0.0965 ms** | **0.1414 ms** | **234.3750** | **39.0625** | **4.27 MB** | ## Regenerating the results Run the benchmarks project and copy the generated reports into the tables above: ```bash dotnet run -c Release --project src/src/SourceGeneratorFramework.Benchmarks --framework net10.0 ``` Filter to a single benchmark with `--filter "*Name*"`. The Markdown reports are written to `BenchmarkDotNet.Artifacts/results/`. # Release Flow > This page documents how the repository builds, tests, packs, and releases Purview.SourceGeneratorFramework. # Release Flow This page documents how the repository builds, tests, packs, and releases `Purview.SourceGeneratorFramework`. ## Versioning The current version lives in the repository-root `package.json`: ```json { "name": "purview-sourcegenerator-framework", "version": "1.0.0-prerelease.42" } ``` The version is read by the build tooling (for example `just version` runs `bun -p "require('./package.json').version"`), and GitHub releases are tagged `v`, e.g. `v1.0.0-prerelease.42`. ## Workflows ### Pull requests `.github/workflows/pr.yml` runs on `pull_request` to `main`. It calls the shared `purview-dev/build` workflow (`purview-build.yml`) with `run-pack: true` and `validate-pack: true`, so every PR restores, builds, lints, runs tests, packs, and validates the packages. ### Releases `.github/workflows/release.yml` runs on `push` to `main`. It calls the shared `purview-dev/build` workflow (`purview-release.yml`) with `release-mode: NuGet`, which builds, tests, packs, validates, publishes to NuGet, and creates the GitHub release. ## Local pipelines The `Justfile` wraps the shared `Purview.Build` pipeline (installed as a pinned dotnet tool to `.tools/purview-build/purview-build`): | Recipe | Pipeline mode | Purpose | | --- | --- | --- | | `just pipeline-pr` | default | Restore, build, lint, tests. | | `just pipeline-build` | `--Build:RunTests=false --Release:Mode=None` | Build-only pipeline. | | `just pipeline-tests` | `--Build:RunTests=true --Release:Mode=None` | Build with tests. | | `just pipeline-release` | `--Release:Mode=NuGet` | Full release: build, test, pack, publish. | | `just pipeline-local-release` | `--Release:Mode=LocalNuGet` | Build, test, pack, and publish to a local NuGet feed. | Convenience recipes also exist for building (`just build`), testing (`just test`, `just test-unit`), packing (`just pack`), benchmarking (`just benchmark`), linting (`just lint-check`/`just lint-fix`), and cleaning (`just clean`, `just scrub`). ## Pack validation `purview-build.json` configures pack validation: - `PackValidation.RequireSymbolPackage` and `RequireSymbolFiles` — every packable package must ship a `.snupkg` with symbol files. - `PackValidation.RequiredContent` — each package must contain its declared assets. For example, `purview.sourcegeneratorframework` must contain the `lib/netstandard2.0/` framework assembly and shared assembly, the `analyzers/dotnet/cs/` generator/analyzer/code-fixer/shared assemblies, the `build/Purview.SourceGeneratorFramework.props` and `.targets` files, `README.md`, `LICENSE.md`, and `purview-logo-light.png`. See [Packaging.md](../packaging/) for details. - `PackValidation.ForbiddenContent` — `*.pdb` files are forbidden inside the `.nupkg` (PDBs are delivered only through the `.snupkg`). ## Dependency management `Directory.Packages.props` centralises package versions: - `Microsoft.CodeAnalysis.CSharp` / `Microsoft.CodeAnalysis.CSharp.Workspaces` — Roslyn 5.x (`RoslynCompilerVersion`, currently `[5.9.0,)`). - `Microsoft.CodeAnalysis.Analyzers` — `RoslynAnalyzersVersion` `[5.9.0,)`. - `TUnit` / `TUnit.Core` / `TUnit.Assertions` / `TUnit.Mocks` — `[1.67.0,)`. - `System.Reflection.MetadataLoadContext` — used by the testing package for the metadata-only `CompilationResult` view. Central Package Management is enabled with `CentralPackageTransitivePinningEnabled`. ## License This documentation is part of the MIT-licensed `Purview.SourceGeneratorFramework` project. # Step-Cache Tests for Incremental Source Generators > Snapshot-testing generated source is not enough. A generator can produce perfectly correct code while defeating almost all incremental caching: on every edit… # Step-Cache Tests for Incremental Source Generators Snapshot-testing generated source is not enough. A generator can produce perfectly correct code while defeating almost all incremental caching: on every edit the driver re-runs every stage and regenerates every output. The way to prove a generator caches correctly is to track the incremental pipeline steps and assert which stages were recomputed (`Modified`/`New`) and which were reused (`Cached`/`Unchanged`) between runs. This page documents the framework's step-cache testing support: - the `RunIncrementalAsync` runner and its tracked-step model; - the tracking names every pipeline stage receives; - the assertion API for stage-level reasons; - the canonical sample to copy for your own generators. The reference implementation is `src/src/SourceGeneratorFramework.ExampleGenerator/`, and the canonical sample is `src/tests/SourceGeneratorFramework.ExampleGenerator.UnitTests/StepCacheTests.cs`. --- ## How the runner works `SourceGeneratorTestRunner.RunIncrementalAsync(inputs, options)` runs one shared `GeneratorDriver` over a sequence of `IncrementalRunInput`s (each with its own sources and optional analyzer-config overrides): ```csharp var result = await new SourceGeneratorTestRunner().RunIncrementalAsync( [ new IncrementalRunInput([firstSources]), new IncrementalRunInput([changedSources]), ], options, cancellationToken ); ``` The driver is created with `GeneratorDriverOptions(IncrementalGeneratorOutputKind.None, trackIncrementalGeneratorSteps: true)`, so each run's `TrackedSteps` are captured. Because the same driver is reused and the same source set reuses the same compilation, the second run's step reasons tell you exactly what the driver decided to recompute. `GenerateIncrementalAsync` on `TUnitSourceGeneratorTestBase` wraps the same runner for test-base users. ## Step reasons Each pipeline stage (tracked by its tracking name) has outputs, each carrying an `IncrementalStepRunReason`: | Reason | Meaning | |--------------|-------------------------------------------------------------------------| | `New` | The step ran for the first time. | | `Modified` | The step ran and produced a different output than the previous run. | | `Unchanged` | The step ran but produced an output equal to the previous run. | | `Cached` | The step did not run; its previous output was reused unchanged. | ## Tracking names The framework's pipeline helpers assign a tracking name to every stage. Cache tests reference these names: | Helper | Tracking name | |---------------------------------------------------|------------------------------------------| | `IncrementalPipeline.PropertyValueProvider` | `GetMSBuildPropertyValue_{property}` | | `IncrementalPipeline.GenerationContextValueProvider` | `GetGenerationContext_{Capabilities}` | | `IncrementalPipeline` configuration provider | `GetGenerationConfiguration` | | `IncrementalPipeline.ForAttributeWithMetadataName` | `ForAttribute_{AttributeName}` | | `RegisterSourceOutput` extension | `RegisterSourceOutput_{OutputType}` | Bundled generators add their own names, for example `GetAttributeDataTargets`, `GetTypeLibrarySpecClassNames`, `GetTypeLibraryTargets`, and `GetFrameworkTypeLibraryTree` (the cached framework `PurviewTypeLibrary` shape). `WithTrackingName` can rename any stage. ## Assertion API `IncrementalCacheRunExtensions.GetStepReasons()` flattens a run's tracked steps into `ImmutableDictionary>` keyed by tracking name. TUnit assertion extensions on `IncrementalCacheRun` make the assertions fluent: ```csharp await Assert.That(result.Runs[0]).AllStepsNew(); await Assert.That(result.Runs[1]).AllStepsCachedOrUnchanged(); await Assert.That(result.Runs[1]).StepIsModified("ForAttribute_GenerateServiceAttribute"); await Assert.That(result.Runs[1]).StepIsCached("GetGenerationConfiguration"); await Assert.That(result.Runs[1]).HasStepReason("ForAttribute_GenerateServiceAttribute", IncrementalStepRunReason.Unchanged); ``` - `AllStepsNew` — every tracked stage is `New` (a first run). - `AllStepsCachedOrUnchanged` — every tracked stage was reused. Prefer asserting the generator's own stages with `StepIsCached` when the generator emits marker attributes via `RegisterPostInitializationOutput`: Roslyn's internal `ForAttributeWithMetadataName` steps can report `Modified` on an identical rerun because the post-initialization source is regenerated as a new tree. - `StepIsModified(stage)` — the stage was recomputed and produced different output. - `StepIsCached(stage)` — every output of the stage was `Cached` or `Unchanged`. - `HasStepReason(stage, reason)` — the stage contains at least one output with the given reason. ## Golden test matrix At minimum test: 1. **First run is all `New`** — proves every stage runs once. 2. **Identical rerun is cached** — proves value-equatable models short-circuit the pipeline. 3. **Unrelated source edit recomputes but stays unchanged** — proves model equality prevents regeneration. 4. **Editing one target invalidates only that target** — proves per-target incrementality (`ForAttributeWithMetadataName`). 5. **Changing one MSBuild/analyzer-config property invalidates only dependent stages** — proves configuration stages are independent of target stages. 6. **Deleting/renaming a target changes its output** — proves outputs are removed and hint names follow the model. ## Canonical sample `src/tests/SourceGeneratorFramework.ExampleGenerator.UnitTests/StepCacheTests.cs` exercises the full matrix against the reference generator: ```csharp public class StepCacheTests : TUnitSourceGeneratorTestBase { [Test] public async Task FirstRun_AllStagesAreNew(CancellationToken cancellationToken) { var result = await GenerateIncrementalAsync( [new IncrementalRunInput([Source])], cancellationToken: cancellationToken ); await Assert.That(result.Runs[0]).AllStepsNew(); } [Test] public async Task SingleTargetEdit_OnlyInvalidatesThatTarget(CancellationToken cancellationToken) { var result = await GenerateIncrementalAsync( [new IncrementalRunInput([Source]), new IncrementalRunInput([SingleTargetEditSource])], cancellationToken: cancellationToken ); await Assert.That(result.Runs[1]) .HasStepReason("ForAttribute_GenerateServiceAttribute", IncrementalStepRunReason.Modified); await Assert.That(result.Runs[1]) .HasStepReason("ForAttribute_GenerateServiceAttribute", IncrementalStepRunReason.Unchanged); } } ``` Mirror this pattern in every generator project; see the `*CacheTests` classes in `SourceGeneratorShared.UnitTests`, `SourceGeneratorFramework.Generators.UnitTests`, and `SourceGeneratorFramework.ExampleGenerator.UnitTests` for per-generator variants. # Testing > Purview.SourceGeneratorFramework.Testing is the framework-agnostic test runner and assertion library for unit testing incremental C# source generators. # Testing `Purview.SourceGeneratorFramework.Testing` is the framework-agnostic test runner and assertion library for unit testing incremental C# source generators. ## Installation ```bash dotnet add package Purview.SourceGeneratorFramework.Testing ``` ## What's included - **`SourceGeneratorTestRunner`** — compiles a snippet of C# source, runs the generator, automatically registers an isolated framework logging sink, and returns a `DriverRunResult` with generated syntax trees, the output compilation, and captured log entries. - **`SourceGeneratorTestBase`** — abstract base class that accepts an `ITestOutput` instance for framework-specific logging integration. - **`SourceGeneratorTestOptions`** — options for configuring references, namespaces, analyzer-config values, output kind, and whether to emit the output compilation to an assembly. - **`DriverRunResult`** — wrapper around `GeneratorDriverRunResult` that exposes generated trees, the output compilation, emitted assembly, and log entries. - **`DriverRunResultExtensions`** — assertion helpers such as `AssertNoCompilationErrors`, `AssertNoGenerationExceptions`, `AssertSingleGeneratedSource`, `AssertGeneratedSourceContains`, and more. - **`ITestOutput`** / **`NullTestOutput`** — abstraction for capturing generator log output during tests. ## Usage Reference the package from a test project and write a test using the runner directly: ```xml ``` ```csharp using Purview.SourceGeneratorFramework.Testing; public class MyGeneratorTests { [Test] public async Task GeneratesExpectedSource() { var source = """ [MyNamespace.MyAttribute] public partial class MyClass { } """; var runner = new SourceGeneratorTestRunner(); var result = await runner.RunAsync(source); result.AssertNoCompilationErrors(); var generated = result.AssertSingleGeneratedSource(); } } ``` Or derive from `SourceGeneratorTestBase` and plug in your own `ITestOutput` implementation. ## Running the generator in the test project Sometimes the test project's own source uses types produced by the generator — for example, an integration test may attach a generated marker attribute to a fixture class while also passing the generator type to `SourceGeneratorTestRunner`. Reference the generator project twice, once in each role: ```xml ``` The analyzer reference makes generated declarations available to the test project's compilation. The normal reference makes the generator's CLR type available to the testing API. These are separate from the in-memory compilation created by `SourceGeneratorTestRunner`; source supplied to the runner is still compiled and generated independently. The normal reference also exposes the generator's assembly dependencies to every target framework of the test project. This framework is built against Roslyn 5.0, which ships `net8.0` and `net9.0` package assets, so tests targeting .NET 8, .NET 9, and .NET 10 can all load the test runner. The Roslyn version used to compile a generator establishes the minimum compiler-host requirement for projects that consume it as an analyzer — Roslyn 5.0 means `.NET 10` SDK / Visual Studio 2026 or later. Do not centrally pin `System.Collections.Immutable` to a newer runtime version merely to make the generator load. ## Options Configure a test run with `SourceGeneratorTestOptions`: ```csharp var options = new SourceGeneratorTestOptions { IncludeDefaultNamespaces = true, AdditionalNamespaces = ["MyNamespace"], AdditionalAssemblyTypes = [typeof(SomeExternalType)], EnableLogging = true, AnalyzerConfigOptions = { ["MyGenerator_Disable"] = "true" } }; // Emitting the output to an assembly is opt-in because it is expensive. var result = await runner.RunAsync(source, options.Compile()); ``` `Compile()` is an extension method that preserves the concrete options type. A derived options record that wants a typed default must hide the inherited `SourceGeneratorTestOptions.Default` with a typed static, otherwise `Default.Compile()` returns the base type: ```csharp public record MyTestOptions : SourceGeneratorTestOptions { public static new MyTestOptions Default => new(); } // Returns MyTestOptions with CompileToAssembly enabled. var result = await runner.RunAsync(source, MyTestOptions.Default.Compile()); ``` ### Compiled output Emission is fully in-memory (no files are written). On .NET 8+ the emitted assembly is loaded into a fresh **collectible `AssemblyLoadContext`**, so the result is `IDisposable` and the assembly can be unloaded when you are done with it — keeping repeated `CompileToAssembly` runs from accumulating assemblies in the process-wide default context: ```csharp using var result = await runner.RunAsync(source, options.Compile()); result.CompilationResult.Assembly; // runnable assembly (may execute generated code) result.CompilationResult.Metadata; // metadata-only MetadataLoadContext (never executes) result.CompilationResult.MetadataAssembly; // emitted assembly reflected within that context ``` `CompilationResult.Metadata` / `MetadataAssembly` provide a metadata-only reflection view over the emitted assembly: inspect types, members and attributes without loading it into the runtime or executing any code. They are created lazily on first access. Dispose the result (or its `DriverRunResult`) to unload the collectible context and release the metadata view. Analyzer options are preserved under their supplied keys. Keys without the Roslyn `build_property.` prefix are additionally exposed as compiler-visible MSBuild properties, so either `MyGenerator_Disable` or `build_property.MyGenerator_Disable` can be used in tests. ## Querying produced code with `CodeQuery` Every result type exposes a `CodeQuery` so tests can locate syntax nodes in the produced code: ```csharp result.Generated() // DriverRunResult: generated trees (generated-first default) result.Output() // DriverRunResult: whole output compilation analyzerResult.Code() // AnalyzerTestResult / CodeFixTestResult: input compilation codeFixResult.FixedCode() // CodeFixTestResult: fixed source fixAllResult.FixedCode() // CodeFixFixAllResult / RefactorTestResult: changed documents ``` `CodeQuery` provides a `Get`/`Has`/`TryGet` family for declarations and members, generic `Get`/`Has`, syntax-tree lookup, and type-aware matching against `TypeReference`. Every `Get` returns a `CodeQueryResult` — the matched node (`Node`) plus a query scoped to it (`Query`) — with implicit conversions to both the node and the scoped query, so member queries chain without re-passing the query: ```csharp var query = result.Generated(); query.GetClass("ServiceCollectionExtensions").HasMethod("Add", TypeReference.Create()); query.GetClass("Service").GetProperty("Count", TypeReference.Create()); // property + type query.GetClass("Service").GetMethod("DoWork").HasParameters(intType, nullableInt, complexType); query.GetClass("Widget", "Example.Models"); // namespace-scoped lookup query.HasClass(new TypeReference(new TypeIdentity("Widget", "Example.Models"))); // type-identity lookup query.GetClass(TypeIdentity.Create()); // a TypeIdentity is implicitly castable to TypeReference query.GetClass("ResourceDefinition", 1); // generic lookup by type-parameter count ClassDeclarationSyntax cls = query.GetClass("Service"); // implicit conversion to the node query.GetClass("Service").Node.Members; // or use .Node for direct syntax access ``` `Get` throws `SyntaxNotFoundException` when nothing matches; `Has` returns `bool`. Type lookups accept an optional generic arity — `GetClass(name, arity)` / `HasClass(name, arity)` — and the `TypeReference`/`TypeIdentity` overloads match arity automatically from the identity, so `new TypeIdentity("ResourceDefinition", ns, arity: 1)` finds `ResourceDefinition` without matching the non-generic `ResourceDefinition`. Scoped results also expose node-inspection checks through `MemberQueryExtensions`: `HasAccessibility` (resolves C# defaults), `HasGetterAccessibility` / `HasSetterAccessibility`, `HasBaseType`, `HasGenericTypeParameter(s)`, `GetNestedType` / `HasNestedType`, `IsInNamespace` / `IsInGlobalNamespace`, and `GetDeclaredNamespace` on the query itself. ### Nullable expected types in tests Tests asserting a nullable expected type can use the test-only `query.MakeNullable(type)` extension on a `CodeQuery` (it accepts a `TypeReference` or `TypeIdentity`). It resolves the annotation against the query's compilation and, unlike `TypeReference.Nullable()`/`TypeIdentity.MakeNullable()`, does not trigger the `PSGFR16` context-overload suggestion — tests have no generation context to pass. ```csharp var query = result.Generated(); query.GetClass("Service").HasProperty("Name", query.MakeNullable(TypeReference.Create())); ``` ## Refactoring tests `RefactoringTestRunner` runs a `CodeRefactoringProvider` against a test document: ```csharp var runner = new RefactoringTestRunner(); var result = await runner.RunAsync( source, new RefactorTestOptions { NodeSelector = query => query.GetMethod("M"), EquivalenceKey = MyRefactoringProvider.EquivalenceKey, }); result.FixedCode().HasMethod("M"); // query the refactored output ``` The trigger is a `Span` or a `NodeSelector` (which runs against a `CodeQuery` of the input compilation). ## Incremental cache testing `SourceGeneratorTestRunner.RunIncrementalAsync` runs the generator over a sequence of source sets using a single shared driver and captures each run's tracked incremental steps, so tests can prove each pipeline stage caches correctly: ```csharp var result = await runner.RunIncrementalAsync([firstSources, secondSources], options); var reasons = result.Runs[1].Steps["ForAttribute_MyAttribute"] .SelectMany(step => step.Outputs.Select(output => output.Reason)); ``` `IncrementalCacheRunExtensions.GetStepReasons()` flattens a run's steps into an `ImmutableDictionary>`, and the TUnit assertions `AllStepsNew`, `AllStepsCachedOrUnchanged`, `StepIsCached`, `StepIsModified`, and `HasStepReason` make the checks fluent: ```csharp await Assert.That(result.Runs[0]).AllStepsNew(); await Assert.That(result.Runs[1]).StepIsModified("ForAttribute_MyAttribute"); await Assert.That(result.Runs[1]).StepIsCached("GetGenerationConfiguration"); ``` `RunIncrementalAsync(sources, options, ct)` runs the same source set twice (the common "unchanged rerun is cached" case). Per-run MSBuild-property changes use `new IncrementalRunInput(sources, [...])`. Reference cache tests live in the `Purview.SourceGeneratorFramework` source repository — `SourceGeneratorShared.UnitTests/IncrementalPipelineCacheTests` (framework stages), `SourceGeneratorFramework.ExampleGenerator.UnitTests/StepCacheTests` (the canonical golden-matrix sample), and `.../ServiceRegistrationCacheTests` (an end-to-end generator) — and should be replicated into your own test project rather than copied from the package. See [Step-Cache-Tests.md](../step-cache-tests/) for the full walkthrough. ## License This documentation is part of the MIT-licensed `Purview.SourceGeneratorFramework` project. # Testing with TUnit > Purview.SourceGeneratorFramework.Testing.TUnit is the TUnit integration for testing incremental C# source generators built with… # Testing with TUnit `Purview.SourceGeneratorFramework.Testing.TUnit` is the TUnit integration for testing incremental C# source generators built with `Purview.SourceGeneratorFramework`. ## Installation ```bash dotnet add package Purview.SourceGeneratorFramework.Testing.TUnit ``` ## What's included - **`TUnitSourceGeneratorTestBase`** — ready-made base class for TUnit tests. It wires generator log output to `TestContext.Current.OutputWriter`. - **Custom TUnit assertions** for inspecting `DriverRunResult` instances directly in TUnit tests. - **MSBuild `.props`** — automatically adds `global using` directives for `Purview.SourceGeneratorFramework.Testing.TUnit` and `Purview.SourceGeneratorFramework.Testing.TUnit.Assertions`. ## Usage Reference the package from a TUnit test project: ```xml ``` Derive your test class from `TUnitSourceGeneratorTestBase` and use the inherited `GenerateAsync` method: ```csharp using Purview.SourceGeneratorFramework.Testing.TUnit; public class MyGeneratorTests : TUnitSourceGeneratorTestBase { [Test] public async Task GeneratesExpectedSource() { var source = """ [MyNamespace.MyAttribute] public partial class MyClass { } """; var result = await GenerateAsync(source); result.AssertNoCompilationErrors(); var generated = result.AssertSingleGeneratedSource(); await Assert.That(generated).Contains("public static partial class MyClass"); } } ``` The base class also provides access to the underlying `SourceGeneratorTestRunner` behavior through `GenerateAsync`. ## Using generated types in the TUnit project If test source files use generated attributes or other generated declarations while the tests also derive from `TUnitSourceGeneratorTestBase`, reference the generator project both as an analyzer and as a normal assembly: ```xml ``` For example, the analyzer reference allows a test fixture to use `[MyGeneratedAttribute]`, while the normal reference allows the test class to derive from `TUnitSourceGeneratorTestBase`. Do not add `OutputItemType="Analyzer"` to the normal reference. For multi-target TUnit projects, the normal reference means the generator's Roslyn dependencies participate in reference resolution for every target. Build the generator against the Roslyn version that supports its API usage; this framework is built against Roslyn 5.0, which ships `net8.0` and `net9.0` package assets, so a .NET 8–10 test matrix still loads it. Compiler hosts that consume the generator as an analyzer must be Roslyn 5.0 or later (`.NET 10` SDK / Visual Studio 2026). Do not force a newer `System.Collections.Immutable` version through central package management. ## Which base class and method | Roslyn type | Base class | Method | |---|---|---| | Generator | `TUnitSourceGeneratorTestBase` | `GenerateAsync(source, options, ct)` | | Diagnostic analyzer | `TUnitDiagnosticAnalyzerTestBase` | `AnalyzeAsync(source, options, ct)` | | Code fix (single) | `TUnitCodeFixTestBase` | `ApplyCodeFixAsync(source, options, ct)` | | Code fix (fix-all) | `TUnitCodeFixTestBase` | `ApplyFixAllAsync(sources, options, ct)` | | Refactoring | `TUnitRefactoringTestBase` | `RefactorAsync(source, options, ct)` | For cache tests, `TUnitSourceGeneratorTestBase` also exposes `GenerateIncrementalAsync(...)`. ## Easy starting point: derived options Derive a `SourceGeneratorTestOptions` record that seeds namespaces and additional assemblies, then pass it to every test: ```csharp public sealed record MyTestOptions : SourceGeneratorTestOptions { public MyTestOptions() { AdditionalNamespaces = AdditionalNamespaces.Add("My.Namespace"); AdditionalAssemblyTypes = AdditionalAssemblyTypes.AddRange(typeof(SomeDependencyType), typeof(TypeIdentity)); DisableSourceGeneratorPropertyName = "DisableMyGenerator"; } } public class MyGeneratorTests : TUnitSourceGeneratorTestBase { ... } ``` Use `options.Compile()` for `CompileToAssembly`, and the `OnBeforeRun`/`OnBeforeRunAsync`/`OnAfterRun` hooks for per-run customisation. Code-fix/refactoring tests select actions with `EquivalenceKey` or `CodeActionIndex` (and `RefactorTestOptions.NodeSelector`/`Span`). ## Assertion extensions All assertion extensions are under `Purview.SourceGeneratorFramework.Testing.TUnit.Assertions` (globally imported). `await Assert.That(...)` is terminal and returns the value: - `HasGeneratedMethod` / `HasGeneratedMethodReturnType` / `HasGeneratedClass` / `HasGeneratedProperty` / `HasGeneratedField` / `HasGeneratedSyntaxTree` — return the syntax node; `HasGeneratedMethod(name, TypeReference[])` matches parameter types. `HasGeneratedClass(name, arity)` (or a `TypeIdentity` with arity) matches a generic type by its type-parameter count, so `new TypeIdentity("ResourceDefinition", ns, arity: 1)` finds `ResourceDefinition` without matching the non-generic `ResourceDefinition`. - `HasFixedMethod` — same for code-fix and refactoring results. - `HasPropertyOfType` / `HasFieldOfType` / `HasMethodOfType` / `HasConstructorOfType` / `HasAttributeOfType` / `HasNestedType` — chain from a scoped `CodeQueryResult` (for example the result of `HasGeneratedClass`) and return the matched member. The node-producing assertions move the chain onto the matched node, so you can append node-inspection assertions with `.And`: ```csharp var method = await Assert.That(query) .HasGeneratedClass("Service") .And.HasNestedType("Builder") .And.WithAccessibility(Accessibility.Private) .And.HasMethodOfType("Build", []); ``` - `WithAccessibility` / `WithGetterAccessibility` / `WithSetterAccessibility` / `WithBaseType` / `WithGenericTypeParameter(s)` / `IsInNamespace` / `IsInGlobalNamespace` — node-inspection assertions that keep the matched node on the chain. Accessibility resolves C# defaults (an unmodified nested type is `Private`, a top-level type `Internal`, interface/enum members `Public`, and an accessor with no modifier inherits its property's accessibility). - `HasDiagnostic` / `HasDiagnostics` / `HasNoDiagnostics` / `DoesNotHaveDiagnostic` / `HasNoErrorDiagnostics`. - `HasSymbol(TypeIdentity)` / `HasSymbol("Namespace.Type")`. - `GeneratesCode(expected)` / `ContainsGeneratedCode(expected)` (whitespace-flattened). The `CodeQuery` assertions operate on a `CodeQuery` directly, so they accept a query from any test result — `result.Generated()` for generated code, `result.Output()` for the whole compilation, or `result.FixedCode()` for fixed/refactored code. Convenience overloads on the test result types query the generated (or fixed) code for you. To assert a nullable expected type, use the test-only `query.MakeNullable(...)` extension: it resolves the annotation against the query's compilation and, unlike `TypeReference.Nullable()` / `TypeIdentity.MakeNullable()`, does not trigger the `PSGFR16` context-overload suggestion (tests have no generation context to pass). ```csharp var query = result.Generated(); MethodDeclarationSyntax method = await Assert.That(query).HasGeneratedMethod("DoWork", [intType, nullableInt]); await Assert.That(query).HasGeneratedSyntaxTree("Service.g.cs"); await Assert.That(result.FixedCode()).HasFixedMethod("DoWork"); // code-fix / refactor results // Scoped member chaining: CodeQueryResult attributeClass = await Assert.That(query).HasGeneratedClass(hostKitAttribute); await Assert.That(attributeClass).HasPropertyOfType("Name", query.MakeNullable(TypeLibrary.System.String)); ``` ## Incremental cache tests `GenerateIncrementalAsync` proves the pipeline caches stage-by-stage (first run `New`, identical rerun `Cached`/`Unchanged`, targeted changes mark only the affected stage `Modified`). A reference implementation (`ServiceRegistrationCacheTests`) lives in the `Purview.SourceGeneratorFramework` source repository's example generator tests; replicate it in your own project with your own stage names. See [Step-Cache-Tests.md](../step-cache-tests/) for the full walkthrough. ## License This documentation is part of the MIT-licensed `Purview.SourceGeneratorFramework` project. # TypeLibraryGenerator > TypeLibraryGenerator removes the boilerplate of hand-writing a type library — the static class that exposes the TypeIdentity and TypeReference values your… # TypeLibraryGenerator `TypeLibraryGenerator` removes the boilerplate of hand-writing a type library — the static class that exposes the `TypeIdentity` and `TypeReference` values your generator needs to reference framework, reference, and self-generated types. It is part of `Purview.SourceGeneratorFramework.Generators` and runs automatically for any spec annotated with `[GenerateTypeLibrary]`. ## What it generates For a small declarative spec, the generator emits a **self-contained** `public static partial class` (the generated type library) whose nested `public static partial` classes mirror the namespaces of the declared members. Every class — the root and each nested namespace class — exposes a `public const string Namespace` and the leaf classes expose the members as `public static readonly` fields: ```csharp namespace MyGenerator; public static partial class TypeLibrary { public const string Namespace = "MyGenerator"; public static partial class System { public const string Namespace = "System"; public static partial class Diagnostics { public const string Namespace = "System.Diagnostics"; public static readonly TypeIdentity Activity = new("Activity", "System.Diagnostics"); } } } ``` No `extension(...)` blocks are emitted. The generated types are `public static partial` so you can expand them with your own methods in a separate partial file — but the extension partial must be declared `public static partial` in the **same** namespace as the generated type. The generated type is emitted in the namespace given by the `Namespace` argument, or the **global namespace** when it is omitted, so a partial declared inside your project namespace will not merge with it (it silently shadows the generated type instead). `TLB0014` and `TLB0015` flag these mistakes. The framework's own library is `PurviewTypeLibrary` (the two never collide, and composed members reference it directly). The generated class **inherits the full `PurviewTypeLibrary` shape**: every nested namespace class and member of the framework library (`System.String`, `System.Collections.Generic.List`, `Microsoft.Extensions.DependencyInjection.IServiceCollection`, …) is present, emitted as an alias reference to the framework value so arity and generic construction are preserved exactly. Your `[TypeRef]` members are merged into the matching nested classes; a member with the same name as an inherited member in the same nested class shadows it. ## The DSL ```csharp namespace Purview.Telemetry.SourceGenerator; [GenerateTypeLibrary( ClassName = "TelemetryTypeLibrary", // generated type name (default: "TypeLibrary") Namespace = "Purview.Telemetry.SourceGenerator")] // generated type's namespace (default: global) static partial class TypeLibraryModel // spec — separate from the generated type { // Namespace-only: type name defaults to the member name → nested class .Purview.Telemetry: [TypeRef("Purview.Telemetry")] static readonly TypeIdentity ActivitySourceGenerationAttribute = default; // typeof(...) form → nested class .System.Diagnostics: [TypeRef(typeof(global::System.Diagnostics.Activity))] static readonly TypeIdentity Activity = default; // Explicit type + namespace → nested class .Microsoft.Extensions.Logging: [TypeRef("ILogger", "Microsoft.Extensions.Logging")] static readonly TypeIdentity ILogger = default; } ``` The spec class must be declared `static partial` and should use a distinct name from the generated class (`ClassName`, default `TypeLibrary`) — `TLB0012`/`TLB0013` flag a collision, with a code fix that renames the spec (e.g. `TypeLibrary` → `TypeLibraryGenerator`). ### Marker attributes | Attribute | Targets | Purpose | | --- | --- | --- | | `[GenerateTypeLibrary]` | class | Marks the spec; configures `ClassName`, `Namespace`. | | `[TypeRef]` | field | Declares one `TypeIdentity` or `TypeReference` member. | `[TypeRef]` offers three declaration forms: | Form | Type name | Namespace | | --- | --- | --- | | `[TypeRef("Purview.Telemetry")]` | the member name | the string argument | | `[TypeRef(typeof(Activity))]` | the symbol name | inferred from the type (or the named `Namespace`/positional argument) | | `[TypeRef("Activity", "System.Diagnostics")]` | the name string | the second argument | All forms accept an optional generic `arity` argument (`[TypeRef("Test", 1)]`, or the third argument of the explicit form, e.g. `[TypeRef("List`1", "System.Collections.Generic")]`); `typeof(...)` derives the arity from the symbol automatically. Every form also accepts an optional `includeInGetTypes` argument that follows `arity` — `[TypeRef("Test", 0, true)]` or `[TypeRef("ILogger", "Microsoft.Extensions.Logging", -1, true)]` — or as the named argument `includeInGetTypes: true`. It controls whether the member is included in the namespace's generated `GetTypes()` call (see below). ### Member accessibility Members are inert declarations read by the generator at compile time: - **Plain `TypeIdentity` members are markers** and must be declared `private` (an unmodified `static readonly` field is private). The generator produces `new("Name", "Namespace"[, arity])`. - **`TypeReference` members, and `TypeIdentity` members that declare an initializer** (value members), must be declared `internal`; their initializer expression becomes the generated value. The analyzer reports `TLB0008` for invalid accessibility and `TLB0009` when a value member has no initializer; both are fixable (`Make private`/`Make internal` for `TLB0008`). Marker members without an explicit `= default` initializer are flagged by `TLB0010` (with an `Add '= default'` fix). The generator also emits a small partial of the spec class that references the marker fields, so the compiler's unused-member analysis does not flag them — the spec must therefore be declared `partial` (`TLB0011`, with a `Make partial` fix). ### Value members (composed references) A `TypeReference` field — or a `TypeIdentity` field with a real initializer — declares a composed value (generic constructions with arguments, arrays, nullable). The initializer may reference other `[TypeRef]` members by name and the framework `PurviewTypeLibrary`: ```csharp [TypeRef(typeof(ActivityLink))] static readonly TypeIdentity ActivityLink = default; [TypeRef("System.Diagnostics")] internal static readonly TypeReference ActivityLinkArray = new TypeReference(ActivityLink).MakeArray(); [TypeRef("System.Collections.Generic")] internal static readonly TypeReference ActivityTagIEnumerable = global::Purview.SourceGeneratorFramework.PurviewTypeLibrary.System.Collections.Generic.IEnumerable.MakeGeneric( global::Purview.SourceGeneratorFramework.PurviewTypeLibrary.System.String); ``` The generated nested class then exposes `TypeLibrary.System.Diagnostics.ActivityLinkArray` and `TypeLibrary.System.Collections.Generic.ActivityTagIEnumerable` as `TypeReference` fields. Use the fully-qualified `PurviewTypeLibrary.System...` form in initializers so the spec compiles regardless of local `TypeLibrary` names. ### Enum values Use `[EnumValue]` to declare the members of an enum type that the generator emits. The enum type itself must be declared by a sibling `[TypeRef]` marker in the same namespace (`TLB0017` flags a missing declaration). Each value is a private marker field whose name becomes the enum member name: ```csharp [GenerateTypeLibrary(ClassName = "TypeLibrary", Namespace = "MyGenerator")] static partial class TypeLibraryModel { [TypeRef("LikeC4Severity", "Aspire.Hosting.AspireC4", GenerateFullNameConst = true)] static readonly TypeIdentity LikeC4Severity = default; // Explicit enum name + namespace form. [EnumValue("LikeC4Severity", "Aspire.Hosting.AspireC4", 0)] static readonly TypeIdentity Inherit = default; // Single fully-qualified enum type name form. [EnumValue("Aspire.Hosting.AspireC4.LikeC4Severity", 3)] static readonly TypeIdentity Warning = default; } ``` `[EnumValue]` offers the same two declaration forms as `[TypeRef]` — an explicit `enumName` + `namespace` + `value`, or a single fully-qualified enum type name + `value` — plus an optional `aliases` argument (array of alternate names used when matching). The value is a numeric literal of any enum underlying type — `byte`, `sbyte`, `short`, `ushort`, `int` (default), `uint`, `long` or `ulong` — written as `(byte)5`, `5`, `5L`, `5UL`, and so on. The literal's type drives the generated `EnumValueDefinition.UnderlyingType`, and `EnumValueDefinition.Value` is stored as a `decimal` so every underlying type (including `ulong.MaxValue`) is represented exactly. The generator emits a nested `public static partial class {EnumName}Values` alongside the enum's `TypeIdentity`: ```csharp public static partial class AspireC4 { public static readonly TypeIdentity LikeC4Severity = new("LikeC4Severity", "Aspire.Hosting.AspireC4"); public const string LikeC4SeverityFullName = "Aspire.Hosting.AspireC4.LikeC4Severity"; // GenerateFullNameConst public static partial class LikeC4SeverityValues { public const string InheritFullName = LikeC4SeverityFullName + "." + "Inherit"; // when the enum's [TypeRef] sets GenerateFullNameConst public static readonly EnumValueDefinition Inherit = new(LikeC4Severity, "Inherit", 0); public static EnumValueDefinition Get(string name) { if (Inherit.Matches(name)) return Inherit; return EnumValueDefinition.Empty; } } } ``` `EnumValueDefinition` exposes `Name`, `Value` (a `decimal` that represents every underlying type exactly), `UnderlyingType`, `FullName` (`Namespace.Enum.Member`), `Aliases`, and a `Matches(string)` matcher that accepts the member name, its full name, a trailing `Enum.Member` form, or any alias. Use the generated values to emit the enum itself via `writer.Enum(...)` and to reference members as attribute defaults: ```csharp writer.Enum("LikeC4Severity", TypeDeclarationAccessibility.Public, options => options with { IsPartial = false }, ew => { ew.EnumField(Inherit.Name, Inherit.Value); ew.EnumField(Warning.Name, Warning.Value); }); // Attribute default referencing a value: new("severity", TypeLibrary.Aspire.Hosting.AspireC4.LikeC4Severity) { DefaultValue = TypeLibrary.Aspire.Hosting.AspireC4.LikeC4SeverityValues.Warning.FullName, }; ``` Enum value marker fields must be declared `private static readonly` (`TLB0008`), with an optional explicit `= default` (`TLB0010`), and use a `TypeIdentity` or `EnumValueDefinition` field type (`TLB0016`). Duplicate member names in a group are `TLB0018`; duplicate numeric values are flagged as `TLB0019`. ### Using full-name constants as attribute-data model targets A `[TypeRef]` member declared with `GenerateFullNameConst` produces a `public const string {Member}FullName` holding the member's fully-qualified type name (`"Aspire.Hosting.AspireC4.SeverityAttribute"`). That constant can be used as the `[Generate]` target of an attribute-data model instead of a `typeof(...)` value: ```csharp [TypeRef("Aspire.Hosting.AspireC4", GenerateFullNameConst = true)] static readonly TypeIdentity SeverityAttribute = default; [Generate(TypeLibrary.Aspire.Hosting.AspireC4.SeverityAttributeFullName)] public readonly partial record struct SeverityAttributeData( [Argument(IsEnum = true, Name = "severity", DefaultValue = "Inherit")] string Severity, [Property(IsEnum = true, DefaultValue = "Inherit")] string Level ); ``` Because the `TypeLibrary` class is emitted through `TypeLibraryGenerator`'s main pipeline, its constants are **not** present in the compilation that `AttributeDataModelGenerator`'s `ForAttributeWithMetadataName` pipeline sees (only post-initialization output is shared between generators in a single pass). `AttributeDataModelGenerator` therefore reassembles the target from the argument's member-access expression — guarded so the root identifier must match a `[GenerateTypeLibrary]` spec's `ClassName` — and resolves it against the compilation. The target attribute itself (here `SeverityAttribute`) is typically declared by the consumer's own generator as post-initialization output, which is resolvable. For `[Argument]`/`[Property]` members marked `IsEnum = true`, a `DefaultValue` supplied as a **bare member name** (for example `"Inherit"`) is expanded to the fully-qualified `"{EnumFullName}.{Member}"` form (`"Aspire.Hosting.AspireC4.LikeC4Severity.Inherit"`) using the enum type of the target attribute's matching constructor parameter (for `[Argument]`) or property (for `[Property]`). Fully-qualified defaults and defaults whose enum type cannot be resolved are emitted unchanged. ### Including types in `GetTypes()` Mark a member with `includeInGetTypes: true` to include it in its namespace's generated `GetTypes()` method, which returns an `ImmutableArray` of the included members: ```csharp [TypeRef("ILogger", "Microsoft.Extensions.Logging", includeInGetTypes: true)] static readonly TypeIdentity ILogger = default; ``` The nested class then exposes: ```csharp public static ImmutableArray GetTypes() => [ ILogger ]; ``` Plain `TypeIdentity` members and value members both participate. A namespace with no included members emits no `GetTypes()` method. ### XML documentation XML documentation on the spec class and each `[TypeRef]` field is copied onto the corresponding generated class and member. ## Requirements The generated output uses C# 14 features (nested partial types, collection expressions), so consumers must compile with a C# 14 compiler (.NET SDK 10 / Roslyn 5.0 or later). ## Disabling Set the MSBuild property `DisablePurviewTypeLibraryGenerator` to `true` to disable the generator. ## Validation `TypeLibraryValidationAnalyzer` reports `TLB0001`–`TLB0019` for invalid specs, enum value members, and type library partial extensions (non-static class, member type that is not `TypeIdentity`/`TypeReference`, unresolvable type/namespace, duplicate members, invalid class name, invalid namespace, invalid member accessibility, value members without an initializer, marker members without an explicit `= default`, a spec that is not declared `partial`, and a spec class whose name collides with the generated type library class — `TLB0012` when they share a namespace, `TLB0013` when they do not). It also reports `TLB0014` when a source partial class shares the generated library's name but is declared in a different namespace (so it will not merge), `TLB0015` when a same-namespace partial does not match the generated `public static partial` modifiers, and `TLB0016`–`TLB0019` for invalid `[EnumValue]` members (member type, a missing sibling enum declaration, and duplicate members/values). `TLB0002`, `TLB0008`, `TLB0010`, `TLB0011`, `TLB0012`, and `TLB0013` have code fixes. The generator carries these same diagnostics on its `GeneratorResult` and gates generation on `ShouldProcess`. Most are blocking (`IsBlocking: true`) and stop generation, but the non-blocking rules — `TLB0010` (marker without `= default`) and `TLB0013` (warning) — allow generation to continue, so a spec with those issues still produces the type library. See [`GeneratorResult` diagnostics that don't stop generation](https://github.com/purview-dev/sourcegenerator-framework/blob/main/src/src/SourceGeneratorFramework/Sdk/README.md#diagnostics-that-dont-stop-generation). # Telemetry SourceGenerator > Generates ActivitySourcehttps://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.activitysource,… # Purview Telemetry Source Generator Generates [`ActivitySource`](https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.activitysource), [`ILogger`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.extensions.logging.ilogger), and [`Metrics`](https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.metrics) based telemetry from methods you define on an interface. Define the interface once and the generator produces the implementation, DI registration helpers, and multi-target generation — no runtime reflection, no boilerplate. This approach allows for: - **Zero boilerplate** — define methods on an interface, get the full telemetry implementation generated - **Multi-target generation** — generate Activities, Logging, and Metrics from a single interface - **Testable** — easy mocking/substitution for unit testing - **DI-ready** — automatic dependency injection registration helpers - **OpenTelemetry-aligned** — defaults to OpenTelemetry semantic conventions for better observability ## Supported frameworks - .NET 8 or higher - .NET Framework 4.8 or higher **Build toolchain requirement:** Visual Studio 2026 (18.x) or the .NET 10 SDK (Roslyn 5.9.0+). ## Documentation ### Getting started - [Getting Started](getting-started/) — install, first interface, and common patterns - [Installation](installation/) — supported frameworks, per-feature dependencies, and build configuration - [Sample Application](sample-application/) — full .NET Aspire demo application ### Core features - [Activities](activities/) — distributed tracing generation with `ActivitySource` - [Logging](logging/) — structured logging generation with `ILogger` - [Generation v2](logging-generation-v2/) — state-based logging with `Microsoft.Extensions.Telemetry.Abstractions` - [Generation v1](logging-generation-v1/) — legacy `LoggerMessage.Define` high-performance mode - [Metrics](metrics/) — metrics generation (Counters, Histograms, Observables) - [Multi-Targeting](multi-targeting/) — combine Activities + Logging + Metrics in one interface ### Configuration and reference - [Generation Options](generation/) — control class names, DI, and code generation - [Tags, Baggage, and Parameter Attributes](tags-and-baggage/) — adding tags/baggage/properties to telemetry - [Generated Output](generated-output/) — examples of generated code - [Diagnostics](diagnostics/) — analyzer warnings and errors - [FAQ](faq/) — frequently asked questions and troubleshooting - [Breaking Changes](breaking-changes/) — migration guide for v3 → v4 → v5 - [Performance](performance/) — cross-runtime benchmark results ### Migrating - [Refactorings](refactorings/) — the shipped IDE code refactorings - [Migration from ILogger](migration-from-ilogger/) - [Migration from Activities](migration-from-activities/) ### Contributing - [Testing](testing/) — mocking generated telemetry interfaces - [Contributing](contributing/) — development setup, build commands, and conventions - [Release Flow](release-flow/) — how releases are produced ## Basic examples Each generation target ([Activities](activities/), [Logging](logging/), and [Metrics](metrics/)) documents what can be inferred and what must be explicit. By default each interface used as a source for generation includes an extension method for registering it with an `IServiceCollection`; more details can be found in [Generation](generation/). :::tip You can mix-and-match generation targets within a single interface; this is called [multi-targeting](multi-targeting/). When you do, inference is disabled and every method must declare its targets explicitly. ::: :::note In .NET, Activities, Events, and Metrics capture additional properties at creation, recording, or observation time as **tags**. OpenTelemetry calls these **attributes**. Because this source generator makes extensive use of marker attributes to control code generation, these docs use *tags* for the properties and *attributes* for the .NET [`Attribute`](https://learn.microsoft.com/en-us/dotnet/api/system.attribute) type. ::: All marker attributes are generated as `[Conditional("PURVIEW_TELEMETRY_ATTRIBUTES")]`, so they are not present in your build unless you define the `PURVIEW_TELEMETRY_ATTRIBUTES` constant. They are generated as internal to avoid exposing them outside the assembly. ### Activities Basic example of an activity-based telemetry interface. There is one Activity (`GettingItemFromCache`) and four events. Calling these adds an `ActivityEvent` to the `activity` parameter; if no Activity is passed in, `Activity.Current` is used instead. There is also a context method that adds its parameters as either tags or baggage to the current Activity. ```csharp using Purview.Telemetry; [ActivitySource("some-activity")] interface IActivityTelemetry { [Activity] Activity? GettingItemFromCache([Baggage]string key, [Tag]string itemType); [Event("cachemiss")] void Miss(Activity? activity); [Event("cachehit")] void Hit(Activity? activity); [Event] void Error(Activity? activity, Exception ex); [Event] void Finished(Activity? activity, [Tag]TimeSpan duration); [Context] void AdditionalInfo(Activity? activity, string state); } ``` More information can be found in [Activities](activities/). ### Logging Basic example of a structured logging-based interface. `ProcessingWorkItem` returns an `IDisposable?`, which creates a scoped log entry. All parameters are passed into the logger methods as properties. ```csharp using Purview.Telemetry; [Logger] interface ILoggingTelemetry { [Log] IDisposable? ProcessingWorkItem(Guid id); [Log(LogLevel.Trace)] void ProcessingItemType(ItemTypes itemType); [Error] void FailedToProcessWorkItem(Exception ex); [Info] void ProcessingComplete(bool success, TimeSpan duration); } ``` More information can be found in [Logging](logging/), including the two generation modes and how to disable logging generation when the `Microsoft.Extensions.Logging` types are unavailable. ### Metrics This example shows each meter type currently supported. The `Counter` attribute is demonstrated twice: once with `AutoIncrement = true` (the measurement value is set to 1 per call) and once with the measurement specified explicitly as a parameter. :::caution Non-auto-increment instruments must specify a measurement of one of the supported types: `byte`, `short`, `int`, `long`, `float`, `double`, or `decimal`. ::: :::note Observable instruments must always have a `System.Func<>` parameter with one of the following shapes: - Any supported measurement type (`byte`, `short`, `int`, `long`, `float`, `double`, or `decimal`) - `Measurement` where `T` is a supported measurement type - `IEnumerable>` where `T` is a supported measurement type ::: As with activities, `[Tag]` parameters are included at recording time for the instrument. ```csharp using Purview.Telemetry; [Meter] interface IMeterTelemetry { [AutoCounter] void AutoIncrementMeter([Tag]string someValue); [Counter(AutoIncrement = true)] void AutoIncrementCounterMeter([Tag]string someValue); [Counter] void CounterMeter([InstrumentMeasurement]int measurement, [Tag]float someValue); [Histogram] void HistogramMeter([InstrumentMeasurement]int measurement, [Tag]int someValue, [Tag]bool anotherValue); [ObservableCounter] void ObservableCounterMeter(Func measurement, [Tag]double someValue); [ObservableGauge] void ObservableGaugeMeter(Func> measurement, [Tag]double someValue); [ObservableUpDownCounter] void ObservableUpDownCounter(Func>> measurement, [Tag]double someValue); [UpDownCounter] void UpDownCounterMeter([InstrumentMeasurement]decimal measurement, [Tag]byte someValue); } ``` More information can be found in [Metrics](metrics/). ## Multi-targeting In this example all method-level targets are set explicitly — inferring usage is not supported when multi-targeting. ```csharp using Purview.Telemetry; [ActivitySource("multi-targeting")] [Logger] [Meter] interface IServiceTelemetry { [Activity] [Trace] Activity? StartAnActivity(string tagStringParam, [Baggage]int entityId); [Event] [Info] void AnInterestingEvent(Activity? activity, float aTagValue); [Error] [Event] [AutoCounter] void AnError(Activity? activity, Exception ex); [Context] [AutoCounter] [Debug] void InterestingInfo(Activity? activity, float anotherTagValue, int intTagValue); [Histogram] [Trace] void ProcessingEntity(int entityId, string property1); [Info] [Counter] void ACounter([Tag]int value); } ``` More information can be found in [Multi-Targeting](multi-targeting/). # Activities > All activity-related attributes live in the Purview.Telemetry namespace. To signal an interface for Activity generation, decorate it with the ActivitySource… # Activities All activity-related attributes live in the `Purview.Telemetry` namespace. To signal an interface for Activity generation, decorate it with the `[ActivitySource]` attribute. :::caution All attributes are now in the unified `Purview.Telemetry` namespace. See [Breaking Changes](../breaking-changes/#namespace-consolidation) for migration details. ::: ## ActivitySource naming In v5 the ActivitySource name defaults to the **assembly name with casing preserved** (OpenTelemetry convention). You can override it at the interface or assembly level: - Interface: `ActivitySourceAttribute.Name` - Assembly: `ActivitySourceGenerationAttribute.Name` :::note `purview` is only used as a fallback when no assembly name is available, in which case the `TSG3001` diagnostic is generated. ::: ## Activity, Event, or Context There are three method types: 1. **Activity** methods that generate either a started or un-started [`Activity`](https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.activity). 2. **Event** methods that generate an [`ActivityEvent`](https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.activityevent) attached to an Activity. 3. **Context** methods that add either tags or baggage to the current Activity. :::tip Always specify the `Activity` explicitly — otherwise `Activity.Current` is used, which may not be the Activity you expect. When generating an Activity, always return `Activity` or `Activity?`. When using Event and Context methods, always pass in the `Activity` instance returned by an Activity method. ::: ### Activity Decorate the method with the `[Activity]` attribute to explicitly define an Activity method. Parameters can be passed directly to `ActivitySource.CreateActivity` or `ActivitySource.StartActivity`: - **tags** — the parameter type must be an [`ActivityTagsCollection`](https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.activitytagscollection), `IEnumerable>`, or [`TagList`](https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.taglist). - **parentContext** — the parameter type must be [`ActivityContext`](https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.activitycontext). - **parentId** — the parameter must be named `parentId` with type `string`. - **links** — the parameter type must be an `IEnumerable`. - **startTime** — the parameter must be named `startTime` with type `DateTimeOffset`. Other parameters can be marked with `[Tag]` or `[Baggage]` to specify where they are applied. The return type must be `void`, `Activity`, or `Activity?`; returning the Activity returns the created or started Activity. ### Events Decorate the method with the `[Event]` attribute to generate a method that creates an `ActivityEvent` and attaches it to the specified `Activity` or `Activity.Current`. To specify an Activity explicitly, the parameter type must be `Activity` or `Activity?`. The tags collection can be populated with a parameter of type `ActivityTagsCollection`, `IEnumerable>`, or `TagList`. To specify the `timestamp`, name the parameter `timestamp` with type `DateTimeOffset`. The return type must be `void`, `Activity`, or `Activity?`. When returning an Activity, either the one specified as a parameter is used, or `Activity.Current`. #### Exceptions When an `Exception` parameter is present, the default behaviour follows the [OpenTelemetry exception rules](https://opentelemetry.io/docs/specs/otel/trace/exceptions/): an event named `exception` is added with the following tags: - `exception.escaped` — `true` by default; override it by decorating a `bool` parameter with `[Escape]`. - `exception.message` — the value of `Exception.Message`. - `exception.stacktrace` — the value of `Exception.StackTrace`. - `exception.type` — the `Type.FullName` of the exception. This behaviour can be overridden with the `EventAttribute` options (see below). ### Context Decorate the method with the `[Context]` attribute to generate a method that populates tags and/or baggage on a specified `Activity` or `Activity.Current`. Parameters are marked with `[Tag]` or `[Baggage]`. The return type must be `void`, `Activity`, or `Activity?`. ## Inferring method type On a **single-target** Activities interface you can skip the method-level attribute: - If the method name ends with `Event` (case-sensitive), it is treated as an Event. - If the first parameter is an `Activity`, it is treated as an Event. - If the method name ends with `Context` (case-sensitive), it is treated as a Context. - Anything else defaults to creating an Activity. :::note Inference is disabled on multi-target interfaces — see [Multi-Targeting](../multi-targeting/). ::: ## Inferring tags or baggage If you decorate a parameter with `[Tag]` or `[Baggage]`, that stops inference. Any undecorated parameter is treated as a tag or baggage based on the default settings: - `ActivitySourceAttribute.DefaultToTags` (interface) — `true` means tags, `false` means baggage. Default `true`. - `ActivitySourceGenerationAttribute.DefaultToTags` (assembly) — same. Default `true`. See also [Tags, Baggage, and Parameter Attributes](../tags-and-baggage/). ## Attribute reference ### `[Activity]` Defines Activity creation on a method. | Property | Type | Description | | --- | --- | --- | | `Name` | `string?` | The name of the Activity. If not provided, the method name is used. Available on construction. | | `Kind` | [`ActivityKind`](https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.activitykind) | The kind used to create the Activity. Default `Internal`. Available on construction. | | `CreateOnly` | `bool` | Whether the created Activity is started or not. Default `false`. When `true`, you must return the `Activity`/`Activity?` from the method. | ### `[ActivitySource]` Defines the creation of the [`ActivitySource`](https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.activitysource) on an interface. | Property | Type | Description | | --- | --- | --- | | `Name` | `string?` | The ActivitySource name. If not provided, `ActivitySourceGenerationAttribute.Name` is used, then the assembly name. A warning (`TSG3001`) is generated when no custom name is defined anywhere. Available on construction. | | `DefaultToTags` | `bool` | Whether undecorated parameters are added as tags (`true`) or baggage (`false`). Special-case parameters are matched first. Default `true`. | | `BaggageAndTagPrefix` | `string?` | Prefix for tag/baggage names. Useful for grouping. Default `null`. | | `IncludeActivitySourcePrefix` | `bool` | Whether the `ActivitySourceGenerationAttribute.BaggageAndTagSeparator` is used to generate a prefix. Default `true`. | | `LowercaseBaggageAndTagKeys` | `bool` | Whether tag/baggage names are lower-cased. Default `true`. | ### `[ActivitySourceGeneration]` Defines ActivitySource defaults at the assembly level. | Property | Type | Description | | --- | --- | --- | | `Name` | `string` | Default ActivitySource name when none is defined on an interface. | | `DefaultToTags` | `bool` | Whether undecorated parameters are tags (`true`) or baggage (`false`). Default `true`. | | `BaggageAndTagPrefix` | `string?` | Prefix for tag/baggage names. Default `null`. | | `BaggageAndTagSeparator` | `string` | Separator used when generating prefixes. Default `.`. | | `LowercaseBaggageAndTagKeys` | `bool` | Whether tag/baggage names are lower-cased. Default `true`. | | `GenerateDiagnosticsForMissingActivity` | `bool` | Whether diagnostics (`TSG3014`/`TSG3015`) are raised when Activity parameters or return values are missing. Default `true`. | ### `[Baggage]` Marks a parameter as baggage on an Activity or Event. | Property | Type | Description | | --- | --- | --- | | `Name` | `string?` | The baggage name. Defaults to `null`, meaning the parameter name is used. | | `SkipOnNullOrEmpty` | `bool` | Whether the parameter is skipped when it is `null` or default. Default `false`. | ### `[Context]` Marks a method as adding parameters as tags or baggage to an Activity. There are no properties. ### `[StatusDescription]` Marks a `string` parameter as the status description for an Activity Event — typically used with events that set an error status code. ```csharp [ActivitySource("MyApp")] interface IMyTelemetry { [Event] void OperationFailed( Activity? activity, [StatusDescription]string failureReason ); } ``` The parameter must be of type `string`, and this attribute is only valid on Event methods, not Activity or Context methods. ### `[Escape]` Marks a `bool` parameter as the escape value on an event-based method. See the [OpenTelemetry exception rules](https://opentelemetry.io/docs/specs/otel/trace/exceptions/). ### `[Event]` Defines a method that creates an `ActivityEvent`. | Property | Type | Description | | --- | --- | --- | | `Name` | `string?` | The name of the event. If not provided, the method name is used. Available on construction. | | `StatusCode` | [`ActivityStatusCode`](https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.activitystatuscode) | Sets the status code on the Activity after the event is added. Default `Unset`. Available on construction. When set to `Error`, the status description is sourced (in order of precedence) from a `[StatusDescription]`-marked parameter, the `StatusDescription` property, the first `Exception` parameter's `Message`, or `null`. | | `StatusDescription` | `string?` | A static description for the `StatusCode` when set to `Error`. Overridden by a `[StatusDescription]`-marked parameter if present. | | `UseRecordExceptionRules` | `bool` | Whether the OpenTelemetry exception rules are followed when a parameter is an `Exception`. Default `true`. | | `RecordExceptionAsEscaped` | `bool` | The value used for `exception.escaped` when `UseRecordExceptionRules` is `true` and an exception is present. Overridable with `[Escape]`. Default `true`. | ## Next steps - [Tags, Baggage, and Parameter Attributes](../tags-and-baggage/) — parameter-level attributes - [Multi-Targeting](../multi-targeting/) — combining Activities with Logging and Metrics - [Diagnostics](../diagnostics/) — the `TSG3xxx` Activity rules # Breaking Changes > This page documents breaking changes between major versions to help you migrate your code. # Breaking Changes This page documents breaking changes between major versions to help you migrate your code. ## Table of contents - [v3 to v4](https://github.com/purview-dev/telemetry-sourcegenerator/blob/main/docs/wiki#v3-to-v4) - [Namespace consolidation](https://github.com/purview-dev/telemetry-sourcegenerator/blob/main/docs/wiki#namespace-consolidation) - [OpenTelemetry-aligned naming](https://github.com/purview-dev/telemetry-sourcegenerator/blob/main/docs/wiki#opentelemetry-aligned-naming) - [v4 to v5](https://github.com/purview-dev/telemetry-sourcegenerator/blob/main/docs/wiki#v4-to-v5) - [v1 and v2 to v3](https://github.com/purview-dev/telemetry-sourcegenerator/blob/main/docs/wiki#v1-and-v2-to-v3) ## v3 to v4 Version 4 introduced two major breaking changes: namespace consolidation and OpenTelemetry-aligned naming conventions. ### Namespace consolidation **Impact:** High — requires code changes in all projects using v3 v4 consolidates all attributes into a single namespace to simplify imports. #### Migration required **Before (v3):** ```csharp using Purview.Telemetry.Activities; using Purview.Telemetry.Logging; using Purview.Telemetry.Metrics; ``` **After (v4):** ```csharp using Purview.Telemetry; // single namespace ``` | v3 namespace | v4 namespace | Affected attributes | | --- | --- | --- | | `Purview.Telemetry.Activities` | `Purview.Telemetry` | `[ActivitySource]`, `[Activity]`, `[Event]`, `[Context]`, `[Baggage]` | | `Purview.Telemetry.Logging` | `Purview.Telemetry` | `[Logger]`, `[Log]`, `[Debug]`, `[Info]`, `[Warning]`, `[Error]`, `[Critical]` | | `Purview.Telemetry.Metrics` | `Purview.Telemetry` | `[Meter]`, `[Counter]`, `[AutoCounter]`, `[Histogram]`, `[Observable*]`, `[UpDownCounter]` | | `Purview.Telemetry` | `Purview.Telemetry` | `[Tag]`, `[TelemetryGeneration]`, `[Exclude]` | #### Migration steps 1. Replace `using Purview.Telemetry.Activities;` / `using Purview.Telemetry.Logging;` / `using Purview.Telemetry.Metrics;` with `using Purview.Telemetry;`. 2. Remove duplicate imports. 3. Rebuild and test. ### OpenTelemetry-aligned naming **Impact:** Medium to High — changes generated telemetry names (may break dashboards/queries) **Introduced in:** v4.0.0-alpha.5 **Default behaviour:** Enabled v4 defaults to **OpenTelemetry semantic conventions** for generated telemetry names. This is a breaking change if you rely on specific telemetry names in dashboards, queries, or monitoring tools. #### What changed | Telemetry type | v3 behaviour | v4 default (OpenTelemetry) | Example change | | --- | --- | --- | --- | | ActivitySource name | Assembly name lowercased | Assembly name casing preserved | `"myapp"` → `"MyApp"` | | Activity names | Method name lowercased | Method name casing preserved | `"getentity"` → `"GetEntity"` | | Tag/baggage keys | Lowercased, smashed compounds | snake_case with underscores | `"entityid"` → `"entity_id"` | | Metric instrument names | Lowercased, smashed | Hierarchical with meter prefix | `"recordcount"` → `"myapp.products.record.count"` | | Metric tag keys | Lowercased, smashed | snake_case with underscores | `"requestcount"` → `"request_count"` | #### Examples **Before (v3):** ```csharp [ActivitySource("MyApp")] interface IOrderTelemetry { [Activity] Activity? ProcessingOrder([Baggage]int orderId, [Tag]string customerName); } // Generated ActivitySource name: "myapp" // Generated Activity name: "processingorder" // Generated tag keys: "orderid", "customername" ``` **After (v4+ OpenTelemetry mode — default):** ```csharp [ActivitySource("MyApp")] interface IOrderTelemetry { [Activity] Activity? ProcessingOrder([Baggage]int orderId, [Tag]string customerName); } // Generated ActivitySource name: "MyApp" // Generated Activity name: "ProcessingOrder" // Generated tag keys: "order_id", "customer_name" ``` #### Migration options **Option 1 — adopt OpenTelemetry naming (recommended):** upgrade and update dashboards/queries to use snake_case keys and hierarchical metric names. **Option 2 — revert to v3 legacy naming:** ```csharp using Purview.Telemetry; // Revert ALL telemetry to v3 naming (assembly-level) [assembly: TelemetryGeneration(NamingConvention = NamingConvention.Legacy)] // Or set per-interface [TelemetryGeneration(NamingConvention = NamingConvention.Legacy)] interface IMyTelemetry { } ``` ```csharp public enum NamingConvention { Legacy = 0, // v3 behaviour: lowercase, smashed compounds OpenTelemetry = 1 // v4+ default: OTel conventions } ``` **Option 3 — mixed mode:** apply different conventions per interface with `[TelemetryGeneration]`. :::tip For high-impact scenarios, consider using `NamingConvention.Legacy` initially, then gradually migrating to OpenTelemetry conventions during a planned maintenance window. ::: ## v4 to v5 **Impact:** Low to Medium — affects the default meter name ### Meter default-name resolution In v4, when `[Meter(Name = ...)]` was not specified, the meter name defaulted to the interface name without the leading `I` (for example, `ICacheServiceTelemetry` → `CacheServiceTelemetry`). In v5 the default meter name is resolved in this order: 1. `MeterAttribute.Name` (interface) 2. `MeterGenerationAttribute.MeterName` (assembly) 3. The assembly name If you rely on the old interface-name meter default, set the name explicitly: ```csharp [Meter("CacheServiceTelemetry")] interface ICacheServiceTelemetry { } ``` ### `MeterGenerationAttribute` additions v5 adds `MeterName` and `MeterNameGenerationType` to `[MeterGeneration]`, controlling the default meter name and whether instrument names are prefixed with the meter name. See [Metrics](../metrics/#metergeneration). ### Activity return type should be nullable The new `TSG3022` warning recommends returning `Activity?` from Activity methods. It is a warning, not an error, but plan to move to nullable Activity return types as the Activity can be `null` when no listeners are active. ## v1 and v2 to v3 ### Logging event-name generation Previously, the default event name generated for a logging method included a trimmed-down version of the class name combined with the method name. ```csharp [Logger] interface IServiceTelemetry { void LogAThing(int theThing); } ``` :::caution In v1 and v2 the default event name for `LogAThing` was `Service.LogAThing`. As of v3, the default is `LogAThing`. ::: This is supported by changes to `LogPrefixType`: | Field | Old behaviour | New behaviour | | --- | --- | --- | | `Default` | Generated a prefix based on the generated class name. | Generates no suffix. | | `NoSuffix` | Generated no suffix. | **Field removed.** | | `TrimmedClassName` | Previously the default behaviour. | New field; generates a suffix based on the generated class name. | To return to the previous behaviour, set `LoggerGenerationAttribute.DefaultPrefixType` to `LogPrefixType.TrimmedClassName` (assembly level) or `LoggerAttribute.PrefixType` to `LogPrefixType.TrimmedClassName` (interface level). # Contributing > Contributions are welcome! This page covers development setup, build commands, and the conventions enforced in this repository. # Contributing Contributions are welcome! This page covers development setup, build commands, and the conventions enforced in this repository. ## Prerequisites - .NET 10 SDK (projects target `net10.0`; the source generator targets `netstandard2.0`) - [Bun](https://bun.sh) for the `package.json`/`.build/*.ts` scripts - The `Purview.BuildSdk` MSBuild SDK (pinned in `global.json` under `msbuild-sdks`) - `csharpier` dotnet tool (pinned in `.config/dotnet-tools.json`) for linting/formatting ## Clone and set up ```bash git clone https://github.com/purview-dev/telemetry-sourcegenerator cd telemetry-sourcegenerator ``` All `just` recipes read the [`Justfile`](https://github.com/purview-dev/telemetry-sourcegenerator/blob/main/Justfile); configuration defaults to **Debug**. ## Common commands | Command | Purpose | | --- | --- | | `just build` | Builds `src/Telemetry.SourceGenerator.slnx` (Debug). | | `just test` | Runs the integration tests (Debug) with a tree-node filter. | | `just build-s` | Builds `samples/SampleApp/SampleApp.slnx`. | | `just test-s` | Runs the sample test solution. | | `just lint` | Runs `dotnet csharpier check .` (no writes). | | `just lint-fix` | Runs `dotnet csharpier format .` (writes). | | `just clean` | Cleans the main solution. | | `just restore` | Restores packages for the main solution. | | `just scrub` | Removes `bin`/`obj` folders, cleans, restores, and shuts down build servers. | ## Build and validation workflow After making changes to the source generator: 1. `just build` — build the main solution. 2. `just test` — run the integration tests. 3. `just build-s && just test-s` — build and test the sample application end to end. 4. `just lint` / `just format` — ensure C# formatting compliance before committing. :::note Build and test commands can take a long time (tens of minutes) in slow environments. Give them a generous timeout and never cancel them part-way. ::: ## Testing Integration tests live in `src/tests/SourceGenerator.IntegrationTests` and target `net8.0;net9.0;net10.0`, plus `net48` on Windows only. They use `Purview.SourceGeneratorFramework.Testing.TUnit` (TUnit + the framework's `CodeQuery` syntax-lookup API and assertion extensions). There is no Verify/snapshot library; refactoring tests use the framework's `CodeRefactoringTestBase` snapshot approach. When generator behaviour changes, add or update tests in `src/tests/SourceGenerator.IntegrationTests/`. ## Conventions - **Conventional commits** — enforced by commitlint (`.config/lefthook.yml`). Types: `build`, `chore`, `ci`, `docs`, `feat`, `fix`, `perf`, `refactor`, `revert`, `style`, `test`. - **Formatting** — C# is formatted with csharpier; check with `just lint`. - **Solutions** — `.slnx` solution files are used. - **Source generator** — targets `netstandard2.0` and uses `Purview.SourceGeneratorFramework` (CodeWriter-based emission, incremental pipeline, value-equatable models). ## Pipelines The repo mirrors its GitHub CI/CD pipelines locally with the reusable `purview-build` tool: | Command | Purpose | | --- | --- | | `just pipeline-pr` | Restore, build, lint, and tests (the PR gate). | | `just pipeline-build` | Restore, build, lint (no tests). | | `just pipeline-tests` | Build with tests enabled. | | `just pipeline-release` | Full release: build, test, pack, publish, GitHub release. | | `just pipeline-local-release` | Pack + publish to a local NuGet feed (see the Justfile note on argument quoting). | ## Source-generator skills Before doing specialist work, load the relevant skill from `.agents/skills/` — see `AGENTS.md` ("Source-generator and testing skills") for the full list and when to load each one. ## Release process See [`docs/release-process.md`](https://github.com/purview-dev/telemetry-sourcegenerator/blob/main/docs/release-process.md) for the full flow. In short: push a feature branch and open a PR to `main` (`pr.yml` runs build + lint + tests); merging to `main` triggers the reusable release pipeline (build, test, pack, publish, GitHub release). The version lives in `package.json`; after bumping it, run `just update-version` to sync docs/samples, then `just pipeline-local-release` to validate locally. ## Next steps - [Release Flow](../release-flow/) — how releases are produced - [Testing](../testing/) — mocking generated telemetry interfaces # Diagnostics > The package ships a single Roslyn analyzer, TelemetryDiagnosticAnalyzer, which validates telemetry interfaces and raises diagnostics with the TSG prefix. All… # Diagnostics The package ships a single Roslyn analyzer, `TelemetryDiagnosticAnalyzer`, which validates telemetry interfaces and raises diagnostics with the `TSG` prefix. All diagnostics are grouped by category. - **General** — `TSG1xxx` - **Logging** — `TSG2xxx` - **Activities** — `TSG3xxx` - **Metrics** — `TSG4xxx` ## General diagnostics (TSG1xxx) | ID | Severity | Description | | --- | --- | --- | | `TSG1000` | Error | Fatal execution error occurred (`Failed to execute the generation stage: {0}`). Raised by the generator itself when an unexpected exception escapes the pipeline. | | `TSG1001` | Error | Inferring generation targets is not supported when using multi-target generation. A method on a multi-target interface has no explicit generation attribute. | | `TSG1002` | Error | Multiple attributes from the same target family are not supported. Only one Activity, Logging, or Metrics attribute is allowed per method. | | `TSG1003` | Error | Duplicate method names are not supported. Two or more methods on the interface share the same name. | | `TSG1004` | Error | Generic interfaces are not supported. | | `TSG1005` | Error | Generic methods are not supported. | | `TSG1006` | Warning | `ExcludeTargets` references a target not present on this method. | | `TSG1007` | Warning | `ExcludeTargets` results in an empty or invalid parameter set for a target. | | `TSG1008` | Warning | Activity parameter has no Activity target. A parameter of type `Activity` is present on a method with no `[Activity]`/`[Event]`/`[Context]` attribute. | | `TSG1010` | Error | Method target not registered on interface. A method carries an attribute for a target family that is not registered on the interface. | | `TSG1011` | Error | Unsupported target framework. The compilation targets neither .NET 8+ nor .NET Framework 4.8+; define `PURVIEW_TELEMETRY_NON_NULLABLE` to opt out. | ## Logging diagnostics (TSG2xxx) | ID | Severity | Description | | --- | --- | --- | | `TSG2000` | Error | Too many exception parameters. A non-scoped log method has more than one `Exception`-typed parameter. | | `TSG2001` | Error | More than 6 parameters. A log method in v1 generation mode has more than 6 non-exception parameters. | | `TSG2002` | Info | Inferring error log level. In v1 generation, a single `Exception` parameter is present with no explicit level, so `Error` is inferred. | | `TSG2003` | Warning | Could not find a reference to `Microsoft.Extensions.Logging.ILogger`, skipping log generation. | | `TSG2004` | Error | Cannot mix ordinal and named property placeholders in a message template. | | `TSG2005` | Error | Ordinal values exceed parameter count. The maximum ordinal placeholder value exceeds the number of provided parameters. | | `TSG2006` | Error | Using `[LogProperties]` and `[ExpandEnumerable]` on the same parameter is not supported. | | `TSG2007` | Warning | A scoped log shouldn't have a `LogLevel`; it will be ignored. | | `TSG2008` | Warning | Unbounded enumeration possible. `[ExpandEnumerable]` has a `MaximumValueCount` greater than the recommended default of 5. | | `TSG2021` | Error | Log method must return void or IDisposable. (Returning `Activity` is allowed when the method is also an Activity method.) | ## Activities diagnostics (TSG3xxx) | ID | Severity | Description | | --- | --- | --- | | `TSG3000` | Warning | Baggage parameter types only accept strings (`ToString()` will be called). | | `TSG3001` | Warning | No activity source specified. Generation defaults to `purview` when no name is available anywhere. | | `TSG3002` | Error | Invalid return type. An Activity/Event method returns something other than `void` or `System.Diagnostics.Activity`. | | `TSG3003` | Error | Duplicate reserved parameters defined. More than one parameter maps to the same reserved destination. | | `TSG3004` | Error | Activity parameter is not valid. An `Activity`-destination parameter appears on an Activity method (only valid on `[Event]` methods). | | `TSG3005` | Error | Timestamp parameter is not valid. A `timestamp` parameter appears on a method that is not an `[Event]` method. | | `TSG3006` | Error | Start time parameter is not valid on Create activity or Event method. | | `TSG3007` | Error | Parent context or Parent Id parameter is not valid on event. | | `TSG3008` | Error | Activity links parameters are not valid on events or context methods. | | `TSG3009` | Error | Activity tags parameter is not valid on context methods. | | `TSG3010` | Error | Escaped parameters must be a boolean. | | `TSG3011` | Error | Escaped parameters are only valid on Events, not Activity or Context methods. | | `TSG3012` | Info | There are no Activity methods defined, assumed use of `Activity.Current`. | | `TSG3013` | Warning | Should return the created Activity. An Activity method does not return the created `Activity`. | | `TSG3014` | Warning | Should accept an Activity to apply the Event/Tags/Baggage to. An Event/Context method has no `Activity` parameter. Opt-in via `ActivitySourceGeneration.GenerateDiagnosticsForMissingActivity` (default `true`). | | `TSG3015` | Info | Activity should be the first parameter. Opt-in via `GenerateDiagnosticsForMissingActivity`. | | `TSG3016` | Error | Status description parameter should be a string. | | `TSG3017` | Error | Status Description parameters are only valid on Events, not Activity or Context methods. | | `TSG3021` | Info | Exception event does not use OpenTelemetry standard name. An `[Event]` method records an exception but the event name is not the standard `"exception"` (suggest `[Event(Name = "exception")]`). | | `TSG3022` | Warning | Activity return type should be nullable. An Activity method returns non-nullable `Activity`; use `Activity?` because the Activity can be null when no listeners are active. | ## Metrics diagnostics (TSG4xxx) | ID | Severity | Description | | --- | --- | --- | | `TSG4000` | Error | No instrument defined. A method on a Metrics interface has no instrument attribute and is not excluded. | | `TSG4001` | Error | Must return void or bool. A metrics-owned method returns something other than `void` or `bool`. | | `TSG4002` | Error | Auto increment counter and measurement defined. An auto-increment instrument also has a measurement parameter. | | `TSG4003` | Error | Multiple measurement values defined. | | `TSG4004` | Error | No measurement value defined. A non-auto-increment instrument method has no measurement parameter. | | `TSG4005` | Error | Observable instrument requires `Func`. | | `TSG4006` | Error | Invalid measurement type. Not one of `byte`, `short`, `int`, `long`, `double`, `float`, `decimal`, `Measurement`, or `IEnumerable>`. | | `TSG4007` | Error | Observable metrics cannot return bool. | | `TSG4008` | Error | AutoCounter must return void. | | `TSG4009` | Warning | Instrument name matches the instrument type name. Use a name that describes what is measured. | ## Common resolutions ### TSG1001 — inference disabled on multi-target interfaces A method on a multi-target interface (`[ActivitySource]` + `[Logger]` + `[Meter]`) has no explicit attribute. Add the attributes for each target the method should emit, or mark it `[Exclude]`. ```csharp [ActivitySource("MyApp")] [Logger] [Meter] interface IMyTelemetry { [Info] // ✅ explicit target void ProcessItem(int id); } ``` ### TSG1003 — duplicate method names Two or more methods share the same name, which is used to generate members on the implementation class. Rename the methods. ### TSG2008 — unbounded enumeration `[ExpandEnumerable]` is configured with a `MaximumValueCount` greater than 5. Reduce it to the recommended default (5) unless you have tested the performance impact. ### TSG3013 / TSG3014 — missing Activity An Activity method does not return the created `Activity`, or an Event/Context method has no `Activity` parameter. Return the `Activity`/`Activity?` and pass it to Event/Context methods. These best-practice diagnostics are controlled by `ActivitySourceGeneration.GenerateDiagnosticsForMissingActivity`. ### TSG3022 — non-nullable Activity return Return `Activity?` so callers can handle the `null` case when no listeners are active. ### TSG4002 — auto-increment counter with a measurement `[AutoCounter]` (or `[Counter(AutoIncrement = true)]`) must not declare a measurement parameter — the measurement is fixed to 1. ## See also - [Getting Started](../getting-started/) - [Activities](../activities/), [Logging](../logging/), [Metrics](../metrics/) - [Multi-Targeting](../multi-targeting/) # Frequently Asked Questions (FAQ) > Common questions and answers about the Purview Telemetry Source Generator. # Frequently Asked Questions (FAQ) Common questions and answers about the Purview Telemetry Source Generator. ## General questions ### What is the Purview Telemetry Source Generator? A .NET incremental source generator that produces implementation code for Activities (distributed tracing), Logging (structured logs), and Metrics from interface definitions you create. Instead of writing boilerplate telemetry code, you define methods on an interface and the generator creates the implementation, DI registration helpers, and multi-target generation. ### Why use a source generator for telemetry? - **Zero boilerplate** — no manual implementation code to write or maintain - **Type safety** — compile-time validation of telemetry code - **Testability** — easy to mock interfaces in unit tests - **Consistency** — all telemetry follows the same patterns - **DI-ready** — automatic dependency injection registration - **Performance** — generated code is optimized - **Maintainability** — changes to interfaces propagate automatically ### What .NET versions are supported? - .NET 8 or higher - .NET Framework 4.8 or higher ### Is this compatible with OpenTelemetry? Yes. v4+ defaults to OpenTelemetry semantic conventions for generated names. The generated Activities, Logs, and Metrics work with OpenTelemetry exporters and collectors. ## Installation & setup ### How do I install the package? Add the NuGet package to your `.csproj`: ```xml all analyzers ``` See [Getting Started](../getting-started/) for more details. ### Why do I need `PrivateAssets` and `IncludeAssets`? - `PrivateAssets="all"` — prevents the package from being exposed to consuming projects - `IncludeAssets="analyzers"` — includes only what is needed for source generation ### Do I need any other packages? Depends on what you generate: - **Activities** — no additional packages (uses `System.Diagnostics.DiagnosticSource`) - **Logging** — `Microsoft.Extensions.Logging.Abstractions`; optionally `Microsoft.Extensions.Telemetry.Abstractions` for v2 features - **Metrics** — no additional packages (uses `System.Diagnostics.Metrics`) - **DI** — `Microsoft.Extensions.DependencyInjection.Abstractions` (usually already present) ### How do I view the generated code? ```xml true ``` Generated files appear in `obj/Debug|Release//generated/Purview.Telemetry.SourceGenerator/Purview.Telemetry.SourceGenerator.TelemetrySourceGenerator/`. ## Activities ### When should I use Activities vs Logging vs Metrics? - **Activities** — distributed operations across services, end-to-end latency, trace spans - **Logging** — events, state changes, debugging with structured data, error details - **Metrics** — counting occurrences, distributions, gauges for dashboards **Pro tip:** use [Multi-Targeting](../multi-targeting/) to combine all three. ### Why does my Activity method return `Activity?` instead of `Activity`? Activities can be `null` when no listeners are subscribed to the ActivitySource or sampling determines the activity should not be recorded. Always return `Activity?` and guard against `null`. ### Should I use `Activity.Current` or pass Activity parameters? **Always pass Activity parameters explicitly.** `Activity.Current` may not be the activity you expect, especially in async code or with nested activities. ```csharp // Good [Event] void OrderProcessed(Activity? activity, int orderId); // Avoid [Event] void OrderProcessed(int orderId); // uses Activity.Current implicitly ``` ### What's the difference between `[Tag]` and `[Baggage]`? - **`[Tag]`** — added as tags to the Activity or ActivityEvent; recorded with the activity but not automatically propagated to child activities - **`[Baggage]`** — added as baggage; automatically propagated to child activities and across service boundaries Use baggage sparingly as it increases overhead; use tags for most properties. ## Logging ### What's the difference between Generation v1 and v2? See [Logging](../logging/) for the full comparison. In brief: - **Generation v2** — state-based output resembling the built-in `[LoggerMessage]` generator; supports dynamic message templates, `[ExpandEnumerable]`, and `[LogProperties]`; requires `Microsoft.Extensions.Telemetry.Abstractions`. - **Generation v1** — `LoggerMessage.Define` high-performance mode; limited to 6 non-exception parameters plus one `Exception`; no `[ExpandEnumerable]`/`[LogProperties]`. The default `LoggerGenerationMode.Auto` selects the best mode **per method**. ### How do I create scoped logs? Return `IDisposable?` from a log method: ```csharp [Logger] interface IOrderTelemetry { [Info] IDisposable? ProcessingOrder(Guid orderId); } using (telemetry.ProcessingOrder(orderId)) { // Duration logged automatically when disposed } ``` ### Can I customize log message templates? Yes — `[Log].MessageTemplate` sets the template. Placeholders map to method parameters: ```csharp [Info("Order {OrderId} placed for {CustomerName}")] void OrderPlaced(int orderId, string customerName); ``` If no template is specified, one is generated from the method name and parameters. ### How do I disable logging generation? ```xml EXCLUDE_PURVIEW_TELEMETRY_LOGGING ``` Useful when `Microsoft.Extensions.Logging` types are not available. ## Metrics ### What's the difference between Counter and AutoCounter? - **`[Counter]`** — you specify the measurement value: ```csharp [Counter] void ItemsProcessed([InstrumentMeasurement]int count, [Tag]string type); ``` - **`[AutoCounter]`** — automatically increments by 1 per call: ```csharp [AutoCounter] void ItemProcessed([Tag]string type); ``` ### When should I use Histogram vs Counter? - **Counter** — discrete events that only go up (requests, errors, completions) - **Histogram** — distributions where percentiles matter (latency, size, duration) ### What are observable metrics and when should I use them? Observable instruments (`[ObservableCounter]`, `[ObservableGauge]`, `[ObservableUpDownCounter]`) are pull-based — the collector calls your `Func` to get the current value. Use them for values that already exist (memory usage, queue depth, cache size) or expensive calculations you don't want to run on every update. ```csharp [ObservableGauge] void QueueDepth(Func measurement); telemetry.QueueDepth(() => _queue.Count); ``` ### When do metric names include the meter name? Instrument names are generated with the meter name as a lowercase dot-separated prefix when using `NamingConvention.OpenTelemetry` combined with `MeterNameGenerationType.OpenTelemetry`: ```csharp [Meter("MyApp.Orders")] interface IOrderMetrics { [Counter] void OrderProcessed([InstrumentMeasurement]int count); } // Generated name: "myapp.orders.order.processed" // ^^^^^^^^^^^^^^^ meter prefix (lowercase) // ^^^^^^^^^^^^^^^^ instrument name ``` The default `MeterNameGenerationType.DotNet` does not add the meter-name prefix. See [Metrics](../metrics/#meter-naming). ## Multi-targeting ### Can I generate Activities, Logging, AND Metrics from one interface? Yes — this is called [Multi-Targeting](../multi-targeting/): ```csharp [ActivitySource("MyApp")] [Logger] [Meter("MyApp")] interface IMyTelemetry { [Activity] [Info] [AutoCounter] Activity? ProcessingRequest([Baggage]string requestId); } ``` ### What's the difference between single-target and multi-target interfaces? - **Single-target** — one class-level attribute; method-level inference is supported. - **Multi-target** — multiple class-level attributes; every method must declare its targets explicitly (no inference). ## Configuration & generation ### How do I control the generated class name? ```csharp [TelemetryGeneration(ClassName = "MyCustomTelemetry")] [ActivitySource("MyApp")] interface IMyTelemetry { } ``` ### How do I disable dependency injection generation? ```csharp [TelemetryGeneration(GenerateDependencyExtension = false)] [ActivitySource("MyApp")] interface IMyTelemetry { } ``` ### How do I exclude a method from generation? Use `[Exclude]` and implement it manually in a partial class: ```csharp [ActivitySource("MyApp")] interface IMyTelemetry { [Activity] Activity? NormalMethod(int id); [Exclude] void CustomMethod(int id); } partial class MyTelemetryCore { public void CustomMethod(int id) { // your custom implementation } } ``` ## Migration ### What are the breaking changes in v4 and v5? Two major v4 changes — **namespace consolidation** into `Purview.Telemetry` and **OpenTelemetry naming** — plus v5 changes including the meter default-name resolution. See [Breaking Changes](../breaking-changes/). ### How do I keep v3 naming? ```csharp [assembly: TelemetryGeneration(NamingConvention = NamingConvention.Legacy)] ``` See [OpenTelemetry-Aligned Naming](../breaking-changes/#opentelemetry-aligned-naming). ### Can I use v4 alongside v3? Not in the same project. In a multi-project solution different projects can use different versions, though that is not recommended. ## Troubleshooting ### The generator isn't producing any code 1. The interface has a class-level attribute (`[ActivitySource]`, `[Logger]`, or `[Meter]`) 2. Methods have appropriate method-level attributes 3. The interface is not generic (generics are not supported) 4. Rebuild the project to trigger generation 5. Check the Error List for `TSG` diagnostics ### I'm getting TSG diagnostic errors See [Diagnostics](../diagnostics/) for the complete list of codes and meanings. ### Generated code doesn't compile Common causes: missing packages (`Microsoft.Extensions.Logging.Abstractions`, `Microsoft.Extensions.DependencyInjection.Abstractions`, `Microsoft.Extensions.Telemetry.Abstractions` for v2), wrong parameter types, incorrect attribute usage, or generic interfaces/methods. ### Can I use async methods? The generated methods are synchronous, but your calling code can be async — telemetry operations are lightweight and non-blocking. ## Performance ### Does using a source generator impact performance? No runtime impact — the code is generated at compile time. The generated code is as fast as hand-written telemetry with minimal allocations. See [Performance](../performance/). ## Advanced topics ### Can I customize the generated code? Not directly, but you can use `[Exclude]` with partial-class implementations, control naming via `[TelemetryGeneration]`, and configure defaults through the assembly-level generation attributes. ### Does this work with .NET Native AOT? The source generator itself works with AOT. Generated code uses no reflection and is trimmable; validate your full scenario with AOT analysis enabled. ### Can I use this in a library? Yes. Generated implementation and DI classes are internal by default, so they don't leak to consumers. Make the DI class public with `[TelemetryGeneration(DependencyInjectionClassIsPublic = true)]` if you need public registration helpers. ### How do I test code that uses telemetry interfaces? Just mock the interface — see [Testing](../testing/): ```csharp var mockTelemetry = Substitute.For(); var service = new OrderService(mockTelemetry); ``` ## Getting help - **Documentation** — [purview.dev](https://purview.dev/docs/telemetry-sourcegenerator/) - **Issues** — [GitHub Issues](https://github.com/purview-dev/telemetry-sourcegenerator/issues) - **Sample** — [Sample Application](../sample-application/) When reporting a bug, include the package version, .NET version, a minimal repro, expected vs actual behaviour, and any `TSG` diagnostics. # Generated Output > This page shows real generated output from the sample applicationSample-Application.md, produced by 5.0.0-prerelease.8. The interface: # Generated Output This page shows real generated output from the [sample application](../sample-application/), produced by `5.0.0-prerelease.8`. The interface: ```csharp [ActivitySource] [Logger] [Meter] interface IEntityStoreTelemetry { [Activity] [Info] [AutoCounter] Activity? GettingEntityFromStore(int entityId, [Baggage] string serviceUrl); [Event] [Trace] void GetDuration(Activity? activity, int durationInMS); [Context] void RetrievedEntity(Activity? activity, float totalValue, int lastUpdatedByUserId); [Warning] void EntityNotFound(int entityId); [Histogram] void RecordEntitySize(int sizeInBytes); } ``` generates one partial `EntityStoreTelemetryCore` class split across Activity, Logging, and Metric files, plus a DI extension class and an assembly-level `TelemetryNames` class. :::note To inspect the generated output in your own project, enable `EmitCompilerGeneratedFiles` and look under `obj///generated/Purview.Telemetry.SourceGenerator/Purview.Telemetry.SourceGenerator.TelemetrySourceGenerator/`. ::: ## Activities (`EntityStoreTelemetryCore.Activity.g.cs`) ```csharp // #nullable enable [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] internal sealed partial class EntityStoreTelemetryCore : IEntityStoreTelemetry { private static readonly global::System.Diagnostics.ActivitySource _activitySource = new global::System.Diagnostics.ActivitySource("sample-weather-app-api"); public static void RecordExceptionInternal( global::System.Diagnostics.Activity? activity, global::System.Exception? exception, bool escape ) { if (activity == null || exception == null) { return; } global::System.Diagnostics.ActivityTagsCollection tagsCollection = new global::System.Diagnostics.ActivityTagsCollection(); tagsCollection.Add("exception.escaped", escape); tagsCollection.Add("exception.message", exception.Message); tagsCollection.Add("exception.type", exception.GetType().FullName); tagsCollection.Add("exception.stacktrace", exception.StackTrace); global::System.Diagnostics.ActivityEvent recordExceptionEvent = new global::System.Diagnostics.ActivityEvent(name: "exception", timestamp: default, tags: tagsCollection); activity.AddEvent(recordExceptionEvent); } private global::System.Diagnostics.Activity? GettingEntityFromStore_Activity( int entityId, string serviceUrl ) { if (!_activitySource.HasListeners()) { return null; } global::System.Diagnostics.Activity? activityGettingEntityFromStore = _activitySource.StartActivity("GettingEntityFromStore", global::System.Diagnostics.ActivityKind.Internal, parentId: default, tags: default, links: default, startTime: default); if (activityGettingEntityFromStore != null) { activityGettingEntityFromStore.SetTag("entity_id", entityId); activityGettingEntityFromStore.SetBaggage("service_url", serviceUrl); } return activityGettingEntityFromStore; } public global::System.Diagnostics.Activity? GettingEntityFromStore( int entityId, string serviceUrl ) { var activityResult = GettingEntityFromStore_Activity(entityId, serviceUrl); GettingEntityFromStore_Logging(entityId, serviceUrl); GettingEntityFromStore_Metrics(entityId, serviceUrl); return activityResult; } public void RetrievedEntity( global::System.Diagnostics.Activity? activity, float totalValue, int lastUpdatedByUserId ) { if (!_activitySource.HasListeners()) { return; } if (activity != null) { activity.SetTag("total_value", totalValue); activity.SetTag("last_updated_by_user_id", lastUpdatedByUserId); } } } ``` Key points: - A `HasListeners()` fast-path avoids work when no listener is subscribed. - Tag/baggage keys use snake_case (`entity_id`, `service_url`) — the OpenTelemetry naming convention. - Multi-target methods orchestrate the per-target private methods. ## Logging (`EntityStoreTelemetryCore.Logging.g.cs`) ```csharp internal sealed partial class EntityStoreTelemetryCore : IEntityStoreTelemetry { private readonly global::Microsoft.Extensions.Logging.ILogger _logger; private static readonly global::System.Action _gettingEntityFromStoreAction = global::Microsoft.Extensions.Logging.LoggerMessage.Define(global::Microsoft.Extensions.Logging.LogLevel.Information, new global::Microsoft.Extensions.Logging.EventId(0, "GettingEntityFromStore"), "GettingEntityFromStore: EntityId = {EntityId}, ServiceUrl = {ServiceUrl}"); private static readonly global::System.Action _entityNotFoundAction = global::Microsoft.Extensions.Logging.LoggerMessage.Define(global::Microsoft.Extensions.Logging.LogLevel.Warning, new global::Microsoft.Extensions.Logging.EventId(0, "EntityNotFound"), "EntityNotFound: EntityId = {EntityId}"); public EntityStoreTelemetryCore( global::Microsoft.Extensions.Logging.ILogger logger, global::System.Diagnostics.Metrics.IMeterFactory meterFactory ) { _logger = logger; InitializeMeters(meterFactory); } private void GettingEntityFromStore_Logging(int entityId, string serviceUrl) { if (!_logger.IsEnabled(global::Microsoft.Extensions.Logging.LogLevel.Information)) { return; } _gettingEntityFromStoreAction(_logger, entityId, serviceUrl, null); } public void EntityNotFound(int entityId) { if (!_logger.IsEnabled(global::Microsoft.Extensions.Logging.LogLevel.Warning)) { return; } _entityNotFoundAction(_logger, entityId, null); } } ``` These methods fit the v1 limits (≤ 6 non-exception parameters, no `[ExpandEnumerable]`/`[LogProperties]`), so `LoggerGenerationMode.Auto` selected v1 `LoggerMessage.Define` generation with an `IsEnabled` fast-path. See [Logging](../logging/). ## Metrics (`EntityStoreTelemetryCore.Metric.g.cs`) ```csharp internal sealed partial class EntityStoreTelemetryCore : IEntityStoreTelemetry { private global::System.Diagnostics.Metrics.Meter _meter = default!; private global::System.Diagnostics.Metrics.Counter _gettingEntityFromStoreInstrument = default!; private global::System.Diagnostics.Metrics.Histogram _recordEntitySizeInstrument = default!; public void InitializeMeters(global::System.Diagnostics.Metrics.IMeterFactory meterFactory) { if (_meter != null) { throw new global::System.Exception("The meters have already been initialized."); } global::System.Collections.Generic.Dictionary meterTags = new global::System.Collections.Generic.Dictionary(); PopulateMeterTags(meterTags); _meter = meterFactory.Create(new global::System.Diagnostics.Metrics.MeterOptions("SampleApp.APIService") { Version = null, Tags = meterTags }); _gettingEntityFromStoreInstrument = _meter.CreateCounter(name: "entity_store.getting_entity_from_store", unit: null, description: null, tags: gettingEntityFromStoreTags); _recordEntitySizeInstrument = _meter.CreateHistogram(name: "entity_store.record_entity_size", unit: null, description: null, tags: recordEntitySizeTags); } partial void PopulateMeterTags(global::System.Collections.Generic.Dictionary meterTags); partial void PopulateGettingEntityFromStoreTags(global::System.Collections.Generic.Dictionary instrumentTags); partial void PopulateRecordEntitySizeTags(global::System.Collections.Generic.Dictionary instrumentTags); private void GettingEntityFromStore_Metrics(int entityId, string serviceUrl) { _gettingEntityFromStoreInstrument.Add(1, new global::System.Collections.Generic.KeyValuePair("entity_id", entityId), new global::System.Collections.Generic.KeyValuePair("service_url", serviceUrl)); } public void RecordEntitySize(int sizeInBytes) { _recordEntitySizeInstrument.Record(sizeInBytes); } } ``` Key points: - The meter name defaults to the **assembly name** (`SampleApp.APIService`). - `[AutoCounter]` emits `Add(1, ...)`. - `PopulateMeterTags`/`Populate{Method}Tags` partial methods let you add tags at initialisation. ## DI registration (`EntityStoreTelemetryCoreDIExtension.DependencyInjection.g.cs`) ```csharp namespace Microsoft.Extensions.DependencyInjection { [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] internal static partial class EntityStoreTelemetryCoreDIExtension { public static global::Microsoft.Extensions.DependencyInjection.IServiceCollection AddEntityStoreTelemetry( this global::Microsoft.Extensions.DependencyInjection.IServiceCollection services ) { return services.AddSingleton(); } } } ``` ## Telemetry names (`SampleApp.APIService.TelemetryNames.g.cs`) ```csharp namespace SampleApp.APIService { public static partial class TelemetryNames { public static readonly string[] MeterNames = new string[] { "SampleApp.APIService" }; public static readonly string[] ActivitySourceNames = new string[] { "sample-weather-app-api" }; } } ``` The assembly-level `[assembly: ActivitySourceGeneration("sample-weather-app-api")]` supplies the ActivitySource name; the meter name defaults to the assembly name. ## See also - [Sample Application](../sample-application/) — the full source - [Generation](../generation/) — controlling class names, DI, and names - [Diagnostics](../diagnostics/) — analyzer rules # Generation Options > This page describes the TelemetryGeneration attribute and the other assembly-level generation attributes that control how code is generated: class names, DI… # Generation Options This page describes the `[TelemetryGeneration]` attribute and the other assembly-level generation attributes that control how code is generated: class names, DI registration, naming conventions, and the `TelemetryNames` class. ## Generated class naming For an interface `IOrderServiceTelemetry`, the generator produces: - An implementation class named `OrderServiceTelemetryCore` (the interface name without the leading `I`, plus `Core`). - A static DI extension class `OrderServiceTelemetryCoreDIExtension` with an `AddOrderServiceTelemetry` extension method. Both can be overridden with the `[TelemetryGeneration]` attribute. ## `[TelemetryGeneration]` `[TelemetryGeneration]` can be applied at the **assembly** level (affects every generated interface) or on an individual **interface**. ```csharp // Assembly-level [assembly: TelemetryGeneration(NamingConvention = NamingConvention.Legacy)] // Interface-level [TelemetryGeneration(ClassName = "MyCustomTelemetry")] interface IOrderServiceTelemetry { } ``` | Property | Default | Description | | --- | --- | --- | | `GenerateDependencyExtension` | `true` | Whether to emit the static DI extension class with the `Add{InterfaceName}()` method. | | `ClassName` | `null` | Overrides the generated implementation class name. When unset, the name is `{InterfaceName without I}Core`. | | `DependencyInjectionClassName` | `null` | Overrides the DI extension class name. When unset, the name is `{implementationClassName}DIExtension`. | | `DependencyInjectionClassIsPublic` | `false` | When `true` the DI extension class is `public`; otherwise it is `internal`. | | `NamingConvention` | `NamingConvention.OpenTelemetry` | Controls generated telemetry names; see [Naming conventions](https://github.com/purview-dev/telemetry-sourcegenerator/blob/main/docs/wiki#naming-conventions). | | `GenerateTelemetryNamesClass` | `true` | When `false`, suppresses the whole-assembly `TelemetryNames` class. | | `TelemetryNamesClassName` | `null` | Custom name for the `TelemetryNames` class (default `TelemetryNames`). | | `TelemetryNamesNamespace` | `null` | When set, relocates the implementation classes, the DI extension class, and the `TelemetryNames` class into this namespace. | Resolution order is interface-level first, then assembly-level, then built-in defaults. ## Naming conventions The `NamingConvention` enum controls the generated telemetry names. ```csharp public enum NamingConvention { Legacy = 0, // v3 behaviour: lowercase, smashed compounds OpenTelemetry = 1 // v4+ default: OTel conventions } ``` | Telemetry type | Legacy (v3) | OpenTelemetry (default) | | --- | --- | --- | | ActivitySource name | Assembly name lowercased: `"myapp"` | Assembly name preserved: `"MyApp"` | | Tag / baggage keys | Lowercased, smashed: `"entityid"` | snake_case: `"entity_id"` | | Metric instrument names | Lowercased, smashed: `"recordhistogram"` | Hierarchical dot.separated: `"myapp.products.record.histogram"` | | Metric tag keys | Lowercased, smashed: `"requestcount"` | snake_case: `"request_count"` | ```csharp // Revert all telemetry to v3 naming (assembly-level) [assembly: TelemetryGeneration(NamingConvention = NamingConvention.Legacy)] // Or set per-interface [TelemetryGeneration(NamingConvention = NamingConvention.Legacy)] interface IMyTelemetry { } ``` :::tip Use `NamingConvention.OpenTelemetry` (the default) for new projects. Only use `Legacy` if you need exact v3 name compatibility. ::: ## Dependency injection By default every telemetry interface generates a static extension class: ```csharp public static class OrderServiceTelemetryCoreDIExtension { public static IServiceCollection AddOrderServiceTelemetry(this IServiceCollection services); } ``` - The extension method name is `Add` + `{InterfaceName without the leading I}`. - The class lives in the `Microsoft.Extensions.DependencyInjection` namespace so the extension method is discoverable with the standard `using`. - The generated registration is `services.AddSingleton()`. - Set `GenerateDependencyExtension = false` to disable it, or `DependencyInjectionClassIsPublic = true` to make the class public. ## The `TelemetryNames` class When a compilation contains at least one `[ActivitySource]` or `[Meter]` target, the generator emits a single `TelemetryNames` static class per assembly with the distinct source names: ```csharp public static class TelemetryNames { public static readonly string[] MeterNames; public static readonly string[] ActivitySourceNames; } ``` It is used to register names with OpenTelemetry/ServiceDefaults: ```csharp builder.AddServiceDefaults(TelemetryNames.MeterNames, TelemetryNames.ActivitySourceNames); ``` Control it with `GenerateTelemetryNamesClass`, `TelemetryNamesClassName`, and `TelemetryNamesNamespace`. ## `[Exclude]` Mark a method with `[Exclude]` to skip it entirely during generation. This is useful for interface members that should not produce telemetry. ```csharp [Logger] interface IOrderServiceTelemetry { [Info] void OrderPlaced(int orderId); [Exclude] void DiagnosticOnly(); } ``` ## Assembly-level generation attributes | Attribute | What it controls | | --- | --- | | `[ActivitySourceGeneration]` | Default ActivitySource name, `DefaultToTags` behaviour, baggage/tag prefix and separator, and whether missing-Activity diagnostics (`TSG3014`/`TSG3015`) are generated. | | `[LoggerGeneration]` | Assembly-level default log level, default `LogPrefixType`, and default logging generation mode. | | `[MeterGeneration]` | Default meter name, meter-name generation type, instrument prefix/separator, and casing defaults. | See [Activities](../activities/), [Logging](../logging/), and [Metrics](../metrics/) for the full property tables. ## Next steps - [Activities](../activities/), [Logging](../logging/), [Metrics](../metrics/) — per-target attribute reference - [Tags, Baggage, and Parameter Attributes](../tags-and-baggage/) — parameter-level attributes - [Diagnostics](../diagnostics/) — analyzer rules # Getting Started > This guide gets you up and running with the Purview Telemetry Source Generator in minutes. You will add the package, define a telemetry interface, register it… # Getting Started This guide gets you up and running with the Purview Telemetry Source Generator in minutes. You will add the package, define a telemetry interface, register it with dependency injection, and start emitting Activities, structured logs, and Metrics. ## Installation Add the analyzer package to your project: ```bash dotnet add package Purview.Telemetry.SourceGenerator ``` Or reference it in your `.csproj` or `Directory.Build.props`: ```xml all analyzers ``` You may also need runtime dependencies depending on which telemetry types you use: - `System.Diagnostics.DiagnosticSource` for Activities - `Microsoft.Extensions.Logging.Abstractions` for `ILogger` - `System.Diagnostics.Metrics` for metrics (built into .NET 8+) See [Installation](../installation/) for the full dependency matrix. ## Define a telemetry interface Create a `public interface` and decorate it with class-level attributes. The generator creates the implementation and a DI registration extension. ### Single-target examples ```csharp using Purview.Telemetry; [Logger] public interface IOrderServiceLogs { [Info] void OrderPlaced(int orderId, string customerName); [Warning] void OrderNotFound(int orderId); } ``` ```csharp [ActivitySource] public interface IOrderServiceTracing { [Activity] Activity? PlacingOrder(int orderId); [Event] void OrderValidated(Activity? activity, int orderId); } ``` ```csharp [Meter] public interface IOrderServiceMetrics { [Counter] void OrderPlaced(int itemsInOrder); [Histogram] void OrderProcessingTime(long milliseconds); } ``` ### Multi-target example One interface can generate Activities, Logging, and Metrics from the same methods: ```csharp [ActivitySource] [Logger] [Meter] public interface IOrderServiceTelemetry { [Activity] [Info] [AutoCounter] Activity? PlacingOrder(int orderId, [Baggage] string region); [Event] [Trace] void OrderProcessed(Activity? activity, long durationMs); [Warning] void OrderFailed(int orderId, Exception exception); } ``` ## Register with DI The generator creates an extension method named `Add{InterfaceNameWithoutI}()`: ```csharp services.AddOrderServiceTelemetry(); ``` Inject the interface into your service: ```csharp public class OrderService(IOrderServiceTelemetry telemetry) { public void PlaceOrder(int orderId) { using var activity = telemetry.PlacingOrder(orderId, "EMEA"); // ... telemetry.OrderProcessed(activity, stopwatch.ElapsedMilliseconds); } } ``` A single method call can emit an Activity, a log entry, and a metric simultaneously. See [Generation](../generation/) for how registration helpers are produced and how to control them. ## Register names with OpenTelemetry The generator also produces a `TelemetryNames` static class containing the meter and activity source names: ```csharp builder.AddServiceDefaults(TelemetryNames.MeterNames, TelemetryNames.ActivitySourceNames); ``` ## Common patterns ### Pattern 1: Scoped logging Return `IDisposable?` from a log method to create scoped log entries: ```csharp [Logger] interface IOrderTelemetry { [Info] IDisposable? ProcessingOrder(Guid orderId); // Logs at start and end of scope [Error] void OrderFailed(Exception ex, Guid orderId); } public async Task ProcessOrderAsync(Guid orderId) { using (telemetry.ProcessingOrder(orderId)) { // Processing logic here } } ``` ### Pattern 2: Activity with multiple events Track multiple stages within a single activity: ```csharp [ActivitySource("ShippingService")] interface IShippingTelemetry { [Activity] Activity? ShippingPackage([Baggage]string trackingNumber); [Event] void PackageLabeled(Activity? activity); [Event] void PackageWeighed(Activity? activity, [Tag]decimal weight); [Event] void PackageShipped(Activity? activity, [Tag]string carrier); } ``` ### Pattern 3: Auto-incrementing counters Use `[AutoCounter]` for simple counting without a measurement parameter — every call increments by 1: ```csharp [Meter("ApiService")] interface IApiMetrics { [AutoCounter] void RequestReceived([Tag]string endpoint, [Tag]string method); [AutoCounter] void RequestFailed([Tag]string endpoint, [Tag]int statusCode); } ``` ## Viewing generated code To inspect the generated source, add this to your `.csproj`: ```xml true ``` Generated files appear under `obj/Debug|Release//generated/Purview.Telemetry.SourceGenerator/Purview.Telemetry.SourceGenerator.TelemetrySourceGenerator/`. ## Tips and best practices 1. **Return `Activity?`** from Activity-starting methods, not `Activity` or `void`, so callers can dispose and reuse the activity. 2. **Pass activities explicitly** — pass the `Activity?` from the starting method to event/context methods rather than relying on `Activity.Current`. 3. **One namespace** — use the single `using Purview.Telemetry;` import. 4. **OpenTelemetry naming** — v5 defaults to OpenTelemetry conventions (snake_case tags, hierarchical metrics). See [Naming conventions](../generation/#naming-conventions). 5. **DI registration** — use the generated `Add{InterfaceName}()` extension methods. 6. **Testing** — telemetry interfaces are easy to mock in unit tests. See [Testing](../testing/). ## Next steps - [Activities](../activities/) — deep dive into distributed tracing with Activities, Events, and Context - [Logging](../logging/) — structured logging and the v1/v2 generation modes - [Metrics](../metrics/) — counters, histograms, and observable instruments - [Multi-Targeting](../multi-targeting/) — combine multiple telemetry types in one interface - [Generation Options](../generation/) — control code generation, DI, and naming - [Generated Output](../generated-output/) — see what code is actually generated - [Migration](../refactorings/) — convert existing `ILogger`/`ActivitySource`/metrics code using the IDE refactorings # Installation > This page covers supported frameworks, installation methods, per-feature dependencies, and build configuration for the Purview.Telemetry.SourceGenerator… # Installation This page covers supported frameworks, installation methods, per-feature dependencies, and build configuration for the `Purview.Telemetry.SourceGenerator` package. ## Supported frameworks **Consumer runtime targets:** - .NET Framework 4.8 - .NET 8 or higher **Build toolchain requirement:** - Visual Studio 2026 (18.x) or the .NET 10 SDK (Roslyn 5.9.0+) The generator itself is a Roslyn component targeting `netstandard2.0`, so it works in any supported compiler host. ## Install the package ### .NET CLI ```bash dotnet add package Purview.Telemetry.SourceGenerator ``` ### Package Manager Console ```powershell Install-Package Purview.Telemetry.SourceGenerator ``` ### Project file (.csproj or Directory.Build.props) ```xml all analyzers ``` `PrivateAssets="all"` keeps the generator out of consumers' dependencies and `IncludeAssets="analyzers"` treats it purely as an analyzer. ## Runtime dependencies | Telemetry type | Required reference | Notes | | --- | --- | --- | | Activities | `System.Diagnostics.DiagnosticSource` | Included in the SDK for .NET 5+ | | Logging | `Microsoft.Extensions.Logging.Abstractions` | Required for `[Logger]` interfaces; `TSG2003` is raised if `ILogger` cannot be resolved | | Logging (v2) | `Microsoft.Extensions.Telemetry.Abstractions` | Required for state-based v2 generation (`LoggerMessageHelper`, `[LogProperties]`) | | Metrics | `System.Diagnostics.Metrics` | Included in the SDK for .NET 8+ | ## Verifying the install Create an interface with a telemetry attribute and build: ```csharp using Purview.Telemetry; [Logger] interface IMyTelemetry { [Info] void Hello(); } ``` If generation succeeds you will see a `MyTelemetryCore` implementation and an `AddMyTelemetry` extension method. To inspect the generated code, enable: ```xml true ``` Generated files land in `obj/Debug|Release//generated/Purview.Telemetry.SourceGenerator/Purview.Telemetry.SourceGenerator.TelemetrySourceGenerator/`. ## Build configuration ### `PURVIEW_TELEMETRY_ATTRIBUTES` Marker attributes are generated as `[Conditional("PURVIEW_TELEMETRY_ATTRIBUTES")]` and are internal to your assembly. Define this constant if you want to retain them in your build (for example, so the analyzer sees attribute usage in projects that share attribute source): ```xml $(DefineConstants);PURVIEW_TELEMETRY_ATTRIBUTES ``` ### `EXCLUDE_PURVIEW_TELEMETRY_LOGGING` Define this constant to disable logging generation entirely. This is useful when the `Microsoft.Extensions.Logging` types are not available in the compilation: ```xml $(DefineConstants);EXCLUDE_PURVIEW_TELEMETRY_LOGGING ``` ### `PURVIEW_TELEMETRY_NON_NULLABLE` Define this constant to opt out of the `TSG1011` unsupported-target-framework check for non-net8+/net48+ compilations: ```xml $(DefineConstants);PURVIEW_TELEMETRY_NON_NULLABLE ``` ## Project-type snippets ### ASP.NET Core ```csharp builder.Services.AddWeatherServiceTelemetry(); ``` ### Console / library ```csharp services.AddOrderServiceTelemetry(); ``` ### .NET Aspire ```csharp builder.AddServiceDefaults(TelemetryNames.MeterNames, TelemetryNames.ActivitySourceNames); ``` See the [sample application](../sample-application/) for a complete Aspire setup on .NET 10. ## Troubleshooting **"Type or namespace 'ActivitySourceAttribute' could not be found"** - Ensure `using Purview.Telemetry;` is present (all attributes live in the single `Purview.Telemetry` namespace). - Check the package reference includes `IncludeAssets="analyzers"`. **"No implementation found for interface"** - Verify the interface has at least one of `[ActivitySource]`, `[Logger]`, or `[Meter]`. - Check that methods have the appropriate method-level attributes. - Rebuild to trigger source generation. **"ActivitySource not producing traces"** - Ensure the generated `Add{InterfaceName}()` DI registration method has been called. - Configure OpenTelemetry to listen to your ActivitySource name. **"Logs not appearing"** - Verify `ILogger` is configured in your application. - Check that log-level filters are not excluding your messages. - Ensure DI registration was called. **"Metrics not collected"** - Configure a metrics exporter in your application. - Verify the meter name matches what your collector expects. - Confirm instruments are recorded with valid measurement values. ## Next steps - [Getting Started](../getting-started/) — write your first telemetry interface - [Generation Options](../generation/) — control class names, DI, and naming - [Diagnostics](../diagnostics/) — analyzer warnings and errors # Logging > There are two distinct generated types for ILoggerhttps://learn.microsoft.com/en-us/dotnet/api/microsoft.extensions.logging.ilogger-based generation. # Logging There are two distinct generated types for [`ILogger`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.extensions.logging.ilogger)-based generation. To signal an interface for logging generation, decorate it with the `[Logger]` attribute. To signal a method, use `[Log]` (or one of the semantic level attributes). All logging attributes live in the `Purview.Telemetry` namespace. ## Generation v1 vs v2 | | Generation v1 | Generation v2 | | --- | --- | --- | | Implementation | [`LoggerMessage`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.extensions.logging.loggermessage)/[`LoggerMessage.Define`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.extensions.logging.loggermessagedefine) high-performance logging | State-based output resembling the built-in [`[LoggerMessage]`](https://learn.microsoft.com/en-us/dotnet/core/extensions/logger-message-generator) generator | | Parameter limit | Maximum 6 non-exception parameters plus one optional `Exception` | No fixed parameter limit | | `[ExpandEnumerable]` | No effect | Supported | | `[LogProperties]` | No effect | Supported | | Required reference | `Microsoft.Extensions.Logging` | `Microsoft.Extensions.Telemetry.Abstractions` | ### How `Auto` mode works The default `LoggerGenerationMode.Auto` selects the mode **per method**: - **v1** is used when the method is within the v1 limits (≤ 6 non-exception parameters, at most one `Exception`, and no `[ExpandEnumerable]` or `[LogProperties]` parameters). - **v2** is used when the method needs it (more than 6 parameters, `[ExpandEnumerable]`, or `[LogProperties]`), which requires `Microsoft.Extensions.Telemetry.Abstractions`. You can force a mode with the `GenerationMode` property: - `[Log].GenerationMode` — per method - `[Logger].GenerationMode` — per interface - `[LoggerGeneration].GenerationMode` — per assembly ```csharp // Force v2 for one method [Logger] interface IOrderServiceTelemetry { [Info(GenerationMode = LoggerGenerationMode.V2)] void OrderPlaced(int orderId, string customerName); } ``` :::caution If you force `V2` (or use a method that requires v2) without referencing `Microsoft.Extensions.Telemetry.Abstractions`, the generated code will not compile because it uses `LoggerMessageHelper` and `LogPropertiesAttribute` from that package. ::: ## Disabling logging generation Define the `EXCLUDE_PURVIEW_TELEMETRY_LOGGING` constant to ignore the logging attributes entirely: ```xml EXCLUDE_PURVIEW_TELEMETRY_LOGGING ``` This is primarily used when the [`ILogger`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.extensions.logging.ilogger) type is unavailable — without it your project will fail to compile because the generated attributes reference related types such as [`LogLevel`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.extensions.logging.loglevel). ## Scoped loggers Return `IDisposable`/`IDisposable?` from a log method to generate a scoped log entry: ```csharp [Logger] interface IOrderServiceTelemetry { [Info] IDisposable? ProcessingOrder(Guid orderId); } ``` When a method returns `IDisposable?`, the entry is emitted when the scope is created and the duration is logged when it is disposed. A scoped method should not specify an explicit level (`TSG2007` warns if it does, since the level is ignored). ## Next pages - [Generation v1](../logging-generation-v1/) — `LoggerMessage.Define` mode and its limits - [Generation v2](../logging-generation-v2/) — state-based mode, `[ExpandEnumerable]`, `[LogProperties]` - [Breaking Changes](../breaking-changes/#namespace-consolidation) — namespace migration - [Diagnostics](../diagnostics/) — the `TSG2xxx` Logging rules # Logging Generation v1 > Generation v1 is the previous style of log generation, built on… # Logging Generation v1 :::caution All attributes are now in the unified `Purview.Telemetry` namespace. See [Breaking Changes](../breaking-changes/#namespace-consolidation) for migration details. ::: Generation v1 is the previous style of log generation, built on [`LoggerMessage.Define`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.extensions.logging.loggermessagedefine) from the [high-performance logging](https://learn.microsoft.com/en-us/dotnet/core/extensions/high-performance-logging) libraries. It has these limitations: - A maximum of **6** non-exception parameters. - At most **one** `Exception` parameter. - No support for expanding enumerations (`[ExpandEnumerable]` has no effect). - No support for `[LogProperties]`. :::caution The maximum number of parameters allowed by the `LoggerMessage` class is **6**, plus one optional `Exception`. You must reference the `Microsoft.Extensions.Logging` package. ::: ## Selecting v1 v1 is used automatically in `LoggerGenerationMode.Auto` for any method that is within the v1 limits. You can force it for all methods on an interface or assembly: ```csharp [Logger(GenerationMode = LoggerGenerationMode.V1)] interface IOrderServiceTelemetry { } ``` ## Log generation To generate a log entry, decorate the method with `[Log]` and return either `void` (non-scoped) or `IDisposable`/`IDisposable?` (scoped). Parameters form part of the structured log generation and are referenced by the `MessageTemplate`. You can also use the semantic level attributes instead of `[Log].Level`: - `[Trace]`, `[Debug]`, `[Info]`, `[Warning]`, `[Error]`, `[Critical]` These support all the same properties as `[Log]`, except `Level`. ## Inferring When not using multi-targeting you can omit `[Log]` entirely — every method on a `[Logger]` interface becomes a log method. Set the default level with `[Logger].DefaultLevel` (interface) or `[LoggerGeneration].DefaultLevel` (assembly). If no level is specified on the method and an `Exception` parameter is present, the level is changed to `Error` automatically. This raises the `TSG2002` diagnostic, which can safely be ignored if you are comfortable with the inferred behaviour. ## Attribute reference ### `[Log]` | Property | Type | Description | | --- | --- | --- | | `Level` | [`LogLevel`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.extensions.logging.loglevel) | The level used when defining the log entry. Available on construction. Defaults to `Information`, unless an `Exception` is detected in the parameters, then `Error`. | | `MessageTemplate` | `string?` | The template used to populate the log entry. If not specified, one is generated from the prefixes and available parameters. Available on construction. Default `null`. | | `EventId` | `int?` | Used when generating the [`EventId`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.extensions.logging.eventid). Available on construction. Default `null` (one is generated if not supplied). | | `Name` | `string?` | The name of the log entry. If not defined, the method name is used. Available on construction. Default `null`. | | `GenerationMode` | `LoggerGenerationMode` | Per-method generation-mode override. `Auto` (default) inherits from the interface/assembly level. | ### `[Logger]` | Property | Type | Description | | --- | --- | --- | | `DefaultLevel` | `LogLevel` | The default level when one is not provided. Available on construction. Default `Information`. | | `CustomPrefix` | `string?` | Used when generating the log entry name's prefix. When set, `PrefixType` is automatically `Custom`. Available on construction. Default `null`. | | `PrefixType` | `LogPrefixType` | The prefix type used when generating the log entry name. Default `Default`. | | `GenerationMode` | `LoggerGenerationMode` | Controls the generation mode for all log methods on the interface. `Auto` (default) selects the best mode per method. | ### `[LoggerGeneration]` | Property | Type | Description | | --- | --- | --- | | `DefaultLevel` | `LogLevel` | The default level when one is not provided. Available on construction. Default `Information`. | | `GenerationMode` | `LoggerGenerationMode` | Controls the generation mode for all log methods in the assembly. `Auto` (default) selects the best mode per method. | | `DefaultPrefixType` | `LogPrefixType` | The default prefix type for all log entries in the assembly. Default `Default`. | ### `LogPrefixType` | Value | Description | | --- | --- | | `Default` | No prefix. | | `Interface` | Uses the name of the interface. | | `Class` | The name of the class used for generation (`TelemetryGenerationAttribute.ClassName` or auto-generated). | | `Custom` | Used when `LoggerAttribute.CustomPrefix` is set. | | `TrimmedClassName` | The interface name without the `I` prefix or `Log`, `Logger`, or `Telemetry` suffixes. | ## Next steps - [Logging Generation v2](../logging-generation-v2/) — the state-based mode with `[ExpandEnumerable]`/`[LogProperties]` - [Logging](../logging/) — choosing between v1 and v2 - [Diagnostics](../diagnostics/) — the `TSG2xxx` Logging rules # Logging Generation v2 > Generation v2 is the state-based logging mode. Its output closely resembles the built-in… # Logging Generation v2 :::caution All attributes are now in the unified `Purview.Telemetry` namespace. See [Breaking Changes](../breaking-changes/#namespace-consolidation) for migration details. ::: Generation v2 is the state-based logging mode. Its output closely resembles the built-in [`[LoggerMessage]`](https://learn.microsoft.com/en-us/dotnet/core/extensions/logger-message-generator) generator, but with additional features: dynamically generated `MessageTemplate`s and the ability to expand array/`IEnumerable` parameters. It is used automatically in `LoggerGenerationMode.Auto` for methods that exceed the [v1](../logging-generation-v1/) limits (more than 6 non-exception parameters, or `[ExpandEnumerable]`/`[LogProperties]` parameters). :::caution Generation v2 requires the `Microsoft.Extensions.Telemetry.Abstractions` package (`LoggerMessageHelper` and `LogPropertiesAttribute`). Reference it directly or transitively when any method uses v2 generation. ::: ## Selecting v2 ```csharp // Force v2 for the whole interface [Logger(GenerationMode = LoggerGenerationMode.V2)] interface IOrderServiceTelemetry { } // Or per method [Logger] interface IOrderServiceTelemetry { [Info(GenerationMode = LoggerGenerationMode.V2)] void OrderPlaced(int orderId, string customerName); } ``` ## Log generation As with v1, decorate a method with `[Log]` (or a semantic level attribute) and return either `void` (non-scoped) or `IDisposable`/`IDisposable?` (scoped). The shared `[Log]`, `[Logger]`, `[LoggerGeneration]`, and `LogPrefixType` reference tables are on the [Logging Generation v1](../logging-generation-v1/#log) page. ## Custom message templates `[Log].MessageTemplate` lets you customise the log message. Template placeholders map to method parameters: ```csharp [Logger] interface IOrderServiceTelemetry { [Info("Order {OrderId} placed for {CustomerName}")] void OrderPlaced(int orderId, string customerName); } ``` If no template is specified, one is generated from the method name and parameters. ## `[ExpandEnumerable]` Applied to an array or `IEnumerable` parameter, it logs the individual elements rather than the collection as a whole. | Property | Type | Description | | --- | --- | --- | | `MaximumValueCount` | `int` | The maximum number of elements to output. Default `5`. | ```csharp [Logger] interface IOrderServiceTelemetry { [Info] void OrdersRetrieved(int orderId, [ExpandEnumerable(maximumValueCount: 100)] string[] orderNumbers); } ``` :::note A `MaximumValueCount` greater than the recommended default of 5 generates the `TSG2008` warning. It can be ignored, but test your application's performance thoroughly. ::: ## `[LogProperties]` The external `Microsoft.Extensions.Logging.LogPropertiesAttribute` (from `Microsoft.Extensions.Telemetry.Abstractions`) expands an object's public properties into individual log properties. | Property | Type | Description | | --- | --- | --- | | `OmitReferenceName` | `bool` | Whether the reference name is omitted from property names. Default `false`. | | `SkipNullProperties` | `bool` | Whether null properties are skipped. Default `false`. | | `Transitive` | `bool` | Whether nested objects are expanded transitively. Default `false`. | The companion `LogPropertyIgnoreAttribute` marks a property to be skipped during expansion. :::caution `[LogProperties]` and `[ExpandEnumerable]` cannot be applied to the same parameter — this raises `TSG2006`. ::: ## Next steps - [Logging Generation v1](../logging-generation-v1/) — the `LoggerMessage.Define` mode and its limits - [Logging](../logging/) — choosing between v1 and v2 - [Diagnostics](../diagnostics/) — the `TSG2xxx` Logging rules # Metrics > All metric-related attributes live in the Purview.Telemetry namespace. To signal an interface for meter generation, decorate it with the Meter attribute. # Metrics :::caution All attributes are now in the unified `Purview.Telemetry` namespace. See [Breaking Changes](../breaking-changes/#namespace-consolidation) for migration details. ::: All metric-related attributes live in the `Purview.Telemetry` namespace. To signal an interface for meter generation, decorate it with the `[Meter]` attribute. When creating the meter types, the [`IMeterFactory`](https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.metrics.imeterfactory) is used when available (it is not available on .NET Framework 4.8, where a `new Meter(...)` is created instead). ## Meter naming The meter name is resolved in this order: 1. `MeterAttribute.Name` (interface) 2. `MeterGenerationAttribute.MeterName` (assembly) 3. The assembly name ```csharp [Meter("InventoryService")] interface IInventoryMetrics { } ``` When the `NamingConvention.OpenTelemetry` naming convention is combined with `MeterNameGenerationType.OpenTelemetry`, instrument names are generated with the meter name as a dot-separated prefix (for example, `myapp.products.record.count`). ## Initialisation During initialisation you can implement a partial method to provide additional tags to any meters created by the `IMeterFactory`: ```csharp partial void PopulateMeterTags(System.Collections.Generic.Dictionary meterTags) { meterTags["environment"] = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT"); } ``` Any tags added to `meterTags` are included on every meter created. ## Instrument types Each instrument is determined by its corresponding attribute: - `[AutoCounter]` and `[Counter]` generate the [`Counter`](https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.metrics.counter-1) instrument. - `[Histogram]` generates the [`Histogram`](https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.metrics.histogram-1) instrument. - `[UpDownCounter]` generates the [`UpDownCounter`](https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.metrics.updowncounter-1) instrument. - `[ObservableCounter]` generates the [`ObservableCounter`](https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.metrics.observablecounter-1) instrument. - `[ObservableGauge]` generates the [`ObservableGauge`](https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.metrics.observablegauge-1) instrument. - `[ObservableUpDownCounter]` generates the [`ObservableUpDownCounter`](https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.metrics.observableupdowncounter-1) instrument. The measurement type must be one of `byte`, `short`, `int`, `long`, `float`, `double`, or `decimal`. ## The measurement value The measurement parameter is the parameter decorated with `[InstrumentMeasurement]`. For non-auto-increment instruments, if no parameter is decorated the first parameter with a valid measurement type that is not a `[Tag]`/`[Baggage]` is used. - `[AutoCounter]` and `[Counter(AutoIncrement = true)]` increment by **1** each time the method is called and must **not** declare a measurement parameter (`TSG4002`). - `[Counter]`, `[Histogram]`, and `[UpDownCounter]` require a measurement value (`TSG4004`). - Observable instruments must declare a `Func` parameter (`TSG4005`). ```csharp [Meter] interface IMeterTelemetry { [AutoCounter] void AutoIncrementMeter([Tag]string someValue); [Counter(AutoIncrement = true)] void AutoIncrementCounterMeter([Tag]string someValue); [Counter] void CounterMeter([InstrumentMeasurement]int measurement, [Tag]float someValue); [Histogram] void HistogramMeter([InstrumentMeasurement]int measurement, [Tag]int someValue, [Tag]bool anotherValue); [UpDownCounter] void UpDownCounterMeter([InstrumentMeasurement]decimal measurement, [Tag]byte someValue); } ``` ### Observable instruments Observable instruments always take a `System.Func<>` parameter with one of the following shapes: - Any supported measurement type: `byte`, `short`, `int`, `long`, `float`, `double`, or `decimal` - [`Measurement`](https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.metrics.measurement-1) where `T` is a supported measurement type - `IEnumerable>` where `T` is a supported measurement type ```csharp [ObservableCounter] void Counter(Func func); [ObservableGauge] void Gauge(Func> func); [ObservableUpDownCounter] void UpDownCounter(Func>> func); ``` ### Tags Other parameters on the method are used as tags. This is implicit for non-measurement parameters, but can also be made explicit with [`[Tag]`](../tags-and-baggage/). When there are four or more tags, the generated code uses a stack-allocated [`TagList`](https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.taglist). ## Attribute reference ### `[Meter]` Defines and controls the generation of meters and instruments on an interface, overriding assembly-level defaults. | Property | Type | Description | | --- | --- | --- | | `Name` | `string?` | The name of the meter, used to group instruments. If not specified, `MeterGenerationAttribute.MeterName` is used, then the assembly name. Default `null`. | | `InstrumentPrefix` | `string?` | The prefix used when generating instrument names. Default `null`. | | `IncludeAssemblyInstrumentPrefix` | `bool` | Whether the `MeterGenerationAttribute.InstrumentPrefix` prefix is included in instrument names. Default `true`. | | `LowercaseInstrumentName` | `bool` | Whether instrument names (including prefix) are lower-cased. Default `true`. | | `LowercaseTagKeys` | `bool` | Whether tag names are lower-cased. Default `true`. | ### `[MeterGeneration]` Controls meter and instrument defaults at the assembly level. | Property | Type | Description | | --- | --- | --- | | `MeterName` | `string?` | The default meter name when none is defined on an interface. Available on construction. | | `MeterNameGenerationType` | `MeterNameGenerationType` | Whether the meter name is used as a dot-separated prefix for instrument names. `OpenTelemetry` (0) adds the prefix; `DotNet` (1, default) does not. Available on construction. | | `InstrumentPrefix` | `string?` | The prefix used when generating instrument names. Default `null`. | | `InstrumentSeparator` | `string` | The separator used when generating prefixes. Default `.`. | | `LowercaseInstrumentName` | `bool` | Whether instrument names (including prefix) are lower-cased. Default `true`. | | `LowercaseTagKeys` | `bool` | Whether tag names are lower-cased. Default `true`. | ### `[InstrumentMeasurement]` Marks the parameter used as the instrument measurement value. Not supported with `[AutoCounter]` or `[Counter(AutoIncrement = true)]`. ## Instrument attributes ### `[AutoCounter]` Creates a `Counter` that increments by 1 per call. | Property | Type | Description | | --- | --- | --- | | `Name` | `string?` | Instrument name; defaults to the method name. Available on construction. Default `null`. | | `Unit` | `string?` | The unit used during meter generation. Available on construction. Default `null`. | | `Description` | `string?` | The description used during meter generation. Available on construction. Default `null`. | ### `[Counter]` Creates a `Counter`. | Property | Type | Description | | --- | --- | --- | | `AutoIncrement` | `bool` | When `true`, generates an auto-incrementing counter instead of accepting a measurement value from a parameter. Available on construction. Default `false`. | | `Name` | `string?` | Instrument name; defaults to the method name. Available on construction. Default `null`. | | `Unit` | `string?` | The unit used during meter generation. Available on construction. Default `null`. | | `Description` | `string?` | The description used during meter generation. Available on construction. Default `null`. | When `AutoIncrement` is `true` and `[InstrumentMeasurement]` is used, the `TSG4002` diagnostic is raised. ### `[Histogram]` and `[UpDownCounter]` Create a `Histogram` or `UpDownCounter`. Both share the `Name`, `Unit`, and `Description` properties described above. ### Observable attributes `[ObservableCounter]`, `[ObservableGauge]`, and `[ObservableUpDownCounter]` share the `Name`, `Unit`, and `Description` properties, plus: | Property | Type | Description | | --- | --- | --- | | `ThrowOnAlreadyInitialized` | `bool` | Whether the method throws when called more than once. Available on construction. Default `false`. | ## Next steps - [Tags, Baggage, and Parameter Attributes](../tags-and-baggage/) — tagging parameters - [Multi-Targeting](../multi-targeting/) — combining Metrics with Activities and Logging - [Diagnostics](../diagnostics/) — the `TSG4xxx` Metrics rules # Migration from Activities > This guide covers converting hand-written ActivitySource/Activity code to generated telemetry interfaces. For the fastest path, use the IDE… # Migration from Activities This guide covers converting hand-written `ActivitySource`/`Activity` code to generated telemetry interfaces. For the fastest path, use the [IDE refactorings](../refactorings/) — this page documents the manual fallback. ## Before ```csharp using System.Diagnostics; public class OrderService { static readonly ActivitySource _source = new("OrderService"); public void PlaceOrder(int orderId) { using var activity = _source.StartActivity("PlaceOrder", ActivityKind.Internal); activity?.AddEvent(new ActivityEvent("Validated")); } } ``` ## After ```csharp using System.Diagnostics; using Purview.Telemetry; [ActivitySource] public interface IOrderServiceTracing { [Activity] Activity? PlaceOrder(int orderId); [Event] void Validated(Activity? activity); } public class OrderService(IOrderServiceTracing tracing) { public void PlaceOrder(int orderId) { using var activity = tracing.PlaceOrder(orderId); tracing.Validated(activity); } } ``` Register the interface in DI: ```csharp services.AddOrderServiceTracing(); ``` ## Common conversions | Original code | Generated method signature | | --- | --- | | `_source.StartActivity("PlaceOrder")` | `[Activity] Activity? PlaceOrder();` | | `_source.StartActivity("Order", ActivityKind.Internal)` | `[Activity(ActivityKind.Internal)] Activity? Order();` | | `activity.AddEvent(new ActivityEvent("Loaded"))` | `[Event] void Loaded(Activity? activity);` | | `activity.SetBaggage("tenant", tenantId)` | `[Context] void SetTenant(Activity? activity, [Baggage] string tenantId);` | | `activity.SetTag("tenant", tenantId)` | `[Context] void SetTenant(Activity? activity, [Tag] string tenantId);` | ## Return types - Activity-starting methods should return `Activity?` so callers can dispose and reuse the Activity. - Event and Context methods should accept the `Activity?` as their first parameter and typically return `void`. ## Tags and baggage Use `[Tag]` and `[Baggage]` on parameters to control how values are attached — see [Tags, Baggage, and Parameter Attributes](../tags-and-baggage/). ## Naming The ActivitySource name defaults to the assembly name with casing preserved. Set `[ActivitySource("MyApp")]` to override. v4+ converts tag/baggage keys to snake_case by default; revert with `[assembly: TelemetryGeneration(NamingConvention = NamingConvention.Legacy)]`. ## Checklist 1. Add the `Purview.Telemetry.SourceGenerator` package. 2. Create the `[ActivitySource]` interface (or run the [ActivitySource refactoring](../refactorings/#convert-activitysource-to-iclassnametracing)). 3. Replace `ActivitySource` fields and `StartActivity`/`AddEvent`/`SetTag`/`SetBaggage` calls with the interface. 4. Register with `services.Add{InterfaceNameWithoutI}()`. 5. Rebuild and review the [Diagnostics](../diagnostics/). ## Next steps - [Refactorings](../refactorings/) — the shipped IDE conversions - [Activities](../activities/) — full attribute reference - [Multi-Targeting](../multi-targeting/) — combine with Logging and Metrics # Migration from ILogger > This guide covers converting hand-written ILogger usage to generated telemetry interfaces. For the fastest path, use the IDE refactoringsRefactorings.md —… # Migration from ILogger This guide covers converting hand-written `ILogger` usage to generated telemetry interfaces. For the fastest path, use the [IDE refactorings](../refactorings/) — this page documents the manual fallback. ## Before ```csharp using Microsoft.Extensions.Logging; public class OrderService(ILogger logger) { public void PlaceOrder(int orderId, string customerName) { logger.LogInformation("Placing order {OrderId} for {CustomerName}", orderId, customerName); } public void CancelOrder(int orderId, Exception ex) { logger.LogError(ex, "Order {OrderId} cancelled", orderId); } } ``` ## After ```csharp using Purview.Telemetry; [Logger] public interface IOrderServiceLogs { [Info("Placing order {OrderId} for {CustomerName}")] void OrderPlaced(int orderId, string customerName); [Error("Order {OrderId} cancelled")] void OrderCancelled(Exception ex, int orderId); } public class OrderService(IOrderServiceLogs logger) { public void PlaceOrder(int orderId, string customerName) { logger.OrderPlaced(orderId, customerName); } public void CancelOrder(int orderId, Exception ex) { logger.OrderCancelled(ex, orderId); } } ``` Register the interface in DI: ```csharp services.AddOrderServiceLogs(); ``` ## Log-level mapping | `ILogger` call | Generated attribute | | --- | --- | | `LogTrace(...)` | `[Trace]` | | `LogDebug(...)` | `[Debug]` | | `LogInformation(...)` | `[Info]` | | `LogWarning(...)` | `[Warning]` | | `LogError(...)` | `[Error]` | | `LogCritical(...)` | `[Critical]` | | `Log(LogLevel.X, ...)` | `[Log(LogLevel.X, ...)]` | ## Scoped logging Return `IDisposable?` from the method to generate a scoped log entry: ```csharp [Logger] public interface IOrderServiceLogs { [Info("Processing order {OrderId}")] IDisposable? ProcessingOrder(int orderId); } ``` ## `LoggerMessage.Define` Replace a `LoggerMessage.Define` static field with a generated method: ```csharp // Before static readonly Action, int, Exception?> _failed = LoggerMessage.Define(LogLevel.Error, 0, "Order {OrderId} failed"); _failed(_logger, orderId, ex); ``` ```csharp // After [Error("Order {OrderId} failed")] void OrderFailed(int orderId, Exception ex); logger.OrderFailed(orderId, ex); ``` ## Structured data For collections, use `[ExpandEnumerable]`; for objects, use `[LogProperties]` — see [Logging Generation v2](../logging-generation-v2/). ## Naming v4+ converts parameter names to snake_case by default (OpenTelemetry convention). To revert to v3 naming, apply `[assembly: TelemetryGeneration(NamingConvention = NamingConvention.Legacy)]`. See [Naming conventions](../generation/#naming-conventions). ## Checklist 1. Add the `Purview.Telemetry.SourceGenerator` package and `Microsoft.Extensions.Logging.Abstractions`. 2. Create the `[Logger]` interface (or run the [ILogger refactoring](../refactorings/#convert-ilogger-to-iclassnamelogs)). 3. Replace `ILogger` members and calls with the interface. 4. Register with `services.Add{InterfaceNameWithoutI}()`. 5. Rebuild and review the [Diagnostics](../diagnostics/). ## Next steps - [Refactorings](../refactorings/) — the shipped IDE conversions - [Logging](../logging/) — v1/v2 generation modes - [Multi-Targeting](../multi-targeting/) — combine with Activities and Metrics # Multi-Targeting > Multi-targeting lets you generate multiple types of telemetry Activities, Logs, and Metrics from a single interface or even a single method. One method call… # Multi-Targeting Multi-targeting lets you generate **multiple types of telemetry** (Activities, Logs, and Metrics) from a single interface or even a single method. One method call can emit an Activity, a structured log entry, and a metric simultaneously. ## Interface-level multi-targeting Apply multiple generation attributes to an interface to enable all telemetry types: ```csharp using Purview.Telemetry; [ActivitySource("OrderService")] [Logger] [Meter("OrderService")] interface IOrderTelemetry { // Methods can use one or more telemetry types } ``` ## Method-level multi-targeting Multiple telemetry attributes can be combined on a single method. When called, the method emits all specified telemetry types: ```csharp [ActivitySource("OrderService")] [Logger] [Meter] interface IOrderTelemetry { // MULTI-TARGET: Creates Activity + Logs Info + Increments Counter from one call [Activity] [Info] [AutoCounter] Activity? ProcessingOrder([Baggage]int orderId, [Tag]string customerName); // MULTI-TARGET: Adds ActivityEvent + Logs as Debug [Event] [Debug] void OrderValidated(Activity? activity, decimal amount); // SINGLE-TARGET: only logs [Warning] void OrderRejected(int orderId, string reason); // SINGLE-TARGET: only metric [Histogram] void OrderProcessingDuration([InstrumentMeasurement]int milliseconds); } ``` ```csharp // Single method call emits 3 telemetry types using var activity = telemetry.ProcessingOrder(123, "Acme Corp"); // ✓ Activity created and started // ✓ Info log entry written // ✓ Counter incremented by 1 ``` ### Supported combinations | Combination | Supported | Example | | --- | --- | --- | | Activity + Log | ✅ | `[Activity]` + `[Info]` | | Activity + Metric | ✅ | `[Activity]` + `[AutoCounter]` | | Log + Metric | ✅ | `[Info]` + `[Histogram]` | | Activity + Log + Metric | ✅ | `[Activity]` + `[Info]` + `[AutoCounter]` | | Event + Log | ✅ | `[Event]` + `[Debug]` | | Context + Log | ✅ | `[Context]` + `[Trace]` | **Rules:** - Only **one attribute per telemetry family** is allowed per method (`TSG1002`). - Activities: use one of `[Activity]`, `[Event]`, or `[Context]`. - Logging: use one of `[Log]`, `[Trace]`, `[Debug]`, `[Info]`, `[Warning]`, `[Error]`, `[Critical]`. - Metrics: use one of `[Counter]`, `[AutoCounter]`, `[Histogram]`, `[UpDownCounter]`, or the observable variants. ```csharp // ERROR TSG1002: multiple activity attributes [Activity] [Event] void InvalidMethod(Activity? activity); // ERROR TSG1002: multiple logging attributes [Info] [Warning] void InvalidMethod(string message); // ERROR TSG1002: multiple metric attributes [Counter] [Histogram] void InvalidMethod([InstrumentMeasurement]int value); ``` ## Excluding parameters from specific targets Use `[ExcludeTargets(Targets.X)]` on a parameter to exclude it from specific telemetry families: ```csharp [ActivitySource("PaymentService")] [Logger] [Meter] interface IPaymentTelemetry { [Activity] [Info] [Counter] Activity? ProcessingPayment( [Baggage]Guid paymentId, // Exclude the verbose message from metrics (would be wasted as a tag) [ExcludeTargets(Targets.Metrics)] string processingMessage, // Measurement only applies to metrics [InstrumentMeasurement] decimal amount, // Exclude internal details from Activity baggage [ExcludeTargets(Targets.Activities)] [Tag] // still included in logs and metrics string internalReference ); } ``` ### The `Targets` enum ```csharp [Flags] public enum Targets { None = 0, Activities = 1, Logging = 2, Metrics = 4, All = Activities | Logging | Metrics } ``` ```csharp // Exclude from multiple targets [ExcludeTargets(Targets.Activities | Targets.Metrics)] string loggingOnlyParameter; // Exclude from a single target [ExcludeTargets(Targets.Logging)] int metricsAndActivityParameter; ``` ## Inference is disabled with multi-targeting When an interface has **multiple** class-level attributes (`[ActivitySource]`, `[Logger]`, `[Meter]`), inference is disabled and every method must declare its targets explicitly: ```csharp [ActivitySource("MyApp")] [Logger] [Meter] interface IMyTelemetry { // ERROR TSG1001: no explicit attribute void ProcessItem(int id); // ✅ CORRECT: explicit attribute [Info] void ProcessItem(int id); // ✅ CORRECT: excluded from generation [Exclude] void ProcessItem(int id); } ``` Single-target interfaces (only one class-level attribute) keep inference — see [Activities](../activities/#inferring-method-type) and [Logging](../logging/). ## Return types - **Activity + other targets** — return `Activity?` so callers can dispose and reuse the Activity: ```csharp [Activity] [Info] [AutoCounter] Activity? ProcessingOrder(int orderId); ``` - **Log + Metric (no Activity)** — return `void` or `IDisposable?`: ```csharp [Info] [Histogram] void RecordOperation([InstrumentMeasurement]int duration, string operation); [Info] [AutoCounter] IDisposable? ProcessingBatch(int batchId); // scoped log + counter ``` - **Event/Context + Log** — return `void`: ```csharp [Event] [Debug] void OrderCompleted(Activity? activity, decimal total); ``` ## Common patterns ### Complete observability method ```csharp [Activity] // distributed tracing [Info] // structured logging [AutoCounter] // count occurrences Activity? ProcessingRequest( [Baggage]string requestId, [Tag]string endpoint, [Tag]string method ); ``` ### Event with context logging ```csharp [Event] [Debug] void StepCompleted( Activity? activity, [Tag]string stepName, [Tag]int duration ); ``` ### Selective parameter usage ```csharp [Activity] [Info] [Counter] Activity? ApiCall( [Baggage]string traceId, // Activity baggage only [ExcludeTargets(Targets.Metrics)] string verboseMessage, // Activity + Log only [InstrumentMeasurement] [ExcludeTargets(Targets.Activities | Targets.Logging)] int callCount, // Metrics only [Tag]string endpoint // All three ); ``` ## Benefits 1. **Less code** — one method definition generates multiple telemetry types 2. **Consistency** — the same parameters feed every telemetry type 3. **Atomicity** — all telemetry emitted together 4. **Maintainability** — change once, affects all telemetry types 5. **Performance** — a single method call instead of several ## Diagnostics | Diagnostic | When | Resolution | | --- | --- | --- | | `TSG1001` | Method has no explicit attribute on a multi-target interface | Add `[Activity]`, `[Info]`, `[Counter]`, etc., or `[Exclude]` | | `TSG1002` | Multiple attributes from the same family on one method | Use only one Activity, Logging, or Metrics attribute per method | | `TSG1006` | `[ExcludeTargets]` references a target not present on the method | Remove `[ExcludeTargets]` or add the target attribute to the method | | `TSG1007` | `[ExcludeTargets]` leaves an invalid parameter set for a target | Adjust exclusions so valid parameters remain for each target | ## See also - [Activities](../activities/) — Activity generation details - [Logging](../logging/) — logging generation details - [Metrics](../metrics/) — metrics generation details - [Tags, Baggage, and Parameter Attributes](../tags-and-baggage/) — `[Tag]`, `[Baggage]`, `[ExcludeTargets]` - [Diagnostics](../diagnostics/) — error codes and resolutions # Performance > Full cross-runtime benchmark results for the Purview Telemetry Source Generator, generated by BenchmarkDotNethttps://benchmarkdotnet.org/ from the… # Performance Full cross-runtime benchmark results for the Purview Telemetry Source Generator, generated by [BenchmarkDotNet](https://benchmarkdotnet.org/) from the [`benchmarks/`](https://github.com/purview-dev/telemetry-sourcegenerator/tree/main/benchmarks) project. ## Reproducing ```bash dotnet run --project benchmarks/Purview.Telemetry.Benchmarks/Purview.Telemetry.Benchmarks.csproj \ --configuration Release --framework net10.0 ``` Results are written to `BenchmarkDotNet.Artifacts/results/` as `*-report-github.md`, `*.csv`, and `*.html`. :::caution BenchmarkDotNet numbers are only meaningful from **Release** builds. Run the suite fresh (`just benchmark-docs`) before relying on the tables below for a specific machine/SDK combination. ::: ## Environment ``` BenchmarkDotNet v0.15.8, Windows 11 (10.0.28020.2991) 13th Gen Intel Core i9-13900KF 3.00GHz, 1 CPU, 32 logical and 24 physical cores .NET SDK 10.0.401 .NET 10.0 : .NET 10.0.12 (10.0.12, 10.0.1226.42308), X64 RyuJIT x86-64-v3 .NET 8.0 : .NET 8.0.31 (8.0.31, 8.0.3126.42015), X64 RyuJIT x86-64-v3 .NET 9.0 : .NET 9.0.20 (9.0.20, 9.0.2026.41315), X64 RyuJIT x86-64-v3 ``` > **Note:** the .NET Framework 4.7/4.8 jobs were executed but produced no results in this run and are excluded from the tables below. Generated hot-path methods carry `[MethodImpl(MethodImplOptions.AggressiveInlining)]`, which lets the JIT inline the small generated telemetry methods into callers. ## Activities **Source:** `ActivityBenchmarks` Compares the source-generator-produced `ActivityOnlyTelemetryCore` against a hand-written `ManualActivityTelemetry` under two conditions: no listener registered (fast-path) and a full-sampling `ActivityListener` active (production path). | Method | Runtime | HasListener | Mean | Ratio | Allocated | | --- | --- | --- | --- | --- | --- | | Manual: start + complete | .NET 10.0 | False | 0.53 ns | 1.00 | - | | Generated: start + complete | .NET 10.0 | False | 0.58 ns | 1.11 | - | | Manual: start + fail | .NET 10.0 | False | 0.75 ns | 1.43 | - | | Generated: start + fail | .NET 10.0 | False | 0.55 ns | 1.04 | - | | Manual: start + complete | .NET 8.0 | False | 0.78 ns | 1.00 | - | | Generated: start + complete | .NET 8.0 | False | 0.88 ns | 1.12 | - | | Manual: start + fail | .NET 8.0 | False | 0.93 ns | 1.19 | - | | Generated: start + fail | .NET 8.0 | False | 0.76 ns | 0.97 | - | | Manual: start + complete | .NET 9.0 | False | 0.55 ns | 1.00 | - | | Generated: start + complete | .NET 9.0 | False | 0.77 ns | 1.40 | - | | Manual: start + fail | .NET 9.0 | False | 0.35 ns | 0.63 | - | | Generated: start + fail | .NET 9.0 | False | 0.59 ns | 1.07 | - | | **Manual: start + complete** | **.NET 10.0** | **True** | **220.37 ns** | **1.00** | **1008 B** | | Generated: start + complete | .NET 10.0 | True | 216.69 ns | 0.98 | 1008 B | | Manual: start + fail | .NET 10.0 | True | 207.55 ns | 0.94 | 920 B | | Generated: start + fail | .NET 10.0 | True | 203.12 ns | 0.92 | 920 B | | Manual: start + complete | .NET 8.0 | True | 259.23 ns | 1.00 | 1008 B | | Generated: start + complete | .NET 8.0 | True | 262.84 ns | 1.01 | 1008 B | | Manual: start + fail | .NET 8.0 | True | 229.10 ns | 0.88 | 920 B | | Generated: start + fail | .NET 8.0 | True | 241.07 ns | 0.93 | 920 B | | Manual: start + complete | .NET 9.0 | True | 226.38 ns | 1.00 | 1008 B | | Generated: start + complete | .NET 9.0 | True | 229.96 ns | 1.02 | 1008 B | | Manual: start + fail | .NET 9.0 | True | 219.02 ns | 0.97 | 920 B | | Generated: start + fail | .NET 9.0 | True | 211.70 ns | 0.94 | 920 B | **Interpretation:** Generated activities match hand-written code within ~2% and allocate identically across all tested runtimes. When a listener is active the absolute times are dominated by the framework's Activity creation, not the generated code. ## Logging **Source:** `LoggerBenchmarks` Compares three logging approaches: hand-written `LoggerMessage.Define` (gold-standard manual), generated v1 (`LoggerMessage.Define` pattern), and generated v2 (state-based `ThreadLocalState` pattern). | Method | Runtime | HasLogging | Mean | Ratio | Allocated | | --- | --- | --- | --- | --- | --- | | **Manual: single Info call** | **.NET 10.0** | **True** | **6.12 ns** | **1.00** | **-** | | Generated v1 — single Info call | .NET 10.0 | True | 4.43 ns | 0.72 | - | | Generated v2 — single Info call | .NET 10.0 | True | 3.80 ns | 0.62 | - | | Manual: full lifecycle (4 calls) | .NET 10.0 | True | 18.88 ns | 3.08 | - | | Generated v1 — full lifecycle | .NET 10.0 | True | 19.58 ns | 3.20 | - | | Generated v2 — full lifecycle | .NET 10.0 | True | 15.01 ns | 2.45 | - | | Manual: single Info call | .NET 8.0 | True | 7.55 ns | 1.00 | - | | Generated v1 — single Info call | .NET 8.0 | True | 7.51 ns | 0.99 | - | | Generated v2 — single Info call | .NET 8.0 | True | 5.10 ns | 0.68 | - | | Manual: full lifecycle | .NET 8.0 | True | 29.98 ns | 3.97 | - | | Generated v1 — full lifecycle | .NET 8.0 | True | 30.72 ns | 4.07 | - | | Generated v2 — full lifecycle | .NET 8.0 | True | 22.11 ns | 2.93 | - | | Manual: single Info call | .NET 9.0 | True | 6.88 ns | 1.00 | - | | Generated v1 — single Info call | .NET 9.0 | True | 6.50 ns | 0.95 | - | | Generated v2 — single Info call | .NET 9.0 | True | 5.06 ns | 0.74 | - | | Manual: full lifecycle | .NET 9.0 | True | 25.55 ns | 3.72 | - | | Generated v1 — full lifecycle | .NET 9.0 | True | 24.88 ns | 3.62 | - | | Generated v2 — full lifecycle | .NET 9.0 | True | 22.79 ns | 3.31 | - | **Interpretation:** Generated v1 and v2 both allocate **zero bytes** per call across all runtimes. On .NET 10.0 the generated variants are **faster** than the hand-written `LoggerMessage.Define` baseline for single calls (v1 0.72x, v2 0.62x) thanks to `[MethodImpl(AggressiveInlining)]`; v2 (state-based) is the fastest path on every runtime. The full-lifecycle cost is dominated by the four `IsEnabled` checks and message-formatting paths that all three implementations share. ## Multi-target **Source:** `MultiTargetVsSingleTargetBenchmarks` Measures the overhead of emitting Activity + Logging + Metrics from a single method call (multi-target) vs. Activity-only (single-target), comparing generated and manual code. | Method | Runtime | HasListener | Mean | Ratio | Allocated | Alloc Ratio | | --- | --- | --- | --- | --- | --- | --- | | **Single-target (generated): start + complete** | **.NET 10.0** | **True** | **216.15 ns** | **1.00** | **1008 B** | **1.00** | | Multi-target (generated): start + complete | .NET 10.0 | True | 230.41 ns | 1.07 | 1032 B | 1.02 | | Multi-target (manual): start + complete | .NET 10.0 | True | 260.18 ns | 1.20 | 1032 B | 1.02 | | Multi-target (generated): start + complete + record latency | .NET 10.0 | True | 229.65 ns | 1.06 | 1032 B | 1.02 | | Multi-target (manual): start + complete + record latency | .NET 10.0 | True | 234.58 ns | 1.09 | 1032 B | 1.02 | | Single-target (generated): start + complete | .NET 8.0 | True | 258.36 ns | 1.00 | 1008 B | 1.00 | | Multi-target (generated): start + complete | .NET 8.0 | True | 260.21 ns | 1.01 | 1032 B | 1.02 | | Multi-target (manual): start + complete | .NET 8.0 | True | 267.22 ns | 1.03 | 1032 B | 1.02 | | Single-target (generated): start + complete | .NET 9.0 | True | 220.24 ns | 1.00 | 1008 B | 1.00 | | Multi-target (generated): start + complete | .NET 9.0 | True | 249.15 ns | 1.13 | 1032 B | 1.02 | | Multi-target (manual): start + complete | .NET 9.0 | True | 257.27 ns | 1.17 | 1032 B | 1.02 | **Interpretation:** When an Activity listener is active (production path), multi-target generation adds ~7% overhead over single-target Activity-only on .NET 10.0 — the real cost of the extra log call and metric increment, not generated-code overhead. The generated multi-target code is faster than the hand-written multi-target baseline (~1.07x vs ~1.20x of single-target). See the [Generated Output](../generated-output/) page for what the multi-target implementation looks like. ## Logger multi-target **Source:** `LoggerMultiTargetBenchmarks` Compares single-target logging-only vs. multi-target (Activity + Logging + Metrics) generated code with a hand-written multi-target baseline. | Method | Runtime | HasListener | Mean | Ratio | Allocated | | --- | --- | --- | --- | --- | --- | | **Multi-target (manual): start + complete** | **.NET 10.0** | **True** | **264.31 ns** | **1.00** | **1032 B** | | Multi-target (generated v1): start + complete | .NET 10.0 | True | 236.44 ns | 0.89 | 1032 B | | Multi-target (generated v2): start + complete | .NET 10.0 | True | 227.94 ns | 0.86 | 1032 B | | Multi-target (manual): full lifecycle | .NET 10.0 | True | 243.84 ns | 0.92 | 1032 B | | Multi-target (generated v1): full lifecycle | .NET 10.0 | True | 249.32 ns | 0.94 | 1032 B | | Multi-target (generated v2): full lifecycle | .NET 10.0 | True | 216.86 ns | 0.82 | 1032 B | | Single-target (generated v1): full lifecycle | .NET 10.0 | True | 18.21 ns | 0.07 | - | | Single-target (generated v2): full lifecycle | .NET 10.0 | True | 15.69 ns | 0.06 | - | **Interpretation:** Generated and manual multi-target implementations are within ~6% of each other on .NET 10.0, allocating identically (1032 B with a listener active). Logging-only accounts for a small fraction (~16-18 ns) of the multi-target cost (~230 ns); Activity creation dominates when a listener is active. ## Metrics **Source:** `MetricsBenchmarks` and `TagListBenchmarks` All instruments are **0 allocations** on every runtime. | Scenario | Generated (.NET 10.0) | Generated (.NET 8.0) | Generated (.NET 9.0) | | --- | --- | --- | --- | | auto-counter (0 tags) | 0.40 ns | 0.55 ns | 0.21 ns | | auto-counter (1 tag) | 0.35 ns | 0.56 ns | 0.30 ns | | up-down counter | 0.37 ns | 0.39 ns | 0.20 ns | | histogram (0 tags) | 0.42 ns | 0.35 ns | 0.18 ns | | histogram (1 tag) | 0.34 ns | 0.36 ns | 0.37 ns | The source generator uses a tag-count optimization: methods with fewer than 4 tags pass inline `KeyValuePair` parameters (no heap allocation), while methods with 4 or more tags use a stack-allocated [`TagList`](https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.taglist) struct. | Scenario | .NET 10.0 | .NET 8.0 | .NET 9.0 | | --- | --- | --- | --- | | 0 tags: histogram record | 0.38 ns | 0.56 ns | 0.19 ns | | 1 tag: auto-counter add | 0.41 ns | 0.35 ns | 0.19 ns | | 3 tags: histogram record | 0.84 ns | 0.74 ns | 0.55 ns | | 4 tags (TagList): auto-counter add | 7.91 ns | 7.79 ns | 3.62 ns | | 5 tags (TagList): auto-counter add | 5.46 ns | 8.95 ns | 9.37 ns | | 6 tags (TagList): histogram record | 6.37 ns | 4.32 ns | 3.99 ns | The TagList path costs 14–21× more CPU than the inline path on .NET 10.0, but both remain in the single-digit-nanosecond range with zero allocations. ## Observable instruments `ObservableCounter`, `ObservableGauge`, and `ObservableUpDownCounter` are **not benchmarked** because they have no per-operation hot path to compare. They register a callback once at construction time (via `meter.CreateObservable*`) and are polled by the metrics collection pipeline — the generator produces the identical `CreateObservable*` call you would write by hand, so there is no wrapper layer on the measurement path. ## Raw results Full CSV and HTML benchmark artifacts are available in [`BenchmarkDotNet.Artifacts/results/`](https://github.com/purview-dev/telemetry-sourcegenerator/tree/main/BenchmarkDotNet.Artifacts/results). ## See also - [README.md § Performance](https://github.com/purview-dev/telemetry-sourcegenerator#performance) — condensed summary of the key .NET 10.0 numbers # Refactorings > The NuGet package ships four Visual Studio code refactorings alongside the source generator. Right-click a class containing hand-written telemetry and choose… # Refactorings The NuGet package ships four Visual Studio code refactorings alongside the source generator. Right-click a class containing hand-written telemetry and choose the relevant conversion — the refactoring creates a generated telemetry interface and rewrites the class to use it. ## Available refactorings | Refactoring | Converts | Generated interface | | --- | --- | --- | | **Convert ILogger to I{ClassName}Logs** | Hand-written `ILogger`/`ILogger` fields, properties, constructor parameters, and `Log*`/`Log` calls | `[Logger]` interface | | **Convert ActivitySource to I{ClassName}Tracing** | `ActivitySource` fields/properties/parameters and `StartActivity(...)` calls | `[ActivitySource]` interface | | **Convert Metrics to I{ClassName}Metrics** | `Counter`, `Histogram`, `UpDownCounter` fields/properties and `.Add(...)`/`.Record(...)` calls | `[Meter]` interface | | **Convert all telemetry to I{ClassName}Telemetry** | Any combination of the above in the same class | Single `[ActivitySource]`+`[Logger]`+`[Meter]` interface | Each refactoring offers scope options: - **In this class** — converts the selected class only - **In this document** — converts all matching classes in the file - **In this project** — converts all matching classes in the project - **In this solution** — converts all matching classes in the solution ## Convert ILogger to I{ClassName}Logs - `LogTrace` → `[Trace]`, `LogDebug` → `[Debug]`, `LogInformation` → `[Info]`, `LogWarning` → `[Warning]`, `LogError` → `[Error]`, `LogCritical` → `[Critical]`; `Log(LogLevel.X, ...)` maps to the matching semantic attribute (unmapped levels fall back to `[Log(LogLevel.X, ...)]`). - Literal message templates are embedded in the attribute: `[Info("Getting weather for {City}")]`. - Literal `int` event IDs are embedded: `[Info(42, "...")]`. - `Exception` parameters become an `exception` parameter of type `System.Exception`. - Method names derive from the message-template words (PascalCased), deduplicated with numeric suffixes. - Multiple logger fields/parameters on one constructor are consolidated into a single canonical injection. The refactoring fires when a class contains at least one `ILogger`/`ILogger` member **and** at least one recognized `Log*`/`Log` call on it. ## Convert ActivitySource to I{ClassName}Tracing - Each `StartActivity("name")` call becomes an interface method returning `Activity?`, decorated `[Activity]` (for kind `Internal`/unspecified) or `[Activity(ActivityKind.X)]` for explicit kinds. - Method names derive from the activity-name string, PascalCased (for example, `"get-weather"` → `GetWeather`); duplicates get numeric suffixes. - `ActivitySource`-typed fields/properties/parameters are rewritten to the interface type, and `StartActivity(...)` invocations become interface method calls. The refactoring fires when a class has at least one `ActivitySource` member **and** at least one `.StartActivity(...)` call on it. ## Convert Metrics to I{ClassName}Metrics - `Counter.Add(1)` (literal 1) → `[AutoCounter]` method with no parameters. - `Counter.Add(value)` → `[Counter] void Method(T value)`. - `Histogram.Record(value)` → `[Histogram] void Method(T value)`. - `UpDownCounter.Add(value)` → `[UpDownCounter] void Method(T value)`. - Additional tag arguments become `string tag1`, `string tag2`, ... parameters. - Method names derive from the field name with instrument suffixes (`UpDownCounter`, `Histogram`, `Counter`, `Gauge`, `Meter`) stripped, then PascalCased. - The `Meter` itself is not converted — the constructor's `Meter`/`IMeterFactory` usage and `CreateCounter` registration calls remain. The refactoring fires when a class has at least one metrics instrument member **and** at least one `.Add(...)`/`.Record(...)` call on it. :::note `System.Diagnostics.Metrics` is not available on .NET Framework, so the metrics refactoring is not available for those target frameworks. ::: ## Convert all telemetry to I{ClassName}Telemetry Composes the per-family conversions into a single interface decorated with `[ActivitySource]`, `[Logger]`, and/or `[Meter]` for only the families actually present (multi-targeting). It fires when a class uses any combination of logger/ActivitySource/metrics members **and** has at least one corresponding call. ## What the refactorings produce ```csharp using Purview.Telemetry; [Logger] public interface IOrderServiceLogs { [Info] void OrderPlaced(int orderId, string customerName); } ``` The original class is rewritten to use the interface: ```csharp public class OrderService(IOrderServiceLogs logger) { public void PlaceOrder(int orderId, string customerName) { logger.OrderPlaced(orderId, customerName); } } ``` ## Manual fallback If a refactoring does not cover a call pattern, convert it manually: 1. Identify the telemetry type (Logging, Activity, Metric). 2. Create a new interface with the matching class-level attribute (`[Logger]`, `[ActivitySource]`, `[Meter]`). 3. Add a method for each distinct operation, using the method-level attribute from the mapping below. 4. Replace the hand-written call with the interface method. 5. Register the interface in DI with `services.Add{InterfaceNameWithoutI}()`. | Hand-written telemetry | Generated attribute | | --- | --- | | `ILogger.LogInformation(...)` | `[Info]` | | `ILogger.LogDebug(...)` | `[Debug]` | | `ILogger.LogTrace(...)` | `[Trace]` | | `ILogger.LogWarning(...)` | `[Warning]` | | `ILogger.LogError(...)` | `[Error]` | | `ILogger.LogCritical(...)` | `[Critical]` | | `ActivitySource.StartActivity(...)` | `[Activity]` | | `activity.AddEvent(...)` | `[Event]` | | `activity.AddBaggage(...)` / `SetBaggage(...)` | `[Context]` with `[Baggage]` parameter | | `activity.SetTag(...)` | `[Context]` with `[Tag]` parameter | | `Counter.Add(...)` | `[Counter]`, or `[AutoCounter]` for `Add(1)` | | `Histogram.Record(...)` | `[Histogram]` | | `UpDownCounter.Add(...)` | `[UpDownCounter]` | ## See also - [Migration from ILogger](../migration-from-ilogger/) - [Migration from Activities](../migration-from-activities/) # Release Flow > Releases are driven by GitHub Actions using the reusable purview-dev/buildhttps://github.com/purview-dev/build pipelines. No release steps are performed… # Release Flow Releases are driven by GitHub Actions using the reusable [`purview-dev/build`](https://github.com/purview-dev/build) pipelines. No release steps are performed manually. ```text Feature branch → PR to main (pr.yml runs purview-build.yml: restore + build + lint + tests) → merge to main (release.yml runs purview-release.yml: build, test, pack, publish) → GitHub Release (NuGet package + changelog, created by the pipeline) ``` ## Workflow components | Component | File | Purpose | | --- | --- | --- | | PR gate | `.github/workflows/pr.yml` | Runs the reusable `purview-dev/build` `purview-build.yml` pipeline (restore, build, lint, tests) and builds/tests the sample solution. | | CD pipeline | `.github/workflows/release.yml` | Runs the reusable `purview-dev/build` `purview-release.yml` pipeline on push to `main`. | | Local pipeline | `Justfile` `pipeline-*` recipes | Mirror the CI/CD pipelines locally via the `Purview.Build` tool (`.tools/purview-build`). | ## Developer workflow 1. Create a feature branch and make your changes. 2. Validate locally with `just pipeline-pr` (restore, build, lint, tests — the same gate CI enforces). 3. Push and open a PR to `main`. `pr.yml` must pass (build, lint, tests, plus the sample build/test job). 4. Merge the PR into `main`. 5. The release is published automatically — `release.yml` restores and builds the main and sample solutions, runs the integration tests, packs the NuGet package, publishes it, and creates a GitHub Release. ## Versioning - The version lives in `package.json`. **Current Version:** 5.0.0-prerelease.8 - It is applied to `Version`/`PackageVersion` by `Purview.BuildSdk` via package.json version detection. - `just version` prints the current version. - After bumping `package.json`, run `just update-version` to sync the version into docs/samples. ## Building the package locally | Command | Purpose | | --- | --- | | `just pack` | Updates the version, then packs the NuGet package into `artifacts/`. | | `just pipeline-local-release` | Packs and publishes to a local NuGet feed (see the Justfile note on argument quoting for the feed path). | | `just pipeline-release` | Full release pipeline (build, test, pack, publish, GitHub release). | ## Pre-release validation Before a release, validate locally: 1. `just pipeline-pr` — restore, build, lint, tests. 2. `just build-s && just test-s` — sample solution build and tests. 3. `just pipeline-local-release` — confirm the pack + local publish succeed. ## Releasing 1. Bump the version in `package.json`. 2. Run `just update-version` to sync docs/samples. 3. Push a PR with the version bump; merge to `main`. 4. `release.yml` publishes the release automatically. > The legacy `scripts/setup-release.*` files describe a changeset-based (`@changesets/cli`) flow that is **not** used by this repository. They are retained for reference only; the actual release automation lives in `.github/workflows/pr.yml` and `.github/workflows/release.yml`. ## See also - [Contributing](../contributing/) — development setup and conventions # Sample Application > The .NET Aspire samplehttps://github.com/purview-dev/telemetry-sourcegenerator/tree/main/samples/SampleApp demonstrates Activities, Logs, and Metrics… # Sample Application The [.NET Aspire sample](https://github.com/purview-dev/telemetry-sourcegenerator/tree/main/samples/SampleApp) demonstrates Activities, Logs, and Metrics generation working together with the Aspire Dashboard. ## Solution layout | Project | Purpose | | --- | --- | | `SampleApp.AppHost` | The Aspire orchestrator. | | `SampleApp.APIService` | Backend service exposing the telemetry interfaces and the generated `TelemetryNames` registration. | | `SampleApp.APIService.UnitTests` | TUnit + NSubstitute unit tests over the generated interfaces. | | `SampleApp.Shared` | Shared DTOs (e.g. `WeatherForecast`). | | `SampleApp.Web` | Frontend client that also generates its own telemetry. | | `SampleApp.ServiceDefaults` | Aspire service defaults; registers the generated meter/activity source names. | The sample targets `net10.0` and enables `EmitCompilerGeneratedFiles`, so generated output can be inspected under: ``` obj//net10.0/generated/Purview.Telemetry.SourceGenerator/Purview.Telemetry.SourceGenerator.TelemetrySourceGenerator/ ``` ## Telemetry interfaces ### `IEntityStoreTelemetry` (APIService) The multi-target interface from the README — one method emits an Activity, an Info log, and an AutoCounter simultaneously: ```csharp [ActivitySource] [Logger] [Meter] interface IEntityStoreTelemetry { [Activity] [Info] [AutoCounter] Activity? GettingEntityFromStore(int entityId, [Baggage] string serviceUrl); [Event] [Trace] void GetDuration(Activity? activity, int durationInMS); [Context] void RetrievedEntity(Activity? activity, float totalValue, int lastUpdatedByUserId); [Warning] void EntityNotFound(int entityId); [Histogram] void RecordEntitySize(int sizeInBytes); } ``` ### `IWeatherServiceTelemetry` (APIService) Demonstrates single-method multi-targeting, Activity status codes, and enumerable expansion: ```csharp [ActivitySource] [Logger] [Meter] public interface IWeatherServiceTelemetry { [Activity(ActivityKind.Client)] [Trace] Activity? GettingWeatherForecast([Baggage] string someRandomBaggageInfo, int requestedCount); [Event] void ForecastReceived(Activity? activity, int minTempInC, int maxTempInC); [Event(ActivityStatusCode.Error)] void FailedToRetrieveForecast(Activity? activity, Exception exception); [Event(ActivityStatusCode.Ok)] void TemperaturesReceived(Activity? activity, TimeSpan elapsed); [AutoCounter] [Warning] [Event] void ItsTooCold(Activity? activity, int minTempInC, int tooColdCount); [Histogram] void HistogramOfTemperature(int temperature); [Error] [AutoCounter] void RequestedCountIsOutOfRange(int requestCount); [Info] void TemperaturesWithinRange([ExpandEnumerable(maximumValueCount: 100)] int[] temperaturesInC); } ``` ### `IWeatherAPIClientTelemetry` (Web) Demonstrates `[ExcludeTargets]`, an instrument prefix, and `HttpStatusCode` tags: ```csharp [ActivitySource] [Logger] [Meter(InstrumentPrefix = "weather")] public interface IWeatherAPIClientTelemetry { [Activity(ActivityKind.Client)] [Info] [AutoCounter] Activity? GetWeatherForecasts(int? count); [Event] [Error] [AutoCounter] void FailedToGetForecast(Activity? activity, Exception ex, [ExcludeTargets(Targets.Activities)] int? count); [Event] void RequestComplete(Activity? activity, HttpStatusCode statusCode, bool isSuccessStatusCode); [AutoCounter] void RequestSuccess(); [Event] [Warning] void NoForecastsRecieved(Activity? activity); [Event(ActivityStatusCode.Ok)] [Debug] void ForecastsRecieved( Activity? activity, int forecastCount, [ExpandEnumerable(100), ExcludeTargets(Targets.Activities)] WeatherForecast[] weatherForecasts ); } ``` ## Registering names with Aspire `SampleApp.ServiceDefaults` wires the generated names into Aspire's OpenTelemetry setup: ```csharp builder.AddServiceDefaults(TelemetryNames.MeterNames, TelemetryNames.ActivitySourceNames); ``` ## Unit testing `SampleApp.APIService.UnitTests` uses TUnit with NSubstitute to verify `WeatherService` behaviour against the generated interfaces without emitting real telemetry. See [Testing](../testing/) for the pattern. ## Running ```bash just build-s just test-s ``` With `dotnet run --project samples/SampleApp/SampleApp.AppHost`, the Aspire Dashboard shows the generated Activities, Logs, and Metrics in real time. ## See also - [Getting Started](../getting-started/) - [Multi-Targeting](../multi-targeting/) - [Generated Output](../generated-output/) — real generated code from this sample # Tags, Baggage, and Parameter Attributes > Parameters on telemetry methods can be decorated to control how they are emitted. This page covers the parameter-level attributes. # Tags, Baggage, and Parameter Attributes :::caution All attributes are now in the unified `Purview.Telemetry` namespace. ::: Parameters on telemetry methods can be decorated to control how they are emitted. This page covers the parameter-level attributes. ## `[Tag]` Used within [Activity](../activities/) and [Metrics](../metrics/) generation to add a parameter as a tag. Tag names follow the configured `NamingConvention`: - **OpenTelemetry** (default): `snake_case` for compound words (e.g. `"entity_id"`) - **Legacy**: lowercased, smashed (e.g. `"entityid"`) | Property | Type | Default | Description | | --- | --- | --- | --- | | `Name` | `string?` | `null` | Explicitly sets the tag name. When `null`, the parameter name is used (transformed according to the naming convention). | | `SkipOnNullOrEmpty` | `bool` | `false` | When `true`, the tag is not added if the parameter value is `null` or default. | ### Examples ```csharp using Purview.Telemetry; [ActivitySource("OrderService")] interface IOrderTelemetry { [Activity] Activity? ProcessingOrder( // Auto-named tag (becomes "order_id" in OpenTelemetry mode) [Tag]int orderId, // Explicitly named tag (explicit names are not transformed) [Tag(Name = "customer.name")]string customerName, // Skip if null [Tag(SkipOnNullOrEmpty = true)]string? notes ); } ``` **OpenTelemetry convention (default):** ```csharp [Tag]int orderId // Generated: "order_id" [Tag]string userName // Generated: "user_name" [Tag(Name = "my.custom.tag")]int value // Generated: "my.custom.tag" (not transformed) ``` **Legacy convention:** ```csharp [Tag]int orderId // Generated: "orderid" [Tag]string userName // Generated: "username" ``` To change the convention, see [Naming conventions](../generation/#naming-conventions). ### Best practices 1. **Use explicit names for cross-service tags** to prevent breakage if parameter names change: ```csharp [Tag(Name = "trace.id")]string traceId ``` 2. **Use `SkipOnNullOrEmpty` for optional tags** to avoid cluttering telemetry with null values: ```csharp [Tag(SkipOnNullOrEmpty = true)]string? optionalContext ``` 3. **Follow OpenTelemetry semantic conventions** for standard names such as `http.method`, `http.status_code`, `service.name`. ## `[Baggage]` Marks a parameter as baggage on an Activity or ActivityEvent. Baggage propagates across service boundaries, unlike tags. | Property | Type | Default | Description | | --- | --- | --- | --- | | `Name` | `string?` | `null` | Explicitly sets the baggage name. When `null`, the parameter name is used. | | `SkipOnNullOrEmpty` | `bool` | `false` | When `true`, the parameter is skipped when `null` or default. | :::note `[Baggage]` parameters should be `string`; `TSG3000` warns if a non-string is used (`ToString()` is called). ::: ## `[ExcludeTargets]` Excludes a parameter from specific telemetry targets. See [Multi-Targeting](../multi-targeting/) for the `Targets` enum values (`None`, `Activities`, `Logging`, `Metrics`, `All`). | Property | Type | Description | | --- | --- | --- | | `ExcludedTargets` | `Targets` | The targets to exclude the parameter from. Available on construction. | ```csharp [ExcludeTargets(Targets.Metrics)] string verboseMessage; // excluded from metrics only ``` ## `[ExpandEnumerable]` Applied to an array or `IEnumerable` parameter on a log method, it logs the individual elements. See [Logging Generation v2](../logging-generation-v2/#expandenumerable). | Property | Type | Default | Description | | --- | --- | --- | --- | | `MaximumValueCount` | `int` | `5` | The maximum number of elements to output from the enumeration/array. | ## `[InstrumentMeasurement]` Marks the parameter used as the instrument measurement value on a metrics method. See [Metrics](../metrics/#the-measurement-value). ## `[Escape]` Marks a `bool` parameter as the escape value for an exception event on an Event method. See [Activities](../activities/#escape). ## `[StatusDescription]` Marks a `string` parameter as the status description for an Event that sets an error status code. See [Activities](../activities/#statusdescription). ## See also - [Activities](../activities/) — tags/baggage in Activity generation - [Metrics](../metrics/) — tags in metrics generation - [Multi-Targeting](../multi-targeting/) — excluding parameters per target - [Generation](../generation/) — naming conventions - [Breaking Changes](../breaking-changes/#opentelemetry-aligned-naming) — v3 to v4 naming changes # Testing > Telemetry interfaces are plain interfaces, so they are easy to mock or substitute in unit tests. Because the generated implementation only lives at the… # Testing Telemetry interfaces are plain interfaces, so they are easy to mock or substitute in unit tests. Because the generated implementation only lives at the interface boundary, standard mocking frameworks (NSubstitute, Moq, TUnitMocks, ...) work directly. ## Inject the interface ```csharp public class OrderService(IOrderServiceTelemetry telemetry) { public void PlaceOrder(int orderId, string customerName) { using var activity = telemetry.PlacingOrder(orderId, customerName); // ... } } ``` ## Mock it in tests ```csharp public class OrderServiceTests { [Test] public void PlaceOrder_EmitsTelemetry() { var telemetry = Substitute.For(); var service = new OrderService(telemetry); service.PlaceOrder(42, "Alice", "EMEA"); telemetry.Received().PlacingOrder(42, "Alice", "EMEA"); } } ``` ```csharp // Moq equivalent var telemetry = new Mock(); var service = new OrderService(telemetry.Object); service.PlaceOrder(42, "Alice", "EMEA"); telemetry.Verify(x => x.PlacingOrder(42, "Alice", "EMEA"), Times.Once); ``` ## Testing real emission To assert against real telemetry output, set up a listener or collector: ```csharp using var listener = new ActivityListener { ShouldListenTo = _ => true, Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllData, }; ActivitySource.AddActivityListener(listener); var telemetry = Substitute.For(); // Configure the substitute to return a real Activity when the method is called: telemetry.PlacingOrder(Arg.Any(), Arg.Any()) .Returns(_ => new Activity("placing-order").Start()); ``` For full multi-target scenarios, the [sample application](../sample-application/) demonstrates a TUnit + NSubstitute setup. ## Integration tests in this repository The repository's own test suite lives in `src/tests/SourceGenerator.IntegrationTests` and uses `Purview.SourceGeneratorFramework.Testing.TUnit` (TUnit with the framework's `CodeQuery` syntax-lookup API and assertion extensions). See [Contributing](../contributing/) for running them. # Value Objects > This guide walks through modeling DTOs and domain values with Purview.ValueObjects. # Getting Started This guide walks through modeling DTOs and domain values with `Purview.ValueObjects`. ## 1. Reference the package ```text dotnet add package Purview.ValueObjects ``` The package includes the runtime contracts, the source generator, and the diagnostic analyzer. ## 2. Scalar value objects A scalar value object wraps a single primitive value. It is the F#-style single-case union for C#. ```csharp using Purview.ValueObjects.Serialization; [Scalar] public readonly partial record struct EmailAddress { public string Value { get; } static partial void OnNormalize(ref string value) => value = value?.Trim().ToLowerInvariant()!; static partial void OnValidate(string value) { if (string.IsNullOrWhiteSpace(value)) throw new ArgumentException("Email is required.", nameof(value)); if (!value.Contains('@', StringComparison.Ordinal)) throw new ArgumentException("Invalid email format.", nameof(value)); } } ``` The generator adds: - `EmailAddress.Create(string)` – normalizes, validates, then constructs. Throws on invalid input. - `EmailAddress.Hydrate(string)` – constructs without re-validating (for persisted data). - `EmailAddress.TryCreate(string, out EmailAddress)` – returns `false` instead of throwing. - `EmailAddress.Empty` – a default instance. - Equality, comparison, `CompareTo`, `ToString`, implicit conversions, and a JSON converter. ```csharp var email = EmailAddress.Create(" Demo@Example.COM "); // email.Value == "demo@example.com" bool ok = EmailAddress.TryCreate("not-an-email", out _); // ok == false string json = System.Text.Json.JsonSerializer.Serialize(email); // json == "\"demo@example.com\"" ``` ## 3. Complex value objects A complex value object wraps multiple members and validates them together. ```csharp [ValueObject] public readonly partial record struct Money { public decimal Amount { get; } public CurrencyCode Currency { get; } partial void OnValidate(decimal amount, CurrencyCode currency) { if (amount < 0) throw new ArgumentOutOfRangeException(nameof(amount), "Amount cannot be negative."); if (currency == CurrencyCode.Empty) throw new ArgumentException("Currency cannot be empty.", nameof(currency)); } } ``` ## 4. Contextual value objects When validity depends on the owning instance, implement `IContextualValueObject`: ```csharp [Scalar] public readonly partial record struct OrderStatus : IContextualValueObject { public OrderStatusCode Value { get; } public static OrderStatus Create(OrderStatusCode value, in ValueObjectContext context) { var current = context.Owner.Status.Value; return IsValidTransition(current, value) ? new(value) : throw new InvalidOperationException($"Invalid transition {current} -> {value}"); } } ``` `ValueObjectContext` carries the owner instance, the member name being assigned, and an optional reason. ## 5. JSON serialization Scalar value objects serialize as their underlying value. The generator emits a `[JsonConverter]` per value object, so they round-trip with default options. For a shared options instance, register `ScalarJsonConverterFactory`: ```csharp var options = new JsonSerializerOptions(); options.Converters.Add(new ScalarJsonConverterFactory()); ``` `ValueObjectDeserializationMode` controls which factory deserialization uses: - `Hydrate` (default) – reconstructs via `Hydrate`, skipping validation. - `Strict` – reconstructs via `Create`, re-running validation. ```csharp [Scalar(DeserializationMode = ValueObjectDeserializationMode.Strict)] public readonly partial record struct EmailAddress { // ... } ``` ## 6. Assembly-level defaults Use `[ValueObjectDefaults]` to set generic defaults for the whole assembly. Every option that can be set on `[Scalar]`/`[ValueObject]` can be defaulted here (`GenerateJsonConverter`, `GenerateComparable`, `GenerateComparisonOperators`, `GenerateEnumProperties`, `GenerateImplicitFromPrimitive`, `GenerateImplicitToPrimitive`, `GenerateEmpty`, `GenerateConstructor`, `DeserializationMode`, and `ZodSchemaMode`): ```csharp [assembly: ValueObjectDefaults( GenerateConstructor = false, GenerateJsonConverter = false, ZodSchemaMode = ZodSchemaMode.InsteadOfHooks )] ``` Assembly defaults apply to every value object in the assembly; an option explicitly set on an individual `[Scalar]`/`[ValueObject]` attribute always overrides it. ## 7. Validate with ZodSharp [Purview.ZodSharp](https://www.nuget.org/packages/Purview.ZodSharp) is a C# port of Zod that can validate value objects. Add `[ZodSchema]` (plus DataAnnotations on the underlying value) to generate a zero-allocation validator, or build a schema for the raw value with `Z.String()`, `Z.Number()`, `Z.Enum()`: ```csharp using System.ComponentModel.DataAnnotations; using ZodSharp; [Scalar] [ZodSchema] public readonly partial record struct EmailAddress { [EmailAddress] [StringLength(254, MinimumLength = 3)] public string Value { get; } // ... } var email = EmailAddress.Create("demo@example.com"); var result = EmailAddressSchema.Validate(email); // ValidationResult ``` Because `EmailAddress` is both `[Scalar]` and `[ZodSchema]`, the generated `Create` **also** validates the constructed instance through `EmailAddressSchema` — `EmailAddress.Create("not-an-email")` throws a `ZodException`. Use `ZodSchemaMode.InsteadOfHooks` on the attribute to run the schema instead of the `OnValidate` hook. In ASP.NET Core, `Purview.ZodSharp.AspNetCore` converts those `ZodException`s into standard Problem Details responses — combine `ValueObjectDeserializationMode.Strict` with `AddZodSharpProblemDetails()` + `UseExceptionHandler()` so invalid request bodies return `HttpValidationProblemDetails` automatically. See the `src/src/ZodSharp.AspNetCoreSample` project. See `ZodSharp-Validation.md` and the `src/src/ZodSharpSample` project. ## Next steps - `Entity-Framework.md` – mapping value objects to EF JSON columns. - `Value-Object-Design.md` – where validation lives and the `Create`/`Hydrate` split. - `ZodSharp-Validation.md` – validating value objects with Purview.ZodSharp. - The `src/src/Sample` and `src/src/ZodSharpSample` projects for runnable examples. # Entity Framework > Purview.ValueObjects value objects work well as Entity Framework property types, especially with JSON columns on SQL Server json/jsonb and Postgres jsonb. # Entity Framework `Purview.ValueObjects` value objects work well as Entity Framework property types, especially with JSON columns on SQL Server (`json`/`jsonb`) and Postgres (`jsonb`). ## JSON columns Scalar value objects serialize as their underlying primitive, so they store naturally in a JSON column. Use a `JsonSerializerOptions` that registers `ScalarJsonConverterFactory` and assign it to the JSON column. ```csharp public static readonly JsonSerializerOptions EntityJsonOptions = CreateOptions(); static JsonSerializerOptions CreateOptions() { var options = new JsonSerializerOptions(); options.Converters.Add(new ScalarJsonConverterFactory()); return options; } ``` In your `DbContext`: ```csharp protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder .Entity() .Property(c => c.Email) .HasColumnType("jsonb") .HasConversion( v => JsonSerializer.Serialize(v, EntityJsonOptions), v => JsonSerializer.Deserialize(v, EntityJsonOptions)! ); } ``` Because scalar value objects serialize to a single primitive, the stored JSON is compact and query-friendly. ## Value converters For scalar value objects you can also use a plain EF `ValueConverter` without JSON, mapping directly to the underlying primitive: ```csharp builder .Entity() .Property(c => c.Email) .HasConversion( v => v.Value, v => EmailAddress.Hydrate(v) ); ``` `Hydrate` is used so that already-validated persisted values are not re-validated on read. ## Complex value objects Complex `[ValueObject]` types serialize as an object graph. Store them in a JSON column with the same pattern, using the generated `[JsonConverter]` (present by default) or the shared options. ## Queryability notes - Scalar value objects with primitive inner values map naturally to the underlying primitive for filtering. - For complex values stored as JSON, deep predicates translate depending on the provider and column type. Test the exact predicate against your provider before relying on it. ## EF Core compatibility - `[ValueObject]` types generate a private parameterless constructor by default (see `[ValueObjectDefaults]`) to support EF Core materialization. - Value objects are immutable; EF tracks them by value like any struct/record. See `src/src/Sample` for a runnable DTO + JSON-column example. # Value Object Design > Every value object exposes two static factories: # Value Object Design ## The `Create` / `Hydrate` split Every value object exposes two static factories: - `Create(value)` – the strict creation path. It runs `OnNormalize` then `OnValidate`, then constructs. Callers use this for command/input data. - `Hydrate(value)` – the reconstruction path. It constructs without re-validating. Use this for persisted state, replay, and deserialization where validation has already happened. `TryCreate(value, out result)` wraps `Create` and returns `false` (instead of throwing) when validation fails. ## Normalization `OnNormalize(ref T value)` transforms the raw input before validation. Common uses: - trimming whitespace - casing canonicalization (email, currency codes) - stripping separators Normalization is deterministic and runs on every `Create`. ## Validation `OnValidate(T value)` (scalar) or `partial void OnValidate(...)` (complex) enforces invariants and throws domain-appropriate exceptions. Keep validation pure and deterministic; it must not perform I/O. ## Where validation lives | Concern | Location | | --- | --- | | Primitive format and canonicalization | `OnNormalize` / `OnValidate` in the value object | | Cross-field invariants | `partial void OnValidate(...)` in a `[ValueObject]` | | Owner/state-machine transitions | contextual `Create(TValue, in ValueObjectContext)` | | External schema rules / DTO validation | Purview.ZodSharp schemas (see `ZodSharp-Validation.md`) | Use the value object hooks for invariants that must hold for every construction path. Use ZodSharp when you need schema-driven validation (DataAnnotations-based `[ZodSchema]` validators, hand-built `Z.*` schemas, or DTO validation before mapping to value objects). When a value object is annotated with both `[Scalar]`/`[ValueObject]` and `[ZodSchema]`, the generator runs the ZodSharp schema automatically inside `Create` (construct → `{Type}Schema.Validate(instance)` → throw `ZodException` on failure). `ZodSchemaMode` controls whether the `OnValidate` hook also runs (`InAdditionToHooks`, the default) or is replaced (`InsteadOfHooks`); `OnNormalize` always runs. ## Deserialization modes `[Scalar]` and `[ValueObject]` default to `ValueObjectDeserializationMode.Hydrate`, so JSON reads do not re-validate. Use `Strict` when round-trip fidelity requires re-running validation on read. ## Contextual value objects `IContextualValueObject` lets a value object validate against the owning instance: ```csharp public static OrderStatus Create(OrderStatusCode value, in ValueObjectContext context) { ... } ``` `ValueObjectContext` provides `Owner`, `MemberName`, and an optional `Reason`. Keep the contextual `Create` deterministic and side-effect free. ## Queryability Primitive scalar values are the most query-friendly shape for database filters. Scalar values that wrap complex CLR types preserve invariants and serialization, but deep predicates through `.Value` may not translate to SQL in all providers. If you need deep filtering, expose a separately mapped mirror property derived from canonical state. # Validating Value Objects with ZodSharp > Purview.ZodSharphttps://www.nuget.org/packages/Purview.ZodSharp is a high-performance C# port of the Zodhttps://github.com/colinhacks/zod schema validation… # Validating Value Objects with ZodSharp [Purview.ZodSharp](https://www.nuget.org/packages/Purview.ZodSharp) is a high-performance C# port of the [Zod](https://github.com/colinhacks/zod) schema validation library. It complements `Purview.ValueObjects`: the value object owns the invariants, ZodSharp owns the rule definitions and validation results. Three patterns are covered here, demonstrated in the `src/src/ZodSharpSample` project: 1. **Generator-integrated validation** — a value object annotated with both `[Scalar]`/`[ValueObject]` and `[ZodSchema]` has its generated `Create` wired to the ZodSharp-generated schema. 2. **Generated validators** — annotate a value object or DTO with `[ZodSchema]` and DataAnnotations; a source generator emits a zero-allocation `{Type}Schema` validator. 3. **Schema-first validation** — build a schema for the scalar's underlying value with `Z.String()`, `Z.Number()`, `Z.Enum()`, then construct the value object through its strict `Create` factory. ## Install ```text dotnet add package Purview.ZodSharp ``` ## 1. Generated validators on value objects Mark a `[Scalar]` value object with `[ZodSchema]` and add DataAnnotations to its underlying value. The generator produces a static `{Type}Schema` class plus a `{Type}SchemaValidator` adapter. ```csharp using System.ComponentModel.DataAnnotations; using Purview.ValueObjects.Serialization; using ZodSharp; [Scalar] [ZodSchema] public readonly partial record struct EmailAddress { [EmailAddress] [StringLength(254, MinimumLength = 3)] public string Value { get; } static partial void OnNormalize(ref string value) => value = value?.Trim().ToLowerInvariant()!; static partial void OnValidate(string value) { if (string.IsNullOrWhiteSpace(value)) throw new ArgumentException("Email is required.", nameof(value)); } } ``` Validate the value object directly: ```csharp var email = EmailAddress.Create("demo@example.com"); var result = EmailAddressSchema.Validate(email); // ValidationResult if (result.IsSuccess) Console.WriteLine(result.Value); // demo@example.com var parsed = EmailAddressSchema.Parse(email); // throws ZodException on failure // Compose additional rules: var allowed = EmailAddressSchema.ApplyRefine( email, static e => e.Domain == "example.com", "Only example.com addresses allowed"); ``` `[ZodSchema]` supports classes, structs, and records, and reads DataAnnotations such as `[Required]`, `[StringLength]`, `[Range]`, `[RegularExpression]`, `[EmailAddress]`, `[AllowedValues]`, and `[DeniedValues]`. ## 2. Generator-integrated validation When a value object is annotated with **both** `[Scalar]`/`[ValueObject]` **and** `[ZodSchema]`, the value-object generator detects it and routes the generated `Create(...)` through the ZodSharp-generated schema — no manual schema wiring needed: ```csharp [Scalar] [ZodSchema] public readonly partial record struct EmailAddress { [EmailAddress] public string Value { get; } // ... } EmailAddress.Create("not-an-email"); // throws ZodException via EmailAddressSchema.Validate ``` The generated `Create` constructs the instance, calls `EmailAddressSchema.Validate(instance)`, and throws a `ZodException` when validation fails. `Hydrate(...)` remains replay-safe (no re-validation), and `ValueObjectDeserializationMode.Strict` (which deserializes through `Create`) picks up the schema validation automatically. `ZodSchemaMode` on `[Scalar]`/`[ValueObject]` controls how the schema and the hand-written hooks combine: - `ZodSchemaMode.InAdditionToHooks` (default) — the schema runs **and** the `OnValidate` hook runs. - `ZodSchemaMode.InsteadOfHooks` — the schema runs **instead of** the `OnValidate` hook. `OnNormalize` still runs so input is canonicalized first. ```csharp [Scalar(ZodSchemaMode = ZodSchemaMode.InsteadOfHooks)] [ZodSchema] public readonly partial record struct PhoneNumber { [RegularExpression(@"^\+?\d{7,15}$")] public string Value { get; } } ``` The `[ZodSchema]` attribute also exposes generator options that tune the emitted schema: - `RefinementMethodName` — names a synchronous instance refinement method (default `Validate`) that the generator runs after the DataAnnotations rules. - `CustomValidationMethodName` — names a static async method that the generated validator's `ValidateAsync` awaits after the synchronous rules pass (default `CustomValidationAsync`). - `GenerateParseMethod` / `GenerateValidateMethod` / `EnableComposition` — toggle the emitted `Parse`, `Validate`, and composition (`ApplyAnd`/`ApplyOr`/`ApplyRefine`) members. > Note: `SchemaName` on `[ZodSchema]` is reserved by the attribute today but is not yet applied by the > ZodSharp generator — the generated schema class is always named `{TypeName}Schema`. Use the default > name when combining `[Scalar]`/`[ValueObject]` with `[ZodSchema]`. ## 3. Schema-first validation When you do not want the generator involved, build a schema for the scalar's underlying value and map a successful result onto the value object: ```csharp using ZodSharp; public static class ScalarSchemas { public static readonly IZodSchema EmailSchema = Z.String().Email().Min(3).Max(254); public static readonly IZodSchema CurrencySchema = Z.String().Regex("^[A-Z]{3}$"); public static readonly IZodSchema OrderStatusSchema = Z.Enum(); public static readonly IZodSchema MoneyAmountSchema = Z.Number().Positive(); public static ValidationResult ValidateEmail(string value) => Map(EmailSchema.Validate(value), EmailAddress.Create); static ValidationResult Map( ValidationResult result, Func construct) => result.IsSuccess ? ValidationResult.Success(construct(result.Value!)) : ValidationResult.Failure(result.Errors); } ``` Note that ZodSharp validates the raw value exactly as supplied — normalization (trimming, casing) is the value object's job in `OnNormalize`. Validate the raw input, then construct with `Create` so the value object normalizes and wraps it. ## 4. Validating DTOs before mapping to value objects Annotate a request/DTO class with `[ZodSchema]`, validate it, then map the validated values onto value objects: ```csharp [ZodSchema(RefinementMethodName = nameof(ValidateRegistration))] public sealed class RegistrationDto { [Required, StringLength(100, MinimumLength = 2)] public string Name { get; init; } = string.Empty; [Range(13, 120)] public int Age { get; init; } [Required, EmailAddress] public string Email { get; init; } = string.Empty; // Custom sync refinement, discovered via the RefinementMethodName option. The generator runs // these errors after the DataAnnotations rules. public IEnumerable ValidateRegistration() { if (Name.StartsWith("x", StringComparison.OrdinalIgnoreCase)) yield return new ValidationError("name", "Name cannot start with 'x'.", [nameof(Name)]); } } var result = RegistrationDtoSchema.Validate(dto); if (result.IsSuccess) { var email = EmailAddress.Create(result.Value.Email); var currency = CurrencyCode.Create("USD"); var money = Money.Create(19.99m, currency); } ``` ### Async custom validation `CustomValidationMethodName` names a static async method with the signature `static ValueTask> Method(T value, CancellationToken cancellationToken)`. The generated `{Type}SchemaValidator` (which implements `IZodSchemaValidator`) awaits it in its `ValidateAsync` after the synchronous rules pass: ```csharp [ZodSchema(CustomValidationMethodName = nameof(ValidatePromoCodeAsync))] public sealed class PromoCode { [Required, RegularExpression(@"^[A-Z0-9]{4,10}$")] public string Code { get; init; } = string.Empty; internal static ValueTask> ValidatePromoCodeAsync( PromoCode value, CancellationToken cancellationToken) => ValueTask.FromResult( value.Code is "SAVE10" or "WELCOME20" ? ValidationResult.Success(value) : ValidationResult.Failure( new ValidationError("code", "Unknown promotional code.", [nameof(Code)])) ); } PromoCodeSchemaValidator validator = new(); var result = await validator.ValidateAsync(new PromoCode { Code = "HOMERUN42" }); ``` ## 5. Dependency injection and the schema factory `ZodSchemaFactory` resolves validators by validated type. Register the generated adapter or wrap a hand-built schema with `ZodSchemaValidator`: ```csharp using ZodSharp.Core; ZodSchemaFactory factory = new(); factory.Register(new EmailAddressSchemaValidator()); // generated adapter factory.Register(new ZodSchemaValidator(ScalarSchemas.EmailSchema)); // hand-built var emailResult = factory.Validate(EmailAddress.Create("demo@example.com")); var stringResult = factory.Validate("demo@example.com"); ``` ## Error handling `ValidationResult` is a struct with `IsSuccess`, `Value` (only when successful), and `Errors` (`ImmutableArray`). Each `ValidationError` has a `Path` and a `Message`: ```csharp foreach (var error in result.Errors) Console.WriteLine($"{string.Join(".", error.Path)}: {error.Message}"); ``` Use `Parse` / `GetValueOrThrow()` to throw a `ZodException` on failure instead of inspecting the result. ### ASP.NET Core Problem Details In ASP.NET Core, `Purview.ZodSharp.AspNetCore` maps thrown `ZodException`s to standard `HttpValidationProblemDetails` responses. This covers strict deserialization of value objects (`ValueObjectDeserializationMode.Strict`) and any `Create`/`Parse` failure that bubbles up as a `ZodException`. Wire the handler into the pipeline: ```csharp builder.Services.AddZodSharpProblemDetails(); builder.Services.AddProblemDetails(); var app = builder.Build(); app.UseExceptionHandler(); ``` Mark the value object for strict deserialization and ZodSharp validation so invalid request bodies throw during model binding: ```csharp [Scalar( ZodSchemaMode = ZodSchemaMode.InsteadOfHooks, DeserializationMode = ValueObjectDeserializationMode.Strict)] [ZodSchema] public readonly partial record struct EmailAddress { [Required, EmailAddress] public string Value { get; } } builder.Services.ConfigureHttpJsonOptions(options => options.SerializerOptions.Converters.Add(new ScalarJsonConverterFactory())); ``` A `POST` body with an invalid email now returns `400 application/problem+json` with the structured issues in the `issues` extension. Map error codes to HTTP statuses and formatted messages with `ErrorType` + `ErrorTypeRegistry`. Mark a static partial class with `[ErrorType]` on a `static readonly ErrorType` field and the bundled `ErrorTypeGenerator` emits `Create{Field}(...)` (builds a `ValidationError`) and `Throw{Field}(...)` (a `void` + `[DoesNotReturn]` method that throws the `ZodException`): ```csharp [ErrorType] public static readonly ErrorType SaveFailed = new( Code: "aggregate_save_failed", Description: "The order could not be saved because it was modified concurrently.", HttpStatus: StatusCodes.Status409Conflict, MessageFormat: "Order '{OrderId}' (of type {AggregateType}) failed to save") { Parameters = ["OrderId", "AggregateType"] }; ErrorTypeRegistry.Default.Register(ErrorTypes.SaveFailed); ``` The generated `ThrowSaveFailed(orderId, aggregateType)` throws a `ZodException` carrying the `aggregate_save_failed` code, yielding a `409 Conflict` whose message is formatted from the error's parameters. Because it is `void` + `[DoesNotReturn]`, use it as a terminal call — for example a `void` minimal-API handler that always throws (the endpoint returns the mapped `409` via the exception handler): ```csharp app.MapPost("/orders/{orderId}/confirm", ConfirmOrder); static void ConfirmOrder(string orderId) => ErrorTypes.ThrowSaveFailed(orderId, "Order"); ``` (If you prefer not to use the generator, construct the `ZodException` manually with `ValidationError.Create(code, message, path: [], parameters: ...)` — it maps the same way.) The bundled `ZODSASP001` analyzer flags `MessageFormat` placeholders missing from `Parameters` at compile time. See the [ASP.NET Core integration](https://purview.dev/docs/zodsharp/aspnetcore-integration/) guide and the `src/src/ZodSharp.AspNetCoreSample` project. ## JSON Schema export Export a schema to JSON Schema (Draft 2020-12) for cross-platform sharing with TypeScript Zod: ```csharp var jsonSchema = Z.ToJsonSchema(ScalarSchemas.EmailSchema, new ToJsonSchemaOptions { Title = "Email" }); ``` ## Direct-reference note When a project uses `Purview.ZodSharp` types directly (as this sample does), reference the package explicitly — do not rely on transitive flow. The ZodSharp source generator is active in any project that references the package, so `[ZodSchema]` is available there. ## See also - The runnable `src/src/ZodSharpSample` project. - [Getting Started](../) - [Value Object Design](../value-object-design/) # ZodSharp > Purview.ZodSharp is a high-performance schema validation library for C#, ported from TypeScript Zodhttps://github.com/colinhacks/zod. It uses struct-based… # Purview.ZodSharp Wiki Purview.ZodSharp is a high-performance schema validation library for C#, ported from TypeScript [Zod](https://github.com/colinhacks/zod). It uses struct-based rules and `Span` to minimise allocations, ships a fluent API that mirrors Zod, exports and imports JSON Schema, and includes a compile-time source generator for maximum performance. This wiki is the project documentation hub for the core API, source generator, JSON integration packages, and the cross-platform TypeScript tooling. It is a fork of [guinhx/ZodSharp](https://github.com/guinhx/ZodSharp), maintained under the `Purview.*` package IDs. ## Start here - [Getting Started](getting-started/) - [Core Concepts](core-concepts/) - [Fluent Schema API](fluent-schema-api/) - [Source Generator](source-generator/) - [Guarantees and Limitations](guarantees-and-limitations/) - [Performance](performance/) - [Contributing](contributing/) ## Core validation - [String Validation](string-validation/) - [Number Validation](number-validation/) - [Object Validation](object-validation/) - [Arrays and Other Schemas](arrays-and-other-schemas/) - [Unions and Discriminated Unions](unions-and-discriminated-unions/) - [Composition and Transforms](composition-and-transforms/) - [Compiled Validators and Caching](compiled-validators-and-caching/) - [Dependency Injection](dependency-injection/) ## JSON integration - [JSON Schema Export](jsonschema-export/) - [JSON Schema Import](jsonschema-import/) - [System.Text.Json Integration](systemtextjson-integration/) - [Newtonsoft.Json Integration](newtonsoftjson-integration/) - [ASP.NET Core Integration](aspnetcore-integration/) ## Source generator - [Source Generator](source-generator/) - [Source Generator DataAnnotations](source-generator-dataannotations/) - [Source Generator Diagnostics](source-generator-diagnostics/) ## Cross-platform and workflow - [Cross-Platform Interop](cross-platform-interop/) - [Performance](performance/) - [Release Flow](release-flow/) - [Contributing](contributing/) ## Feature highlights - **Zero-allocation validation** — validation rules are `readonly record struct`s and hot paths use `Span`; every valid input path validates without allocating. - **Fluent API** — `Z.String().Min(3).Max(50).Email()`, composable objects, arrays, unions, tuples, records, discriminators, and more. - **Structured issues** — failures carry machine-readable `Code`, `Path`, `Origin`, `Minimum`/`Maximum`, and `Inclusive` metadata in addition to a human message. - **JSON Schema interoperability** — export via `Z.ToJsonSchema` (core package) and import via `Z.FromJsonSchema` (in either JSON integration package), enabling cross-language reuse with TypeScript/Zod. - **Compile-time source generation** — the `[ZodSchema]` attribute turns a class, struct, or record into a zero-allocation static validator, honouring DataAnnotations attributes such as `[Required]`, `[Length]`, `[Range]`, and `[EmailAddress]`. - **Integration packages** — `Purview.ZodSharp.SystemTextJson`, `Purview.ZodSharp.NewtonsoftJson`, and `Purview.ZodSharp.AspNetCore` (ProblemDetails). - **Cross-platform tests** — a shared TypeScript/Zod fixture set is generated into the repo and asserted against from both the C# test suite and a vitest suite. - **Multi-target** — packages target `net8.0`, `net9.0`, and `net10.0`; the source generator targets `netstandard2.0` so it runs in any compiler host. # Arrays and Other Schemas > ZodArray namespace ZodSharp.Schemas validates T values. Each element is validated against the element schema; failures carry index paths such as "0". A… # Arrays and Other Schemas ## Arrays `ZodArray` (namespace `ZodSharp.Schemas`) validates `T[]` values. Each element is validated against the element schema; failures carry index paths such as `["0"]`. A rebuilt array is produced only when a transform changed an element. ```csharp using ZodSharp; var numbers = Z.Array(Z.Number()).Min(1).Max(10); var result = numbers.Validate(new[] { 1.0, 2.0, 3.0 }); ``` | Method | Behaviour | |---|---| | `Min(int minLength, string? message)` | `too_small` when count < min | | `Max(int maxLength, string? message)` | `too_big` when count > max | | `Length(int length, string? message)` | exact length (both bounds) | | `NonEmpty(string? message)` | `minLength = 1` | ## Boolean `ZodBoolean` validates `bool`; there are no fluent methods. ```csharp var schema = Z.Boolean(); ``` ## Null `ZodNull` succeeds only for `null` input, and implements `IAcceptsNull` so it can serve as an object field accepting `null`. ```csharp var schema = Z.Null(); ``` ## Enum (string values) `ZodEnum` validates against a set of allowed strings. Failure produces `invalid_enum_value`. ```csharp var schema = Z.Enum("admin", "user", "guest"); ``` ## Native enum `ZodNativeEnum` validates a native `System.Enum` using `Enum.IsDefined`. Failure produces `invalid_enum_value`. ```csharp var schema = Z.Enum(); // Color : struct, Enum ``` ## Literal `ZodLiteral` (where `T : IEquatable`) accepts exactly one value. Failure produces `invalid_literal`. ```csharp var schema = Z.Literal("active"); var schema2 = Z.Literal(42); ``` ## Record `ZodRecord` validates `Dictionary`, validating every value against the value schema with key-prefixed error paths. It always produces a fresh validated dictionary. ```csharp var schema = Z.Record(Z.Number()); ``` ## Tuple `ZodTuple` validates fixed-length tuples. Two- and three-element overloads exist; input is `object?[]`. ```csharp var schema = Z.Tuple(Z.String(), Z.Number()); var result = schema.Validate(new object?[] { "John", 30.0 }); // (string, double) success value ``` Failures: `invalid_type` on `null`, `invalid_tuple_length` on wrong length, and per-index `invalid_type` with paths like `["[0]"]`. ## Lazy `ZodLazy` defers schema construction to first use, enabling recursive and circular schemas. The inner `Schema` is resolved lazily and thread-safely. ```csharp var categorySchema = Z.Lazy>(() => Z.Object() .Field("name", Z.String()) .Field("subcategories", Z.Array(categorySchema)) .Build()); ``` ## Optional / Nullable `Z.Optional(schema)` (`T : class`) accepts `null` or a value matching the inner schema. `Z.Nullable(schema)` (`T : struct`) is the value-type counterpart. ```csharp var optional = Z.Optional(Z.String()); optional.Validate(null); // Success optional.Validate("value"); // Success ``` # ASP.NET Core Integration > The Purview.ZodSharp.AspNetCore package converts failed validation results into standard ProblemDetails / HttpValidationProblemDetails payloads while… # ASP.NET Core Integration The `Purview.ZodSharp.AspNetCore` package converts failed validation results into standard `ProblemDetails` / `HttpValidationProblemDetails` payloads while preserving the structured validation issues. It also registers Purview.ZodSharp schema resolution into your application's dependency injection container. ## Install ```bash dotnet add package Purview.ZodSharp.AspNetCore ``` ## ProblemDetails ```csharp using ZodSharp.AspNetCore; var result = BasketSchema.Validate(basket); if (!result.IsSuccess) { var problem = result.ToHttpValidationProblemDetails(); return Results.ValidationProblem( problem.Errors, extensions: new Dictionary { ["issues"] = problem.Extensions["issues"], }); } ``` | Member | Behaviour | |---|---| | `ToHttpValidationProblemDetails(ValidationResult result, int statusCode = 400)` | `HttpValidationProblemDetails`; error paths flattened to dotted keys (`user.email`, array indexes as `[0]`) | | `ToValidationProblemDetails(ValidationResult result, int statusCode = 400)` | `ValidationProblemDetails` | Both throw `InvalidOperationException` when the result `IsSuccess`. The structured metadata is preserved in the `issues` extension as a `ValidationIssue[]`: ```csharp public sealed class ValidationIssue { public required string Code { get; init; } public string? Origin { get; init; } public int? Minimum { get; init; } public int? Maximum { get; init; } public bool? Inclusive { get; init; } public required string[] Path { get; init; } public required string Message { get; init; } public IReadOnlyDictionary? Parameters { get; init; } } ``` ## Exception handling A thrown `ZodException` (for example from `Parse`, `GetValueOrThrow()`, or a value object's generated `Create` under strict deserialization) can be mapped to ProblemDetails on demand: ```csharp using ZodSharp.AspNetCore; try { var parsed = EmailAddressSchema.Parse(rawValue); } catch (ZodException ex) { return Results.ValidationProblem(ex.ToHttpValidationProblemDetails().Errors); } ``` Or handled automatically by an `IExceptionHandler`. Register it and ensure `UseExceptionHandler()` is in the pipeline: ```csharp builder.Services.AddZodSharpProblemDetails(); var app = builder.Build(); app.UseExceptionHandler(); ``` `AddZodSharpProblemDetails(Action? configure)` registers `ZodExceptionHandler` and configures `ZodProblemDetailsOptions`: - `ErrorTypeRegistry Registry` — resolves error codes to `ErrorType`s. Defaults to `ErrorTypeRegistry.Default`. - `bool FormatMessages` — when `true`, messages are formatted from the matched `ErrorType.MessageFormat`. - `Func, int>? StatusCodeSelector` — an escape hatch that takes complete control of the response status code. ## Mapping error types to status codes Register an `ErrorType` (code, description, HTTP status, and optional message template) in a registry, then let the mapper derive the status code, title, detail, and formatted messages automatically: ```csharp using ZodSharp.AspNetCore; public static partial class ConcurrentErrorType { [ErrorType] public static readonly ErrorType SaveFailed = new( Code: "aggregate_save_failed", Description: "The aggregate could not be saved.", HttpStatus: StatusCodes.Status409Conflict, MessageFormat: "Aggregate '{AggregateId}' (of type {AggregateType}) failed to save", Parameters: [ new("AggregateId", typeof(string)), new("AggregateType", typeof(string)) ]); } // Register once at startup: ErrorTypeRegistry.Default.Register(ConcurrentErrorType.SaveFailed); ``` Parameters can also be declared with the `ErrorType.Param("Name")` helper instead of an explicit `typeof(...)`: ```csharp Parameters: [ new("AggregateId", typeof(string)), ErrorType.Param("AggregateType") ] ``` Both the bundled `ZODSASP001` analyzer and the source generator treat `ErrorType.Param` entries exactly like any other declared parameter, so placeholder checking and helper generation are unchanged. When an error carries that code, the response status, title, and message are derived automatically: ```csharp throw new ZodException([ ValidationError.Create( "aggregate_save_failed", "The aggregate could not be saved.", path: [], parameters: new Dictionary { ["AggregateId"] = "agg-123", ["AggregateType"] = "Invoice", }), ]); ``` ### Generated `Create` / `Throw` helpers Marking the field with `[ErrorType]` and the containing class `partial` lets the bundled source generator turn each field into strongly typed static helpers. For the field above it generates `ConcurrentErrorType.CreateSaveFailed(...)` and `ConcurrentErrorType.ThrowSaveFailed(...)` with one strongly typed parameter per entry in `Parameters` (the declared `typeof(...)` type, or the `ErrorType.Param` generic type argument): ```csharp // Returns a ValidationError with the code, the formatted message, and the typed parameters. var error = ConcurrentErrorType.CreateSaveFailed("agg-123", "Invoice"); // Throws a ZodException carrying the same ValidationError. ConcurrentErrorType.ThrowSaveFailed("agg-123", "Invoice"); // The path and structured issue metadata can be populated too: var error = ConcurrentErrorType.CreateSaveFailed( "agg-123", "Invoice", path: ["order", "items", "[0]"], origin: "collection", minimum: 1, maximum: 10, inclusive: true); ``` The generated `Create` builds a typed `ErrorTypeParameters` instance (validated against the declared parameter types and exposed through `ValidationError.Parameters`) and sets the message from `ErrorType.FormatMessage`, so `error.Message` already reads `Aggregate 'agg-123' (of type Invoice) failed to save` and mapping through the registry produces the `409 Conflict` response described below. The analyzers `ZODSASP001`/`ZODSASP002`/`ZODSASP003` (bundled with the package) warn when a `MessageFormat` placeholder is not declared in `Parameters`, when an `[ErrorType]` field's containing class is not `partial`, or when the field is not `static readonly`. Produces a `409 Conflict` `HttpValidationProblemDetails` with: ```json { "status": 409, "detail": "The aggregate could not be saved.", "errors": { "": ["Aggregate 'agg-123' (of type Invoice) failed to save"] }, "traceId": "...", "aggregateId": "agg-123", "issues": [ { "code": "aggregate_save_failed", "path": [], "message": "Aggregate 'agg-123' (of type Invoice) failed to save", "parameters": { "AggregateId": "agg-123", "AggregateType": "Invoice" } } ] } ``` Mapping rules: - **Status** — the highest matched `ErrorType.HttpStatus` wins; unmapped codes fall back to the default (`400`). Override with `ZodProblemDetailsOptions.StatusCodeSelector`. - **Title / Type / Detail** — taken from the highest-status matched `ErrorType`; otherwise defaulted. - **Message** — `MessageFormat` named placeholders (for example `{AggregateId}`) are substituted from `ValidationError.Parameters`. Placeholders without a matching value are left as-is so templating gaps stay visible. The analyzer `ZODSASP001` (bundled with the package) warns at compile time when a `MessageFormat` placeholder is not declared in `Parameters`. - **Parameters** — the error's `ValidationError.Parameters` are surfaced both per-issue in the `issues` extension and merged (camel-cased) into the top-level ProblemDetails extensions for client correlation. `ToHttpValidationProblemDetails` / `ToValidationProblemDetails` accept an `ErrorTypeRegistry` or a `Func` lookup for on-demand mapping, and the same mapping applies to `ValidationResult`. ## Dependency injection ```csharp builder.Services.AddZodSharp(options => { options.ScanAssemblies.Add(typeof(UserDto).Assembly); }); ``` `AddZodSharp(Action? configure)` registers `IZodSchemaFactory` as a singleton, applies `options.ConfigureFactory`, and calls `factory.RegisterFromAssembly(assembly)` for each entry in `options.ScanAssemblies`. This auto-discovers source-generated `[assembly: ZodSchemaGenerated(typeof(...))]` registrations. `ZodSchemaFactoryOptions`: - `List ScanAssemblies` — assemblies to scan for generated schemas. - `Action? ConfigureFactory` — additional factory configuration. See [Dependency Injection](../dependency-injection/) for the underlying factory and options-validation wiring. # Compiled Validators and Caching > CompiledValidator namespace ZodSharp.Expressions compiles a schema into an expression tree and a delegate. # Compiled Validators and Caching ## CompiledValidator `CompiledValidator` (namespace `ZodSharp.Expressions`) compiles a schema into an expression tree and a delegate. ```csharp using ZodSharp; using ZodSharp.Expressions; var compiled = CompiledValidator.Compile(schema); var result = compiled(value); // ValidationResult var parser = CompiledValidator.CompileParser(schema); var value = parser(input); // T, throws ZodException on failure ``` | Member | Signature | Returns | |---|---|---| | `Compile` | `Func> Compile(IZodSchema schema)` | compiled validation delegate | | `CompileParser` | `Func CompileParser(IZodSchema schema)` | returns the value or throws `ZodException` | The expression tree calls `IZodSchema.Validate` on the schema bound as a constant, removing interface dispatch overhead. ## SchemaCache `SchemaCache` (namespace `ZodSharp.Core`) is a `ConcurrentDictionary`-backed cache for expensive schema construction. ```csharp using ZodSharp.Core; var schema = SchemaCache.GetOrCreate("user", () => Z.Object().Field("name", Z.String()).Build()); ``` | Member | Behaviour | |---|---| | `GetOrCreate(string key, Func factory)` | returns the cached instance or creates and stores it (`T : class`) | | `TryGet(string key, out T value)` | typed lookup | | `Remove(string key)` | removes an entry | | `Count` | number of cached entries | | `Clear()` | empties the cache | Schemas are immutable and shareable, so caching identical definitions avoids repeated construction cost across request boundaries. ## The source generator alternative For the highest performance, prefer the compile-time source generator: `[ZodSchema]` emits a static validator with no runtime compilation or dispatch overhead. See [Source Generator](../source-generator/). # Composition and Transforms > Every schema derives from ZodType, so the composition methods below are available on every schema guarded so they only apply when input… # Composition and Transforms Every schema derives from `ZodType`, so the composition methods below are available on every schema (guarded so they only apply when input equals output). Each method returns a **new** wrapping schema; the original is unchanged. ## Transform ```csharp var schema = Z.String().Transform(s => s.ToUpperInvariant()); var result = schema.Validate("hello"); // "HELLO" ``` `ZodTransform` validates the input schema first, then runs the transform. If the transform throws, the exception is caught into a `transform_error` `ValidationError` (`"Transform failed: {message}"`). Transforms chain: ```csharp var schema = Z.String().Transform(s => s.Trim()).Transform(s => s.ToUpperInvariant()); ``` `ZodString` ships convenience transforms: `.ToLower()`, `.ToUpper()`, and `.Trim()`. ## Refine `ZodRefinement` runs the base schema first, then a predicate. A failing predicate produces code `refinement_failed` (custom `message` or `"Custom validation failed"`). ```csharp var even = Z.Number().Refine(n => n % 2 == 0, "Must be even"); ``` ## SuperRefine `ZodSuperRefinement` takes an `Action>` and can emit multiple, path-located issues. ```csharp var password = Z.String().SuperRefine(ctx => { if (!ctx.Value.Any(char.IsUpper)) ctx.AddIssue("Must contain an uppercase letter", new[] { "uppercase" }); if (ctx.Value.Length < 8) ctx.AddIssue("too_short", "Must be at least 8 characters", new[] { "length" }); }); ``` `RefineCtx` exposes: - `T Value` — the value being refined. - `ImmutableArray Path` — the base path. - `AddIssue(string code, string message, string[]? path)` — appends to the base path; throws `ArgumentException` for null/whitespace code or message. - `AddIssue(string message, string[]? path)` — shorthand with code `refinement_failed`. - `Issues` / `HasIssues`. ## Pipe `ZodPipe` runs the source schema, then validates its output against a target schema. ```csharp var schema = Z.String().Pipe(Z.String().Min(10)); ``` ## Catch `ZodCatch` swallows inner failures and returns a fallback as a **successful** result. The fallback can be a constant or a factory that receives the input and the errors. ```csharp var withFallback = Z.String().Catch("n/a"); var computed = Z.Number().Catch((value, errors) => 0); ``` ## Default `ZodDefault` substitutes a value when input is `null` and reports `IsOptional = true` and `ProvidesValueOnMissing = true`. The default is **not** re-validated. ```csharp var schema = Z.String().Default("unknown"); var result = schema.Validate(null); // "unknown" ``` ## Prefault `ZodPrefault` substitutes a value when the input equals `default(T)`, then **still validates** the substituted value through the inner schema. ```csharp var schema = Z.Number().Prefault(1); ``` ## And / Or - `.And(other)` → `ZodIntersection` — both must succeed (see [Unions and Discriminated Unions](../unions-and-discriminated-unions/)). - `.Or(other)` → `ZodTypedUnion` — either may succeed. ## Example: layered validation ```csharp var schema = Z.String() .Min(3) .Transform(s => s.Trim()) .Refine(s => s.StartsWith("PUR-", StringComparison.Ordinal), "Must start with PUR-") .Default("PUR-UNKNOWN"); ``` ## Behavioral differences at a glance | Wrapper | Trigger | Re-validates substituted value | |---|---|---| | `Default(value)` | `null` input | No | | `Prefault(value)` | input equals `default(T)` | Yes | | `Catch(value\|factory)` | inner schema fails | No (returns fallback as success) | | `Refine(predicate)` | predicate returns false | — | | `SuperRefine(action)` | issues added to context | — | # Contributing > Contributions are welcome. Please open an issue or pull request against purview-dev/zodsharphttps://github.com/purview-dev/zodsharp. # Contributing Contributions are welcome. Please open an issue or pull request against [purview-dev/zodsharp](https://github.com/purview-dev/zodsharp). ## Repository layout ``` src/ src/ ZodSharp/ Core validation library + JSON Schema export (Z.ToJsonSchema) SourceGenerators/ Compile-time [ZodSchema] generator (netstandard2.0, Roslyn) SystemTextJson/ System.Text.Json integration + JSON Schema import NewtonsoftJson/ Newtonsoft.Json integration + JSON Schema import AspNetCore/ ASP.NET Core ProblemDetails integration Examples.CLI/ Usage examples Benchmarks/ BenchmarkDotNet performance suite tests/ *.UnitTests/ TUnit test projects src/ts/ TypeScript (Zod) schema + fixture generation tests/ts/ Vitest cross-platform tests docs/wiki/ This documentation suite ``` ## Commands ```bash just build # dotnet build src/ZodSharp.slnx -c Debug just test # dotnet test src/ZodSharp.slnx -c Debug --treenode-filter "/*/*/*/*" just lint-check # dotnet csharpier check . just lint-fix # dotnet csharpier format . just pack # dotnet pack src/ZodSharp.slnx -c Debug -o artifacts just perf-tests # dotnet run --project src/src/Benchmarks/Benchmarks.csproj -c Release ``` TypeScript tooling uses Bun: ```bash bun install bun run test # vitest run (cross-platform TS tests) bun run generate-fixtures # regenerates src/ts/fixtures/*.json from Zod ``` ## Testing bar - Framework: **TUnit**. - `[Test]` methods take a `CancellationToken cancellationToken` parameter where relevant. - Use `// Arrange`, `// Act`, `// Assert` comments. - Use meaningful, descriptive names (`Action_GivenCondition_ExpectedResult`). - Treat work as incomplete until the relevant tests pass. For source generator tests, use the `Purview.SourceGeneratorFramework.Testing.TUnit` base classes (`TUnitSourceGeneratorTestBase`, `TUnitDiagnosticAnalyzerTestBase`) and assert with `CodeQuery`; test incrementally, not just generated text. ## Documentation This wiki lives in `docs/wiki/`. The site build (purview-dev Astro/Starlight) pulls these files via `github-path` sync and rewrites relative `.md` links into site routes. Conventions: - `_Sidebar.md` declares page order (parsed by the sync, never rendered). - Every page starts with a single `# ` heading (the page title) followed by a one-paragraph description. - Link to other pages with relative `.md` links: `[Getting Started](../getting-started/)`. - GitHub alert blockquotes (`> [!NOTE]`, `> [!TIP]`, `> [!WARNING]`, `> [!CAUTION]`, `> [!IMPORTANT]`) are converted to Starlight asides. - Relative repository-path links (e.g. `src/src/ZodSharp/Z.cs`) are rewritten to GitHub blob URLs automatically. - Keep the `CrossPlatformUserSchema` (C#) and `UserSchema` (TypeScript) in sync when either changes. ## Formatting Formatting is enforced with CSharpier. `.editorconfig` at the repo root defines style (tabs for code, 2-space for XML/JSON/YAML/markdown). # Core Concepts > Every schema derives from ZodType namespace ZodSharp.Core. Validation is a two-phase pipeline: # Core Concepts ## The validation pipeline Every schema derives from `ZodType` (namespace `ZodSharp.Core`). Validation is a two-phase pipeline: 1. **ParseInternal** — each schema overrides this hook to perform its type check and traversal (rejecting `null` where not allowed, coercing types, walking objects/arrays/tuples/unions, producing structured failures). 2. **Rules** — on success, the accumulated `IValidationRule` structs are evaluated. A failing rule emits a `ValidationError` with code `"validation_failed"`. ```csharp ValidationResult result = schema.Validate(value); ``` ## Validate, SafeParse, Parse, ValidateAsync | Member | Behaviour | |---|---| | `Validate(TInput value)` | Returns a `ValidationResult`. Never throws. | | `SafeParse(TInput value)` | Alias of `Validate`. | | `Parse(TInput value)` | Returns the validated `TOutput`, or throws `ZodException` on failure (via `GetValueOrThrow()`). | | `ValidateAsync(TInput value, CancellationToken)` | `ValueTask` wrapper around `Validate`. The pipeline is synchronous; this exists for interface symmetry and the source generator's custom async validation. | ## ValidationResult `ValidationResult` is a `readonly record struct` in `ZodSharp.Core`: - `bool IsSuccess` — annotated `[MemberNotNullWhen(true, nameof(Value))]`. - `T? Value` — the validated value; valid only when `IsSuccess`. - `ImmutableArray Errors` — populated on failure. Static factories: `Success(value)`, `Failure(ValidationError)`, `Failure(ImmutableArray)`, `Failure(IEnumerable)`, and `Merge(lhs, rhs)` (succeeds only if both succeed; concatenates errors). ## ValidationError `ValidationError` is a `readonly record struct` carrying machine-readable metadata: - `Code` — e.g. `"invalid_type"`, `"too_small"`, `"too_big"`, `"invalid_union"`, `"missing_field"`, `"unrecognized_key"`, `"validation_failed"`, `"refinement_failed"`, `"transform_error"`. - `Message` — human-readable message. - `Path` — `ImmutableArray`, e.g. `["user", "email"]`; array indexes appear as string segments such as `"0"`. - `Origin` — category for structured size issues (`"string"`, `"array"`, `"collection"`). - `Minimum` / `Maximum` — inclusive bounds for size issues. - `Inclusive` — whether the bound is inclusive. - `Parameters` — `IReadOnlyDictionary`, e.g. union failures carry every option error under `"errors"`. Create custom issues with `ValidationError.Create(code, message, path, parameters, origin, minimum, maximum, inclusive)`. ## ZodException `ZodException` (namespace `ZodSharp.Core`) is thrown by `Parse` and `GetValueOrThrow()`. It exposes `ImmutableArray Errors`. Its `ToString()` renders one line per error: `"{joinedPath}: {Message} ({Code})"`. ```csharp try { var value = schema.Parse("AB"); } catch (ZodException ex) { foreach (var error in ex.Errors) Console.WriteLine($"{string.Join(".", error.Path)}: {error.Message}"); } ``` ## Schemas and rules - Schemas are classes deriving from `ZodType`; the fluent methods return `this` (or a wrapping schema) so chains read naturally. - Rules are `readonly record struct` implementations of `IValidationRule` (`bool IsValid(in T value)`, `string GetErrorMessage(in T value)`) — zero allocation. - `ValidateSpan(ReadOnlySpan value)` is available on `ZodString` for span-based validation. - A schema's `Description` is set with `.Describe("...")`. ## Composition model `ZodType` composes via wrappers rather than mutation. The base type guards that input and output types match, then returns a new schema: - `Transform(Func)` → `ZodTransform`. - `Refine(Func, string? message)` → `ZodRefinement`. - `SuperRefine(Action>)` → `ZodSuperRefinement`. - `Pipe(IZodSchema)` → `ZodPipe`. - `Catch(TOutput | Func, TOutput>)` → `ZodCatch`. - `Prefault(TOutput)` → `ZodPrefault`. - `Default(TOutput)` → `ZodDefault`. - `And(IZodSchema)` → `ZodIntersection`. - `Or(IZodSchema)` → `ZodTypedUnion`. See [Composition and Transforms](../composition-and-transforms/) for details and semantics. ## Optionality and null handling Schemas report optionality through the internal `IOptionalSchema` interface: - `IsOptional` — `true` for `ZodOptional`, `ZodNullable`, `ZodDefault`, `ZodPrefault`. - `ProvidesValueOnMissing` — `true` for `ZodDefault` and `ZodPrefault`; object fields route missing values through `Validate(null!)` to inject the produced value. - `IAcceptsNull.ValidateNull()` is implemented by nullable/optional/default/prefault schemas and `ZodNull`; object-field and union wrappers route `null` input to it instead of failing coercion. ## Type coercion Object fields and union options wrap typed schemas so boxed values from a `Dictionary` can be validated: - `null` routes to `IAcceptsNull.ValidateNull()` where supported. - Exact-typed values reuse the original boxed value. - Numeric coercion uses `IConvertible` (invariant culture), so a boxed `long` can satisfy a `Z.Number()` field. - Non-nullable value types reject `null`; anything else falls through to failure. ## Dependency injection `IZodSchemaFactory` is the registry that resolves validators by type. See [Dependency Injection](../dependency-injection/). # Cross-Platform Interop > Purview.ZodSharp ships a cross-platform fixture pipeline that proves the C# implementation agrees with TypeScript/Zod. The TypeScript side runs on… # Cross-Platform Interop Purview.ZodSharp ships a cross-platform fixture pipeline that proves the C# implementation agrees with TypeScript/Zod. The TypeScript side runs on [Bun](https://bun.sh); the C# side runs under the TUnit test suite. ## The schema Both sides define the same user schema — TypeScript `UserSchema` in `src/ts/schema.ts` and the C# `CrossPlatformUserSchema` in `src/tests/SystemTextJson.UnitTests/CrossPlatformFixtures.cs` (mirrored by `NewtonsoftJson.UnitTests`): | Field | Zod (TS) | Purview.ZodSharp (C#) | |---|---|---| | `name` | `z.string().min(1)` | min-length 1 | | `age` | `z.number().int().min(0).max(120)` | `0..120` integer | | `email` | `z.string().email().optional()` | optional email | | `tags` | `z.array(z.string()).default([])` | string array, default `[]` | ## Fixture generation ```bash bun run generate-fixtures # runs src/ts/generate-fixtures.ts ``` For each of the eight fixture cases (three valid, five invalid) the script: 1. Writes a JSON file to `src/ts/fixtures/{key}.json`. 2. Runs `UserSchema.safeParse(value)` and records the outcome in `src/ts/fixtures/manifest.json` — the authoritative `{ "valid": true|false }` result for every fixture. ## The validation loop 1. **`bun run generate-fixtures`** writes `src/ts/fixtures/*.json` and `manifest.json` (Zod is the authority on validity). 2. **`just test`** (C# TUnit) reads the fixtures and manifest, asserts outcomes match, and writes validated C# output to `src/tests/cross-platform/output/{systemtext,newtonsoft}-valid.json`. 3. **`bun run test`** (vitest) re-checks fixture validity under Zod and parses the C# output JSON — closing the TypeScript ↔ C# loop. **C# cross-platform tests** (`SystemTextCrossPlatformTests`, `NewtonsoftCrossPlatformTests`) load every fixture and assert the C# outcome matches `manifest.json`, deserialize-and-validate valid fixtures, round-trip TS fixture → C# → JSON → C#, and serialize a validated `CrossPlatformUser` into `src/tests/cross-platform/output/{systemtext,newtonsoft}-valid.json`. **Vitest cross-platform tests** (`tests/ts/cross-platform.test.ts`) re-check fixture validity under Zod, verify canonical serialization, and parse every JSON file in `cross-platform/output` with Zod to prove C# output is acceptable to TypeScript/Zod. If the C# output directory is empty, the vitest suite emits a note instructing you to run the C# cross-platform tests first. ## JSON Schema bridge The same interop goal is available without fixtures via JSON Schema: - Export: `Z.ToJsonSchema` (core package) → JSON Schema, or `z.toJSONSchema` on the TypeScript side (Zod v4+). - Import: `Z.FromJsonSchema` (in the System.Text.Json or Newtonsoft.Json package). See [JSON Schema Export](../jsonschema-export/) and [JSON Schema Import](../jsonschema-import/). ## Directory layout ``` src/ts/ TypeScript (Zod) schemas + fixture generation src/ts/fixtures/ generated JSON fixtures + manifest.json tests/ts/ vitest cross-platform tests src/tests/cross-platform/ shared output directory for C#-generated JSON ``` # Dependency Injection > Purview.ZodSharp can resolve validators through an IZodSchemaFactory registry, and can validate options objects through IValidateOptions. # Dependency Injection Purview.ZodSharp can resolve validators through an `IZodSchemaFactory` registry, and can validate options objects through `IValidateOptions`. ## IZodSchemaFactory `IZodSchemaFactory` (namespace `ZodSharp.Core`) resolves validators by type: | Member | Behaviour | |---|---| | `Resolve()` | returns `IZodSchemaValidator?` or `null` when unregistered | | `ResolveRequired()` | returns the validator or throws `InvalidOperationException` | | `Validate(T value)` | validates through the registered validator for `T` | | `Register(IZodSchemaValidator)` | registers a validator | | `Register(Type, IZodSchemaValidator)` | non-generic registration | | `TryRegister(IZodSchemaValidator)` | returns `false` if already registered | | `IsRegistered()` | checks for a registration | The default implementation is `ZodSchemaFactory` (concurrent dictionary keyed by `Type`). `IZodSchemaValidator` extends `IZodSchema`, so a resolved validator validates like any other schema. Hand-built schemas can be registered by wrapping them: ```csharp using ZodSharp.Core; factory.Register(new ZodSchemaValidator>(myObjectSchema)); ``` ## Registering source-generated validators The source generator emits `[assembly: ZodSchemaGenerated(typeof({Type}))]` attributes and a `{Type}SchemaValidator` adapter. Scan an assembly to register every generated validator: ```csharp factory.RegisterFromAssembly(typeof(User).Assembly); // or factory.RegisterFromAssembly(); ``` `ZodSchemaFactoryExtensions.RegisterFromAssembly` looks up `{TypeName}SchemaValidator` in the target type's namespace/assembly and registers it. It throws `InvalidOperationException` when the validator type or `IZodSchemaValidator` implementation is missing. ## Registering the factory in DI The core package provides a `Microsoft.Extensions.DependencyInjection` extension: ```csharp builder.Services.AddZodSharpFactory(factory => factory.RegisterFromAssembly(typeof(User).Assembly)); ``` `AddZodSharpFactory(Action? configure = null)` registers a singleton `IZodSchemaFactory` and invokes the configuration callback. The `Purview.ZodSharp.AspNetCore` package offers the richer `AddZodSharp` with `ScanAssemblies` — see [ASP.NET Core Integration](../aspnetcore-integration/). ## Validating options objects Wire generated validators into the options framework so invalid configuration fails fast at startup: ```csharp builder.Services.AddZodSchemaOptionsValidator(); ``` `AddZodSchemaOptionsValidator()` registers a singleton `IValidateOptions` (`ZodSchemaOptionsValidator`) that resolves `IZodSchemaFactory` from DI and validates `T` when options are instantiated. Types without a registered validator pass through untouched (`ValidateOptionsResult.Success`). The source generator also auto-generates `IValidateOptions` validators for types whose names end in configurable suffixes — see [Source Generator](../source-generator/). ## Example: consuming a factory ```csharp public sealed class OrderService(IZodSchemaFactory factory) { public void ValidateProduct(Product product) { var result = factory.Validate(product); // ValidationResult } } ``` # Fluent Schema API > public static class Z namespace ZodSharp is the entry point for creating schemas. # Fluent Schema API ## The `Z` factory `public static class Z` (namespace `ZodSharp`) is the entry point for creating schemas. | Method | Signature | Returns | |---|---|---| | `String()` | `Z.String()` | `ZodString` | | `Number()` | `Z.Number()` | `ZodNumber` | | `Boolean()` | `Z.Boolean()` | `ZodBoolean` | | `Null()` | `Z.Null()` | `ZodNull` | | `Array` | `Z.Array(IZodSchema elementSchema)` | `ZodArray` | | `Optional` | `Z.Optional(IZodSchema schema)` — `T : class` | `ZodOptional` | | `Nullable` | `Z.Nullable(IZodSchema schema)` — `T : struct` | `ZodNullable` | | `Object()` | `Z.Object()` | `ZodObjectBuilder` | | `Union` (untyped) | `Z.Union(params IZodSchema[] options)` | `ZodUnion` | | `Union` (typed) | `Z.Union(IZodSchema, IZodSchema)` | `ZodTypedUnion` | | `Intersection` | `Z.Intersection(IZodSchema left, IZodSchema right)` | `ZodIntersection` | | `Literal` | `Z.Literal(T value)` — `T : IEquatable` | `ZodLiteral` | | `Lazy` | `Z.Lazy(Func> schemaGetter)` | `ZodLazy` | | `DiscriminatedUnion` | `Z.DiscriminatedUnion(string discriminator)` | `ZodDiscriminatedUnionBuilder` | | `Enum` (string values) | `Z.Enum(params string[] values)` | `ZodEnum` | | `Enum` (native) | `Z.Enum()` — `TEnum : struct, Enum` | `ZodNativeEnum` | | `Record` | `Z.Record(IZodSchema valueSchema)` | `ZodRecord` | | `Tuple` | `Z.Tuple(IZodSchema, IZodSchema)` | `ZodTuple` | | `Tuple` | `Z.Tuple(IZodSchema, IZodSchema, IZodSchema)` | `ZodTuple` | | `ToJsonSchema` | `Z.ToJsonSchema(IZodSchema schema, ToJsonSchemaOptions? options = null)` | `JsonSchemaDefinition` | :::note `Enum` and `Union` are overloaded: `Enum(params string[])` vs `Enum()`, and `Union(params ...)` vs `Union(...)`. `ToJsonSchema` lives in the core package; `FromJsonSchema` is an extension on `Z` provided by the JSON integration packages. ::: ## Schema type reference Each schema type has its own page: - [String Validation](../string-validation/) — `ZodString`. - [Number Validation](../number-validation/) — `ZodNumber`. - [Object Validation](../object-validation/) — `ZodObject`, `ZodObjectBuilder`. - [Arrays and Other Schemas](../arrays-and-other-schemas/) — `ZodArray`, `ZodBoolean`, `ZodNull`, `ZodEnum`, `ZodNativeEnum`, `ZodLiteral`, `ZodRecord`, `ZodTuple`, `ZodLazy`. - [Unions and Discriminated Unions](../unions-and-discriminated-unions/) — `ZodUnion`, `ZodTypedUnion`, `ZodDiscriminatedUnion`. - [Composition and Transforms](../composition-and-transforms/) — `ZodTransform`, `ZodRefinement`, `ZodSuperRefinement`, `ZodPipe`, `ZodCatch`, `ZodDefault`, `ZodPrefault`, `ZodIntersection`. - [Compiled Validators and Caching](../compiled-validators-and-caching/) — `CompiledValidator`, `SchemaCache`. ## The interfaces - `IZodSchema` — `Validate` / `ValidateAsync`; `IZodSchema` is the convenience form where input equals output. - `IZodSchemaValidator` (marker) and `IZodSchemaValidator` — the DI-facing adapter surface (see [Dependency Injection](../dependency-injection/)). - `IValidationRule` — the rule contract implemented by every struct rule. ## Convenience composition on any schema Because composition is implemented on the base `ZodType`, every schema can chain `.Describe(...)`, `.Transform(...)`, `.Refine(...)`, `.SuperRefine(...)`, `.Pipe(...)`, `.Catch(...)`, `.Prefault(...)`, `.Default(...)`, `.And(...)`, and `.Or(...)`. See [Composition and Transforms](../composition-and-transforms/). # Getting Started > dotnet add package Purview.ZodSharp # Getting Started ## Install ```bash dotnet add package Purview.ZodSharp ``` Add the integration packages you need: ```bash # System.Text.Json integration + JSON Schema import dotnet add package Purview.ZodSharp.SystemTextJson # Newtonsoft.Json integration + JSON Schema import dotnet add package Purview.ZodSharp.NewtonsoftJson # ASP.NET Core ProblemDetails integration dotnet add package Purview.ZodSharp.AspNetCore ``` - `Purview.ZodSharp` — core library, source generator, and JSON Schema **export**. - `Purview.ZodSharp.SystemTextJson` — System.Text.Json deserialize-and-validate, validating converters, and JSON Schema **import**. - `Purview.ZodSharp.NewtonsoftJson` — Newtonsoft.Json deserialize-and-validate, validating converters, and JSON Schema **import**. - `Purview.ZodSharp.AspNetCore` — failed validation results converted to standard `ProblemDetails` / `HttpValidationProblemDetails` payloads. :::tip JSON Schema import (`Z.FromJsonSchema`) is provided by whichever JSON integration package you reference, so pick one. Export (`Z.ToJsonSchema`) lives in the core package. ::: ## First schema ```csharp using ZodSharp; var nameSchema = Z.String().Min(3).Max(50); var result = nameSchema.Validate("John"); if (result.IsSuccess) Console.WriteLine($"Valid name: {result.Value}"); ``` ## Validate, SafeParse, and Parse - `Validate` returns a `ValidationResult` — no exceptions. - `SafeParse` is an alias of `Validate`. - `Parse` throws `ZodException` on failure. ```csharp var value = nameSchema.Parse("AB"); // throws ZodException var result = nameSchema.SafeParse("AB"); // non-throwing if (!result.IsSuccess) { foreach (var error in result.Errors) Console.WriteLine($" - {string.Join(".", error.Path)}: {error.Message}"); } ``` ## Object validation ```csharp var userSchema = Z.Object() .Field("name", Z.String().Min(1)) .Field("age", Z.Number().Min(0).Max(120).Int()) .Field("email", Z.String().Email()) .Build(); var userData = new Dictionary { { "name", "John Doe" }, { "age", 30.0 }, { "email", "john@example.com" } }; var result = userSchema.Validate(userData); ``` ## Source-generated validators Mark a class, struct, or record with `[ZodSchema]` and a zero-allocation static validator is generated at compile time: ```csharp using System.ComponentModel.DataAnnotations; using ZodSharp; [ZodSchema] public class User { [Required] [StringLength(50, MinimumLength = 3)] public string Name { get; set; } = string.Empty; [Range(0, 120)] public int Age { get; set; } [EmailAddress] public string? Email { get; set; } } var result = UserSchema.Validate(user); var validated = UserSchema.Parse(user); // throws on failure ``` See the [Source Generator](../source-generator/) and [Source Generator DataAnnotations](../source-generator-dataannotations/) pages for the full feature set. ## JSON integration ```csharp // System.Text.Json or Newtonsoft.Json var json = """{ "name": "John", "age": 30 }"""; var result = userSchema.DeserializeAndValidate(json); ``` ```csharp // JSON Schema export (core) var jsonSchema = Z.ToJsonSchema(userSchema, new ToJsonSchemaOptions { Title = "User" }); // JSON Schema import (requires an integration package) var imported = Z.FromJsonSchema(jsonSchemaString); ``` See [System.Text.Json Integration](../systemtextjson-integration/), [Newtonsoft.Json Integration](../newtonsoftjson-integration/), [JSON Schema Export](../jsonschema-export/), and [JSON Schema Import](../jsonschema-import/). ## Next pages - [Core Concepts](../core-concepts/) - [Fluent Schema API](../fluent-schema-api/) - [Source Generator](../source-generator/) - [Dependency Injection](../dependency-injection/) - [Cross-Platform Interop](../cross-platform-interop/) - [Performance](../performance/) # Guarantees and Limitations > - Zero-allocation on valid inputs. Primitives, strings, arrays, objects, discriminated unions, and first-option unions validate without allocating when the… # Guarantees and Limitations ## Guarantees - **Zero-allocation on valid inputs.** Primitives, strings, arrays, objects, discriminated unions, and first-option unions validate without allocating when the input is valid. See [Performance](../performance/). - **No reflection on hot paths.** The runtime library uses expression trees only in the opt-in `CompiledValidator`; the source generator emits direct typed codegen. - **Deterministic, reviewable generated code.** The `[ZodSchema]` generator output is stable and de-duplicated; no scope leaks in emitted code. - **Cross-platform parity.** The C# implementation is exercised against TypeScript/Zod fixtures (see [Cross-Platform Interop](../cross-platform-interop/)). - **Multi-targeting.** Packages target `net8.0`, `net9.0`, and `net10.0`; the source generator targets `netstandard2.0` so it runs in any compiler host. - **Immutable, shareable schemas.** Composing returns new schemas; schemas are safe to cache and share across threads. ## Limitations ### Attribute flags that are not yet honoured `SchemaName`, `GenerateValidateMethod`, and `GenerateParseMethod` on `[ZodSchema]` are parsed but ignored: the schema class is always `{TypeName}Schema` and `Validate`/`Parse` are always emitted. `EnableComposition` and the `IValidateOptions` flags are honoured. ### Generated size-failure Origin The generator reports `Origin = "array"` for both arrays and collections — there is no `"collection"` origin in generated code, even though `ValidationError.Origin` supports it. ### Async validation is synchronous underneath `ValidateAsync` wraps the synchronous `Validate` pipeline in a `ValueTask`. The only genuinely async path is the source generator's custom async validation method (`CustomValidationAsync`), which is awaited after the sync validation. ### JSON Schema import scope `Z.FromJsonSchema` supports **local** `$ref` (`#/...`) references only; external `$ref` targets throw `NotSupportedException`. `FromJsonSchemaOptions` is currently empty (reserved for future options). ### Referencing both JSON integration packages `Purview.ZodSharp.SystemTextJson` and `Purview.ZodSharp.NewtonsoftJson` both declare types with identical full names (`ZodSharp.ZExtensions`, `ZodSharp.JsonSchema.FromJsonSchemaOptions`, `FromJsonSchemaParser`, `JsonSchemaSerializerOptions`). Reference one JSON integration package; referencing both requires `extern alias`. ### Typed union allocation `ZodTypedUnion`/`ZodUnion` allocate while attempting non-matching options and on failure. Discriminated unions dispatch directly and stay zero-allocation. ### Rule errors Rules evaluated by the base `Validate` pipeline produce `validation_failed` errors with an empty path. Structured `too_small`/`too_big` issues (with `Minimum`/`Maximum`/`Inclusive`) come from the array schema and the source generator's size validators. ### String transforms allocate `ToLower`, `ToUpper`, and `Trim` produce new strings on every validation (transform outputs are new strings by nature). ### `IStringValidationRule` The span-based `IStringValidationRule` interface is declared but not implemented by any shipped rule struct; span validation is available through `ZodString.ValidateSpan`. ### Number semantics `ZodNumber` operates on `double`. `Int()`, `Safe()`, and `Finite()` are validation rules, not conversions; `.Int()` rejects fractional values rather than rounding them. `MultipleOf` uses a tolerance-based comparison and rejects a zero divisor. ### Enum semantics `ZodEnum` (string values) and `ZodNativeEnum` validate against defined members; they do not parse or convert values. ## Contract vs. underlying libraries - `System.ComponentModel.DataAnnotations` semantics are honoured where documented — e.g. `[Length]` treats `null` as valid unless `[Required]` is present; `[RegularExpression]` runs only on non-empty strings. - Validation failure payloads map to `ProblemDetails`/`HttpValidationProblemDetails` in the ASP.NET Core package (see [ASP.NET Core Integration](../aspnetcore-integration/)). # JSON Schema Export > Export a Purview.ZodSharp schema to a JSON Schema Draft 2020-12 definition with Z.ToJsonSchema, which lives in the core Purview.ZodSharp package. # JSON Schema Export Export a Purview.ZodSharp schema to a JSON Schema (Draft 2020-12) definition with `Z.ToJsonSchema`, which lives in the core `Purview.ZodSharp` package. ```csharp using ZodSharp; var userSchema = Z.Object() .Field("name", Z.String().Min(3)) .Field("email", Z.String().Email()) .Field("age", Z.Number().Min(0).Int()) .Build(); var jsonSchema = Z.ToJsonSchema>(userSchema, new ToJsonSchemaOptions { Title = "User", Id = "https://example.com/schemas/user.json" }); ``` ## ToJsonSchemaOptions | Property | Default | Purpose | |---|---|---| | `IncludeSchema` | `true` | emit `$schema: "https://json-schema.org/draft/2020-12/schema"` | | `Id` | `null` | sets `$id` | | `Title` | `null` | sets `title` | ## JsonSchemaDefinition `Z.ToJsonSchema` returns `JsonSchemaDefinition` (namespace `ZodSharp.JsonSchema`), a mutable POCO mirroring the JSON Schema keywords: - **Identity**: `Schema` (`$schema`), `Id` (`$id`), `Ref` (`$ref`). - **Type/title**: `Type`, `Title`, `Description`, `Default`, `Format`. - **String**: `MinLength`, `MaxLength`, `Pattern`. - **Number**: `Minimum`, `Maximum`, `ExclusiveMinimum`, `ExclusiveMaximum`, `MultipleOf`. - **Array**: `Items`, `MinItems`, `MaxItems`, `UniqueItems`. - **Object**: `Properties`, `Required`, `AdditionalProperties`. - **Composition**: `AnyOf`, `OneOf`, `AllOf`. - **Enum/const**: `Enum`, `Const`. - **Definitions**: `Defs` (2020-12) and `Definitions` (draft-07). - **Metadata**: `Deprecated`, `ReadOnly`, `WriteOnly`, `Examples`, `Nullable` (OpenAPI 3.0). ## Supported schema types `ToJsonSchemaConverter` handles `ZodString`, `ZodNumber`, `ZodBoolean`, `ZodNull`, `ZodObject`, `ZodOptional`, `ZodUnion`, `ZodArray`, `ZodLiteral`, `ZodNullable`, and `ZodLazy`. - Objects emit `additionalProperties: false`. - Literals emit `const` (and `type`). - Lazy/recursive schemas emit `$ref` entries under `$defs` (e.g. `#/$defs/__lazyN`). ## Serialize the definition Pick the JSON serializer that matches the integration package you referenced: ```csharp // System.Text.Json (Purview.ZodSharp.SystemTextJson) using ZodSharp.JsonSchema; var json = System.Text.Json.JsonSerializer.Serialize(jsonSchema, JsonSchemaSerializerOptions.Default); // Newtonsoft.Json (Purview.ZodSharp.NewtonsoftJson) using ZodSharp.JsonSchema; var json = JsonConvert.SerializeObject(jsonSchema, JsonSchemaSerializerOptions.Default); ``` `JsonSchemaSerializerOptions.Default` (camelCase, ignore nulls, indented) and `.Reading` (camelCase, ignore nulls) are provided by each integration package. ## Round-trip Import the exported definition back into Purview.ZodSharp with `Z.FromJsonSchema` from an integration package — see [JSON Schema Import](../jsonschema-import/) and the round-trip example in the example app (`JsonSchemaExamples`). # JSON Schema Import > Import a JSON Schema into a Purview.ZodSharp schema with Z.FromJsonSchema. This API is provided by the JSON integration packages — reference either… # JSON Schema Import Import a JSON Schema into a Purview.ZodSharp schema with `Z.FromJsonSchema`. This API is provided by the JSON integration packages — reference either `Purview.ZodSharp.SystemTextJson` or `Purview.ZodSharp.NewtonsoftJson` (both expose the same surface). ```csharp using ZodSharp; var jsonSchemaString = """ { "type": "object", "properties": { "name": { "type": "string", "minLength": 3 }, "email": { "type": "string", "format": "email" } }, "required": ["name", "email"] } """; var userSchema = Z.FromJsonSchema(jsonSchemaString); var result = userSchema.Validate(userData); ``` ## Overloads | Signature | Notes | |---|---| | `IZodSchema FromJsonSchema(string jsonSchema, FromJsonSchemaOptions? options = null)` | parses the JSON string into a `JsonSchemaDefinition`, then into a schema | | `IZodSchema FromJsonSchema(JsonSchemaDefinition schema, FromJsonSchemaOptions? options = null)` | import from an already-deserialized definition | `FromJsonSchemaOptions` is currently an empty placeholder reserved for future options. :::note `Z.FromJsonSchema` is implemented as a C# 14 extension member on `Z`, so it only exists when a JSON integration package is referenced. `Z.ToJsonSchema` is a real static member on `Z` in the core package. ::: ## Supported keywords `FromJsonSchemaParser` (namespace `ZodSharp.JsonSchema`) maps: - `type` — `string` / `number` / `integer` / `boolean` / `null` / `object` / `array`. - `enum` → `ZodUnion` of literals (a single member becomes a literal); `const` → literal. - `anyOf` / `oneOf` → `ZodUnion`; `allOf` → first schema. - String constraints — `minLength`, `maxLength`, `pattern`, and `format` (`email`, `uri`, `uuid`). The `uuid`/`guid` format maps to the versionless `.UUID()`; JSON Schema has no versioned `uuid` format, so a versioned `.UUID(UuidVersion.V7)` schema exports back as plain `format: "uuid"`. - Numeric constraints — `minimum`, `maximum`, `multipleOf`; `integer` additionally applies `.Int()`. - Objects — `required` and optional fields via `Z.Object().Field(...)`. - Arrays — `items`, `minItems`, `maxItems`. ## Limitations - `$ref` is supported only for **local** references (`#/...`); external `$ref` targets throw `NotSupportedException`. - The options type is currently empty; behaviour is fixed by the supported keyword set above. ## Cross-platform reuse Export a TypeScript/Zod schema to JSON Schema (Zod v4+ `z.toJSONSchema`) and import it on the backend: ```typescript import { z } from "zod"; const UserSchema = z.object({ username: z.string().min(3), email: z.string().email() }); const jsonSchema = z.toJSONSchema(UserSchema); ``` ```csharp var userSchema = Z.FromJsonSchema(jsonSchemaString); var result = userSchema.Validate(incomingData); ``` See [Cross-Platform Interop](../cross-platform-interop/) for the repository's fixture-based verification of this loop. # Newtonsoft.Json Integration > The Purview.ZodSharp.NewtonsoftJson package adds Newtonsoft.Json deserialize-and-validate, validating converters, and JSON Schema import to the core library.… # Newtonsoft.Json Integration The `Purview.ZodSharp.NewtonsoftJson` package adds Newtonsoft.Json deserialize-and-validate, validating converters, and JSON Schema import to the core library. All extension methods live in the `ZodSharp` namespace. ## Install ```bash dotnet add package Purview.ZodSharp.NewtonsoftJson ``` ## Deserialize and validate ```csharp using ZodSharp; var userSchema = Z.Object() .Field("name", Z.String().Min(3)) .Field("age", Z.Number().Min(0).Int()) .Build(); var json = """{ "name": "John", "age": 30 }"""; var result = userSchema.DeserializeAndValidate(json); if (result.IsSuccess) Console.WriteLine($"Valid: {result.Value}"); ``` Async stream and `JToken` overloads: ```csharp await using var stream = File.OpenRead("user.json"); var result = await userSchema.DeserializeAndValidateAsync(stream); var jToken = JObject.Parse(json); var result2 = userSchema.DeserializeAndValidate(jToken); ``` ## Validate and serialize ```csharp var result = userSchema.ValidateAndSerialize(user); // ValidationResult var result2 = await userSchema.ValidateAndSerializeAsync(user, stream, formatting: Formatting.Indented); ``` ## Validating converter ```csharp var converter = userSchema.CreateValidatingConverter(); var value = JsonConvert.DeserializeObject(json, converter); ``` `CreateValidatingConverter()` returns a non-generic `Newtonsoft.Json.JsonConverter`. Invalid JSON throws `JsonSerializationException` with a `"Validation failed: ..."` message. The converter clones the `JsonSerializer` (without itself) to avoid recursion. ## API surface | Member | Signature | |---|---| | `DeserializeAndValidate` | `ValidationResult DeserializeAndValidate(this IZodSchema schema, string json, JsonSerializerSettings? settings = null)` | | `DeserializeAndValidate` | `ValidationResult DeserializeAndValidate(this IZodSchema schema, JToken token, JsonSerializer? serializer = null)` | | `DeserializeAndValidateAsync` | `Task> DeserializeAndValidateAsync(this IZodSchema schema, Stream jsonStream, JsonSerializerSettings? settings = null, CancellationToken cancellationToken = default)` | | `ValidateAndSerialize` | `ValidationResult ValidateAndSerialize(this IZodSchema schema, T value, JsonSerializerSettings? settings = null, Formatting formatting = Formatting.None)` | | `ValidateAndSerializeAsync` | `Task> ValidateAndSerializeAsync(this IZodSchema schema, T value, Stream output, JsonSerializerSettings? settings = null, Formatting formatting = Formatting.Indented, CancellationToken cancellationToken = default)` | | `CreateValidatingConverter` | `JsonConverter CreateValidatingConverter(this IZodSchema schema)` | ## Failure codes Deserialize/validation failures produce `ValidationError` entries with codes `deserialization_failed` and `json_error` in addition to the schema's own codes. ## JSON Schema import `Z.FromJsonSchema` is available with this package referenced; see [JSON Schema Import](../jsonschema-import/). ## System.Text.Json vs Newtonsoft.Json | Aspect | SystemTextJson | NewtonsoftJson | |---|---|---| | Async result type | `ValueTask<...>` | `Task<...>` | | Options parameter | `System.Text.Json.JsonSerializerOptions` | `Newtonsoft.Json.JsonSerializerSettings` | | Converter return | generic `JsonConverter` | non-generic `JsonConverter` | | `JToken` overload | no | yes | | Formatting control | via `JsonSerializerOptions` | explicit `Newtonsoft.Json.Formatting` argument | | Invalid-data exception | `System.Text.Json.JsonException` | `JsonSerializationException` | | JSON plumbing | `JsonElement` | `JToken`/`JObject`/`JArray` | :::caution Both packages declare types with identical full names (`ZodSharp.ZExtensions`, `ZodSharp.JsonSchema.FromJsonSchemaOptions`, `ZodSharp.JsonSchema.FromJsonSchemaParser`, `ZodSharp.JsonSchema.JsonSchemaSerializerOptions`). Referencing both packages in one project creates type ambiguity unless `extern alias` is used — reference one JSON integration package. ::: # Number Validation > ZodNumber namespace ZodSharp.Schemas validates double values. ParseInternal rejects double.NaN with an invalidtype error "Expected number, but got NaN"; on… # Number Validation `ZodNumber` (namespace `ZodSharp.Schemas`) validates `double` values. `ParseInternal` rejects `double.NaN` with an `invalid_type` error (`"Expected number, but got NaN"`); on success the accumulated rules run. ```csharp using ZodSharp; var schema = Z.Number().Min(0).Max(120).Int(); var result = schema.Validate(30.0); ``` ## Methods | Method | Signature | Rule added | |---|---|---| | `Min` | `Min(double minValue)` | `MinValueRule` — `Value must be at least ...` | | `Max` | `Max(double maxValue)` | `MaxValueRule` | | `Int` | `Int()` | `IntRule` — `value == Math.Truncate(value)` | | `Positive` | `Positive()` | `MinValueRule(0.0)` | | `Negative` | `Negative()` | `MaxValueRule(0.0)` | | `MultipleOf` | `MultipleOf(double divisor, string? message)` | `MultipleOfRule` — throws `ArgumentException` for a zero divisor; tolerance-based | | `Finite` | `Finite(string? message)` | `FiniteRule` — `double.IsFinite` | | `Safe` | `Safe(string? message)` | `SafeIntegerRule` — integer within `int.MinValue`..`int.MaxValue` | ## Examples ```csharp var positive = Z.Number().Positive(); var negative = Z.Number().Negative(); var multipleOf = Z.Number().MultipleOf(10); // multiples of 10 var finite = Z.Number().Finite(); // rejects Infinity / NaN var safe = Z.Number().Safe(); // safe integer range var whole = Z.Number().Int(); // no fractional part var age = Z.Number().Min(0).Max(120).Int().Validate(25.0); ``` ## Numeric coercion When a `Z.Number()` is used as an object field or union option, boxed values are coerced via `IConvertible` (invariant culture) — for example a `long` from a `Dictionary` validates against a `Z.Number()` field. Non-numeric values fail with `invalid_type`. # Object Validation > ZodObject namespace ZodSharp.Schemas validates Dictionary values. Build schemas with the ZodObjectBuilder returned by Z.Object. # Object Validation `ZodObject` (namespace `ZodSharp.Schemas`) validates `Dictionary` values. Build schemas with the `ZodObjectBuilder` returned by `Z.Object()`. ```csharp using ZodSharp; var userSchema = Z.Object() .Field("name", Z.String().Min(1)) .Field("age", Z.Number().Min(0).Max(120).Int()) .Field("email", Z.String().Email()) .Build(); var result = userSchema.Validate(new Dictionary { { "name", "John Doe" }, { "age", 30.0 }, { "email", "john@example.com" } }); ``` ## Behaviour - `null` input → `invalid_type` (`"Expected object, but got null"`). - Missing fields are allowed only when the key is optional (`Partial`/`Required`/`.Optional` semantics) or the field schema is itself optional (`IOptionalSchema.IsOptional`, e.g. `Z.Optional(...)`); otherwise `missing_field` with path `[key]`. - When a missing field's schema `ProvidesValueOnMissing` (e.g. `Z.Default(...)`), the produced value is injected into the output. - Field values are validated against their schema; failures get the field name prepended to the error path. Changed/coerced values trigger a rebuild of the output dictionary. - Unknown keys are handled by the object's `UnknownKeyPolicy` or `CatchallSchema` (below). ## Unknown key policies `UnknownKeyPolicy` is an enum with three values. `Strip` is the default. | Policy | Behaviour | |---|---| | `Strip` (default) | Unknown keys are dropped from the output. | | `Passthrough` | Unknown keys are kept as-is. | | `Strict` | Unknown keys fail with `unrecognized_key` and path `[key]`. | ```csharp var strict = Z.Object().Field("name", Z.String()).Build().Strict(); var permissive = Z.Object().Field("name", Z.String()).Build().Passthrough(); ``` ## Catchall `Catchall(IZodSchema schema)` validates every unknown key against the schema and includes the validated value in the output. The schema argument must not be `null`. ```csharp var schema = Z.Object() .Field("name", Z.String()) .Catchall(Z.Number()) .Build(); ``` ## Fluent methods These return a **new** `ZodObject` instance: | Method | Behaviour | |---|---| | `Extend(string key, IZodSchema schema)` | add or replace a field | | `Merge(ZodObject other)` | other's shape overrides; adopts other's `UnknownKeyPolicy` + `CatchallSchema`; optionality per contributing object | | `Pick(params string[] keys)` | keep only the given keys | | `Omit(params string[] keys)` | remove the given keys | | `Partial()` | every shape key optional | | `Required()` | no optional keys; all shape keys required | | `Passthrough()` | `UnknownKeyPolicy.Passthrough` | | `Strict()` | `UnknownKeyPolicy.Strict` | | `Strip()` | `UnknownKeyPolicy.Strip` | | `Catchall(IZodSchema schema)` | validate unknown keys against a schema | ## Exposed shape `ZodObject` exposes `Shape`, `UnknownKeyPolicy`, `OptionalKeys`, `RequiredKeys`, and `CatchallSchema` as read-only properties, so metadata is inspectable (used by the JSON Schema exporter). ## Builder `ZodObjectBuilder` validates its arguments: a null/whitespace field name or a null schema throws `ArgumentNullException`. Typed fields are wrapped so boxed values coerce correctly (see [Core Concepts](../core-concepts/)). # Performance > Purview.ZodSharp is designed for maximum performance: validation rules are readonly record structs, hot paths use Span, and the source generator emits… # Performance Purview.ZodSharp is designed for maximum performance: validation rules are `readonly record struct`s, hot paths use `Span`, and the source generator emits direct typed codegen with no reflection. The committed BenchmarkDotNet suite measures every scenario. ## Running the benchmarks ```bash # All suites dotnet run --project src/src/Benchmarks/Benchmarks.csproj -c Release # or just perf-tests # A specific suite (the `--` passes the filter to BenchmarkDotNet) dotnet run --project src/src/Benchmarks/Benchmarks.csproj -c Release -- --filter "*ObjectPerformanceTests*" ``` Results are written to `BenchmarkDotNet.Artifacts/` (HTML, Markdown, logs) in the project directory. Use `-c Release`; the suite uses `[MemoryDiagnoser]` and a `[SimpleJob]` profile. ## Measurement environment - BenchmarkDotNet 0.15.8, .NET 10.0.12, Windows 11 (10.0.28020.2991). - 13th Gen Intel Core i9-13900KF 3.00 GHz (24 physical / 32 logical cores), X64 RyuJIT x86-64-v3. Numbers are indicative; re-run on your own hardware for local planning. ## Core validation (`BasicPerformanceTests`) | Scenario | Mean | Allocated | |---|---|---| | ValidateBoolean | 2.170 ns | 0 B | | ValidateNumber | 10.702 ns | 0 B | | ValidateString | 41.627 ns | 0 B | | ValidateStringArray | 57.031 ns | 0 B | | ValidateStringWithMultipleRules | 77.313 ns | 0 B | | ValidateNumberWithMultipleRules | 16.427 ns | 0 B | ## Objects (`ObjectPerformanceTests`) | Scenario | Mean | Allocated | |---|---|---| | ValidateSimpleObject (2 fields) | 89.78 ns | 0 B | | ValidateMediumObject (6 fields) | 340.18 ns | 0 B | | ValidateComplexObject (13 fields, nested) | 795.69 ns | 0 B | | ValidateComplexObjectInvalid | 1,152.80 ns | 1,960 B | ## Arrays (`ArrayPerformanceTests`) | Scenario | Mean | Allocated | |---|---|---| | ValidateSmallArray | 124.4 ns | — | | ValidateLargeArray (1000 items) | 11,682.3 ns | — | | ValidateMediumArray (100 items) | 4,585.3 ns | — | | ValidateNumberArray | 12,797.3 ns | — | | ValidateLargeArrayWithComplexSchema | 8,011.8 ns | — | | ValidateLargeArrayInvalid | 11,206.7 ns | 904 B | ## Heavy scenarios (`HeavyPerformanceTests`) | Scenario | Mean | |---|---| | ValidateDeepNestedObject (4 levels) | 198.5 ns | | ValidateWideObject (50 fields) | 2,217.2 ns | | ValidateNestedArray | 177.7 ns | | ValidateStringWithManyRefinements | 114.7 ns | | ValidateLargeObjectWithArrays | 19,578.0 ns | ## Transforms (`TransformPerformanceTests`) | Scenario | Mean | Allocated | |---|---|---| | TransformToLower | 22.26 ns | 48 B | | TransformToUpper | 28.39 ns | 48 B | | TransformTrim | 29.10 ns | 48 B | | TransformChained | 40.71 ns | 96 B | | TransformWithValidation | 78.79 ns | 112 B | ## Unions (`UnionPerformanceTests`) | Scenario | Mean | Allocated | |---|---|---| | ValidateUnion_String (first option) | 48.70 ns | 0 B | | ValidateUnion_Number (second option) | 69.38 ns | 592 B | | ValidateUnion_Boolean (third option) | 99.71 ns | 792 B | | ValidateDiscriminatedUnion_FirstOption | 169.33 ns | 0 B | | ValidateDiscriminatedUnion_SecondOption | 171.90 ns | 0 B | | ValidateUnion_Invalid | 195.87 ns | 1,360 B | :::note Union validations allocate on the failure path and while attempting non-matching options — the string option is free, but matching a later option allocates the error collection from the earlier attempts. Discriminated unions dispatch directly and remain zero-allocation. ::: ## Memory (`MemoryPerformanceTests`) All valid-input paths are zero-allocation: | Scenario | Mean | Ratio | |---|---|---| | ValidateString_Allocations (baseline) | 45.82 ns | 1.00 | | ValidateObject_Allocations | 94.28 ns | 2.06 | | ValidateArray_Allocations | 1,180.24 ns | 25.82 | ## UUID validation (`UuidPerformanceTests`) UUID validation uses a zero-allocation char-scan (version nibble at position 14, variant nibble at position 19) instead of a regex. Measured against the previous compiled regex: | Scenario | Mean | Allocated | |---|---|---| | Rule_CharScan_Valid (`.UUID()`) | 21.99 ns | 0 B | | Rule_LegacyRegex_Valid (previous implementation) | 27.50 ns | 0 B | | Rule_CharScan_Invalid | < 1 ns | 0 B | | Rule_LegacyRegex_Invalid | 14.22 ns | 0 B | | Rule_CharScan_Nil | 20.67 ns | 0 B | | Rule_CharScanV7_Valid (`.UUID(UuidVersion.V7)`) | 20.89 ns | 0 B | | Rule_CharScanV7_Mismatch | 20.11 ns | 0 B | | Schema_UUID_Valid | 29.80 ns | 0 B | | Schema_UUIDV7_Valid | 26.64 ns | 0 B | The char-scan is ~20% faster than the previous regex on the valid path, is version-aware at no extra cost, and rejects wrong-length strings in under a nanosecond. ## Optimizations that make it fast 1. **Struct-based rules** — every rule is a `readonly record struct` implementing `IValidationRule`, so there is no per-validation object allocation. 2. **Zero-allocation helpers** — `Span`/`ReadOnlySpan` string validation (`ValidateSpan`) and `ArrayPool`-backed helpers. 3. **Compiled validators** — `CompiledValidator.Compile` removes interface dispatch (see [Compiled Validators and Caching](../compiled-validators-and-caching/)). 4. **Source generation** — `[ZodSchema]` emits direct property access and typed equality checks with no reflection (see [Source Generator](../source-generator/)). 5. **Fluent composition** — schemas are immutable and shareable, so `SchemaCache` avoids repeated construction (see [Compiled Validators and Caching](../compiled-validators-and-caching/)). The only allocations on a successful validation are the string transforms (`ToLower`/`ToUpper`/`Trim` produce new strings) and the union non-first-option paths noted above. # Release Flow > Purview.ZodSharp releases are driven by the shared purview-dev/buildhttps://github.com/purview-dev/build pipeline through the GitHub Actions workflows in… # Release Flow Purview.ZodSharp releases are driven by the shared [purview-dev/build](https://github.com/purview-dev/build) pipeline through the GitHub Actions workflows in `.github/workflows/`. ## Versioning The package version comes from `package.json` (`version` field). The repo is currently on the `2.0.0-prerelease.*` line. Bump `package.json` to release a new version. Package identities are `Purview.ZodSharp.*` (core, SystemTextJson, NewtonsoftJson, AspNetCore). Central package management lives in `Directory.Packages.props`; package versions there are minimum requirements, not exact pins, so the resolved graph can drift. ## Workflows | Workflow | Trigger | Pipeline mode | |---|---|---| | `pr.yml` | pull requests against `main` | `purview-build.yml` with `run-pack: true`, `validate-pack: true` | | `release.yml` | pushes to `main` | `purview-release.yml` with `release-mode: NuGet` | Both consume `purview-build.json`: - `Build` — solution (`src/ZodSharp.slnx`), test root (`src/tests`), patterns (`*Tests.csproj`), filter (`/*/*/*/*`). - `PackValidation` — requires symbol packages/files and validates `RequiredContent` per package (per-TFM DLL + XML, analyzer assemblies and `buildTransitive/Purview.ZodSharp.props` for the core package, `README.md` and `purview-logo-light.png` for every package). - `Release.Mode` — `None` for PR/local runs. ## Pipeline commands ```bash just pipeline-pr # restore, build, lint, tests, pack, validate pack just pipeline-build # restore, build, lint (no tests, no release) just pipeline-tests # pipeline with tests enabled just pipeline-release # pack, publish, GitHub release (NuGet mode) just pipeline-local-release # pack + publish to a local NuGet feed ``` See the `Justfile` for the full command set (`just build`, `just test`, `just lint-check`, `just lint-fix`, `just pack`, `just perf-tests`). ## Commit conventions Commits must follow [Conventional Commits](https://www.conventionalcommits.org/), enforced by Lefthook + Commitlint (`.config/lefthook.yml` and `commitlint.config.mts`). Allowed types: `build`, `chore`, `ci`, `docs`, `feat`, `fix`, `perf`, `refactor`, `revert`, `style`, `test`. ## Quality gates - `just build` succeeds with no new warnings/errors. - Relevant tests pass (`just test`). - `just lint-check` (CSharpier) reports no formatting changes. - Packed packages match `purview-build.json` `PackValidation`. - Generated code is deterministic and reviewable. # Source Generator > Mark a class, struct, or record with ZodSchema and the generator emits a static, zero-allocation validator at compile time. The ZodSchema attribute is… # Source Generator Mark a class, struct, or record with `[ZodSchema]` and the generator emits a static, zero-allocation validator at compile time. The `[ZodSchema]` attribute is generated into the `ZodSharp` namespace by the generator itself (assembly `Purview.ZodSharp.SourceGenerators`), so no extra package is needed beyond `Purview.ZodSharp`. ```csharp using System.ComponentModel.DataAnnotations; using ZodSharp; [ZodSchema] public class User { [Required] [StringLength(50, MinimumLength = 3)] public string Name { get; set; } = string.Empty; [Range(0, 120)] public int Age { get; set; } [EmailAddress] public string? Email { get; set; } } var result = UserSchema.Validate(user); var validated = UserSchema.Parse(user); // throws ZodException on failure ``` ## Generated types For a `[ZodSchema]` target type `{TypeName}`, the generator emits: | Artifact | Shape | |---|---| | `{TypeName}Schema` | static partial class — the validator; access mirrors the target (public/internal/private for private nested types); contains `Validate`, `Parse`, and (when composition is enabled) `ApplyAnd`, `ApplyOr`, `ApplyRefine` | | `{TypeName}SchemaValidator` | `partial class {TypeName}SchemaValidator : IZodSchemaValidator<{TypeName}>` — DI-friendly adapter with `Validate` / `ValidateAsync`; emitted only for the primary schema | | `{TypeName}Validator` | `sealed partial class {TypeName}Validator : IValidateOptions<{TypeName}>` — emitted only when `IValidateOptions` support is enabled (and the target is a class) | | `[assembly: ZodSchemaGenerated(typeof({TypeName}))]` | registration marker consumed by `IZodSchemaFactory` assembly scanning; emitted only for primary, non-nested schemas | ```csharp // Value-first composition methods (EnableComposition, default true): var adult = UserSchema.ApplyRefine(user, u => u.Age >= 18, "Must be adult"); var both = UserSchema.ApplyAnd(user, u => u.Name.Length > 5, "Name too short"); var either = UserSchema.ApplyOr(user, u => u.Age < 18, "Must be an adult or a minor with consent"); ``` ## Attribute options All options are optional. | Property | Default | Purpose | |---|---|---| | `SchemaName` | `null` | Reserved — the schema class is always named `{TypeName}Schema`. | | `GenerateValidateMethod` | `true` | Reserved — `Validate` is always emitted. | | `GenerateParseMethod` | `true` | Reserved — `Parse` is always emitted. | | `EnableComposition` | `true` | Emits `ApplyAnd`, `ApplyOr`, `ApplyRefine` value-first composition methods. | | `CustomValidationMethodName` | `null` | Name of an async custom validation method; default lookup name `CustomValidationAsync`. | | `RefinementMethodName` | `null` | Name of a synchronous refinement method; default lookup name `Validate` (an instance method on the model). | | `GenerateIValidateOptions` | `false` | Force `IValidateOptions` generation. | | `SuppressIValidateOptions` | `false` | Opt out even when auto-detection would enable it. | :::note `SchemaName`, `GenerateValidateMethod`, and `GenerateParseMethod` are parsed by the attribute but not yet honoured by the generator — the class is always `{TypeName}Schema` with `Validate` and `Parse`. ::: ## Custom async validation Declare a partial `{TypeName}SchemaValidator` (or a static method on the model type): ```csharp public partial class UserSchemaValidator { public async ValueTask> CustomValidationAsync(User value, CancellationToken ct) { await Task.Delay(1, ct); return ValidationResult.Success(value); } } ``` Requirements: - Signature `ValueTask> Name(T value, CancellationToken ct)`. - A method declared on the model type must be `static`; a method on the generated `{TypeName}SchemaValidator` partial may be an instance method. - The generated `ValidateAsync` runs the synchronous `Validate`, then awaits the custom method, and merges the error sets. ## Synchronous refinement Declare an instance method on the model (default name `Validate`) returning `IEnumerable`: ```csharp [ZodSchema(RefinementMethodName = "Validate")] public class Order { public decimal Total { get; set; } public IEnumerable Validate() { if (Total < 0) yield return ValidationError.Create("invalid_range", "Total cannot be negative", []); } } ``` Parameterless or `IEnumerable Validate(RefineCtx ctx)` variants are supported. ## IValidateOptions support Generated options validators are enabled by: 1. `GenerateIValidateOptions = true` on the attribute, or 2. auto-detection: `GenerateIValidateOptions` unset, target is not a value type, and the type name ends with a configured suffix (default `Options` or `Settings`), or 3. MSBuild override. MSBuild switches: | Property | Default | Behaviour | |---|---|---| | `DisableZodSharpSourceGenerator` | unset | disables the generator entirely when truthy | | `ZodSharpAutoGenerateOptionsValidators` | `true` | auto-detect `IValidateOptions` (only explicit `false` disables) | | `ZodSharpAutoGenerateOptionsValidatorSuffixes` | `Options;Settings` | semicolon/comma-separated suffix list | ## What is validated - Properties must be public, non-static, non-indexer. - A property is included when it carries any DataAnnotations attribute or its type is a source-defined complex type with a nested schema. - Classes, structs, and records are supported; structs do not receive `IValidateOptions` (ZODSGEN028 if requested). - Nested complex types are discovered recursively and get their own generated `{TypeName}Schema`, even when the nested type does not itself carry `[ZodSchema]`. - Nullable properties are null-guarded before value-set/type validation; a nullable target rejects `null` with `invalid_type`. See [Source Generator DataAnnotations](../source-generator-dataannotations/) for the attribute coverage and structured issue shape, and [Source Generator Diagnostics](../source-generator-diagnostics/) for the `ZODSGEN*` diagnostics. # Source Generator DataAnnotations > The ZodSchema generator reads System.ComponentModel.DataAnnotations attributes and emits direct, typed codegen — no reflection at runtime. # Source Generator DataAnnotations The `[ZodSchema]` generator reads `System.ComponentModel.DataAnnotations` attributes and emits direct, typed codegen — no reflection at runtime. ## Supported attributes | Attribute | Generated behaviour | Failure code | |---|---|---| | `[Required]` | nullable property must not be null (strings with `AllowEmptyStrings=false` must be non-empty) | `missing_field` | | `[Length(min, max)]` | min/max size with `too_small`/`too_big`; applies to strings, arrays (incl. jagged/rectangular), and countable collections | `too_small` / `too_big` | | `[StringLength(max)]` / `[StringLength(max, MinimumLength=min)]` | string size limits via direct `.Length` | `too_small` / `too_big` | | `[MinLength(n)]` | only checked when `n > 0` | `too_small` | | `[MaxLength(n)]` | only checked when `n >= 0` | `too_big` | | `[Range(...)]` | inclusive (or exclusive) numeric/parsed bounds | `invalid_range` | | `[RegularExpression(pattern)]` | compiled `Regex` field, checked on non-empty strings | `invalid_string` | | `[AllowedValues(...)]` | typed equality checks against the allowed set | `invalid_value` | | `[DeniedValues(...)]` | typed equality checks against the denied set | `invalid_value` | | `[EmailAddress]` | reuses `ZodSharp.Rules.EmailRule` on non-empty strings | `invalid_string` | | `[Url]` | reuses `UrlRule` | `invalid_string` | | `[Phone]` | reuses `PhoneRule` | `invalid_string` | | `[CreditCard]` | reuses `CreditCardRule` | `invalid_string` | | `[Base64String]` | reuses `Base64StringRule` | `invalid_string` | | `[Compare(otherProperty)]` | typed equality between two properties | `mismatch` | | `[Display(Name=...)]` | not validated; `Name` used as the display name in messages and `{0}` placeholders | — | `[Length]` follows DataAnnotations null semantics: `null` is valid unless `[Required]` is also present. ## Size validators and structured issues Size attributes generate direct `Length` or `Count` access when possible: - `string` → `.Length`. - arrays (including rectangular arrays) → `.Length`. - jagged arrays → outer-array `.Length`. - countable collections → `.Count`. - `IEnumerable` / `IEnumerable` → a single counted pass via `CollectionCountHelper.GetCount` (fast paths for `ICollection`, `IReadOnlyCollection`, and non-generic `ICollection`). Structured size failures expose the same metadata as the runtime API: - `Code`: `too_small` or `too_big`. - `Origin`: `string` for strings, `array` for arrays and collections. - `Minimum` / `Maximum`: the inclusive bound. - `Inclusive`: `true`. - `Path`: the property path. ```csharp [ZodSchema] public sealed class Basket { [Required] [Length(2, 5)] public List? Items { get; set; } } var result = BasketSchema.Validate(new Basket { Items = ["apple"] }); // result.Errors[0].Code == "too_small" // result.Errors[0].Minimum == 2 // result.Errors[0].Origin == "array" // result.Errors[0].Inclusive == true ``` :::note Today the generator reports `Origin = "array"` for both arrays and collections; there is no `"collection"` origin in generated code. ::: ## Range `[Range]` supports three constructor shapes plus `MinimumIsExclusive`, `MaximumIsExclusive`, `ConvertValueInInvariantCulture`, and `ParseLimitsInInvariantCulture`: - `[Range(int, int)]` and `[Range(double, double)]` — literal numeric bounds. - `[Range(typeof(T), "min", "max")]` — parsed bounds for numeric types and comparable types. Comparable range targets include `TimeSpan`, `DateTime`, `DateTimeOffset`, `DateOnly`, `TimeOnly`, and `Version` (which uses `CompareTo`), plus any type implementing `IComparable` with user-defined comparison operators. Bounds are emitted as static typed fields and compared without runtime attribute execution. ## Error message customization Honours `ErrorMessage`, or `ErrorMessageResourceName` + `ErrorMessageResourceType`, with `{0}` (display name), `{1}`, and `{2}` (bound) placeholders formatted via `string.Format(CultureInfo.CurrentCulture, ...)`. Providing only one of the resource name/type pair is reported as ZODSGEN005. ## Type applicability diagnostics Misuse is reported at compile time rather than silently ignored: - `[Length]` with `min > max` → ZODSGEN003. - `[Length]` on an unsupported target (e.g. `decimal`) → ZODSGEN004. - String-only attributes (`[RegularExpression]`, `[EmailAddress]`, `[Url]`, `[Phone]`, `[CreditCard]`, `[Base64String]`) on non-string targets, `[AllowedValues]`/`[DeniedValues]` on unsupported types, or `[Range]` on unsupported types → ZODSGEN006. - `[Compare]` referencing an unknown property → ZODSGEN020. See [Source Generator Diagnostics](../source-generator-diagnostics/) for the full list. # Source Generator Diagnostics > The ZodSchema generator ships an analyzer category ZodSharp.SourceGenerator that reports configuration and usage problems at compile time. All diagnostics… # Source Generator Diagnostics The `[ZodSchema]` generator ships an analyzer (category `ZodSharp.SourceGenerator`) that reports configuration and usage problems at compile time. All diagnostics below are errors, enabled by default. | ID | Meaning | |---|---| | ZODSGEN001 | Unhandled generator exception (`"Source generator failed for {0}: {1}"`) | | ZODSGEN003 | Invalid `[Length]` configuration (min > max) | | ZODSGEN004 | Unsupported `[Length]` target | | ZODSGEN005 | Invalid DataAnnotations error-message resource configuration (name without type, or type without name) | | ZODSGEN006 | Unsupported DataAnnotations usage (string-only attributes on non-string targets; `[AllowedValues]`/`[DeniedValues]` on unsupported types; `[RegularExpression]` on non-strings; `[Range]` on unsupported types) | | ZODSGEN007 | Custom/synchronous validation method configured but not found (when a name is explicitly configured) | | ZODSGEN008 | Custom method return type is not `ValueTask>` | | ZODSGEN009 | Custom method parameter count is not 2 | | ZODSGEN010 | First custom method parameter is not the model type | | ZODSGEN011 | Second custom method parameter is not `CancellationToken` | | ZODSGEN012 | Custom/synchronous method is generic | | ZODSGEN013 | Custom method must be static when defined on the model type | | ZODSGEN014 | Custom method is inaccessible from the generated validator (private/protected) | | ZODSGEN015 | Ambiguous custom/synchronous method overloads (only when at least two valid candidates exist) | | ZODSGEN016 | Configured method name is not a valid C# identifier | | ZODSGEN017 | Custom/synchronous method is abstract | | ZODSGEN018 | Custom/synchronous method is an unimplemented partial method | | ZODSGEN019 | Custom/synchronous method uses `ref`/`in`/`out`/`params`/`scoped` parameters | | ZODSGEN020 | `[Compare]` references an unknown property | | ZODSGEN021 | `System.ComponentModel.DataAnnotations` reference missing | | ZODSGEN022 | Synchronous refinement return type is not `IEnumerable` (arrays/derived assignable types accepted) | | ZODSGEN023 | Synchronous refinement has more than one parameter | | ZODSGEN024 | Synchronous refinement must be an instance method | | ZODSGEN025 | Synchronous refinement must be public or internal | | ZODSGEN026 | Synchronous refinement's single parameter must be `RefineCtx` matching the model | | ZODSGEN027 | `IValidateOptions` requested but `Microsoft.Extensions.Options` reference is missing | | ZODSGEN028 | `IValidateOptions` requested on a struct (requires a class) | ## Suppressing Diagnostics can be suppressed per-project or per-site with the standard `#pragma warning disable ZODSGEN006` / `NoWarn` mechanisms. Refer to the analyzer's shipped release notes (`AnalyzerReleases.Shipped.md` / `AnalyzerReleases.Unshipped.md` in the generator project) for the canonical catalog. # String Validation > ZodString namespace ZodSharp.Schemas validates string values. ParseInternal rejects null with an invalidtype error "Expected string, but got null"; on success… # String Validation `ZodString` (namespace `ZodSharp.Schemas`) validates `string` values. `ParseInternal` rejects `null` with an `invalid_type` error (`"Expected string, but got null"`); on success the accumulated rules run. ```csharp using ZodSharp; var schema = Z.String().Min(3).Max(50).Email(); var result = schema.Validate("user@example.com"); ``` ## Methods | Method | Signature | Rule added | |---|---|---| | `Min` | `Min(int minLength)` | `MinLengthRule` — `too_small` via `validation_failed` when too short | | `Max` | `Max(int maxLength)` | `MaxLengthRule` | | `Length` | `Length(int length)` | exact length (both bounds) | | `Email` | `Email()` | `EmailRule` — static compiled regex | | `Regex` | `Regex(Regex pattern, string? message)` / `Regex(string pattern, string? message)` | `RegexRule`; the string overload compiles with a 100 ms timeout | | `Url` | `Url(string? message)` | `UrlRule` — regex or absolute `http`/`https` URI | | `Phone` | `Phone(string? message)` | `PhoneRule` — digits plus `() .+-`, at least one digit | | `CreditCard` | `CreditCard(string? message)` | `CreditCardRule` — Luhn algorithm | | `Base64String` | `Base64String(string? message)` | `Base64StringRule` — `Convert.FromBase64String` | | `UUID` | `UUID(string? message)` | `UUIDRule` — char-scan, RFC 9562 versions 1-8, variant nibble `8-9/a-b`, plus nil and max | | `UUID` | `UUID(UuidVersion version, string? message)` | `UUIDRule` — requires a specific version (e.g. `V7`), variant nibble `8-9/a-b`, nil/max rejected | | `StartsWith` | `StartsWith(string prefix, string? message)` | `StartsWithRule` — ordinal comparison | | `EndsWith` | `EndsWith(string suffix, string? message)` | `EndsWithRule` — ordinal comparison | | `ToLower` | `ToLower()` | wraps a transform (`ToLowerInvariant`), returns a `ZodString` | | `ToUpper` | `ToUpper()` | wraps a transform (`ToUpperInvariant`) | | `Trim` | `Trim()` | wraps a transform (`Trim`) | | `ValidateSpan` | `ValidateSpan(ReadOnlySpan value)` | zero-allocation span validation | :::note `ToLower`, `ToUpper`, and `Trim` produce a new string on every validation — these are the only string validations that allocate on a successful path. ::: ## Examples ```csharp var email = Z.String().Email().Validate("user@example.com"); var url = Z.String().Url().Validate("https://example.com"); var uuid = Z.String().UUID().Validate("550e8400-e29b-41d4-a716-446655440000"); var uuidV7 = Z.String().UUID(UuidVersion.V7).Validate("0192b4c1-7a9b-7f5e-9a3c-2d4e6f8a0b1c"); var prefix = Z.String().StartsWith("https://"); var suffix = Z.String().EndsWith(".com"); var exact = Z.String().Length(10); var normalized = Z.String().Trim().ToUpper().Validate(" hello "); // "HELLO" ReadOnlySpan span = "user@example.com".AsSpan(); var spanResult = Z.String().Min(3).Max(50).Email().ValidateSpan(span); ``` ## Error messages Rules produce `ValidationError` entries with code `validation_failed` and an empty path. Many methods accept a custom `message` parameter. Rule structs live in `ZodSharp.Rules` and can be reused standalone with `IValidationRule`. ## Span validation `ValidateSpan(ReadOnlySpan value)` avoids string allocations on the validation path. An empty span validates successfully as `""`. # System.Text.Json Integration > The Purview.ZodSharp.SystemTextJson package adds System.Text.Json deserialize-and-validate, validating converters, and JSON Schema import to the core library.… # System.Text.Json Integration The `Purview.ZodSharp.SystemTextJson` package adds System.Text.Json deserialize-and-validate, validating converters, and JSON Schema import to the core library. All extension methods live in the `ZodSharp` namespace. ## Install ```bash dotnet add package Purview.ZodSharp.SystemTextJson ``` ## Deserialize and validate ```csharp using ZodSharp; var userSchema = Z.Object() .Field("name", Z.String().Min(3)) .Field("age", Z.Number().Min(0).Int()) .Build(); var json = """{ "name": "John", "age": 30 }"""; var result = userSchema.DeserializeAndValidate(json); if (result.IsSuccess) Console.WriteLine($"Valid: {result.Value}"); ``` Async stream overload: ```csharp await using var stream = File.OpenRead("user.json"); var result = await userSchema.DeserializeAndValidateAsync(stream); ``` ## Validate and serialize ```csharp var result = userSchema.ValidateAndSerialize(user); // ValidationResult var result2 = await userSchema.ValidateAndSerializeAsync(user, stream); ``` ## Validating converter ```csharp var converter = userSchema.CreateValidatingConverter(); var options = new JsonSerializerOptions { Converters = { converter } }; var value = JsonSerializer.Deserialize(json, options); ``` `CreateValidatingConverter()` returns a `System.Text.Json.Serialization.JsonConverter`. When the JSON is invalid, deserialization throws `JsonException` with a `"Validation failed: ..."` message. The converter strips itself from the options it uses internally to avoid recursion. ## API surface | Member | Signature | |---|---| | `DeserializeAndValidate` | `ValidationResult DeserializeAndValidate(this IZodSchema schema, string json, JsonSerializerOptions? options = null)` | | `DeserializeAndValidateAsync` | `ValueTask> DeserializeAndValidateAsync(this IZodSchema schema, Stream jsonStream, JsonSerializerOptions? options = null, CancellationToken cancellationToken = default)` | | `ValidateAndSerialize` | `ValidationResult ValidateAndSerialize(this IZodSchema schema, T value, JsonSerializerOptions? options = null)` | | `ValidateAndSerializeAsync` | `ValueTask> ValidateAndSerializeAsync(this IZodSchema schema, T value, Stream output, JsonSerializerOptions? options = null, CancellationToken cancellationToken = default)` | | `CreateValidatingConverter` | `JsonConverter CreateValidatingConverter(this IZodSchema schema)` | ## Failure codes Deserialize/validation failures produce `ValidationError` entries with codes `deserialization_failed` and `json_error` in addition to the schema's own codes. ## JSON Schema import `Z.FromJsonSchema` is available with this package referenced; see [JSON Schema Import](../jsonschema-import/). ## Comparing with Newtonsoft See the API comparison table on the [Newtonsoft.Json Integration](../newtonsoftjson-integration/) page for the differences between the two JSON packages. # Unions and Discriminated Unions > ZodUnion namespace ZodSharp.Schemas tries each option in order and returns the first success. # Unions and Discriminated Unions ## Untyped union `ZodUnion` (namespace `ZodSharp.Schemas`) tries each option in order and returns the first success. ```csharp using ZodSharp; var schema = Z.Union(Z.String(), Z.Number(), Z.Boolean()); var result = schema.Validate(42.0); // matches Z.Number() ``` When no option matches, a single `invalid_union` error is produced (`"Value does not match any of the union options"`) whose `Parameters["errors"]` carries every option's errors. :::note On the failure path the accumulated option errors are collected, which allocates. Matching a later option (e.g. number or boolean after a string option) also allocates while the earlier options are attempted — see [Performance](../performance/). ::: ## Typed union `ZodTypedUnion` dispatches on runtime type (`is T1` / `is T2`) and produces a `Union` result value. ```csharp var schema = Z.Union(Z.String(), Z.Number()); var result = schema.Validate(42.0); if (result.IsSuccess) result.Value.Match( str => Console.WriteLine($"string: {str}"), num => Console.WriteLine($"number: {num}")); ``` ## Union and Union The `Union<...>` value type (namespace `ZodSharp.Unions`) is the result of a typed union: - `Create(T1)` / `Create(T2)` (and a three-case variant) — tagged construction. - Implicit conversions from the case types. - `int Tag` and `object Value` (`Value` throws if uninitialized). - `TryGetValue(out T1)` / `TryGetValue(out T2)`. - `Match(Func, Func)` and `Switch(Action, Action)`. - `==` / `!=`, `Equals`, `GetHashCode`, `ToString`. ## Discriminated union `ZodDiscriminatedUnion` dispatches on a discriminator value read from the input — a dictionary key or a public instance property — resolved case-insensitively. ```csharp var union = Z.DiscriminatedUnion("type") .Option("user", userSchema) .Option("admin", adminSchema) .Build(); var result = union.Validate(new Dictionary { { "type", "user" }, { "name", "John" } }); ``` Failures: - No discriminator present → `missing_discriminator`. - Value not among the options → `invalid_discriminator` listing the expected values. - `null` input → `invalid_type`. The builder (`ZodDiscriminatedUnionBuilder`) accepts untyped `IZodSchema` options via `Option(string value, IZodSchema schema)` and typed options via `Option(string value, IZodSchema schema)`, which wrap the schema for coercion and `null` handling. ## Intersection `ZodIntersection` (created with `Z.Intersection(left, right)` or `.And(other)`) succeeds only when both schemas validate; failures merge both error sets. ```csharp var schema = Z.String().Min(3).And(Z.String().Max(10)); ```