Project Organization¶
A Roslyn toolkit isn't one project — it's six (or more), with strict dependency rules between them. Get the structure wrong and you'll fight circular references, bloated NuGet packages, and analyzers that accidentally ship generator code to users.
This guide uses the Deepstaging source generator suite as a reference architecture.
The Layer Pattern¶
src/
├── Deepstaging.Abstractions/ # Attributes, enums, marker types
├── Deepstaging.Projection/ # Queries and models
├── Deepstaging.Generators/ # Source generators + Writers
├── Deepstaging.Analyzers/ # Diagnostic analyzers
├── Deepstaging.CodeFixes/ # Code fix providers
├── Deepstaging.Runtime/ # Runtime services (optional, net10.0+)
└── Deepstaging/ # Metapackage — bundles everything
Each layer has a single responsibility and a strict dependency direction:
Abstractions ← Projection ← Generators
← Analyzers ← CodeFixes
Abstractions ← Runtime (independent of Roslyn layers)
| Project | Purpose | References |
|---|---|---|
| Abstractions | Attributes, enums, and marker types. This is what users reference at compile time. | None |
| Projection | Queries and models — the single source of truth for interpreting attributes. | Abstractions, Deepstaging.Roslyn |
| Generators | Thin wiring + Writer classes for code generation. | Projection |
| Analyzers | Diagnostic rules. | Projection |
| CodeFixes | Quick-fix providers. | Analyzers (for diagnostic IDs) |
| Runtime | Runtime interfaces and in-memory defaults. Consumers get this via the metapackage. | Abstractions |
Why separate Projection?
Both generators and analyzers need to interpret the same attributes. The Projection layer ensures they share the same logic — one set of queries, one set of models, consistent behavior everywhere.
Runtime is optional
Not every toolkit needs a Runtime layer. If your generators only produce compile-time code (like typed IDs or serializers), you don't need one. Deepstaging has it because effects, dispatch, event queues, and other modules need runtime infrastructure — in-memory defaults, DI registration, middleware, etc.
File Organization¶
Abstractions¶
Keep this project minimal. Users depend on it directly, so it should have no Roslyn or Deepstaging.Roslyn dependency.
Deepstaging.Abstractions/
├── Effects/
│ ├── RuntimeAttribute.cs
│ ├── EffectsModuleAttribute.cs
│ ├── UsesAttribute.cs
│ └── CapabilityAttribute.cs
├── Dispatch/
│ ├── CommandHandlerAttribute.cs
│ ├── QueryHandlerAttribute.cs
│ └── DispatchModuleAttribute.cs
├── Ids/
│ ├── TypedIdAttribute.cs
│ ├── TypedIdProfileAttribute.cs
│ ├── BackingType.cs
│ └── IdConverters.cs
├── DataStore/
│ └── DataStoreAttribute.cs
├── EventStore/
│ └── EventStoreAttribute.cs
├── EventQueue/
│ └── EventQueueAttribute.cs
├── Config/
│ └── ConfigProviderAttribute.cs
├── HttpClient/
│ ├── HttpClientAttribute.cs
│ ├── GetAttribute.cs
│ └── PostAttribute.cs
├── Search/
│ └── SearchIndexAttribute.cs
├── Jobs/
│ └── JobWorkerAttribute.cs
└── Web/
└── WebAppAttribute.cs
Group by feature domain. Each domain gets its own namespace.
Projection¶
Organize by domain, with a consistent three-part structure: Attributes/ (queries), Models/ (records), and Queries.cs (extensions).
Deepstaging.Projection/
├── Ids/
│ ├── Attributes/
│ │ └── TypedIdAttributeQuery.cs
│ ├── Models/
│ │ └── TypedIdModel.cs
│ └── Queries.cs
├── Effects/
│ ├── Attributes/
│ │ ├── EffectsModuleAttributeQuery.cs
│ │ ├── UsesAttributeQuery.cs
│ │ └── CapabilityAttributeQuery.cs
│ ├── Models/
│ │ ├── RuntimeModel.cs
│ │ ├── EffectsModuleModel.cs
│ │ ├── CapabilityModel.cs
│ │ ├── EffectMethodModel.cs
│ │ └── EffectParameterModel.cs
│ └── Queries.cs
├── Dispatch/
│ ├── Attributes/
│ ├── Models/
│ └── Queries.cs
├── DataStore/
│ ├── Attributes/
│ ├── Models/
│ └── Queries.cs
├── EventStore/
│ ├── Attributes/
│ ├── Models/
│ └── Queries.cs
├── EventQueue/
│ ├── Attributes/
│ ├── Models/
│ └── Queries.cs
├── Config/
│ ├── Attributes/
│ ├── Models/
│ └── Queries.cs
├── HttpClient/
│ ├── Attributes/
│ ├── Models/
│ └── Queries.cs
└── GlobalUsings.cs
Each domain follows the same pattern:
- Attributes/ —
AttributeQueryrecords that wrapAttributeDatawith typed properties - Models/ —
[PipelineModel]records capturing everything needed for generation - Queries.cs — Extension methods on
ValidSymbol<T>that chain queries into models
Generators¶
Generators are thin — they wire the Projection layer to Writer classes. Writers live in a Writers/ directory, organized by domain.
Deepstaging.Generators/
├── TypedIdGenerator.cs
├── EffectsGenerator.cs
├── DispatchGenerator.cs
├── DataStoreGenerator.cs
├── EventStoreGenerator.cs
├── EventQueueGenerator.cs
├── ConfigModuleGenerator.cs
├── HttpClientGenerator.cs
├── PreludeGenerator.cs
├── Writers/
│ ├── Ids/
│ │ └── TypedIdWriter.cs
│ ├── Effects/
│ │ ├── RuntimeWriter.cs
│ │ ├── EffectsModuleWriter.cs
│ │ └── CapabilityWriter.cs
│ ├── Dispatch/
│ │ └── DispatchWriter.cs
│ ├── DataStore/
│ │ └── DataStoreWriter.cs
│ ├── EventStore/
│ │ └── EventStoreWriter.cs
│ ├── EventQueue/
│ │ └── EventQueueWorkerWriter.cs
│ ├── Config/
│ │ └── ConfigWriter.cs
│ └── HttpClient/
│ ├── ClientWriter.cs
│ └── RequestWriter.cs
└── GlobalUsings.cs
Analyzers¶
One file per diagnostic. Group by domain.
Deepstaging.Analyzers/
├── Effects/
│ ├── RuntimeMustBePartialAnalyzer.cs
│ ├── EffectsModuleMustBePartialAnalyzer.cs
│ ├── EffectsModuleShouldBeSealedAnalyzer.cs
│ └── EffectsModuleTargetMustBeInterfaceAnalyzer.cs
├── Ids/
│ ├── TypedIdMustBePartialAnalyzer.cs
│ └── TypedIdShouldBeReadonlyAnalyzer.cs
├── Dispatch/
│ ├── ValidatorTypeMismatchAnalyzer.cs
│ └── ValidatedMutualExclusionAnalyzer.cs
├── DataStore/
│ └── DataStoreMustBePartialAnalyzer.cs
├── EventStore/
│ └── EventStoreMustBePartialAnalyzer.cs
├── EventQueue/
│ └── IntegrationEventMissingAnalyzer.cs
├── Config/
│ └── ConfigProviderMustBePartialAnalyzer.cs
└── HttpClient/
└── HttpClientMustBePartialAnalyzer.cs
CodeFixes¶
Code fixes reference the Analyzers project to access diagnostic IDs. Generic structural fixes live at the root; domain-specific fixes go in subdirectories.
Deepstaging.CodeFixes/
├── ClassMustBePartialCodeFix.cs
├── StructMustBePartialCodeFix.cs
├── ClassShouldBeSealedCodeFix.cs
├── StructShouldBeReadonlyCodeFix.cs
├── MethodMustBePartialCodeFix.cs
├── Dispatch/
│ └── ScaffoldFieldValidatorsCodeFix.cs
├── Effects/
│ └── ...
└── Config/
└── ...
Runtime¶
The Runtime layer provides runtime services that generated code depends on — interfaces, in-memory implementations, DI helpers, middleware. This project targets net10.0 only (no netstandard2.0).
Deepstaging.Runtime/
├── Effects/
│ └── ...
├── Dispatch/
│ └── ...
├── EventQueue/
│ └── ...
├── Storage/
│ └── ...
├── Web/
│ └── ...
├── Caching/
│ └── ...
├── Search/
│ └── ...
└── Clock/
└── ...
Tests¶
Mirror the source project structure. Deepstaging uses separate test projects for core and infrastructure:
test/
├── Deepstaging.Tests/ # Core: generators, analyzers, code fixes, projection
│ ├── Generators/
│ │ ├── Ids/
│ │ ├── Effects/
│ │ ├── Dispatch/
│ │ └── ...
│ ├── Analyzers/
│ ├── CodeFixes/
│ └── Projection/
└── Deepstaging.Infrastructure.Tests/ # Infrastructure: Postgres, Marten, Azure, etc.
├── Postgres/
├── Azure/
└── ...
Dependency Constraints¶
These constraints prevent circular dependencies and keep the architecture clean:
- Abstractions → nothing. This is the user-facing package. No Roslyn dependencies.
- Projection → Abstractions. The Projection layer reads attributes but never generates code.
- Generators → Projection. Generators never reference Analyzers. They get all data through the Projection layer.
- Analyzers → Projection. Analyzers use the same queries as generators but for validation, not generation.
- CodeFixes → Analyzers. Code fixes need analyzer diagnostic IDs for
[CodeFix("...")]attributes. - Runtime → Abstractions. Runtime depends only on attributes — never on Roslyn, Projection, or Generators.
Never reference Generators from Analyzers
If you find an analyzer needing generator logic, move that logic to the Projection layer.
Runtime must not reference Roslyn layers
The Runtime layer ships in lib/ as a regular assembly. If it referenced Projection or Generators, those Roslyn-specific types would leak into every consumer's compile-time references.
Packaging¶
Everything ships as a single NuGet package. The root metapackage project bundles Roslyn DLLs into analyzers/ and declares NuGet dependencies on Abstractions and Runtime:
MyPackage.nupkg
├── analyzers/dotnet/cs/
│ ├── MyPackage.Generators.dll
│ ├── MyPackage.Analyzers.dll
│ ├── MyPackage.CodeFixes.dll
│ ├── MyPackage.Projection.dll (generator/analyzer dependency)
│ └── Deepstaging.Roslyn.dll (generator dependency)
├── satellite/netstandard2.0/
│ └── MyPackage.Projection.dll (for downstream generators)
└── build/
├── MyPackage.props (auto-imported MSBuild properties)
└── MyPackage.targets (auto-imported MSBuild targets)
NuGet dependencies (resolved by NuGet, not bundled):
MyPackage.Abstractions— all TFMsMyPackage.Runtime—net10.0only (optional)
Consumers only need:
The roslynkit template generates this structure automatically via ./build/pack.sh.
Satellite Projection¶
The satellite/ folder enables downstream packages to build upon your Projection layer — your models, query extensions, and attribute wrappers — without you publishing a separate package.
The Problem¶
When package A defines attributes and a generator, and package B wants to generate code that builds upon A's semantics, B needs access to A's Projection.dll as a compile-time reference. But the copy in analyzers/dotnet/cs/ is only loaded by the Roslyn compiler — it's not available for B's code to reference.
Putting Projection in lib/ would expose Roslyn-specific types (symbol wrappers, query extensions) to all consumers via IntelliSense — types that only make sense for generator authors.
The Solution¶
A second copy of Projection.dll lives in satellite/netstandard2.0/. The package's build props conditionally add it as a reference when an opt-in property is set.
In the package's build/MyPackage.props:
<ItemGroup Condition="'$(MyPackageSatellite)' == 'true'">
<Reference Include="MyPackage.Projection"
HintPath="$(MSBuildThisFileDirectory)../satellite/netstandard2.0/MyPackage.Projection.dll"/>
</ItemGroup>
Downstream projects opt in with one property:
Naming Convention¶
Every package uses {PackageNameNoDots}Satellite as its property name:
| Package | Property | Exposes |
|---|---|---|
Deepstaging |
DeepstagingSatellite |
Deepstaging.Projection.dll |
Deepstaging.Web |
DeepstagingWebSatellite |
Deepstaging.Web.Projection.dll |
MyCompany.Foo |
MyCompanyFooSatellite |
MyCompany.Foo.Projection.dll |
This lets a downstream project compose multiple satellite references:
<PropertyGroup>
<DeepstagingSatellite>true</DeepstagingSatellite>
<DeepstagingWebSatellite>true</DeepstagingWebSatellite>
</PropertyGroup>
Implementing Satellite Support¶
-
Create build props at
build/{PackageId}.props: -
Pack the build props in your root
.csproj: -
Pack Projection to satellite:
-
Suppress NU5100 (NuGet warns about DLLs outside
lib/):
The roslynkit template includes all of this. The satellitePrefix template symbol automatically derives the property name by stripping dots from the package name.
Getting Started¶
| Resource | Purpose |
|---|---|
| RoslynKit Template | Use dotnet new roslynkit to scaffold a new solution with this structure |
| Deepstaging source | Production reference with 15 generators, 70+ analyzers, and 16 code fixes |
| Samples | See how end-users consume the generated code |