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 IX: Requirements DSL -- The Type-Safe Chain

The Problem: String-Based Requirement Tracking

In most enterprise projects, the chain from "what we decided to build" to "what we actually built" is held together by strings, conventions, and hope.

The Jira ID Approach

// Somewhere in a Jira ticket: FEAT-456 "User Roles and Permissions"
// Acceptance criteria:
//   1. Admin users can assign roles
//   2. Viewer users have read-only access
//   3. Role changes take effect immediately

// In the codebase:
public static class Requirements
{
    public const string FEATURE_USER_ROLES = "FEAT-456";
}

[Feature(Id = "FEAT-456", AcceptanceCriteria = new[]
{
    "Admin users can assign roles",
    "Viewer users have read-only access",
    "Role changes take effect immediately"
})]
public class UserRoleFeature { } // Marker class. Zero behavior. Dead code.

[Implements("FEAT-456")]
public void AssignRole(User u, Role r) { /* ... */ }

[FeatureTest("FEAT-456", 0)] // AC[0]? What if someone reorders the array?
public void AdminCanAssign() { /* ... */ }

Every link in this chain is fragile:

Problem Example Consequence
Typo in string ID "FEAT-456" vs "FEAT-465" Compiles. Silently points nowhere.
AC by index AC[0] becomes AC[1] when someone inserts an AC Test now verifies the wrong criterion.
Rename Jira ticket renamed, code not updated Stale reference. No one notices.
Delete AC removed from Jira, code still references it Ghost link. Coverage report lies.
No contract AssignRole claims to implement FEAT-456 Compiler cannot verify the claim.
No signature "Admin can assign roles" says nothing about inputs/outputs What is "admin"? A role? A claim? A bool?

The fundamental issue: requirements are metadata on marker classes, not types with behavior. The compiler cannot reason about them. The IDE cannot navigate them. The test runner cannot validate them.

The Magic String Approach

// Slightly better: const strings instead of inline literals
public static class Requirements
{
    public const string FEATURE_USER_ROLES = "FEAT-456";
    public const string AC_ADMIN_ASSIGN = "AdminCanAssignRoles";
}

[Implements(Requirements.FEATURE_USER_ROLES)]
public void AssignRole() { }

[FeatureTest(Requirements.FEATURE_USER_ROLES, Requirements.AC_ADMIN_ASSIGN)]
public void AdminCanAssign() { }

Better -- no typos. But still: the compiler does not know what FEATURE_USER_ROLES means. It does not know how many ACs the feature has. It cannot verify that AssignRole satisfies the AC_ADMIN_ASSIGN criterion. The const string is a label, not a contract.


The Solution: Requirements ARE Types

The Requirements DSL makes three shifts:

  1. A requirement is a type. Not a marker class, not an attribute, not a string. A C# abstract record with properties and abstract methods.
  2. An acceptance criterion is an abstract method. Its parameters define the inputs. Its return type (AcceptanceCriterionResult) enforces verifiability.
  3. A specification is an interface. The domain must implement it. The colon operator (: ISpec) IS the guarantee.

These three shifts convert the entire chain from string-based to type-based. The compiler enforces it. The IDE navigates it. The test runner validates it.


Base Types

RequirementMetadata

Every requirement in the system extends this base:

namespace MyApp.Requirements;

/// <summary>
/// Base record for all requirement types.
/// Provides metadata properties that every requirement must override.
/// </summary>
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 requirement hierarchy (Epic > Feature > Story > Task, Bug) is enforced by generic constraints:

namespace MyApp.Requirements;

/// <summary>
/// Strategic goal container. The top of the hierarchy.
/// </summary>
public abstract record Epic : RequirementMetadata;

/// <summary>
/// User-facing capability with acceptance criteria.
/// Must be parented under an Epic via generic constraint.
/// </summary>
public abstract record Feature<TParent> : RequirementMetadata
    where TParent : Epic;

/// <summary>
/// Root-level feature (no parent epic). Used for cross-cutting features.
/// </summary>
public abstract record Feature : RequirementMetadata;

/// <summary>
/// Implementable unit of work. Can be parented under any requirement.
/// </summary>
public abstract record Story<TParent> : RequirementMetadata
    where TParent : RequirementMetadata;

