Skip to main content
Welcome. This site supports keyboard navigation and screen readers. Press ? at any time for keyboard shortcuts. Press [ to focus the sidebar, ] to focus the content. High-contrast themes are available via the toolbar.
serard@dev00:~/cv

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);
}

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;
}

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);
    }
}

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 }) { ... }
}

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 analyzers

Each 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
    }
}

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;

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.

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'

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);

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;

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
  • .editorconfig configuration 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:

  1. Features are types, not strings. A feature has a name, a shape, and abstract methods — not a string ID in a database.
  2. ACs are methods, not bullet points. Each AC is a first-class member of the type, renameable and refactorable.
  3. The link is compiler-checked. keyof T in TypeScript, nameof() in C# — both catch typos before the code runs.
  4. Completeness is computable. Because the system knows all ACs (abstract methods) and all test references (decorators/attributes), it can compute what's missing.
  5. 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.