Requirements as Code: Making the Compiler Enforce Your Development Lifecycle
The Problem with Requirement Silos
In most software projects, requirements live in Jira. Code lives in a repository. Tests live somewhere else again. When a developer asks "which code implements Feature-456?", the answer requires jumping between systems, reading comments, grepping for magic strings, and hoping someone documented things correctly.
In a wide solution -- or worse, a multi-solution mega mono-repo -- this problem is exponentially worse. A single Feature like "Order Processing" spans OrderService.dll (API server), PaymentGateway.dll (separate service on another machine), NotificationWorker.dll (background worker), and OrderTests.dll (verification). A developer touching one of these has no way to know which Feature it belongs to, which acceptance criteria are validated, which tests cover them, or what the Product Owner actually specified. The requirement silo isn't just an inconvenience -- it's a structural gap that makes large-scale development fragile.
Common "solutions" make it worse:
// String-based approach: looks safe, is not
[Feature(Id = "FEATURE-456", AcceptanceCriteria = new[] { "Admin can assign roles" })]
public class UserRoleFeature { } // Empty marker class — dead code
[Implements("FEATURE-456")] // String. Typo? Compiles fine.
public void AssignRole() { }
[FeatureTest("FEATURE-456", 0)] // Index 0? What if someone reorders the array?
public void AdminCanAssign() { }// String-based approach: looks safe, is not
[Feature(Id = "FEATURE-456", AcceptanceCriteria = new[] { "Admin can assign roles" })]
public class UserRoleFeature { } // Empty marker class — dead code
[Implements("FEATURE-456")] // String. Typo? Compiles fine.
public void AssignRole() { }
[FeatureTest("FEATURE-456", 0)] // Index 0? What if someone reorders the array?
public void AdminCanAssign() { }The problems are structural:
- Requirements are metadata on marker classes -- not real types with behavior
- Links are string-based --
"FEATURE-456"is not checked by the compiler. Typos, renames, and deletions are silent - Acceptance criteria are string arrays --
"Admin can assign roles"says nothing the compiler can verify - Tests reference requirements by index --
AC[0]breaks when someone reorders the acceptance criteria list - No compile-time guarantee that an implementation actually satisfies a specification
What if requirements were types? What if acceptance criteria were abstract methods? What if the compiler refused to build until every AC was implemented and tested?
This approach applies the same principle as DDD -- model the problem domain first, let the compiler enforce the model -- and the same meta-metamodeling architecture: requirements become M2 concepts, and specific features become M1 instances that the source generator validates at build time.
The Chain: Six Projects, One Compiler
The design is six C# projects linked by <ProjectReference>. Each boundary is enforced by the C# type system. Each project compiles to a DLL artifact with a clear role:
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 (DI, controllers, middleware)
├── test/
│ └── MyApp.Tests/ ← Type-linked verification
└── tools/
└── MyApp.Requirements.Analyzers/ ← Source generator (registry, matrix, diagnostics)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 (DI, controllers, middleware)
├── test/
│ └── MyApp.Tests/ ← Type-linked verification
└── tools/
└── MyApp.Requirements.Analyzers/ ← Source generator (registry, matrix, diagnostics)Project reference graph:
Requirements ──┐
├──> Specifications ──> Domain ← Api
SharedKernel ──┘ │ │
└────────────────┴──> TestsRequirements ──┐
├──> Specifications ──> Domain ← Api
SharedKernel ──┘ │ │
└────────────────┴──> Tests| Project | DLL Artifact | Role | Depends On |
|---|---|---|---|
| Requirements | MyApp.Requirements.dll |
Features as types, AC methods, generated attributes | Nothing |
| SharedKernel | MyApp.SharedKernel.dll |
Domain value types (User, Role, Permission, Result) | Nothing |
| Specifications | MyApp.Specifications.dll |
Interfaces per feature, validator bridges | Requirements + SharedKernel |
| Domain | MyApp.Domain.dll |
Implements spec interfaces -- compiler enforces | Specifications |
| Api | MyApp.Api.dll |
Production host: DI, controllers, middleware | Domain |
| Tests | MyApp.Tests.dll |
Verifies implementation via type refs | Domain + Specifications |
Every link between layers uses typeof() or nameof() -- Ctrl+Click navigable in the IDE, rename-safe, compiler-checked.
Layer 1: MyApp.Requirements -- Requirements ARE Types
The Fundamental Shift
A Feature is not an attribute on a marker class. A Feature is a class. Each acceptance criterion is an abstract method -- its signature defines the inputs, its return type enforces verifiability.
Base Types
namespace MyApp.Requirements;
public abstract record RequirementMetadata
{
public abstract string Title { get; }
public abstract RequirementPriority Priority { get; }
public abstract string Owner { get; }
}
public enum RequirementPriority { Critical, High, Medium, Low, Backlog }
public enum BugSeverity { Critical, Major, Minor, Cosmetic }namespace MyApp.Requirements;
public abstract record RequirementMetadata
{
public abstract string Title { get; }
public abstract RequirementPriority Priority { get; }
public abstract string Owner { get; }
}
public enum RequirementPriority { Critical, High, Medium, Low, Backlog }
public enum BugSeverity { Critical, Major, Minor, Cosmetic }Hierarchy Through Generics
The hierarchy (Epic > Feature > Story > Task, Bug) is enforced by generic constraints:
public abstract record Epic : RequirementMetadata;
public abstract record Feature<TParent> : RequirementMetadata
where TParent : Epic;
public abstract record Feature : RequirementMetadata; // root-level, no parent
public abstract record Story<TParent> : RequirementMetadata
where TParent : RequirementMetadata;
public abstract record Task<TParent> : RequirementMetadata
where TParent : RequirementMetadata
{
public abstract int EstimatedHours { get; }
}
public abstract record Bug : RequirementMetadata
{
public abstract BugSeverity Severity { get; }
}public abstract record Epic : RequirementMetadata;
public abstract record Feature<TParent> : RequirementMetadata
where TParent : Epic;
public abstract record Feature : RequirementMetadata; // root-level, no parent
public abstract record Story<TParent> : RequirementMetadata
where TParent : RequirementMetadata;
public abstract record Task<TParent> : RequirementMetadata
where TParent : RequirementMetadata
{
public abstract int EstimatedHours { get; }
}
public abstract record Bug : RequirementMetadata
{
public abstract BugSeverity Severity { get; }
}You cannot write Feature<JwtRefreshStory> -- the constraint where TParent : Epic prevents it. The type system enforces the hierarchy.
The AC Result Type
Every acceptance criterion method returns this:
public readonly record struct AcceptanceCriterionResult
{
public bool IsSatisfied { get; }
public string? FailureReason { get; }
private AcceptanceCriterionResult(bool satisfied, string? reason)
{
IsSatisfied = satisfied;
FailureReason = reason;
}
public static AcceptanceCriterionResult Satisfied() => new(true, null);
public static AcceptanceCriterionResult Failed(string reason) => new(false, reason);
public static implicit operator bool(AcceptanceCriterionResult r) => r.IsSatisfied;
}public readonly record struct AcceptanceCriterionResult
{
public bool IsSatisfied { get; }
public string? FailureReason { get; }
private AcceptanceCriterionResult(bool satisfied, string? reason)
{
IsSatisfied = satisfied;
FailureReason = reason;
}
public static AcceptanceCriterionResult Satisfied() => new(true, null);
public static AcceptanceCriterionResult Failed(string reason) => new(false, reason);
public static implicit operator bool(AcceptanceCriterionResult r) => r.IsSatisfied;
}Lightweight Domain Concepts
AC method signatures use lightweight value types that represent domain concepts without importing domain implementation details:
public readonly record struct Email(string Value);
public readonly record struct UserId(Guid Value, string FirstName, string LastName, Email Email);
public readonly record struct RoleId(string Value);
public readonly record struct ResourceId(string Value);
public readonly record struct TokenId(Guid Value);public readonly record struct Email(string Value);
public readonly record struct UserId(Guid Value, string FirstName, string LastName, Email Email);
public readonly record struct RoleId(string Value);
public readonly record struct ResourceId(string Value);
public readonly record struct TokenId(Guid Value);Concrete Requirements -- The "What" Without the "How"
namespace MyApp.Requirements.Epics;
public abstract record PlatformScalabilityEpic : Epic
{
public override string Title => "Multi-tenant platform enablement";
public override RequirementPriority Priority => RequirementPriority.High;
public override string Owner => "platform-team";
}namespace MyApp.Requirements.Epics;
public abstract record PlatformScalabilityEpic : Epic
{
public override string Title => "Multi-tenant platform enablement";
public override RequirementPriority Priority => RequirementPriority.High;
public override string Owner => "platform-team";
}namespace MyApp.Requirements.Features;
/// <summary>
/// Feature: User roles and permissions.
/// Each abstract method IS an acceptance criterion.
/// The method signature defines what must be true.
/// The parameters define the domain inputs.
/// The return type enforces that every AC produces a verifiable result.
/// </summary>
public abstract record UserRolesFeature : Feature<PlatformScalabilityEpic>
{
public override string Title => "User roles and permissions";
public override RequirementPriority Priority => RequirementPriority.High;
public override string Owner => "auth-team";
// AC1: Admin users can assign roles to other users
public abstract AcceptanceCriterionResult AdminCanAssignRoles(
UserId actingUser, UserId targetUser, RoleId role);
// AC2: Viewer users have read-only access
public abstract AcceptanceCriterionResult ViewerHasReadOnlyAccess(
UserId viewer, ResourceId resource);
// AC3: Role changes take effect immediately (no restart required)
public abstract AcceptanceCriterionResult RoleChangeTakesEffectImmediately(
UserId user, RoleId previousRole, RoleId newRole);
}namespace MyApp.Requirements.Features;
/// <summary>
/// Feature: User roles and permissions.
/// Each abstract method IS an acceptance criterion.
/// The method signature defines what must be true.
/// The parameters define the domain inputs.
/// The return type enforces that every AC produces a verifiable result.
/// </summary>
public abstract record UserRolesFeature : Feature<PlatformScalabilityEpic>
{
public override string Title => "User roles and permissions";
public override RequirementPriority Priority => RequirementPriority.High;
public override string Owner => "auth-team";
// AC1: Admin users can assign roles to other users
public abstract AcceptanceCriterionResult AdminCanAssignRoles(
UserId actingUser, UserId targetUser, RoleId role);
// AC2: Viewer users have read-only access
public abstract AcceptanceCriterionResult ViewerHasReadOnlyAccess(
UserId viewer, ResourceId resource);
// AC3: Role changes take effect immediately (no restart required)
public abstract AcceptanceCriterionResult RoleChangeTakesEffectImmediately(
UserId user, RoleId previousRole, RoleId newRole);
}namespace MyApp.Requirements.Stories;
public abstract record JwtRefreshStory : Story<UserRolesFeature>
{
public override string Title => "Implement JWT refresh token flow";
public override RequirementPriority Priority => RequirementPriority.Medium;
public override string Owner => "alice@company.com";
public abstract AcceptanceCriterionResult TokensExpireAfterOneHour(
TokenId token, DateTimeOffset issuedAt, DateTimeOffset checkedAt);
public abstract AcceptanceCriterionResult RefreshExtendsBySevenDays(
TokenId token, DateTimeOffset refreshedAt);
}namespace MyApp.Requirements.Stories;
public abstract record JwtRefreshStory : Story<UserRolesFeature>
{
public override string Title => "Implement JWT refresh token flow";
public override RequirementPriority Priority => RequirementPriority.Medium;
public override string Owner => "alice@company.com";
public abstract AcceptanceCriterionResult TokensExpireAfterOneHour(
TokenId token, DateTimeOffset issuedAt, DateTimeOffset checkedAt);
public abstract AcceptanceCriterionResult RefreshExtendsBySevenDays(
TokenId token, DateTimeOffset refreshedAt);
}namespace MyApp.Requirements.Bugs;
public abstract record OrderNegativeTotalBug : Bug
{
public override string Title => "Orders crash when total is negative";
public override RequirementPriority Priority => RequirementPriority.Critical;
public override string Owner => "qa-team";
public override BugSeverity Severity => BugSeverity.Critical;
public abstract AcceptanceCriterionResult NegativeTotalIsRejected(
decimal totalAmount);
}namespace MyApp.Requirements.Bugs;
public abstract record OrderNegativeTotalBug : Bug
{
public override string Title => "Orders crash when total is negative";
public override RequirementPriority Priority => RequirementPriority.Critical;
public override string Owner => "qa-team";
public override BugSeverity Severity => BugSeverity.Critical;
public abstract AcceptanceCriterionResult NegativeTotalIsRejected(
decimal totalAmount);
}What makes this work:
UserRolesFeatureis a type.typeof(UserRolesFeature)works everywhere -- in attributes, in generics, in reflection, in source generators.- Each AC is an abstract method with a typed signature. The parameters document exactly what the AC needs. The return type enforces verifiability.
- The generic constraint
Feature<PlatformScalabilityEpic>creates a compile-time hierarchy. You cannot accidentally parent a Feature under a Story. - Metadata (Title, Priority, Owner) is overridden properties, not string attributes on marker classes.
- No string IDs anywhere. The type IS the identifier.
Layer 1 Generates Navigable Artifacts
Layer 1 includes a Roslyn source generator that scans all types inheriting RequirementMetadata and produces attributes used by every subsequent layer as clickable glue:
// Generated by Requirements source generator
/// <summary>
/// Links any type, interface, or method to a requirement.
/// Ctrl+Click on typeof(Feature) in IDE jumps to the requirement definition.
/// "Find All References" on Feature shows specs, implementations, and tests.
/// </summary>
[AttributeUsage(
AttributeTargets.Class | AttributeTargets.Interface | AttributeTargets.Method,
AllowMultiple = true)]
public sealed class ForRequirementAttribute : Attribute
{
public Type RequirementType { get; }
public string? AcceptanceCriterion { get; }
public ForRequirementAttribute(Type requirementType, string? acceptanceCriterion = null)
{
RequirementType = requirementType;
AcceptanceCriterion = acceptanceCriterion;
}
}
/// <summary>
/// Links a test method to a specific acceptance criterion.
/// The AC is identified by nameof(Feature.ACMethod) -- compiler-checked.
/// </summary>
[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)]
public sealed class VerifiesAttribute : Attribute
{
public Type RequirementType { get; }
public string AcceptanceCriterionName { get; }
public VerifiesAttribute(Type requirementType, string acceptanceCriterionName)
{
RequirementType = requirementType;
AcceptanceCriterionName = acceptanceCriterionName;
}
}
/// <summary>
/// Links a test class to a requirement type.
/// </summary>
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)]
public sealed class TestsForAttribute : Attribute
{
public Type RequirementType { get; }
public TestsForAttribute(Type requirementType) => RequirementType = requirementType;
}// Generated by Requirements source generator
/// <summary>
/// Links any type, interface, or method to a requirement.
/// Ctrl+Click on typeof(Feature) in IDE jumps to the requirement definition.
/// "Find All References" on Feature shows specs, implementations, and tests.
/// </summary>
[AttributeUsage(
AttributeTargets.Class | AttributeTargets.Interface | AttributeTargets.Method,
AllowMultiple = true)]
public sealed class ForRequirementAttribute : Attribute
{
public Type RequirementType { get; }
public string? AcceptanceCriterion { get; }
public ForRequirementAttribute(Type requirementType, string? acceptanceCriterion = null)
{
RequirementType = requirementType;
AcceptanceCriterion = acceptanceCriterion;
}
}
/// <summary>
/// Links a test method to a specific acceptance criterion.
/// The AC is identified by nameof(Feature.ACMethod) -- compiler-checked.
/// </summary>
[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)]
public sealed class VerifiesAttribute : Attribute
{
public Type RequirementType { get; }
public string AcceptanceCriterionName { get; }
public VerifiesAttribute(Type requirementType, string acceptanceCriterionName)
{
RequirementType = requirementType;
AcceptanceCriterionName = acceptanceCriterionName;
}
}
/// <summary>
/// Links a test class to a requirement type.
/// </summary>
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)]
public sealed class TestsForAttribute : Attribute
{
public Type RequirementType { get; }
public TestsForAttribute(Type requirementType) => RequirementType = requirementType;
}The generator also produces a requirement registry:
// Generated: RequirementRegistry.g.cs
public static class RequirementRegistry
{
public static IReadOnlyDictionary<Type, RequirementInfo> All { get; } =
new Dictionary<Type, RequirementInfo>
{
[typeof(PlatformScalabilityEpic)] = new(
Type: typeof(PlatformScalabilityEpic),
Kind: RequirementKind.Epic,
Title: "Multi-tenant platform enablement",
Parent: null,
AcceptanceCriteria: Array.Empty<string>()),
[typeof(UserRolesFeature)] = new(
Type: typeof(UserRolesFeature),
Kind: RequirementKind.Feature,
Title: "User roles and permissions",
Parent: typeof(PlatformScalabilityEpic),
AcceptanceCriteria: new[]
{
nameof(UserRolesFeature.AdminCanAssignRoles),
nameof(UserRolesFeature.ViewerHasReadOnlyAccess),
nameof(UserRolesFeature.RoleChangeTakesEffectImmediately)
}),
};
}
public record RequirementInfo(
Type Type, RequirementKind Kind, string Title,
Type? Parent, string[] AcceptanceCriteria);
public enum RequirementKind { Epic, Feature, Story, Task, Bug }// Generated: RequirementRegistry.g.cs
public static class RequirementRegistry
{
public static IReadOnlyDictionary<Type, RequirementInfo> All { get; } =
new Dictionary<Type, RequirementInfo>
{
[typeof(PlatformScalabilityEpic)] = new(
Type: typeof(PlatformScalabilityEpic),
Kind: RequirementKind.Epic,
Title: "Multi-tenant platform enablement",
Parent: null,
AcceptanceCriteria: Array.Empty<string>()),
[typeof(UserRolesFeature)] = new(
Type: typeof(UserRolesFeature),
Kind: RequirementKind.Feature,
Title: "User roles and permissions",
Parent: typeof(PlatformScalabilityEpic),
AcceptanceCriteria: new[]
{
nameof(UserRolesFeature.AdminCanAssignRoles),
nameof(UserRolesFeature.ViewerHasReadOnlyAccess),
nameof(UserRolesFeature.RoleChangeTakesEffectImmediately)
}),
};
}
public record RequirementInfo(
Type Type, RequirementKind Kind, string Title,
Type? Parent, string[] AcceptanceCriteria);
public enum RequirementKind { Epic, Feature, Story, Task, Bug }MyApp.SharedKernel -- Domain Types Shared Across Layers
Domain types live in their own project -- not in Specifications, not in Domain. Both reference it.
namespace MyApp.SharedKernel;
// Value types from Requirements are reused
using MyApp.Requirements;
public record User(UserId Id, string Name, IReadOnlySet<Role> Roles);
public record Role(RoleId Id, string Name, IReadOnlySet<Permission> Permissions);
public record Resource(ResourceId Id, string Type);
public record Permission(string Name);
public enum Operation { Read, Write, Delete, Admin }
/// <summary>
/// Strongly-typed result for domain operations.
/// Every specification method returns this -- not bool, not void.
/// </summary>
public record Result
{
public bool IsSuccess { get; }
public string? Reason { get; }
private Result(bool success, string? reason) { IsSuccess = success; Reason = reason; }
public static Result Success() => new(true, null);
public static Result Failure(string reason) => new(false, reason);
public static Result Aggregate(params Result[] results) =>
new(results.All(r => r.IsSuccess), null);
}namespace MyApp.SharedKernel;
// Value types from Requirements are reused
using MyApp.Requirements;
public record User(UserId Id, string Name, IReadOnlySet<Role> Roles);
public record Role(RoleId Id, string Name, IReadOnlySet<Permission> Permissions);
public record Resource(ResourceId Id, string Type);
public record Permission(string Name);
public enum Operation { Read, Write, Delete, Admin }
/// <summary>
/// Strongly-typed result for domain operations.
/// Every specification method returns this -- not bool, not void.
/// </summary>
public record Result
{
public bool IsSuccess { get; }
public string? Reason { get; }
private Result(bool success, string? reason) { IsSuccess = success; Reason = reason; }
public static Result Success() => new(true, null);
public static Result Failure(string reason) => new(false, reason);
public static Result Aggregate(params Result[] results) =>
new(results.All(r => r.IsSuccess), null);
}SharedKernel references Requirements (for UserId, RoleId value types). Specifications and Domain both reference SharedKernel. This keeps interfaces and implementations using the same User, Role, Result types without duplication.
Layer 2: MyApp.Specifications -- Contracts the Domain Must Satisfy
This project references MyApp.Requirements and MyApp.SharedKernel. It defines only interfaces -- no entity definitions, no implementations. Every spec interface and method is decorated with [ForRequirement] for IDE navigability.
Specification Interface
namespace MyApp.Specifications;
using MyApp.Requirements.Features;
using MyApp.SharedKernel;
/// <summary>
/// Specification for UserRolesFeature.
/// Any domain service claiming to implement user roles MUST implement this interface.
/// The compiler enforces it -- you cannot compile without satisfying every AC.
/// </summary>
[ForRequirement(typeof(UserRolesFeature))]
public interface IUserRolesSpec
{
[ForRequirement(typeof(UserRolesFeature), nameof(UserRolesFeature.AdminCanAssignRoles))]
Result AssignRole(User actingUser, User targetUser, Role role);
[ForRequirement(typeof(UserRolesFeature), nameof(UserRolesFeature.ViewerHasReadOnlyAccess))]
Result EnforceReadOnlyAccess(User viewer, Resource resource, Operation operation);
[ForRequirement(typeof(UserRolesFeature), nameof(UserRolesFeature.RoleChangeTakesEffectImmediately))]
Result VerifyImmediateRoleEffect(User user, Role previousRole, Role newRole);
}namespace MyApp.Specifications;
using MyApp.Requirements.Features;
using MyApp.SharedKernel;
/// <summary>
/// Specification for UserRolesFeature.
/// Any domain service claiming to implement user roles MUST implement this interface.
/// The compiler enforces it -- you cannot compile without satisfying every AC.
/// </summary>
[ForRequirement(typeof(UserRolesFeature))]
public interface IUserRolesSpec
{
[ForRequirement(typeof(UserRolesFeature), nameof(UserRolesFeature.AdminCanAssignRoles))]
Result AssignRole(User actingUser, User targetUser, Role role);
[ForRequirement(typeof(UserRolesFeature), nameof(UserRolesFeature.ViewerHasReadOnlyAccess))]
Result EnforceReadOnlyAccess(User viewer, Resource resource, Operation operation);
[ForRequirement(typeof(UserRolesFeature), nameof(UserRolesFeature.RoleChangeTakesEffectImmediately))]
Result VerifyImmediateRoleEffect(User user, Role previousRole, Role newRole);
}In the IDE: right-click typeof(UserRolesFeature) in the attribute > "Go To Definition" > jumps to the abstract feature with its AC methods. Right-click nameof(UserRolesFeature.AdminCanAssignRoles) > jumps directly to that AC method signature.
Validator Bridge -- AC Evaluation at Runtime
The bridge connects abstract UserRolesFeature AC methods to the spec interface. In normal production flow, code calls IUserRolesSpec directly. For compliance-sensitive operations (audit, regulatory), the validator bridge evaluates ACs explicitly:
[ForRequirement(typeof(UserRolesFeature))]
public record UserRolesValidator : UserRolesFeature
{
private readonly IUserRolesSpec _spec;
public UserRolesValidator(IUserRolesSpec spec) => _spec = spec;
public override AcceptanceCriterionResult AdminCanAssignRoles(
UserId actingUser, UserId targetUser, RoleId role)
{
var result = _spec.AssignRole(
new User(actingUser, "acting", new HashSet<Role>()),
new User(targetUser, "target", new HashSet<Role>()),
new Role(role, role.Value, new HashSet<Permission>()));
return result.IsSuccess
? AcceptanceCriterionResult.Satisfied()
: AcceptanceCriterionResult.Failed(result.Reason!);
}
public override AcceptanceCriterionResult ViewerHasReadOnlyAccess(
UserId viewer, ResourceId resource)
{
var result = _spec.EnforceReadOnlyAccess(
new User(viewer, "viewer", new HashSet<Role>()),
new Resource(resource, "generic"),
Operation.Write);
return !result.IsSuccess
? AcceptanceCriterionResult.Satisfied()
: AcceptanceCriterionResult.Failed("Viewer was allowed to write");
}
public override AcceptanceCriterionResult RoleChangeTakesEffectImmediately(
UserId user, RoleId previousRole, RoleId newRole)
{
var result = _spec.VerifyImmediateRoleEffect(
new User(user, "user", new HashSet<Role>()),
new Role(previousRole, previousRole.Value, new HashSet<Permission>()),
new Role(newRole, newRole.Value, new HashSet<Permission>()));
return result.IsSuccess
? AcceptanceCriterionResult.Satisfied()
: AcceptanceCriterionResult.Failed(result.Reason!);
}
}[ForRequirement(typeof(UserRolesFeature))]
public record UserRolesValidator : UserRolesFeature
{
private readonly IUserRolesSpec _spec;
public UserRolesValidator(IUserRolesSpec spec) => _spec = spec;
public override AcceptanceCriterionResult AdminCanAssignRoles(
UserId actingUser, UserId targetUser, RoleId role)
{
var result = _spec.AssignRole(
new User(actingUser, "acting", new HashSet<Role>()),
new User(targetUser, "target", new HashSet<Role>()),
new Role(role, role.Value, new HashSet<Permission>()));
return result.IsSuccess
? AcceptanceCriterionResult.Satisfied()
: AcceptanceCriterionResult.Failed(result.Reason!);
}
public override AcceptanceCriterionResult ViewerHasReadOnlyAccess(
UserId viewer, ResourceId resource)
{
var result = _spec.EnforceReadOnlyAccess(
new User(viewer, "viewer", new HashSet<Role>()),
new Resource(resource, "generic"),
Operation.Write);
return !result.IsSuccess
? AcceptanceCriterionResult.Satisfied()
: AcceptanceCriterionResult.Failed("Viewer was allowed to write");
}
public override AcceptanceCriterionResult RoleChangeTakesEffectImmediately(
UserId user, RoleId previousRole, RoleId newRole)
{
var result = _spec.VerifyImmediateRoleEffect(
new User(user, "user", new HashSet<Role>()),
new Role(previousRole, previousRole.Value, new HashSet<Permission>()),
new Role(newRole, newRole.Value, new HashSet<Permission>()));
return result.IsSuccess
? AcceptanceCriterionResult.Satisfied()
: AcceptanceCriterionResult.Failed(result.Reason!);
}
}Two runtime modes:
// Mode 1: Normal production flow -- call ISpec directly
public class RoleController
{
private readonly IUserRolesSpec _spec;
[HttpPost("users/{targetId}/role")]
public IActionResult AssignRole(Guid targetId, [FromBody] AssignRoleRequest req)
{
var result = _spec.AssignRole(actingUser, targetUser, role);
return result.IsSuccess ? Ok() : BadRequest(result.Reason);
}
}
// Mode 2: Compliance mode -- validate ACs explicitly (audit trail, regulatory)
public class ComplianceService
{
private readonly UserRolesValidator _validator;
public AcceptanceCriterionResult AuditRoleAssignment(
UserId acting, UserId target, RoleId role)
{
var result = _validator.AdminCanAssignRoles(acting, target, role);
_auditLog.Record("FEATURE:UserRoles", "AC:AdminCanAssignRoles", result);
return result;
}
}// Mode 1: Normal production flow -- call ISpec directly
public class RoleController
{
private readonly IUserRolesSpec _spec;
[HttpPost("users/{targetId}/role")]
public IActionResult AssignRole(Guid targetId, [FromBody] AssignRoleRequest req)
{
var result = _spec.AssignRole(actingUser, targetUser, role);
return result.IsSuccess ? Ok() : BadRequest(result.Reason);
}
}
// Mode 2: Compliance mode -- validate ACs explicitly (audit trail, regulatory)
public class ComplianceService
{
private readonly UserRolesValidator _validator;
public AcceptanceCriterionResult AuditRoleAssignment(
UserId acting, UserId target, RoleId role)
{
var result = _validator.AdminCanAssignRoles(acting, target, role);
_auditLog.Record("FEATURE:UserRoles", "AC:AdminCanAssignRoles", result);
return result;
}
}What makes Layer 2 work:
IUserRolesSpecis a compiler-enforced contract. If Domain claims to implement user roles, it must implement every method.- Each method on the interface is linked to its AC via
[ForRequirement(typeof(...), nameof(...))]-- clickable in IDE. - The validator bridge supports both runtime modes: normal production flow (ISpec) and compliance evaluation (validator).
- Specifications defines only interfaces. Domain types come from SharedKernel. No duplication.
Layer 3: MyApp.Implementation -- Compiler-Enforced Domain Code
This project references MyApp.Specifications (and transitively, MyApp.Requirements). The domain implements spec interfaces. The colon operator IS the guarantee. Every class and method is decorated with [ForRequirement] for full IDE navigability.
namespace MyApp.Domain.Auth;
using MyApp.Specifications;
using MyApp.Requirements.Features;
/// <summary>
/// Domain service implementing user roles.
/// The compiler FORCES this class to implement all three IUserRolesSpec methods.
/// If a new AC is added to the specification interface, this class
/// will not compile until the new method is implemented.
/// </summary>
[ForRequirement(typeof(UserRolesFeature))]
public class AuthorizationService : IUserRolesSpec
{
private readonly IUserRepository _users;
private readonly IRoleRepository _roles;
private readonly IPermissionCache _cache;
public AuthorizationService(
IUserRepository users, IRoleRepository roles, IPermissionCache cache)
{
_users = users;
_roles = roles;
_cache = cache;
}
[ForRequirement(typeof(UserRolesFeature), nameof(UserRolesFeature.AdminCanAssignRoles))]
public Result AssignRole(User actingUser, User targetUser, Role role)
{
if (!actingUser.Roles.Any(r => r.Name == "Admin"))
return Result.Failure("Only admins can assign roles");
if (role is null)
return Result.Failure("Role cannot be null");
// Domain logic: persist the assignment
_users.AssignRole(targetUser.Id, role.Id);
return Result.Success();
}
[ForRequirement(typeof(UserRolesFeature), nameof(UserRolesFeature.ViewerHasReadOnlyAccess))]
public Result EnforceReadOnlyAccess(User viewer, Resource resource, Operation operation)
{
var isViewer = viewer.Roles.Any(r => r.Name == "Viewer");
if (isViewer && operation != Operation.Read)
return Result.Failure($"Viewer cannot perform {operation} on {resource.Id}");
return Result.Success();
}
[ForRequirement(typeof(UserRolesFeature), nameof(UserRolesFeature.RoleChangeTakesEffectImmediately))]
public Result VerifyImmediateRoleEffect(User user, Role previousRole, Role newRole)
{
_cache.Invalidate(user.Id);
var currentPermissions = _cache.GetPermissions(user.Id);
var hasOldPermissions = currentPermissions.Intersect(previousRole.Permissions)
.Except(newRole.Permissions).Any();
if (hasOldPermissions)
return Result.Failure("Old permissions still cached after role change");
return Result.Success();
}
}namespace MyApp.Domain.Auth;
using MyApp.Specifications;
using MyApp.Requirements.Features;
/// <summary>
/// Domain service implementing user roles.
/// The compiler FORCES this class to implement all three IUserRolesSpec methods.
/// If a new AC is added to the specification interface, this class
/// will not compile until the new method is implemented.
/// </summary>
[ForRequirement(typeof(UserRolesFeature))]
public class AuthorizationService : IUserRolesSpec
{
private readonly IUserRepository _users;
private readonly IRoleRepository _roles;
private readonly IPermissionCache _cache;
public AuthorizationService(
IUserRepository users, IRoleRepository roles, IPermissionCache cache)
{
_users = users;
_roles = roles;
_cache = cache;
}
[ForRequirement(typeof(UserRolesFeature), nameof(UserRolesFeature.AdminCanAssignRoles))]
public Result AssignRole(User actingUser, User targetUser, Role role)
{
if (!actingUser.Roles.Any(r => r.Name == "Admin"))
return Result.Failure("Only admins can assign roles");
if (role is null)
return Result.Failure("Role cannot be null");
// Domain logic: persist the assignment
_users.AssignRole(targetUser.Id, role.Id);
return Result.Success();
}
[ForRequirement(typeof(UserRolesFeature), nameof(UserRolesFeature.ViewerHasReadOnlyAccess))]
public Result EnforceReadOnlyAccess(User viewer, Resource resource, Operation operation)
{
var isViewer = viewer.Roles.Any(r => r.Name == "Viewer");
if (isViewer && operation != Operation.Read)
return Result.Failure($"Viewer cannot perform {operation} on {resource.Id}");
return Result.Success();
}
[ForRequirement(typeof(UserRolesFeature), nameof(UserRolesFeature.RoleChangeTakesEffectImmediately))]
public Result VerifyImmediateRoleEffect(User user, Role previousRole, Role newRole)
{
_cache.Invalidate(user.Id);
var currentPermissions = _cache.GetPermissions(user.Id);
var hasOldPermissions = currentPermissions.Intersect(previousRole.Permissions)
.Except(newRole.Permissions).Any();
if (hasOldPermissions)
return Result.Failure("Old permissions still cached after role change");
return Result.Success();
}
}What makes Layer 3 work:
AuthorizationService : IUserRolesSpec-- the compiler forces all three AC methods to exist with the correct signatures.[ForRequirement(typeof(UserRolesFeature))]-- type reference, not string. Ctrl+Click jumps to the feature. Refactoring tools track it.- Each method is linked to its specific AC via
nameof(UserRolesFeature.AdminCanAssignRoles)-- compiler-checked. - If someone adds AC4 to
IUserRolesSpec,AuthorizationServicefails to compile until AC4 is implemented. This is the entire point.
Layer 4: MyApp.Tests -- Type-Linked Verification
This project references MyApp.Implementation and MyApp.Specifications (and transitively, MyApp.Requirements). Test classes link to requirements through type references.
namespace MyApp.Tests.Auth;
using MyApp.Requirements.Features;
using MyApp.Specifications;
using MyApp.Domain.Auth;
[TestFixture]
[TestsFor(typeof(UserRolesFeature))]
public class UserRolesTests
{
private AuthorizationService _authService;
private User _admin;
private User _viewer;
private Role _editorRole;
[SetUp]
public void Setup()
{
_authService = new AuthorizationService(
new InMemoryUserRepository(),
new InMemoryRoleRepository(),
new InMemoryPermissionCache());
var adminRole = new Role(new RoleId("admin"), "Admin",
new HashSet<Permission> { new("manage-roles") });
var viewerRole = new Role(new RoleId("viewer"), "Viewer",
new HashSet<Permission> { new("read") });
_admin = new User(new UserId(Guid.NewGuid()), "Alice",
new HashSet<Role> { adminRole });
_viewer = new User(new UserId(Guid.NewGuid()), "Bob",
new HashSet<Role> { viewerRole });
_editorRole = new Role(new RoleId("editor"), "Editor",
new HashSet<Permission> { new("read"), new("write") });
}
[Test]
[Verifies(typeof(UserRolesFeature), nameof(UserRolesFeature.AdminCanAssignRoles))]
public void Admin_can_assign_role_to_another_user()
{
var target = new User(new UserId(Guid.NewGuid()), "Charlie", new HashSet<Role>());
var result = _authService.AssignRole(_admin, target, _editorRole);
Assert.That(result.IsSuccess, Is.True);
}
[Test]
[Verifies(typeof(UserRolesFeature), nameof(UserRolesFeature.AdminCanAssignRoles))]
public void Non_admin_cannot_assign_roles()
{
var target = new User(new UserId(Guid.NewGuid()), "Charlie", new HashSet<Role>());
var result = _authService.AssignRole(_viewer, target, _editorRole);
Assert.That(result.IsSuccess, Is.False);
Assert.That(result.Reason, Does.Contain("Only admins"));
}
[Test]
[Verifies(typeof(UserRolesFeature), nameof(UserRolesFeature.ViewerHasReadOnlyAccess))]
public void Viewer_cannot_write()
{
var resource = new Resource(new ResourceId("order-123"), "Order");
var result = _authService.EnforceReadOnlyAccess(_viewer, resource, Operation.Write);
Assert.That(result.IsSuccess, Is.False);
}
[Test]
[Verifies(typeof(UserRolesFeature), nameof(UserRolesFeature.ViewerHasReadOnlyAccess))]
public void Viewer_can_read()
{
var resource = new Resource(new ResourceId("order-123"), "Order");
var result = _authService.EnforceReadOnlyAccess(_viewer, resource, Operation.Read);
Assert.That(result.IsSuccess, Is.True);
}
[Test]
[Verifies(typeof(UserRolesFeature), nameof(UserRolesFeature.RoleChangeTakesEffectImmediately))]
public void Role_change_invalidates_cache_immediately()
{
var previousRole = new Role(new RoleId("viewer"), "Viewer",
new HashSet<Permission> { new("read") });
var result = _authService.VerifyImmediateRoleEffect(_admin, previousRole, _editorRole);
Assert.That(result.IsSuccess, Is.True);
}
}namespace MyApp.Tests.Auth;
using MyApp.Requirements.Features;
using MyApp.Specifications;
using MyApp.Domain.Auth;
[TestFixture]
[TestsFor(typeof(UserRolesFeature))]
public class UserRolesTests
{
private AuthorizationService _authService;
private User _admin;
private User _viewer;
private Role _editorRole;
[SetUp]
public void Setup()
{
_authService = new AuthorizationService(
new InMemoryUserRepository(),
new InMemoryRoleRepository(),
new InMemoryPermissionCache());
var adminRole = new Role(new RoleId("admin"), "Admin",
new HashSet<Permission> { new("manage-roles") });
var viewerRole = new Role(new RoleId("viewer"), "Viewer",
new HashSet<Permission> { new("read") });
_admin = new User(new UserId(Guid.NewGuid()), "Alice",
new HashSet<Role> { adminRole });
_viewer = new User(new UserId(Guid.NewGuid()), "Bob",
new HashSet<Role> { viewerRole });
_editorRole = new Role(new RoleId("editor"), "Editor",
new HashSet<Permission> { new("read"), new("write") });
}
[Test]
[Verifies(typeof(UserRolesFeature), nameof(UserRolesFeature.AdminCanAssignRoles))]
public void Admin_can_assign_role_to_another_user()
{
var target = new User(new UserId(Guid.NewGuid()), "Charlie", new HashSet<Role>());
var result = _authService.AssignRole(_admin, target, _editorRole);
Assert.That(result.IsSuccess, Is.True);
}
[Test]
[Verifies(typeof(UserRolesFeature), nameof(UserRolesFeature.AdminCanAssignRoles))]
public void Non_admin_cannot_assign_roles()
{
var target = new User(new UserId(Guid.NewGuid()), "Charlie", new HashSet<Role>());
var result = _authService.AssignRole(_viewer, target, _editorRole);
Assert.That(result.IsSuccess, Is.False);
Assert.That(result.Reason, Does.Contain("Only admins"));
}
[Test]
[Verifies(typeof(UserRolesFeature), nameof(UserRolesFeature.ViewerHasReadOnlyAccess))]
public void Viewer_cannot_write()
{
var resource = new Resource(new ResourceId("order-123"), "Order");
var result = _authService.EnforceReadOnlyAccess(_viewer, resource, Operation.Write);
Assert.That(result.IsSuccess, Is.False);
}
[Test]
[Verifies(typeof(UserRolesFeature), nameof(UserRolesFeature.ViewerHasReadOnlyAccess))]
public void Viewer_can_read()
{
var resource = new Resource(new ResourceId("order-123"), "Order");
var result = _authService.EnforceReadOnlyAccess(_viewer, resource, Operation.Read);
Assert.That(result.IsSuccess, Is.True);
}
[Test]
[Verifies(typeof(UserRolesFeature), nameof(UserRolesFeature.RoleChangeTakesEffectImmediately))]
public void Role_change_invalidates_cache_immediately()
{
var previousRole = new Role(new RoleId("viewer"), "Viewer",
new HashSet<Permission> { new("read") });
var result = _authService.VerifyImmediateRoleEffect(_admin, previousRole, _editorRole);
Assert.That(result.IsSuccess, Is.True);
}
}What makes Layer 4 work:
[TestsFor(typeof(UserRolesFeature))]-- type reference to the requirement. Source generators enumerate all tests for any feature.[Verifies(typeof(UserRolesFeature), nameof(UserRolesFeature.AdminCanAssignRoles))]-- thenameof()is compiler-checked. Rename the AC method: this updates. Delete the AC method: compile error.- Multiple tests can verify the same AC (positive and negative cases). The coverage report knows
AdminCanAssignRoleshas 2 tests. - Tests call domain code through the specification interface -- the same interface the domain was forced to implement.
Layer 5: MyApp.Api -- The Production Host
This is where the chain becomes a deployable application. The API project references MyApp.Domain (and transitively, everything else). It wires the DI container, exposes controllers, and optionally enables compliance validation.
DI Registration
// Program.cs
var builder = WebApplication.CreateBuilder(args);
// Wire the requirement chain
builder.Services.AddScoped<IUserRolesSpec, AuthorizationService>();
builder.Services.AddScoped<IJwtRefreshSpec, JwtRefreshService>();
// Optional: enable compliance mode for audit-sensitive operations
builder.Services.AddScoped<UserRolesValidator>(sp =>
new UserRolesValidator(sp.GetRequiredService<IUserRolesSpec>()));
// Optional: enable requirement compliance checking at startup
builder.Services.AddHostedService<RequirementComplianceCheck>();
var app = builder.Build();
app.MapControllers();
app.Run();// Program.cs
var builder = WebApplication.CreateBuilder(args);
// Wire the requirement chain
builder.Services.AddScoped<IUserRolesSpec, AuthorizationService>();
builder.Services.AddScoped<IJwtRefreshSpec, JwtRefreshService>();
// Optional: enable compliance mode for audit-sensitive operations
builder.Services.AddScoped<UserRolesValidator>(sp =>
new UserRolesValidator(sp.GetRequiredService<IUserRolesSpec>()));
// Optional: enable requirement compliance checking at startup
builder.Services.AddHostedService<RequirementComplianceCheck>();
var app = builder.Build();
app.MapControllers();
app.Run();Controller -- Normal Production Flow
namespace MyApp.Api.Controllers;
using MyApp.Specifications;
using MyApp.SharedKernel;
using MyApp.Requirements.Features;
[ApiController]
[Route("api/users")]
[ForRequirement(typeof(UserRolesFeature))]
public class UsersController : ControllerBase
{
private readonly IUserRolesSpec _spec;
public UsersController(IUserRolesSpec spec) => _spec = spec;
[HttpPost("{targetId}/role")]
[ForRequirement(typeof(UserRolesFeature), nameof(UserRolesFeature.AdminCanAssignRoles))]
public IActionResult AssignRole(Guid targetId, [FromBody] AssignRoleRequest request)
{
var actingUser = HttpContext.GetCurrentUser();
var targetUser = _users.GetById(new UserId(targetId));
var role = _roles.GetById(new RoleId(request.RoleId));
var result = _spec.AssignRole(actingUser, targetUser, role);
return result.IsSuccess
? Ok(new { message = "Role assigned" })
: BadRequest(new { error = result.Reason });
}
}namespace MyApp.Api.Controllers;
using MyApp.Specifications;
using MyApp.SharedKernel;
using MyApp.Requirements.Features;
[ApiController]
[Route("api/users")]
[ForRequirement(typeof(UserRolesFeature))]
public class UsersController : ControllerBase
{
private readonly IUserRolesSpec _spec;
public UsersController(IUserRolesSpec spec) => _spec = spec;
[HttpPost("{targetId}/role")]
[ForRequirement(typeof(UserRolesFeature), nameof(UserRolesFeature.AdminCanAssignRoles))]
public IActionResult AssignRole(Guid targetId, [FromBody] AssignRoleRequest request)
{
var actingUser = HttpContext.GetCurrentUser();
var targetUser = _users.GetById(new UserId(targetId));
var role = _roles.GetById(new RoleId(request.RoleId));
var result = _spec.AssignRole(actingUser, targetUser, role);
return result.IsSuccess
? Ok(new { message = "Role assigned" })
: BadRequest(new { error = result.Reason });
}
}The [ForRequirement] on the controller and action method means the generated OpenAPI schema includes requirement metadata:
{
"paths": {
"/api/users/{targetId}/role": {
"post": {
"x-requirement": "UserRolesFeature",
"x-acceptance-criterion": "AdminCanAssignRoles",
"summary": "Assign a role to a user"
}
}
}
}{
"paths": {
"/api/users/{targetId}/role": {
"post": {
"x-requirement": "UserRolesFeature",
"x-acceptance-criterion": "AdminCanAssignRoles",
"summary": "Assign a role to a user"
}
}
}
}Startup Compliance Check
public class RequirementComplianceCheck : IHostedService
{
private readonly ILogger<RequirementComplianceCheck> _logger;
public Task StartAsync(CancellationToken ct)
{
// TraceabilityMatrix is source-generated
foreach (var (type, entry) in TraceabilityMatrix.Entries)
{
var info = RequirementRegistry.All[type];
var totalACs = info.AcceptanceCriteria.Length;
var testedACs = entry.AcceptanceCriteriaCoverage.Count;
if (testedACs < totalACs)
_logger.LogWarning("{Feature}: {Tested}/{Total} ACs tested ({Missing} missing)",
info.Title, testedACs, totalACs,
string.Join(", ", info.AcceptanceCriteria
.Except(entry.AcceptanceCriteriaCoverage.Keys)));
else
_logger.LogInformation("{Feature}: all {Total} ACs covered",
info.Title, totalACs);
}
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken ct) => Task.CompletedTask;
}public class RequirementComplianceCheck : IHostedService
{
private readonly ILogger<RequirementComplianceCheck> _logger;
public Task StartAsync(CancellationToken ct)
{
// TraceabilityMatrix is source-generated
foreach (var (type, entry) in TraceabilityMatrix.Entries)
{
var info = RequirementRegistry.All[type];
var totalACs = info.AcceptanceCriteria.Length;
var testedACs = entry.AcceptanceCriteriaCoverage.Count;
if (testedACs < totalACs)
_logger.LogWarning("{Feature}: {Tested}/{Total} ACs tested ({Missing} missing)",
info.Title, testedACs, totalACs,
string.Join(", ", info.AcceptanceCriteria
.Except(entry.AcceptanceCriteriaCoverage.Keys)));
else
_logger.LogInformation("{Feature}: all {Total} ACs covered",
info.Title, totalACs);
}
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken ct) => Task.CompletedTask;
}DLL Artifacts at Build Time
After dotnet build, the solution produces:
bin/
├── MyApp.Requirements.dll ← Feature types, AC methods, generated attributes
├── MyApp.SharedKernel.dll ← User, Role, Permission, Result
├── MyApp.Specifications.dll ← IUserRolesSpec, IJwtRefreshSpec, validator bridges
├── MyApp.Domain.dll ← AuthorizationService : IUserRolesSpec
├── MyApp.Api.dll ← Controllers, DI wiring, startup compliance
└── MyApp.Tests.dll ← Type-linked tests (not deployed)
Generated at compile time (embedded in respective DLLs):
├── RequirementRegistry.g.cs ← Type catalog + hierarchy
├── TraceabilityMatrix.g.cs ← Requirement → Implementation → Test mapping
└── Compiler diagnostics ← IDE warnings for untested ACsbin/
├── MyApp.Requirements.dll ← Feature types, AC methods, generated attributes
├── MyApp.SharedKernel.dll ← User, Role, Permission, Result
├── MyApp.Specifications.dll ← IUserRolesSpec, IJwtRefreshSpec, validator bridges
├── MyApp.Domain.dll ← AuthorizationService : IUserRolesSpec
├── MyApp.Api.dll ← Controllers, DI wiring, startup compliance
└── MyApp.Tests.dll ← Type-linked tests (not deployed)
Generated at compile time (embedded in respective DLLs):
├── RequirementRegistry.g.cs ← Type catalog + hierarchy
├── TraceabilityMatrix.g.cs ← Requirement → Implementation → Test mapping
└── Compiler diagnostics ← IDE warnings for untested ACsIDE Navigation Chain
From any layer, "Find All References" on UserRolesFeature shows the entire traceability chain:
UserRolesFeature <-- Layer 1: requirement definition
|-- IUserRolesSpec <-- Layer 2: [ForRequirement(typeof(UserRolesFeature))]
| |-- .AssignRole <-- Layer 2: [ForRequirement(..., nameof(...AdminCanAssignRoles))]
| |-- .EnforceReadOnlyAccess <-- Layer 2: [ForRequirement(..., nameof(...ViewerHasReadOnlyAccess))]
| '-- .VerifyImmediateRoleEffect <-- Layer 2: [ForRequirement(..., nameof(...RoleChangeTakesEffectImmediately))]
|-- AuthorizationService <-- Layer 3: [ForRequirement(typeof(UserRolesFeature))]
| |-- .AssignRole <-- Layer 3: [ForRequirement(..., nameof(...AdminCanAssignRoles))]
| '-- ...
'-- UserRolesTests <-- Layer 4: [TestsFor(typeof(UserRolesFeature))]
|-- .Admin_can_assign_role <-- Layer 4: [Verifies(..., nameof(...AdminCanAssignRoles))]
|-- .Non_admin_cannot_assign_roles <-- Layer 4: [Verifies(..., nameof(...AdminCanAssignRoles))]
'-- ...UserRolesFeature <-- Layer 1: requirement definition
|-- IUserRolesSpec <-- Layer 2: [ForRequirement(typeof(UserRolesFeature))]
| |-- .AssignRole <-- Layer 2: [ForRequirement(..., nameof(...AdminCanAssignRoles))]
| |-- .EnforceReadOnlyAccess <-- Layer 2: [ForRequirement(..., nameof(...ViewerHasReadOnlyAccess))]
| '-- .VerifyImmediateRoleEffect <-- Layer 2: [ForRequirement(..., nameof(...RoleChangeTakesEffectImmediately))]
|-- AuthorizationService <-- Layer 3: [ForRequirement(typeof(UserRolesFeature))]
| |-- .AssignRole <-- Layer 3: [ForRequirement(..., nameof(...AdminCanAssignRoles))]
| '-- ...
'-- UserRolesTests <-- Layer 4: [TestsFor(typeof(UserRolesFeature))]
|-- .Admin_can_assign_role <-- Layer 4: [Verifies(..., nameof(...AdminCanAssignRoles))]
|-- .Non_admin_cannot_assign_roles <-- Layer 4: [Verifies(..., nameof(...AdminCanAssignRoles))]
'-- ...Every link is a typeof() or nameof() -- Ctrl+Click navigable, refactor-safe, compiler-checked.
Source-Generated Traceability
The Roslyn source generator, referenced by all projects as an analyzer, cross-references [ForRequirement], [TestsFor], and [Verifies] attributes to produce:
Traceability Matrix
// Generated: TraceabilityMatrix.g.cs
public static class TraceabilityMatrix
{
public static IReadOnlyDictionary<Type, TraceabilityEntry> Entries { get; } =
new Dictionary<Type, TraceabilityEntry>
{
[typeof(UserRolesFeature)] = new TraceabilityEntry(
RequirementType: typeof(UserRolesFeature),
Implementations: new[]
{
new ImplementationRef(typeof(AuthorizationService), typeof(IUserRolesSpec))
},
Tests: new[]
{
new TestRef(typeof(UserRolesTests), "Admin_can_assign_role_to_another_user",
nameof(UserRolesFeature.AdminCanAssignRoles)),
new TestRef(typeof(UserRolesTests), "Non_admin_cannot_assign_roles",
nameof(UserRolesFeature.AdminCanAssignRoles)),
new TestRef(typeof(UserRolesTests), "Viewer_cannot_write",
nameof(UserRolesFeature.ViewerHasReadOnlyAccess)),
new TestRef(typeof(UserRolesTests), "Viewer_can_read",
nameof(UserRolesFeature.ViewerHasReadOnlyAccess)),
new TestRef(typeof(UserRolesTests), "Role_change_invalidates_cache_immediately",
nameof(UserRolesFeature.RoleChangeTakesEffectImmediately)),
},
AcceptanceCriteriaCoverage: new Dictionary<string, int>
{
[nameof(UserRolesFeature.AdminCanAssignRoles)] = 2,
[nameof(UserRolesFeature.ViewerHasReadOnlyAccess)] = 2,
[nameof(UserRolesFeature.RoleChangeTakesEffectImmediately)] = 1
}),
};
}// Generated: TraceabilityMatrix.g.cs
public static class TraceabilityMatrix
{
public static IReadOnlyDictionary<Type, TraceabilityEntry> Entries { get; } =
new Dictionary<Type, TraceabilityEntry>
{
[typeof(UserRolesFeature)] = new TraceabilityEntry(
RequirementType: typeof(UserRolesFeature),
Implementations: new[]
{
new ImplementationRef(typeof(AuthorizationService), typeof(IUserRolesSpec))
},
Tests: new[]
{
new TestRef(typeof(UserRolesTests), "Admin_can_assign_role_to_another_user",
nameof(UserRolesFeature.AdminCanAssignRoles)),
new TestRef(typeof(UserRolesTests), "Non_admin_cannot_assign_roles",
nameof(UserRolesFeature.AdminCanAssignRoles)),
new TestRef(typeof(UserRolesTests), "Viewer_cannot_write",
nameof(UserRolesFeature.ViewerHasReadOnlyAccess)),
new TestRef(typeof(UserRolesTests), "Viewer_can_read",
nameof(UserRolesFeature.ViewerHasReadOnlyAccess)),
new TestRef(typeof(UserRolesTests), "Role_change_invalidates_cache_immediately",
nameof(UserRolesFeature.RoleChangeTakesEffectImmediately)),
},
AcceptanceCriteriaCoverage: new Dictionary<string, int>
{
[nameof(UserRolesFeature.AdminCanAssignRoles)] = 2,
[nameof(UserRolesFeature.ViewerHasReadOnlyAccess)] = 2,
[nameof(UserRolesFeature.RoleChangeTakesEffectImmediately)] = 1
}),
};
}Compiler Diagnostics
The generator emits Roslyn diagnostics -- not runtime checks, but IDE warnings and CI build output:
warning REQ001: UserRolesFeature -- all 3 acceptance criteria have tests (100% coverage)
warning REQ002: JwtRefreshStory -- 0/2 acceptance criteria have tests (0% coverage)
-> Missing: TokensExpireAfterOneHour, RefreshExtendsBySevenDays
warning REQ003: No class found implementing IJwtRefreshSpec
-> JwtRefreshStory has a specification but no domain implementationwarning REQ001: UserRolesFeature -- all 3 acceptance criteria have tests (100% coverage)
warning REQ002: JwtRefreshStory -- 0/2 acceptance criteria have tests (0% coverage)
-> Missing: TokensExpireAfterOneHour, RefreshExtendsBySevenDays
warning REQ003: No class found implementing IJwtRefreshSpec
-> JwtRefreshStory has a specification but no domain implementationThese appear in the IDE Error List and in CI build output. No runtime overhead. No reflection.
Roslyn Analyzers: Enforcing the Chain at Compile Time
The MyApp.Requirements.Analyzers project is a Roslyn analyzer + source generator that enforces three invariants: every requirement is specified, every specification is implemented, and every implementation is tested. Violations are compiler errors or warnings -- not runtime failures.
Analyzer 1: Requirement Coverage (REQ1xx)
Scans Layer 1 (MyApp.Requirements) for all types inheriting RequirementMetadata with abstract AC methods. Scans Layer 2 (MyApp.Specifications) for [ForRequirement] attributes. Reports gaps.
| Diagnostic | Severity | Trigger |
|---|---|---|
| REQ100 | Error | Feature type has no [ForRequirement(typeof(Feature))] interface in Specifications |
| REQ101 | Error | Feature AC method has no matching [ForRequirement(..., nameof(AC))] on any spec interface method |
| REQ102 | Warning | Story type has no specification (acceptable for small stories, error in strict mode) |
| REQ103 | Info | Requirement fully specified -- all ACs have spec methods |
// Build output:
error REQ100: UserRolesFeature has 3 acceptance criteria but no ISpec interface
references it via [ForRequirement(typeof(UserRolesFeature))]
→ Create IUserRolesSpec with [ForRequirement(typeof(UserRolesFeature))]
error REQ101: UserRolesFeature.RoleChangeTakesEffectImmediately has no matching
spec method with [ForRequirement(typeof(UserRolesFeature),
nameof(UserRolesFeature.RoleChangeTakesEffectImmediately))]
→ Add a method to IUserRolesSpec with the matching [ForRequirement]// Build output:
error REQ100: UserRolesFeature has 3 acceptance criteria but no ISpec interface
references it via [ForRequirement(typeof(UserRolesFeature))]
→ Create IUserRolesSpec with [ForRequirement(typeof(UserRolesFeature))]
error REQ101: UserRolesFeature.RoleChangeTakesEffectImmediately has no matching
spec method with [ForRequirement(typeof(UserRolesFeature),
nameof(UserRolesFeature.RoleChangeTakesEffectImmediately))]
→ Add a method to IUserRolesSpec with the matching [ForRequirement]Analyzer 2: Specification Implementation (REQ2xx)
Scans Layer 2 interfaces decorated with [ForRequirement]. Scans Layer 3 (MyApp.Domain) for classes implementing those interfaces. Reports unimplemented specs.
| Diagnostic | Severity | Trigger |
|---|---|---|
| REQ200 | Error | Spec interface has no implementing class in Domain |
| REQ201 | Warning | Implementing class does not have [ForRequirement] attribute (works but loses traceability) |
| REQ202 | Warning | Implementing class has [ForRequirement] but method-level attributes are missing |
| REQ203 | Info | Spec fully implemented -- all methods have [ForRequirement] on impl |
// Build output:
error REQ200: IUserRolesSpec is not implemented by any class in MyApp.Domain
→ Create a class that implements IUserRolesSpec
warning REQ201: AuthorizationService implements IUserRolesSpec but is missing
[ForRequirement(typeof(UserRolesFeature))] on the class
→ Add [ForRequirement(typeof(UserRolesFeature))] for traceability
warning REQ202: AuthorizationService.VerifyImmediateRoleEffect implements
IUserRolesSpec but is missing [ForRequirement(typeof(UserRolesFeature),
nameof(UserRolesFeature.RoleChangeTakesEffectImmediately))]
→ Add method-level [ForRequirement] for IDE navigation// Build output:
error REQ200: IUserRolesSpec is not implemented by any class in MyApp.Domain
→ Create a class that implements IUserRolesSpec
warning REQ201: AuthorizationService implements IUserRolesSpec but is missing
[ForRequirement(typeof(UserRolesFeature))] on the class
→ Add [ForRequirement(typeof(UserRolesFeature))] for traceability
warning REQ202: AuthorizationService.VerifyImmediateRoleEffect implements
IUserRolesSpec but is missing [ForRequirement(typeof(UserRolesFeature),
nameof(UserRolesFeature.RoleChangeTakesEffectImmediately))]
→ Add method-level [ForRequirement] for IDE navigationAnalyzer 3: Test Coverage (REQ3xx)
Scans Layer 4 (MyApp.Tests) for [TestsFor] and [Verifies] attributes. Cross-references with Layer 1 AC methods. Reports untested ACs.
| Diagnostic | Severity | Trigger |
|---|---|---|
| REQ300 | Error | Feature has zero [TestsFor] test classes |
| REQ301 | Warning | AC method has no [Verifies(..., nameof(AC))] test |
| REQ302 | Warning | [Verifies] references an AC method that doesn't exist (stale test) |
| REQ303 | Info | Feature fully tested -- all ACs have at least one [Verifies] test |
// Build output:
error REQ300: JwtRefreshStory has 2 acceptance criteria but no test class
with [TestsFor(typeof(JwtRefreshStory))]
warning REQ301: UserRolesFeature.RoleChangeTakesEffectImmediately has no test
with [Verifies(typeof(UserRolesFeature),
nameof(UserRolesFeature.RoleChangeTakesEffectImmediately))]
→ Add a test method with [Verifies] for this AC
warning REQ302: UserRolesTests.OldTest references nameof(UserRolesFeature.DeletedAC)
which no longer exists on UserRolesFeature
→ Remove or update the stale [Verifies] attribute// Build output:
error REQ300: JwtRefreshStory has 2 acceptance criteria but no test class
with [TestsFor(typeof(JwtRefreshStory))]
warning REQ301: UserRolesFeature.RoleChangeTakesEffectImmediately has no test
with [Verifies(typeof(UserRolesFeature),
nameof(UserRolesFeature.RoleChangeTakesEffectImmediately))]
→ Add a test method with [Verifies] for this AC
warning REQ302: UserRolesTests.OldTest references nameof(UserRolesFeature.DeletedAC)
which no longer exists on UserRolesFeature
→ Remove or update the stale [Verifies] attributeAnalyzer 4: Quality Gates (REQ4xx)
Integrates with a dotnet quality-gates tool (invoked as an MSBuild target after test execution) to enforce that tests don't just exist -- they pass, meet coverage thresholds, and satisfy performance budgets.
MSBuild integration:
<!-- MyApp.Tests.csproj -->
<PropertyGroup>
<RequirementMinCoverage>80</RequirementMinCoverage>
<RequirementMinPassRate>100</RequirementMinPassRate>
<RequirementMaxTestDuration>5000</RequirementMaxTestDuration> <!-- ms per test -->
</PropertyGroup>
<Target Name="RequirementQualityGates" AfterTargets="VSTest">
<Exec Command="dotnet quality-gates check
--trx $(TestResultsDir)/*.trx
--coverage $(CoverageResultsDir)/coverage.cobertura.xml
--traceability $(IntermediateOutputPath)/TraceabilityMatrix.g.cs
--min-pass-rate $(RequirementMinPassRate)
--min-coverage $(RequirementMinCoverage)
--max-duration $(RequirementMaxTestDuration)"
ConsoleToMsBuild="true" />
</Target><!-- MyApp.Tests.csproj -->
<PropertyGroup>
<RequirementMinCoverage>80</RequirementMinCoverage>
<RequirementMinPassRate>100</RequirementMinPassRate>
<RequirementMaxTestDuration>5000</RequirementMaxTestDuration> <!-- ms per test -->
</PropertyGroup>
<Target Name="RequirementQualityGates" AfterTargets="VSTest">
<Exec Command="dotnet quality-gates check
--trx $(TestResultsDir)/*.trx
--coverage $(CoverageResultsDir)/coverage.cobertura.xml
--traceability $(IntermediateOutputPath)/TraceabilityMatrix.g.cs
--min-pass-rate $(RequirementMinPassRate)
--min-coverage $(RequirementMinCoverage)
--max-duration $(RequirementMaxTestDuration)"
ConsoleToMsBuild="true" />
</Target>What dotnet quality-gates check validates:
| Gate | What it checks | Fails when |
|---|---|---|
| Pass rate | All [Verifies] tests passed in .trx results |
Any test marked [Verifies] failed |
| AC coverage | Every AC in TraceabilityMatrix has a passing [Verifies] test |
AC exists but its test failed or is missing |
| Code coverage | Lines touched by [Verifies] tests cover the [ForRequirement] methods |
Implementation method has <N% line coverage |
| Duration | Individual test execution time | Any [Verifies] test exceeds max duration |
| Flakiness | Test result consistency across runs | A [Verifies] test has inconsistent pass/fail across retries |
| Fuzz testing | Auto-generated random/boundary inputs for AC methods | Implementation crashes or returns unexpected results on edge cases |
The fuzz testing gate deserves special attention. Because every AC is an abstract method with a typed signature, the tool knows exactly what inputs to generate. For AdminCanAssignRoles(UserId actingUser, UserId targetUser, RoleId role), it can automatically produce: null values, empty GUIDs, duplicate user/target IDs, nonexistent role IDs, extremely long strings, Unicode edge cases, and concurrent calls. The spec method's Result return type means the tool knows what "not crashing" looks like -- a Result.Failure with a reason is acceptable; an unhandled exception is not. This is property-based testing derived from the requirement signature itself.
Output:
$ dotnet quality-gates check --trx results.trx --coverage coverage.xml ...
Quality Gates Report
====================
FEATURE: UserRolesFeature (3 ACs, 5 tests)
✓ AdminCanAssignRoles 2 tests, 100% pass, 94% coverage, avg 12ms
fuzz: 500 inputs, 0 crashes, 12 Result.Failure (expected)
✓ ViewerHasReadOnlyAccess 2 tests, 100% pass, 88% coverage, avg 8ms
fuzz: 500 inputs, 0 crashes, 8 Result.Failure (expected)
✗ RoleChangeTakesEffect 1 test, 100% pass, 62% coverage ← BELOW 80% THRESHOLD
fuzz: 500 inputs, 1 CRASH ← NullReferenceException with empty RoleId
→ AuthorizationService.VerifyImmediateRoleEffect:L45-L52 not covered
STORY: JwtRefreshStory (2 ACs, 0 tests)
✗ TokensExpireAfterOneHour NO TESTS
✗ RefreshExtendsBySevenDays NO TESTS
Summary: 2/3 quality gates passed for UserRolesFeature (fuzz found 1 crash)
0/2 quality gates passed for JwtRefreshStory
Overall: FAIL (exit code 1)$ dotnet quality-gates check --trx results.trx --coverage coverage.xml ...
Quality Gates Report
====================
FEATURE: UserRolesFeature (3 ACs, 5 tests)
✓ AdminCanAssignRoles 2 tests, 100% pass, 94% coverage, avg 12ms
fuzz: 500 inputs, 0 crashes, 12 Result.Failure (expected)
✓ ViewerHasReadOnlyAccess 2 tests, 100% pass, 88% coverage, avg 8ms
fuzz: 500 inputs, 0 crashes, 8 Result.Failure (expected)
✗ RoleChangeTakesEffect 1 test, 100% pass, 62% coverage ← BELOW 80% THRESHOLD
fuzz: 500 inputs, 1 CRASH ← NullReferenceException with empty RoleId
→ AuthorizationService.VerifyImmediateRoleEffect:L45-L52 not covered
STORY: JwtRefreshStory (2 ACs, 0 tests)
✗ TokensExpireAfterOneHour NO TESTS
✗ RefreshExtendsBySevenDays NO TESTS
Summary: 2/3 quality gates passed for UserRolesFeature (fuzz found 1 crash)
0/2 quality gates passed for JwtRefreshStory
Overall: FAIL (exit code 1)Severity Configuration
The analyzer severity is configurable per-project via .editorconfig:
# .editorconfig
[*.cs]
# Requirements → Specifications: treat missing specs as errors
dotnet_diagnostic.REQ100.severity = error
dotnet_diagnostic.REQ101.severity = error
dotnet_diagnostic.REQ102.severity = warning
# Specifications → Implementation: treat missing impl as errors
dotnet_diagnostic.REQ200.severity = error
dotnet_diagnostic.REQ201.severity = warning
dotnet_diagnostic.REQ202.severity = suggestion
# Implementation → Tests: treat missing tests as warnings (errors in CI)
dotnet_diagnostic.REQ300.severity = warning
dotnet_diagnostic.REQ301.severity = warning
dotnet_diagnostic.REQ302.severity = error # stale tests are always errors# .editorconfig
[*.cs]
# Requirements → Specifications: treat missing specs as errors
dotnet_diagnostic.REQ100.severity = error
dotnet_diagnostic.REQ101.severity = error
dotnet_diagnostic.REQ102.severity = warning
# Specifications → Implementation: treat missing impl as errors
dotnet_diagnostic.REQ200.severity = error
dotnet_diagnostic.REQ201.severity = warning
dotnet_diagnostic.REQ202.severity = suggestion
# Implementation → Tests: treat missing tests as warnings (errors in CI)
dotnet_diagnostic.REQ300.severity = warning
dotnet_diagnostic.REQ301.severity = warning
dotnet_diagnostic.REQ302.severity = error # stale tests are always errorsFor CI, override to strict:
<!-- Directory.Build.props -->
<PropertyGroup Condition="'$(CI)' == 'true'">
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<WarningsAsErrors>REQ100;REQ101;REQ200;REQ300;REQ301</WarningsAsErrors>
</PropertyGroup><!-- Directory.Build.props -->
<PropertyGroup Condition="'$(CI)' == 'true'">
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<WarningsAsErrors>REQ100;REQ101;REQ200;REQ300;REQ301</WarningsAsErrors>
</PropertyGroup>The Full CI Pipeline
# .github/workflows/build.yml
jobs:
build:
steps:
- run: dotnet build --warnaserror # REQ1xx, REQ2xx, REQ3xx analyzers
- run: dotnet test --collect:"XPlat Code Coverage" --logger:trx
- run: dotnet quality-gates check # REQ4xx quality gates
--trx TestResults/*.trx
--coverage TestResults/**/coverage.cobertura.xml
--min-pass-rate 100
--min-coverage 80# .github/workflows/build.yml
jobs:
build:
steps:
- run: dotnet build --warnaserror # REQ1xx, REQ2xx, REQ3xx analyzers
- run: dotnet test --collect:"XPlat Code Coverage" --logger:trx
- run: dotnet quality-gates check # REQ4xx quality gates
--trx TestResults/*.trx
--coverage TestResults/**/coverage.cobertura.xml
--min-pass-rate 100
--min-coverage 80Three enforcement points, one build:
dotnet build-- Roslyn analyzers catch structural gaps (missing specs, missing implementations, missing tests)dotnet test-- tests execute against the implementationdotnet quality-gates-- post-test analysis ensures tests are passing, coverage is sufficient, and performance is acceptable
If any gate fails, the build fails. The chain is enforced from requirement to quality-verified production code.
Five-Stage Generation Pipeline
The generator follows the CMF's existing multi-stage Roslyn pipeline:
Stage 0: Metamodel Registration
Register requirement base types (Epic, Feature<T>, Story<T>, Task<T>, Bug) as M3 MetaConcepts.
Stage 1: Requirement Collection
Walk compilation for types inheriting RequirementMetadata. Build the hierarchy graph. Validate constraints (no cycles, valid parent-child types).
Stage 2: Registry Generation
Generate RequirementRegistry.g.cs -- the type catalog with hierarchy, AC method names, and metadata.
Stage 3: Bridge Validators
Generate specification bridge classes (the UserRolesValidator that connects abstract Feature AC methods to ISpec methods).
Stage 4: Traceability Matrix
Cross-reference [ForRequirement], [TestsFor], [Verifies] attributes across all referenced assemblies. Generate TraceabilityMatrix.g.cs. Emit compiler diagnostics for coverage gaps.
Stage 5: Reports
Generate markdown hierarchy reports, JSON export for external tool integration (Jira, Linear, Azure DevOps), and CSV traceability matrices.
Requirement Lifecycle State Machine
All requirements support lifecycle tracking, automatically generated as a state machine:
Draft --> Proposed --> Approved --> InProgress --> Quality --> Translation --> Review --> DoneDraft --> Proposed --> Approved --> InProgress --> Quality --> Translation --> Review --> Donepublic enum RequirementLifecycleState : byte
{
Draft = 0,
Proposed = 1,
Approved = 2,
InProgress = 3,
Quality = 4,
Translation = 5,
Review = 6,
Done = 7
}
public class RequirementLifecycleStateMachine
{
public static bool CanTransition(RequirementLifecycleState from, RequirementLifecycleState to)
{
if (to > from) return to == from + 1; // Forward: one step at a time
if (to < from) return to == from - 1; // Backward: one step back allowed
return false;
}
}public enum RequirementLifecycleState : byte
{
Draft = 0,
Proposed = 1,
Approved = 2,
InProgress = 3,
Quality = 4,
Translation = 5,
Review = 6,
Done = 7
}
public class RequirementLifecycleStateMachine
{
public static bool CanTransition(RequirementLifecycleState from, RequirementLifecycleState to)
{
if (to > from) return to == from + 1; // Forward: one step at a time
if (to < from) return to == from - 1; // Backward: one step back allowed
return false;
}
}The state machine is a by-product of the meta-metamodel -- the M3 layer understands "requirements have a lifecycle," and generation creates the SM automatically.
Integration With Existing CMF DSLs
M3/M2/M1/M0 Mapping
| Level | In CMF | In Requirements Chain |
|---|---|---|
| M3 | MetaConcept, MetaProperty | RequirementMetadata, Feature<T>, Story<T> base types |
| M2 | [AggregateRoot], [Entity] |
UserRolesFeature, JwtRefreshStory -- concrete requirement types |
| M1 | Generated entities, repos, CQRS | RequirementRegistry.g.cs, TraceabilityMatrix.g.cs, diagnostics |
| M0 | Runtime instances | AuthorizationService processing actual User objects |
With DDD DSL
[ForRequirement(typeof(UserRolesFeature))]
[AggregateRoot]
public partial class AuthAggregate : IUserRolesSpec
{
[ForRequirement(typeof(UserRolesFeature), nameof(UserRolesFeature.AdminCanAssignRoles))]
public Result AssignRole(User actingUser, User targetUser, Role role)
{
// DDD generator produces Entity + Repository + CQRS handlers
// ALL tagged with [ForRequirement(typeof(UserRolesFeature))]
}
}[ForRequirement(typeof(UserRolesFeature))]
[AggregateRoot]
public partial class AuthAggregate : IUserRolesSpec
{
[ForRequirement(typeof(UserRolesFeature), nameof(UserRolesFeature.AdminCanAssignRoles))]
public Result AssignRole(User actingUser, User targetUser, Role role)
{
// DDD generator produces Entity + Repository + CQRS handlers
// ALL tagged with [ForRequirement(typeof(UserRolesFeature))]
}
}With Workflow DSL
[ForRequirement(typeof(UserRolesFeature))]
[Workflow(Name = "FeatureApproval", Stages = new[] { "Design", "Impl", "Test", "Review" })]
public partial class FeatureWorkflow { }
// Workflow rules can reference requirement compliance:
[WorkflowRule(Stage = "Test", Condition = "All ACs have [Verifies] tests")]
[WorkflowRule(Stage = "Review", Condition = "Test coverage >= 80%")][ForRequirement(typeof(UserRolesFeature))]
[Workflow(Name = "FeatureApproval", Stages = new[] { "Design", "Impl", "Test", "Review" })]
public partial class FeatureWorkflow { }
// Workflow rules can reference requirement compliance:
[WorkflowRule(Stage = "Test", Condition = "All ACs have [Verifies] tests")]
[WorkflowRule(Stage = "Review", Condition = "Test coverage >= 80%")]With Admin DSL
[ForRequirement(typeof(UserRolesFeature))]
[AdminModule(Name = "FeatureTracking", Path = "/admin/features")]
public partial class FeatureAdminPanel { }
// Generates: dashboard with compliance status, implementations per feature, test coverage[ForRequirement(typeof(UserRolesFeature))]
[AdminModule(Name = "FeatureTracking", Path = "/admin/features")]
public partial class FeatureAdminPanel { }
// Generates: dashboard with compliance status, implementations per feature, test coverageBefore and After
Before -- stringly-typed:
[Feature(Id = "FEATURE-456", AcceptanceCriteria = new[] { "Admin can assign roles" })]
public class UserRoleFeature { } // Empty marker class -- dead code
[Implements(Requirements.FEATURE_USER_ROLES)] // const string = "FEATURE-456"
public void AssignRole(User u, Role r) { }
[FeatureTest(Requirements.FEATURE_USER_ROLES, 0)] // AC[0] by fragile index
public void AdminCanAssign() { }[Feature(Id = "FEATURE-456", AcceptanceCriteria = new[] { "Admin can assign roles" })]
public class UserRoleFeature { } // Empty marker class -- dead code
[Implements(Requirements.FEATURE_USER_ROLES)] // const string = "FEATURE-456"
public void AssignRole(User u, Role r) { }
[FeatureTest(Requirements.FEATURE_USER_ROLES, 0)] // AC[0] by fragile index
public void AdminCanAssign() { }Problems: strings can be wrong, AC indices are fragile, no compiler check that implementation satisfies ACs, marker class is dead code.
After -- type-safe:
// Requirement IS a type with abstract AC methods
public abstract record UserRolesFeature : Feature<PlatformScalabilityEpic>
{
public abstract AcceptanceCriterionResult AdminCanAssignRoles(
UserId actingUser, UserId targetUser, RoleId role);
}
// Specification IS an interface the domain must implement
[ForRequirement(typeof(UserRolesFeature))]
public interface IUserRolesSpec
{
[ForRequirement(typeof(UserRolesFeature), nameof(UserRolesFeature.AdminCanAssignRoles))]
Result AssignRole(User actingUser, User targetUser, Role role);
}
// Implementation MUST implement the interface -- compiler enforces it
[ForRequirement(typeof(UserRolesFeature))]
public class AuthorizationService : IUserRolesSpec
{
public Result AssignRole(User actingUser, User targetUser, Role role) { ... }
// Remove this method --> compile error
}
// Test linked by type + nameof -- compiler-checked
[Verifies(typeof(UserRolesFeature), nameof(UserRolesFeature.AdminCanAssignRoles))]
public void Admin_can_assign_role() { ... }
// Rename AC method --> compile error. Delete AC method --> compile error.// Requirement IS a type with abstract AC methods
public abstract record UserRolesFeature : Feature<PlatformScalabilityEpic>
{
public abstract AcceptanceCriterionResult AdminCanAssignRoles(
UserId actingUser, UserId targetUser, RoleId role);
}
// Specification IS an interface the domain must implement
[ForRequirement(typeof(UserRolesFeature))]
public interface IUserRolesSpec
{
[ForRequirement(typeof(UserRolesFeature), nameof(UserRolesFeature.AdminCanAssignRoles))]
Result AssignRole(User actingUser, User targetUser, Role role);
}
// Implementation MUST implement the interface -- compiler enforces it
[ForRequirement(typeof(UserRolesFeature))]
public class AuthorizationService : IUserRolesSpec
{
public Result AssignRole(User actingUser, User targetUser, Role role) { ... }
// Remove this method --> compile error
}
// Test linked by type + nameof -- compiler-checked
[Verifies(typeof(UserRolesFeature), nameof(UserRolesFeature.AdminCanAssignRoles))]
public void Admin_can_assign_role() { ... }
// Rename AC method --> compile error. Delete AC method --> compile error.| Aspect | Before (strings) | After (types) |
|---|---|---|
| Requirement | "FEATURE-456" const string |
typeof(UserRolesFeature) |
| Acceptance criterion | new[] { "Admin can..." } |
abstract AcceptanceCriterionResult AdminCanAssignRoles(...) |
| Implementation link | [Implements("FEATURE-456")] |
: IUserRolesSpec -- compiler enforces |
| Test link | [FeatureTest("FEATURE-456", 0)] |
[Verifies(typeof(...), nameof(...AdminCanAssignRoles))] |
| Add new AC | Add string to array, hope | Add abstract method --> compile error everywhere |
| Rename | Find-replace, pray | Refactoring tools update all references |
Requirements Driven Development
Features Span DLLs, Processes, and Machines
In a real system, a Feature doesn't live in one project. "Order Processing" touches the API, the payment gateway, the notification worker, the event bus, and the test suite -- each compiled to a separate DLL, potentially running on different machines. The .Requirements project is the cross-cutting source of truth that all of these reference via <ProjectReference>:
MyCompany.sln
├── src/
│ ├── MyCompany.Requirements/ ← Every project references this
│ ├── MyCompany.OrderService/ ← [Implements] OrderProcessingSpec
│ ├── MyCompany.PaymentGateway/ ← [Implements] PaymentValidationSpec
│ ├── MyCompany.NotificationWorker/ ← [Implements] OrderNotificationSpec
│ └── MyCompany.SharedKernel/ ← Domain types shared across services
└── test/
├── MyCompany.OrderService.Tests/ ← [Verifies] OrderProcessing ACs
└── MyCompany.PaymentGateway.Tests/ ← [Verifies] PaymentValidation ACsMyCompany.sln
├── src/
│ ├── MyCompany.Requirements/ ← Every project references this
│ ├── MyCompany.OrderService/ ← [Implements] OrderProcessingSpec
│ ├── MyCompany.PaymentGateway/ ← [Implements] PaymentValidationSpec
│ ├── MyCompany.NotificationWorker/ ← [Implements] OrderNotificationSpec
│ └── MyCompany.SharedKernel/ ← Domain types shared across services
└── test/
├── MyCompany.OrderService.Tests/ ← [Verifies] OrderProcessing ACs
└── MyCompany.PaymentGateway.Tests/ ← [Verifies] PaymentValidation ACsEvery DLL knows which Features it implements. Every test knows which AC it verifies. The .Requirements project is the single artifact that ties them together -- across solution boundaries, across machines, across teams.
Acceptance Criteria Are Live Code
ACs are not documentation. They are static methods that validate business rules -- and they run in two places:
// In the AC definition (MyCompany.Requirements)
public abstract partial record OrderProcessingFeature : Feature
{
// This AC is an abstract method -- but its generated validation logic is concrete
public static bool OrderTotalMustBePositive(Order order)
=> order.Total > 0 && order.Lines.All(l => l.Quantity > 0);
}
// In production code (MyCompany.OrderService)
public class OrderService : IOrderProcessingSpec
{
public Result<Order, IDomainException> CreateOrder(CreateOrderCommand cmd)
{
var order = Order.CreateInstance(cmd);
// AC validation called in production -- same method the tests use
if (!OrderProcessingFeature.OrderTotalMustBePositive(order))
throw new InvalidOrderException("Order total must be positive");
return Result<Order, IDomainException>.Success(order);
}
}
// In unit tests (MyCompany.OrderService.Tests)
[Verifies(typeof(OrderProcessingFeature), nameof(OrderProcessingFeature.OrderTotalMustBePositive))]
public void Order_with_positive_total_passes_AC()
{
var order = CreateValidOrder();
Assert.True(OrderProcessingFeature.OrderTotalMustBePositive(order));
}// In the AC definition (MyCompany.Requirements)
public abstract partial record OrderProcessingFeature : Feature
{
// This AC is an abstract method -- but its generated validation logic is concrete
public static bool OrderTotalMustBePositive(Order order)
=> order.Total > 0 && order.Lines.All(l => l.Quantity > 0);
}
// In production code (MyCompany.OrderService)
public class OrderService : IOrderProcessingSpec
{
public Result<Order, IDomainException> CreateOrder(CreateOrderCommand cmd)
{
var order = Order.CreateInstance(cmd);
// AC validation called in production -- same method the tests use
if (!OrderProcessingFeature.OrderTotalMustBePositive(order))
throw new InvalidOrderException("Order total must be positive");
return Result<Order, IDomainException>.Success(order);
}
}
// In unit tests (MyCompany.OrderService.Tests)
[Verifies(typeof(OrderProcessingFeature), nameof(OrderProcessingFeature.OrderTotalMustBePositive))]
public void Order_with_positive_total_passes_AC()
{
var order = CreateValidOrder();
Assert.True(OrderProcessingFeature.OrderTotalMustBePositive(order));
}The same AC method is called in production to enforce the rule and in tests to verify it. One definition, two uses. This is what gives value to the entire process: ACs are compiled, tested, and running in production. They are not a checklist in Jira that someone forgets to update.
The Developer Sees the Full Scope
In a 50-project mono-repo, a developer working on OrderLine can Ctrl+Click through the entire chain:
- Test →
[Verifies(typeof(OrderProcessingFeature), nameof(OrderTotalMustBePositive))]→ click → lands on the AC method - AC method → defined on
OrderProcessingFeature→ click → lands on the Feature definition - Feature →
[Epic(typeof(ECommerceEpic))]→ click → lands on the Epic - Epic → see all Features, all ACs, all implementations, all tests
No Jira lookup. No "ask the PO." No context switching. The developer understands the full scope of what they're building, what's already implemented, and what's missing -- because it's all in the type system.
The .Requirements Project Is a Company Asset
This is the intersection of TDD (write the failing spec first), BDD (specs are behavior descriptions), and industrial software practice (traceability is mandatory in DO-178C, IEC 62304, ISO 26262). The .Requirements project is the masterpiece that holds the system together:
- It's compiled -- invalid requirements produce compiler errors
- It's tested -- every AC has a
[Verifies]test or the build warns - It's running in production -- AC methods enforce business rules at runtime
- It's versioned -- lifecycle states (Draft → Approved → InProgress → Done) are a generated state machine
- It's traceable -- the source generator produces compliance reports, traceability matrices, and coverage metrics
It deserves the same care as the domain model. It deserves code review, refactoring, and architectural attention. Because it IS the system specification -- not a document about the system, but the system describing itself.
Why This Matters
For developers: Requirements are types with IDE support. Ctrl+Click from a test to the AC it verifies, from the AC to its Feature, from the Feature to its Epic. In a solution with 20+ projects spanning multiple services, every developer can trace from their code to the business requirement it satisfies -- and back down to every test that proves it. ACs are not documentation: they are static methods called in unit tests AND in production code. When you write a test, you're proving an AC. When you read production code, you see which AC it enforces. No context switching. No Jira lookups. No "ask the PO."
For the compiler: Adding an AC to a Feature is adding an abstract method. The build breaks until the specification interface has the method, the domain implements it, and tests verify it. The compiler IS the project manager.
For QA: The traceability matrix is source-generated. Coverage analysis is type-based, not string-matching. Every untested AC is a compiler warning.
For ops: The production host validates compliance at startup. API endpoints are annotated with requirement metadata in the OpenAPI schema. Compliance mode enables runtime AC evaluation for audit trails.
For architects: Requirements are first-class modeling primitives (M2 on M3). They integrate with DDD, Workflow, Admin, Content, and Pages DSLs. The hierarchy is generic-constrained. The lifecycle is a generated state machine. Each layer compiles to a distinct DLL artifact with clear dependencies.
What's Next
This blog post describes the design of a type-safe requirements chain. The implementation follows the CMF's existing multi-stage pipeline:
- Requirements (
MyApp.Requirements.dll): Features as abstract records with AC methods, plus generated[ForRequirement]/[Verifies]attributes andRequirementRegistry - SharedKernel (
MyApp.SharedKernel.dll): Domain value types shared across all layers - Specifications (
MyApp.Specifications.dll): Interfaces decorated with[ForRequirement], plus validator bridges for compliance mode - Domain (
MyApp.Domain.dll): Implements spec interfaces -- compiler-enforced - Api (
MyApp.Api.dll): Production host with DI wiring, controllers with[ForRequirement], startup compliance check, OpenAPI enrichment - Tests (
MyApp.Tests.dll): Type-linked verification with[TestsFor]and[Verifies] - Analyzers: Traceability matrix, compiler diagnostics, markdown/JSON/CSV reports
Six DLLs. One compiler. Every link is a type reference. Requirements are not documents -- they are types, and the compiler enforces the chain from definition to production deployment to end-to-end verification. No strings. No indices. No prayer.