/// <summary>
/// Concrete implementation task with estimated hours.
/// </summary>
public abstract record Task<TParent> : RequirementMetadata
    where TParent : RequirementMetadata
{
    public abstract int EstimatedHours { get; }
}

/// <summary>
/// Defect report with severity. Independent of the hierarchy.
/// </summary>
public abstract record Bug : RequirementMetadata
{
    public abstract BugSeverity Severity { get; }
}

The generic constraint where TParent : Epic on Feature<TParent> means you cannot write Feature<JwtRefreshStory> -- the compiler rejects it. The hierarchy is structural, not conventional.

AcceptanceCriterionResult

Every acceptance criterion method returns this type:

namespace MyApp.Requirements;

/// <summary>
/// The return type for every acceptance criterion method.
/// Forces every AC to produce a verifiable result --
/// either satisfied or failed with a reason.
/// </summary>
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 need to reference domain concepts (users, roles, resources) without importing domain implementation details. These lightweight value types serve as the parameter vocabulary:

namespace MyApp.Requirements;

/// <summary>
/// Lightweight domain concepts for AC method signatures.
/// These carry just enough information for an AC to describe what it checks.
/// They do NOT import domain entities -- those live in SharedKernel.
/// </summary>
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);

These types exist in the Requirements project, which has zero dependencies. SharedKernel references Requirements to reuse these value types. This keeps the requirement signatures self-contained.


Full Concrete Example: UserRolesFeature

The Epic

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

The Feature with Three Acceptance Criteria

namespace MyApp.Requirements.Features;

/// <summary>
/// Feature: User roles and permissions.
///
/// Each abstract method IS an acceptance criterion.
/// The method name IS the AC identifier.
/// The parameters define the domain inputs the AC needs.
/// The return type enforces that every AC is verifiable.
///
/// There are no string IDs. The type IS the identifier.
/// typeof(UserRolesFeature) works everywhere:
///   in attributes, in generics, in reflection, in source generators.
/// </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);
}

A Story Under the Feature

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

A Bug

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:

  1. UserRolesFeature is a type. typeof(UserRolesFeature) is valid in attributes, generics, reflection, and source generators.
  2. Each AC is an abstract method with a typed signature. AdminCanAssignRoles(UserId, UserId, RoleId) documents exactly what the criterion needs.
  3. The generic constraint Feature<PlatformScalabilityEpic> creates a compile-time hierarchy. You cannot parent a Feature under a Story.
  4. Metadata (Title, Priority, Owner) are overridden properties, not string attributes on marker classes.
  5. No string IDs anywhere. The type IS the identifier.

Layer 1 Generates: Navigable Attributes and Registry

The Requirements project includes a Roslyn source generator that scans all types inheriting RequirementMetadata and produces three attribute types plus a registry. These generated artifacts are the glue consumed by every subsequent layer.

Generated: ForRequirementAttribute

// Generated: ForRequirementAttribute.g.cs
// Used by Specifications, Domain, and Api layers
// to link any type, interface, or method to a requirement.

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

Generated: VerifiesAttribute

// Generated: VerifiesAttribute.g.cs
// Used by test methods to link to a specific acceptance criterion.
// The AC is identified by nameof(Feature.ACMethod) -- compiler-checked.

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

Generated: TestsForAttribute

// Generated: TestsForAttribute.g.cs
// Used by test classes to link to a requirement type.

[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)]
public sealed class TestsForAttribute : Attribute
{
    public Type RequirementType { get; }

    public TestsForAttribute(Type requirementType)
        => RequirementType = requirementType;
}

Generated: RequirementRegistry

// Generated: RequirementRegistry.g.cs
// Produced by scanning all types inheriting RequirementMetadata.
// Available at runtime for compliance checks and traceability.

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

            [typeof(JwtRefreshStory)] = new(
                Type: typeof(JwtRefreshStory),
                Kind: RequirementKind.Story,
                Title: "Implement JWT refresh token flow",
                Parent: typeof(UserRolesFeature),
                AcceptanceCriteria: new[]
                {
                    nameof(JwtRefreshStory.TokensExpireAfterOneHour),
                    nameof(JwtRefreshStory.RefreshExtendsBySevenDays)
                }),

            // ... all other requirements discovered in compilation
        };
}

