Part VII: C# Roslyn Source Generators — The Same Philosophy, Different Mechanics
The TypeScript version uses decorators and a regex scanner. The C# version uses abstract records and Roslyn source generators. The philosophy is identical: features are types, ACs are methods, the compiler enforces the chain. The mechanics are different because the languages are different.
What It Is
The C# approach — implemented in the CMF Requirements DSL and described in Requirements as Code in C# — uses C#'s type system and Roslyn's source generation pipeline to achieve the same goal as TypeScript's typed specifications: compiler-verified requirement-test traceability.
This is not a third-party tool. It's the same author's design, applied to a different language and a different scale target. The TypeScript version powers a personal website with 20 features. The C# version is designed for enterprise monorepos with hundreds of features across multiple teams.
The C# Approach
Features as Abstract Records
Where TypeScript uses abstract class, C# uses abstract record with a generic constraint for hierarchy:
// C# — requirements/IdentityEpic.cs
public abstract record IdentityEpic : Epic
{
public override string Title => "Identity & Access Management";
public override RequirementPriority Priority => RequirementPriority.Critical;
public override string Owner => "Platform Team";
}
// C# — requirements/UserRolesFeature.cs
public abstract record UserRolesFeature : Feature<IdentityEpic>
{
public override string Title => "User Roles and Permissions";
public override RequirementPriority Priority => RequirementPriority.Critical;
public override string Owner => "Platform Team";
/// <summary>Admin users can assign roles to other users.</summary>
public abstract AcceptanceCriterionResult AdminCanAssignRoles(
UserId admin, UserId target, RoleId role);
/// <summary>Non-admin users cannot assign roles.</summary>
public abstract AcceptanceCriterionResult NonAdminCannotAssignRoles(
UserId viewer, UserId target, RoleId role);
/// <summary>Role changes take effect immediately.</summary>
public abstract AcceptanceCriterionResult RoleChangeTakesEffectImmediately(
UserId user, RoleId oldRole, RoleId newRole);
}// C# — requirements/IdentityEpic.cs
public abstract record IdentityEpic : Epic
{
public override string Title => "Identity & Access Management";
public override RequirementPriority Priority => RequirementPriority.Critical;
public override string Owner => "Platform Team";
}
// C# — requirements/UserRolesFeature.cs
public abstract record UserRolesFeature : Feature<IdentityEpic>
{
public override string Title => "User Roles and Permissions";
public override RequirementPriority Priority => RequirementPriority.Critical;
public override string Owner => "Platform Team";
/// <summary>Admin users can assign roles to other users.</summary>
public abstract AcceptanceCriterionResult AdminCanAssignRoles(
UserId admin, UserId target, RoleId role);
/// <summary>Non-admin users cannot assign roles.</summary>
public abstract AcceptanceCriterionResult NonAdminCannotAssignRoles(
UserId viewer, UserId target, RoleId role);
/// <summary>Role changes take effect immediately.</summary>
public abstract AcceptanceCriterionResult RoleChangeTakesEffectImmediately(
UserId user, RoleId oldRole, RoleId newRole);
}Compare the TypeScript equivalent:
// TypeScript — requirements/features/user-roles.ts
export abstract class UserRolesFeature extends Feature {
readonly id = 'ROLES';
readonly title = 'User Roles and Permissions';
readonly priority = Priority.Critical;
/** Admin users can assign roles to other users. */
abstract adminCanAssignRoles(): ACResult;
/** Non-admin users cannot assign roles. */
abstract nonAdminCannotAssignRoles(): ACResult;
/** Role changes take effect immediately. */
abstract roleChangeTakesEffectImmediately(): ACResult;
}// TypeScript — requirements/features/user-roles.ts
export abstract class UserRolesFeature extends Feature {
readonly id = 'ROLES';
readonly title = 'User Roles and Permissions';
readonly priority = Priority.Critical;
/** Admin users can assign roles to other users. */
abstract adminCanAssignRoles(): ACResult;
/** Non-admin users cannot assign roles. */
abstract nonAdminCannotAssignRoles(): ACResult;
/** Role changes take effect immediately. */
abstract roleChangeTakesEffectImmediately(): ACResult;
}Linking Tests with typeof() and nameof()
C# uses typeof() and nameof() — both compiler-checked — instead of TypeScript's decorator + keyof T:
// C# — test linking
[ForRequirement(typeof(UserRolesFeature))]
public class UserRolesTests
{
[Verifies(
typeof(UserRolesFeature),
nameof(UserRolesFeature.AdminCanAssignRoles))]
public async Task Admin_Can_Assign_Role_To_User()
{
var admin = await CreateUser(RoleId.Admin);
var target = await CreateUser(RoleId.Viewer);
await _sut.AssignRole(admin.Id, target.Id, RoleId.Editor);
var updated = await _repo.GetAsync(target.Id);
Assert.Equal(RoleId.Editor, updated.Role);
}
[Verifies(
typeof(UserRolesFeature),
nameof(UserRolesFeature.NonAdminCannotAssignRoles))]
public async Task Non_Admin_Cannot_Assign_Roles()
{
var viewer = await CreateUser(RoleId.Viewer);
var target = await CreateUser(RoleId.Viewer);
var result = await _sut.AssignRole(viewer.Id, target.Id, RoleId.Editor);
Assert.False(result.IsSuccess);
Assert.Equal("InsufficientPermissions", result.Error.Code);
}
}// C# — test linking
[ForRequirement(typeof(UserRolesFeature))]
public class UserRolesTests
{
[Verifies(
typeof(UserRolesFeature),
nameof(UserRolesFeature.AdminCanAssignRoles))]
public async Task Admin_Can_Assign_Role_To_User()
{
var admin = await CreateUser(RoleId.Admin);
var target = await CreateUser(RoleId.Viewer);
await _sut.AssignRole(admin.Id, target.Id, RoleId.Editor);
var updated = await _repo.GetAsync(target.Id);
Assert.Equal(RoleId.Editor, updated.Role);
}
[Verifies(
typeof(UserRolesFeature),
nameof(UserRolesFeature.NonAdminCannotAssignRoles))]
public async Task Non_Admin_Cannot_Assign_Roles()
{
var viewer = await CreateUser(RoleId.Viewer);
var target = await CreateUser(RoleId.Viewer);
var result = await _sut.AssignRole(viewer.Id, target.Id, RoleId.Editor);
Assert.False(result.IsSuccess);
Assert.Equal("InsufficientPermissions", result.Error.Code);
}
}Compare TypeScript:
// TypeScript — test linking
@FeatureTest(UserRolesFeature)
class UserRolesTests {
@Implements<UserRolesFeature>('adminCanAssignRoles')
async 'admin can assign role to user'({ page }) { ... }
@Implements<UserRolesFeature>('nonAdminCannotAssignRoles')
async 'non-admin cannot assign roles'({ page }) { ... }
}// TypeScript — test linking
@FeatureTest(UserRolesFeature)
class UserRolesTests {
@Implements<UserRolesFeature>('adminCanAssignRoles')
async 'admin can assign role to user'({ page }) { ... }
@Implements<UserRolesFeature>('nonAdminCannotAssignRoles')
async 'non-admin cannot assign roles'({ page }) { ... }
}The Six-Project Chain
The C# approach structures the solution into six projects with enforced boundaries:
MyApp.sln
├── src/
│ ├── MyApp.Requirements/ ← Features as types, ACs as abstract methods
│ ├── MyApp.SharedKernel/ ← Domain types (User, Role, Permission)
│ ├── MyApp.Specifications/ ← Interfaces the domain must implement
│ ├── MyApp.Domain/ ← Domain implementation (: ISpec)
│ └── MyApp.Api/ ← Production host
├── test/
│ └── MyApp.Tests/ ← Type-linked verification
└── tools/
└── MyApp.Requirements.Analyzers/ ← Source generator + Roslyn analyzersMyApp.sln
├── src/
│ ├── MyApp.Requirements/ ← Features as types, ACs as abstract methods
│ ├── MyApp.SharedKernel/ ← Domain types (User, Role, Permission)
│ ├── MyApp.Specifications/ ← Interfaces the domain must implement
│ ├── MyApp.Domain/ ← Domain implementation (: ISpec)
│ └── MyApp.Api/ ← Production host
├── test/
│ └── MyApp.Tests/ ← Type-linked verification
└── tools/
└── MyApp.Requirements.Analyzers/ ← Source generator + Roslyn analyzersEach project reference enforces a direction. Domain references Specifications but not Requirements directly. Tests reference both. The project graph IS the architecture.
The TypeScript version is flat: feature files in requirements/features/, test files in test/, a scanner script. No project boundaries, no compilation layers.
Source Generator vs. Regex Scanner
This is the biggest mechanical difference. The C# source generator runs inside the Roslyn compilation pipeline with full access to the semantic model:
// What the Roslyn source generator can do (conceptual)
[Generator]
public class RequirementsGenerator : IIncrementalGenerator
{
public void Initialize(IncrementalGeneratorInitializationContext context)
{
// Find all types that extend RequirementMetadata
// Extract abstract methods (ACs)
// Find all [Verifies] attributes in test assemblies
// Cross-reference at compile time
// Emit diagnostics for missing coverage
// Generate RequirementsRegistry.g.cs
}
}// What the Roslyn source generator can do (conceptual)
[Generator]
public class RequirementsGenerator : IIncrementalGenerator
{
public void Initialize(IncrementalGeneratorInitializationContext context)
{
// Find all types that extend RequirementMetadata
// Extract abstract methods (ACs)
// Find all [Verifies] attributes in test assemblies
// Cross-reference at compile time
// Emit diagnostics for missing coverage
// Generate RequirementsRegistry.g.cs
}
}The generator produces:
RequirementsRegistry.g.cs— a compile-time registry of all features, ACs, and their test links- Roslyn diagnostics — IDE squiggles (warnings/errors) for missing
[Verifies]attributes - Traceability matrix — generated at build time, not by a separate script
The TypeScript compliance scanner reads source files with regex:
// What the TypeScript scanner does
const classMatch = src.match(/export abstract class (\w+)/);
const methodRegex = /abstract (\w+)\(/g;
const decoratorRegex = /@Implements<(\w+)>\s*\(\s*'(\w+)'\s*\)/g;// What the TypeScript scanner does
const classMatch = src.match(/export abstract class (\w+)/);
const methodRegex = /abstract (\w+)\(/g;
const decoratorRegex = /@Implements<(\w+)>\s*\(\s*'(\w+)'\s*\)/g;Same goal, fundamentally different mechanism.
Where C# Is Stronger
Semantic Analysis vs. Regex
The Roslyn generator has access to the full type graph. It knows that UserRolesFeature extends Feature<IdentityEpic>, which extends RequirementMetadata. It can resolve typeof() and nameof() to their actual types. It can detect unused ACs, circular dependencies, and type constraint violations.
The TypeScript scanner uses regex on source text. It works because the feature files follow a strict format. But it can't resolve type hierarchies, detect indirect references, or validate anything beyond string pattern matching.
IDE Integration
The Roslyn analyzer emits real compiler diagnostics:
warning REQ001: Feature 'UserRolesFeature' has acceptance criterion
'RoleChangeTakesEffectImmediately' with no [Verifies] attribute
in any test assembly.warning REQ001: Feature 'UserRolesFeature' has acceptance criterion
'RoleChangeTakesEffectImmediately' with no [Verifies] attribute
in any test assembly.This appears as a yellow squiggle in Visual Studio, Rider, and VS Code with OmniSharp. The developer sees the gap while writing code — not after running a separate scanner script.
TypeScript typed specs require running npx tsx scripts/compliance-report.ts separately. The IDE shows no inline feedback about missing coverage.
Hierarchy Enforcement
The C# type system enforces the requirement hierarchy through generics:
public abstract record Feature<TParent> : RequirementMetadata
where TParent : Epic;
// This compiles:
public abstract record UserRolesFeature : Feature<IdentityEpic> { }
// This does NOT compile:
public abstract record UserRolesFeature : Feature<JwtRefreshStory> { }
// Error: 'JwtRefreshStory' does not satisfy constraint 'Epic'public abstract record Feature<TParent> : RequirementMetadata
where TParent : Epic;
// This compiles:
public abstract record UserRolesFeature : Feature<IdentityEpic> { }
// This does NOT compile:
public abstract record UserRolesFeature : Feature<JwtRefreshStory> { }
// Error: 'JwtRefreshStory' does not satisfy constraint 'Epic'TypeScript's typed specs have a flat hierarchy: all features extend Feature. There's no Epic > Feature > Story > Task enforcement. Adding it would require a redesign of the base types — which is exactly what the tspec product series delivers: Feature<TParent extends Epic> with generic constraints, generated from configurable M2 flavors.
AC Method Signatures
C# ACs have typed parameters that document what the criterion tests:
public abstract AcceptanceCriterionResult AdminCanAssignRoles(
UserId admin, UserId target, RoleId role);public abstract AcceptanceCriterionResult AdminCanAssignRoles(
UserId admin, UserId target, RoleId role);The signature says: this AC is about an admin user, a target user, and a role. The domain types (UserId, RoleId) are lightweight value objects that carry just enough semantic meaning.
TypeScript ACs are parameterless:
abstract adminCanAssignRoles(): ACResult;abstract adminCanAssignRoles(): ACResult;The method name and JSDoc comment carry the meaning. The signature carries none. This is simpler but less expressive.
Multi-Project / NuGet Distribution
The C# MyApp.Requirements project compiles to a DLL that can be distributed as a NuGet package. Teams in separate repos can reference the same requirement definitions. The compiler enforces the chain across repository boundaries.
TypeScript's typed specs are single-repo. The scanner reads the local filesystem. Cross-repo feature sharing would require publishing an npm package — possible but not built into the design.
Where TypeScript Is Stronger
Simplicity
The entire TypeScript infrastructure is:
- 75 lines of decorator definitions (
@FeatureTest,@Implements,@Exclude) - 300 lines of compliance scanner
- Zero build tool dependencies — no Roslyn, no source generator project, no analyzer
The C# equivalent requires:
- A source generator project (hundreds of lines of Roslyn API code)
- Understanding of
IIncrementalGenerator, syntax providers, semantic models - A separate analyzer project for diagnostics
.editorconfigconfiguration for severity levels- NuGet packaging for multi-project distribution
A developer can understand the TypeScript system in 30 minutes by reading two files. The C# system requires Roslyn expertise.
Lower Barrier to Entry
Any TypeScript developer can add @FeatureTest and @Implements to existing tests. The decorators are plain functions. The scanner is a standalone script.
Adding Roslyn source generators to a C# project requires:
- Creating a new project targeting
netstandard2.0 - Referencing
Microsoft.CodeAnalysis.CSharp - Implementing
IIncrementalGenerator - Understanding the Roslyn API (syntax trees, semantic model, symbols)
- Debugging generators (which run inside the compiler process)
This is a significant skill requirement that limits adoption.
Speed of Scanner
The TypeScript scanner runs in under 1 second. It reads files, matches regex, cross-references, reports. No compilation needed.
The C# source generator runs during compilation. A full build of a large .NET solution can take 30+ seconds. The traceability matrix is up-to-date after every build, but the feedback cycle is longer.
Language Reach
TypeScript runs everywhere: frontend (React, Vue, Svelte), backend (Node, Deno, Bun), tooling scripts, serverless functions, Electron apps. Typed specs work in all of these contexts.
C# with Roslyn is .NET only. For teams with mixed stacks (TypeScript frontend + .NET backend), the TypeScript approach covers the frontend tests that the C# approach can't reach.
The Philosophical Alignment
Despite the mechanical differences, both systems share the same core insight:
- Features are types, not strings. A feature has a name, a shape, and abstract methods — not a string ID in a database.
- ACs are methods, not bullet points. Each AC is a first-class member of the type, renameable and refactorable.
- The link is compiler-checked.
keyof Tin TypeScript,nameof()in C# — both catch typos before the code runs. - Completeness is computable. Because the system knows all ACs (abstract methods) and all test references (decorators/attributes), it can compute what's missing.
- The chain doesn't require discipline to maintain. String-based systems drift because humans forget. Type-based systems don't drift because the compiler won't let them.
The choice between them is a language decision, not a philosophy decision.
When to Pick Which
| Situation | Recommendation |
|---|---|
| .NET enterprise monorepo, 10+ developers | C# Roslyn. The hierarchy enforcement, IDE diagnostics, and NuGet distribution are worth the Roslyn complexity. |
| TypeScript project, any size | TypeScript typed specs. Simpler, faster to adopt, works everywhere TypeScript runs. |
| Mixed stack (TS frontend + .NET backend) | Both. TypeScript specs for frontend features, C# specs for backend features. They share the philosophy. |
| Starting from scratch, evaluating both | Start with TypeScript. Lower barrier, faster feedback loop. Migrate to C# if you outgrow the regex scanner. |
| Already using Roslyn generators | C# is the natural extension. Adding a Requirements generator to an existing generator pipeline is incremental. |
Side-by-Side Summary
| Dimension | C# Roslyn Source Generators | TypeScript Typed Specs |
|---|---|---|
| Feature definition | abstract record Feature<TParent> |
abstract class Feature |
| AC definition | abstract AcceptanceCriterionResult Method(params) |
abstract method(): ACResult |
| Test linking | [Verifies(typeof(F), nameof(F.AC))] |
@Implements<F>('ac') |
| Typo detection | Compile-time (nameof) |
Compile-time (keyof T) |
| Hierarchy | Generic constraints (Epic > Feature > Story) | Flat (all extend Feature) |
| Scanner/Generator | Roslyn source generator (semantic model) | Regex scanner (300 lines) |
| IDE feedback | Inline diagnostics (squiggles) | Separate script output |
| Infrastructure cost | High (Roslyn generator project) | Low (75 + 300 lines) |
| Scale target | Enterprise monorepo (100+ features) | Small-to-medium (20-50 features) |
| Distribution | NuGet packages | Single repo |
| Self-tracking | Possible (same pattern) | Implemented (REQ-TRACK) |
Previous: Part VI: API Specification Next: Part VIII: The Verdict — where typed specs sit, honest limitations, and a decision guide.