Before 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 |
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.