public record RequirementInfo(
    Type Type,
    RequirementKind Kind,
    string Title,
    Type? Parent,
    string[] AcceptanceCriteria);

public enum RequirementKind { Epic, Feature, Story, Task, Bug }

SharedKernel: Domain Types Shared Across Layers

Domain types live in their own project. Both Specifications and Domain reference it. This avoids duplication and keeps interfaces and implementations using the same User, Role, Result types.

namespace MyApp.SharedKernel;

using MyApp.Requirements;

// Domain types consume lightweight IDs from 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 gives every layer the same domain vocabulary without circular dependencies.


Layer 2: Specifications -- Contracts the Domain Must Satisfy

The Specifications project references MyApp.Requirements and MyApp.SharedKernel. It defines only interfaces -- no entities, no implementations, no business logic. Every spec interface and method is decorated with [ForRequirement] for IDE navigability.

Specification Interface: IUserRolesSpec

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 method.
///
/// Each method is linked to its acceptance criterion via
/// [ForRequirement(typeof(Feature), nameof(Feature.AC))].
/// In the IDE: right-click the typeof() --> Go To Definition -->
/// jumps to the abstract feature record.
/// </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);
}

Validator Bridge: AC Evaluation at Runtime

The validator bridge connects the abstract UserRolesFeature AC methods to the spec interface. It serves the compliance mode -- not the normal production path.

namespace MyApp.Specifications;

using MyApp.Requirements.Features;

/// <summary>
/// Bridges abstract AC methods on UserRolesFeature to IUserRolesSpec.
/// Used only in compliance mode -- normal production code calls
/// IUserRolesSpec directly.
/// </summary>
[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);

        // A viewer being denied write access IS the success condition
        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.
// This is what 99% of production code does.
public class RoleController
{
    private readonly IUserRolesSpec _spec;

    [HttpPost("users/{targetId}/role")]
    [ForRequirement(typeof(UserRolesFeature),
        nameof(UserRolesFeature.AdminCanAssignRoles))]
    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 -- evaluate ACs explicitly.
// Used for audit trails, regulatory compliance, SOC-2 evidence.
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;
    }
}

Layer 3: Implementation -- Compiler-Enforced Domain Code

The Domain project references MyApp.Specifications (and transitively, MyApp.Requirements and MyApp.SharedKernel). Every class and method is decorated with [ForRequirement] for full IDE navigability.

namespace MyApp.Domain.Auth;

using MyApp.Specifications;
using MyApp.Requirements.Features;
using MyApp.SharedKernel;

/// <summary>
/// Domain service implementing user roles.
///
/// The colon operator (: IUserRolesSpec) IS the guarantee.
/// The compiler FORCES this class to implement all three methods.
/// If a new AC is added to IUserRolesSpec, this class fails to 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");

        _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:

  1. AuthorizationService : IUserRolesSpec -- the compiler forces all three methods to exist with the correct signatures.
  2. [ForRequirement(typeof(UserRolesFeature))] is a type reference. Ctrl+Click navigates to the feature definition.
  3. Each method is linked to its specific AC via nameof(UserRolesFeature.AdminCanAssignRoles) -- compiler-checked.
  4. If someone adds AC4 to IUserRolesSpec, AuthorizationService fails to compile until AC4 is implemented. This is the enforcement mechanism.

Layer 4: Tests -- Type-Linked Verification

The Tests project references MyApp.Domain and MyApp.Specifications (and transitively, MyApp.Requirements). Test classes and methods link to requirements through type references -- no strings, no indices.

namespace MyApp.Tests.Auth;

using MyApp.Requirements.Features;
using MyApp.Domain.Auth;
using MyApp.SharedKernel;

