This is the full 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
AcmeXunitNSubstituteNone
```
# 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