[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", "Admin",
                new Email("alice@company.com")),
            "Alice",
            new HashSet<Role> { adminRole });
        _viewer = new User(
            new UserId(Guid.NewGuid(), "Bob", "Viewer",
                new Email("bob@company.com")),
            "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", "Target",
                new Email("charlie@company.com")),
            "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", "Target",
                new Email("charlie@company.com")),
            "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:

  1. [TestsFor(typeof(UserRolesFeature))] is a type reference. The source generator enumerates all tests for any feature.
  2. [Verifies(typeof(UserRolesFeature), nameof(UserRolesFeature.AdminCanAssignRoles))] -- the nameof() is compiler-checked. Rename the AC method: this updates. Delete the AC method: compile error.
  3. Multiple tests verify the same AC (positive and negative cases). The traceability matrix knows AdminCanAssignRoles has 2 tests.
  4. Tests call domain code through the specification interface -- the same interface the domain was forced to implement.

Layer 5: Production Host (MyApp.Api)

The API project references MyApp.Domain (and transitively, all other projects). It wires the DI container, exposes controllers, and optionally enables compliance validation at startup.

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: startup compliance check
builder.Services.AddHostedService<RequirementComplianceCheck>();

var app = builder.Build();
app.MapControllers();
app.Run();

Controller with ForRequirement

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 enriches the generated OpenAPI schema:

{
  "paths": {
    "/api/users/{targetId}/role": {
      "post": {
        "x-requirement": "UserRolesFeature",
        "x-acceptance-criterion": "AdminCanAssignRoles",
        "summary": "Assign a role to a user"
      }
    }
  }
}

Startup Compliance Check

namespace MyApp.Api;

public class RequirementComplianceCheck : IHostedService
{
    private readonly ILogger<RequirementComplianceCheck> _logger;

    public RequirementComplianceCheck(
        ILogger<RequirementComplianceCheck> logger)
        => _logger = 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;
}

Startup output:

info: MyApp.Api.RequirementComplianceCheck
      User roles and permissions: all 3 ACs covered
warn: MyApp.Api.RequirementComplianceCheck
      Implement JWT refresh token flow: 0/2 ACs tested
      (TokensExpireAfterOneHour, RefreshExtendsBySevenDays missing)

IDE Navigation Chain

"Find All References" on UserRolesFeature shows the entire traceability chain across all projects:

UserRolesFeature                          <-- Layer 1: requirement definition
|-- IUserRolesSpec                        <-- [ForRequirement(typeof(UserRolesFeature))]
|   |-- .AssignRole                       <-- [ForRequirement(..., nameof(...AdminCanAssignRoles))]
|   |-- .EnforceReadOnlyAccess            <-- [ForRequirement(..., nameof(...ViewerHasReadOnlyAccess))]
|   '-- .VerifyImmediateRoleEffect        <-- [ForRequirement(..., nameof(...RoleChangeTakesEffectImmediately))]
|-- UserRolesValidator                    <-- [ForRequirement(typeof(UserRolesFeature))]
|-- AuthorizationService                  <-- [ForRequirement(typeof(UserRolesFeature))]
|   |-- .AssignRole                       <-- [ForRequirement(..., nameof(...AdminCanAssignRoles))]
|   |-- .EnforceReadOnlyAccess            <-- [ForRequirement(..., nameof(...ViewerHasReadOnlyAccess))]
|   '-- .VerifyImmediateRoleEffect        <-- [ForRequirement(..., nameof(...RoleChangeTakesEffectImmediately))]
|-- UsersController                       <-- [ForRequirement(typeof(UserRolesFeature))]
|   '-- .AssignRole (action)              <-- [ForRequirement(..., nameof(...AdminCanAssignRoles))]
'-- UserRolesTests                        <-- [TestsFor(typeof(UserRolesFeature))]
    |-- .Admin_can_assign_role            <-- [Verifies(..., nameof(...AdminCanAssignRoles))]
    |-- .Non_admin_cannot_assign_roles    <-- [Verifies(..., nameof(...AdminCanAssignRoles))]
    |-- .Viewer_cannot_write              <-- [Verifies(..., nameof(...ViewerHasReadOnlyAccess))]
    |-- .Viewer_can_read                  <-- [Verifies(..., nameof(...ViewerHasReadOnlyAccess))]
    '-- .Role_change_invalidates_cache    <-- [Verifies(..., nameof(...RoleChangeTakesEffectImmediately))]

Every link is a typeof() or nameof() -- Ctrl+Click navigable, refactor-safe, compiler-checked.

Diagram

Project Reference DAG

The six projects form a directed acyclic graph:

Diagram
Project DLL Artifact Role References
Requirements MyApp.Requirements.dll Features as types, AC methods, generated attributes None
SharedKernel MyApp.SharedKernel.dll Domain value types (User, Role, Result) Requirements
Specifications MyApp.Specifications.dll Interfaces per feature, validator bridges Requirements, SharedKernel
Domain MyApp.Domain.dll Implements spec interfaces Specifications
Api MyApp.Api.dll Production host, DI, controllers Domain
Tests MyApp.Tests.dll Type-linked verification Domain, Specifications

DLL Artifacts Produced by dotnet build

bin/
├── MyApp.Requirements.dll           ← Feature types, AC methods, generated attributes
├── MyApp.SharedKernel.dll           ← User, Role, Permission, Result
├── MyApp.Specifications.dll         ← IUserRolesSpec, 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):
├── ForRequirementAttribute.g.cs     ← Generated attribute for traceability
├── VerifiesAttribute.g.cs           ← Generated attribute for test linking
├── TestsForAttribute.g.cs           ← Generated attribute for test classes
├── RequirementRegistry.g.cs         ← Type catalog + hierarchy
├── TraceabilityMatrix.g.cs          ← Requirement → Impl → Test mapping
└── Compiler diagnostics             ← IDE warnings for coverage gaps

Traceability Flow

Diagram

Before and After Comparison

Aspect Before (String-Based) After (Type-Based)
Requirement "FEAT-456" const string typeof(UserRolesFeature) -- a real type
Acceptance criterion new[] { "Admin can assign roles" } abstract AcceptanceCriterionResult AdminCanAssignRoles(UserId, UserId, RoleId)
AC signature Free-text description Typed parameters documenting exact inputs
Implementation link [Implements("FEAT-456")] : IUserRolesSpec -- compiler enforces
Test link [FeatureTest("FEAT-456", 0)] -- index-based [Verifies(typeof(...), nameof(...AdminCanAssignRoles))]
Add new AC Add string to array, hope Add abstract method -- compile error everywhere
Rename AC Find-replace, pray Refactoring tools update all nameof() references
Delete AC Remove string, stale references survive Remove method -- compile errors on every usage
IDE navigation None. Grep for string. Ctrl+Click typeof() jumps to definition
Traceability Manual spreadsheet Source-generated TraceabilityMatrix.g.cs
Coverage gaps Discovered in QA (or production) Compiler warnings at build time
Fuzz testing Manual edge-case identification Auto-generated from AC method signatures
Audit trail Runtime logging (if remembered) Validator bridge + structured audit records

The Bootstrap Question

This chapter describes a system for tracking requirements as types. The CMF itself has requirements (described in Part II as typed records). Once the CMF is built, it could track its own requirements using its own tooling:

  • DomainModelingEpic would be a real abstract record, compiled by the CMF's own generators
  • AggregateDefinitionFeature would produce IAggregateDefinitionSpec and a real AggregateDefinitionService
  • The CMF's own test suite would use [TestsFor(typeof(AggregateDefinitionFeature))]
  • The CMF's own CI would run dotnet quality-gates check against its own traceability matrix

This would be true self-hosting -- the framework using its own mechanisms to track and verify its own development. Part II uses the notation before the DSL exists (design validation). Self-hosting uses the running DSL to manage the next iteration of itself.

The bootstrap is not circular. It follows a well-known pattern: a compiler written in the language it compiles. Version N of the CMF is built with manual requirements. Version N+1 uses version N's Requirements DSL to track version N+1's requirements.


Summary

The Requirements DSL replaces every string-based link in the requirement chain with a type reference:

What String World Type World
Requirement Jira ticket abstract record : Feature<TEpic>
AC String in array abstract AcceptanceCriterionResult Method(typed params)
Specification Informal contract interface ISpec with [ForRequirement]
Implementation Self-declared : ISpec -- compiler verifies
Test Index-based [Verifies(typeof, nameof)]
Navigation Grep typeof() + Ctrl+Click
Coverage Manual TraceabilityMatrix.g.cs
Enforcement Hope Compiler errors + quality gates

Six projects. One compiler. Every link is a type reference. The chain is unbreakable.