Requirements: Templates vs Types
Requirements are the foundation. Everything downstream — specifications, implementations, tests, documentation — depends on requirements being correct, complete, and traceable. How each approach represents requirements determines everything else.
The Spec-Driven PRD Template
The cogeet-io framework provides a Product Requirements Document template with ~300 fields across 15 sections. Here's what defining a feature looks like:
DEFINE_FEATURE(user_roles_management)
description: "Allow administrators to assign and manage user roles"
user_story: "As an admin, I want to assign roles to users so that
access control is properly managed"
acceptance_criteria:
- "Admin can assign roles to users"
- "Viewer has read-only access to resources"
- "Role changes take effect immediately"
priority: Critical
complexity: ComplexDEFINE_FEATURE(user_roles_management)
description: "Allow administrators to assign and manage user roles"
user_story: "As an admin, I want to assign roles to users so that
access control is properly managed"
acceptance_criteria:
- "Admin can assign roles to users"
- "Viewer has read-only access to resources"
- "Role changes take effect immediately"
priority: Critical
complexity: ComplexThe feature is a text block with string fields. Acceptance criteria are a list of strings. Priority and complexity are enum-like strings. The template supports multiple features, each following the same structure.
The PRD also includes non-functional requirements:
PERFORMANCE_REQUIREMENTS:
max_response_time: "200ms"
request_throughput: "1000 rps"
concurrent_users: 500
data_volume: "10M records"
SECURITY_REQUIREMENTS:
authentication: ["JWT", "OAuth"]
authorization: ["RBAC"]
data_protection: ["AES-256", "TLS 1.3"]
compliance: ["GDPR", "SOC2"]PERFORMANCE_REQUIREMENTS:
max_response_time: "200ms"
request_throughput: "1000 rps"
concurrent_users: 500
data_volume: "10M records"
SECURITY_REQUIREMENTS:
authentication: ["JWT", "OAuth"]
authorization: ["RBAC"]
data_protection: ["AES-256", "TLS 1.3"]
compliance: ["GDPR", "SOC2"]Strengths of the Template Approach
Universally accessible. Anyone can fill in a text template — product owners, designers, QA engineers, developers. No programming language knowledge required.
Tool-agnostic. The PRD doesn't care if you use C#, Python, Rust, or Go. The same template works for any technology stack.
Comprehensive prompting. The template reminds you to think about performance, security, scalability, deployment, monitoring, and operations. These sections exist even if you skip them — they're visible placeholders that prompt completeness.
AI-readable. An AI agent can parse the template, extract features and ACs, and use them directly as implementation context.
Weaknesses of the Template Approach
Strings are unchecked.
"Admin can assign roles to users"is a human-readable sentence, not a verifiable specification. What does "assign" mean? What inputs does it take? What does success look like? The AC tells you what to build in English — not what to verify in code.No structural link to code. The PRD says
priority: Criticalbut nothing in the codebase references this priority. A developer can ignore it. ACriticalfeature can ship without tests. The priority is metadata that exists only in the document.Fragile identifiers. Features are identified by names like
user_roles_management. If someone renames it torbac_managementin the PRD, every downstream reference (in specifications, tests, documentation) must be manually updated. There's no tooling to catch missed references.No hierarchy enforcement. The template supports features, but there's no structural relationship between epics, features, stories, and tasks. You can define a feature with no epic. You can define a story that references a nonexistent feature. The template doesn't validate hierarchies.
Typed Specification Requirements
The typed approach represents the same feature as compiled C# code:
namespace MyApp.Requirements.Epics;
public abstract record PlatformScalabilityEpic : Epic
{
public override string Title => "Platform scalability and access control";
public override RequirementPriority Priority => RequirementPriority.Critical;
public override string Owner => "Platform Team";
}namespace MyApp.Requirements.Epics;
public abstract record PlatformScalabilityEpic : Epic
{
public override string Title => "Platform scalability and access control";
public override RequirementPriority Priority => RequirementPriority.Critical;
public override string Owner => "Platform Team";
}namespace MyApp.Requirements.Features;
public abstract record UserRolesFeature : Feature<PlatformScalabilityEpic>
{
public override string Title => "User roles and permissions management";
public override RequirementPriority Priority => RequirementPriority.Critical;
public override string Owner => "Platform Team";
/// <summary>
/// An administrator with the ManageRoles permission can assign any available
/// role to any user. The assignment must be persisted and the user's effective
/// permissions must reflect the new role.
/// </summary>
public abstract AcceptanceCriterionResult
AdminCanAssignRoles(UserId actingUser, UserId targetUser, RoleId role);
/// <summary>
/// A user with the Viewer role can read all resources in their assigned scope
/// but cannot create, modify, or delete any resource. Attempts to modify must
/// return an authorization failure.
/// </summary>
public abstract AcceptanceCriterionResult
ViewerHasReadOnlyAccess(UserId viewer, ResourceId resource);
/// <summary>
/// When a user's role is changed, the new permissions take effect on the next
/// request without requiring re-authentication. In-flight requests under the
/// old role may complete, but no new request should use stale permissions.
/// </summary>
public abstract AcceptanceCriterionResult
RoleChangeTakesEffectImmediately(UserId user, RoleId previousRole, RoleId newRole);
}namespace MyApp.Requirements.Features;
public abstract record UserRolesFeature : Feature<PlatformScalabilityEpic>
{
public override string Title => "User roles and permissions management";
public override RequirementPriority Priority => RequirementPriority.Critical;
public override string Owner => "Platform Team";
/// <summary>
/// An administrator with the ManageRoles permission can assign any available
/// role to any user. The assignment must be persisted and the user's effective
/// permissions must reflect the new role.
/// </summary>
public abstract AcceptanceCriterionResult
AdminCanAssignRoles(UserId actingUser, UserId targetUser, RoleId role);
/// <summary>
/// A user with the Viewer role can read all resources in their assigned scope
/// but cannot create, modify, or delete any resource. Attempts to modify must
/// return an authorization failure.
/// </summary>
public abstract AcceptanceCriterionResult
ViewerHasReadOnlyAccess(UserId viewer, ResourceId resource);
/// <summary>
/// When a user's role is changed, the new permissions take effect on the next
/// request without requiring re-authentication. In-flight requests under the
/// old role may complete, but no new request should use stale permissions.
/// </summary>
public abstract AcceptanceCriterionResult
RoleChangeTakesEffectImmediately(UserId user, RoleId previousRole, RoleId newRole);
}What Each Element Does
abstract record UserRolesFeature — The feature is a type. It can be referenced by typeof(UserRolesFeature) anywhere in the codebase. Renaming it renames all references. Deleting it produces compile errors wherever it's used.
: Feature<PlatformScalabilityEpic> — The hierarchy is enforced by generics. Feature<T> requires T : Epic. You cannot write Feature<SomeStory> — it won't compile. The parent-child relationship is structural, not textual.
abstract AcceptanceCriterionResult AdminCanAssignRoles(UserId actingUser, UserId targetUser, RoleId role) — The AC is an abstract method. Its parameters define the domain inputs. UserId is a value type from SharedKernel, not a string. The method signature IS the specification — it says "this AC takes an acting user, a target user, and a role, and returns a satisfied/failed result."
XML documentation — The human-readable description lives in /// <summary> comments. This is standard C# documentation that IDEs display on hover and build tools can extract.
Strengths of Typed Requirements
Compiler-checked references. Every reference to a feature uses
typeof(). Every reference to an AC usesnameof(). Typos are compile errors. Renames propagate automatically.Hierarchy enforcement.
Feature<SomeStory>won't compile.Task<SomeTask>won't compile. The generic constraintwhere TParent : Epicis structural — not a naming convention.AC parameters define the contract.
AdminCanAssignRoles(UserId actingUser, UserId targetUser, RoleId role)is not just a description — it's a method signature that the specification interface must match. The spec cannot "forget" an input.IDE navigation. Ctrl+Click on
UserRolesFeaturejumps to the definition. Find All References shows every spec, every implementation, every test that references it. The IDE knows the full dependency graph.Refactoring. Rename
AdminCanAssignRolestoAdministratorCanAssignRoles? The IDE updates everynameof(UserRolesFeature.AdminCanAssignRoles)across all projects automatically.
Weaknesses of Typed Requirements
C# only. The requirement definitions are C# code. Product owners who don't read C# cannot directly author or review requirements. They need a developer as intermediary.
Verbose for simple features. A one-line AC like "user can log in" becomes:
public abstract AcceptanceCriterionResult UserCanLogInWithValidCredentials(Email email, Password password);public abstract AcceptanceCriterionResult UserCanLogInWithValidCredentials(Email email, Password password);That's more ceremony than a string in a PRD template. For simple features, the overhead feels disproportionate.
Compile dependency. Changing a requirement means changing a C# file, which means recompiling. In a large mono-repo, this triggers cascading rebuilds. The spec-driven approach — changing a text file — has zero compilation cost.
Perceived tooling investment. The typed approach requires source generators and Roslyn analyzers. But here's the thing: the moment you want to extract value from specification documents — validate them, cross-reference them, generate code from them, enforce them — you're building custom tooling too. The spec-driven approach starts at "zero tooling" and stays there only as long as the specifications are read-only documents. The typed approach invests upfront, but the result IS C# — the same language your team already knows, the same IDE they already use, the same compiler they already trust. The
Requirements.Dslis not a separate tool to learn; it's a C# library that makes C# itself the product owner's DSL.
The Requirement Lifecycle: A Side-by-Side Walk-Through
Let's trace both approaches through the lifecycle of a requirement: creation, implementation, testing, change, and deletion.
Step 1: Creating a New Requirement
Spec-driven:
# In Product-Requirements-Document.txt
DEFINE_FEATURE(password_reset)
description: "Allow users to reset their password via email"
user_story: "As a user, I want to reset my password so that
I can regain access to my account"
acceptance_criteria:
- "User can request a password reset email"
- "Reset link expires after 24 hours"
- "New password must meet complexity requirements"
priority: High
complexity: Medium# In Product-Requirements-Document.txt
DEFINE_FEATURE(password_reset)
description: "Allow users to reset their password via email"
user_story: "As a user, I want to reset my password so that
I can regain access to my account"
acceptance_criteria:
- "User can request a password reset email"
- "Reset link expires after 24 hours"
- "New password must meet complexity requirements"
priority: High
complexity: MediumDone. One text block. Anyone can write it. No compilation needed.
Typed specifications:
// In MyApp.Requirements/Features/PasswordResetFeature.cs
public abstract record PasswordResetFeature : Feature<UserManagementEpic>
{
public override string Title => "Password reset via email";
public override RequirementPriority Priority => RequirementPriority.High;
public override string Owner => "Identity Team";
public abstract AcceptanceCriterionResult
UserCanRequestPasswordResetEmail(Email userEmail);
public abstract AcceptanceCriterionResult
ResetLinkExpiresAfter24Hours(TokenId resetToken, DateTime requestedAt);
public abstract AcceptanceCriterionResult
NewPasswordMeetsComplexityRequirements(Password newPassword);
}// In MyApp.Requirements/Features/PasswordResetFeature.cs
public abstract record PasswordResetFeature : Feature<UserManagementEpic>
{
public override string Title => "Password reset via email";
public override RequirementPriority Priority => RequirementPriority.High;
public override string Owner => "Identity Team";
public abstract AcceptanceCriterionResult
UserCanRequestPasswordResetEmail(Email userEmail);
public abstract AcceptanceCriterionResult
ResetLinkExpiresAfter24Hours(TokenId resetToken, DateTime requestedAt);
public abstract AcceptanceCriterionResult
NewPasswordMeetsComplexityRequirements(Password newPassword);
}More verbose. But the moment this compiles, the Roslyn analyzer fires:
error REQ100: PasswordResetFeature has 3 acceptance criteria but no ISpec
interface references it via [ForRequirement(typeof(PasswordResetFeature))]error REQ100: PasswordResetFeature has 3 acceptance criteria but no ISpec
interface references it via [ForRequirement(typeof(PasswordResetFeature))]The compiler immediately tells you what's missing. The spec-driven approach waits until a human or CI script checks.
Step 2: Implementing the Requirement
Spec-driven: The AI agent reads the PRD, extracts the feature definition, and generates implementation code. The quality gate checks code coverage and test existence after the fact. If the agent misses an AC, you find out when the coverage report runs — or when QA catches it.
Typed specifications: The developer (or AI agent) creates the specification interface:
[ForRequirement(typeof(PasswordResetFeature))]
public interface IPasswordResetSpec
{
[ForRequirement(typeof(PasswordResetFeature),
nameof(PasswordResetFeature.UserCanRequestPasswordResetEmail))]
Result<ResetToken, DomainException> RequestPasswordReset(Email userEmail);
[ForRequirement(typeof(PasswordResetFeature),
nameof(PasswordResetFeature.ResetLinkExpiresAfter24Hours))]
Result ValidateResetToken(TokenId token);
[ForRequirement(typeof(PasswordResetFeature),
nameof(PasswordResetFeature.NewPasswordMeetsComplexityRequirements))]
Result ResetPassword(TokenId token, Password newPassword);
}[ForRequirement(typeof(PasswordResetFeature))]
public interface IPasswordResetSpec
{
[ForRequirement(typeof(PasswordResetFeature),
nameof(PasswordResetFeature.UserCanRequestPasswordResetEmail))]
Result<ResetToken, DomainException> RequestPasswordReset(Email userEmail);
[ForRequirement(typeof(PasswordResetFeature),
nameof(PasswordResetFeature.ResetLinkExpiresAfter24Hours))]
Result ValidateResetToken(TokenId token);
[ForRequirement(typeof(PasswordResetFeature),
nameof(PasswordResetFeature.NewPasswordMeetsComplexityRequirements))]
Result ResetPassword(TokenId token, Password newPassword);
}Then implements it:
[ForRequirement(typeof(PasswordResetFeature))]
public class PasswordResetService : IPasswordResetSpec
{
public Result<ResetToken, DomainException> RequestPasswordReset(Email userEmail)
{
var user = _userRepository.FindByEmail(userEmail);
if (user is null)
return Result<ResetToken, DomainException>.Failure(
new UserNotFoundException(userEmail));
var token = ResetToken.Generate(user.Id, TimeSpan.FromHours(24));
_tokenStore.Save(token);
_emailService.SendResetLink(userEmail, token);
return Result<ResetToken, DomainException>.Success(token);
}
public Result ValidateResetToken(TokenId token)
{
var stored = _tokenStore.Find(token);
if (stored is null)
return Result.Failure("Token not found");
if (stored.IsExpired(DateTime.UtcNow))
return Result.Failure("Token expired");
return Result.Success();
}
public Result ResetPassword(TokenId token, Password newPassword)
{
var validation = ValidateResetToken(token);
if (!validation.IsSuccess) return validation;
if (!newPassword.MeetsComplexityRequirements())
return Result.Failure("Password does not meet complexity requirements");
var stored = _tokenStore.Find(token)!;
var user = _userRepository.FindById(stored.UserId);
user.ChangePassword(newPassword);
_tokenStore.Invalidate(token);
return Result.Success();
}
}[ForRequirement(typeof(PasswordResetFeature))]
public class PasswordResetService : IPasswordResetSpec
{
public Result<ResetToken, DomainException> RequestPasswordReset(Email userEmail)
{
var user = _userRepository.FindByEmail(userEmail);
if (user is null)
return Result<ResetToken, DomainException>.Failure(
new UserNotFoundException(userEmail));
var token = ResetToken.Generate(user.Id, TimeSpan.FromHours(24));
_tokenStore.Save(token);
_emailService.SendResetLink(userEmail, token);
return Result<ResetToken, DomainException>.Success(token);
}
public Result ValidateResetToken(TokenId token)
{
var stored = _tokenStore.Find(token);
if (stored is null)
return Result.Failure("Token not found");
if (stored.IsExpired(DateTime.UtcNow))
return Result.Failure("Token expired");
return Result.Success();
}
public Result ResetPassword(TokenId token, Password newPassword)
{
var validation = ValidateResetToken(token);
if (!validation.IsSuccess) return validation;
if (!newPassword.MeetsComplexityRequirements())
return Result.Failure("Password does not meet complexity requirements");
var stored = _tokenStore.Find(token)!;
var user = _userRepository.FindById(stored.UserId);
user.ChangePassword(newPassword);
_tokenStore.Invalidate(token);
return Result.Success();
}
}If the developer forgets to implement ResetPassword, the compiler says:
error CS0535: 'PasswordResetService' does not implement interface member
'IPasswordResetSpec.ResetPassword(TokenId, Password)'error CS0535: 'PasswordResetService' does not implement interface member
'IPasswordResetSpec.ResetPassword(TokenId, Password)'This is a standard C# compiler error — not a custom tool. The interface forces completeness.
Step 3: Testing the Requirement
Spec-driven: The Testing-as-Code spec tells you to write unit tests, integration tests, and E2E tests. It specifies coverage thresholds (80% line, 75% branch, 90% function). The CI pipeline runs the tests and checks coverage. But the connection between "test X" and "AC Y" is implicit — based on naming conventions or manual documentation.
Typed specifications:
[TestsFor(typeof(PasswordResetFeature))]
public class PasswordResetFeatureTests
{
[Verifies(typeof(PasswordResetFeature),
nameof(PasswordResetFeature.UserCanRequestPasswordResetEmail))]
public void Valid_email_sends_reset_link()
{
var email = new Email("user@example.com");
var result = _service.RequestPasswordReset(email);
Assert.That(result.IsSuccess, Is.True);
Assert.That(_emailService.SentEmails, Has.Count.EqualTo(1));
Assert.That(_emailService.SentEmails[0].To, Is.EqualTo(email));
}
[Verifies(typeof(PasswordResetFeature),
nameof(PasswordResetFeature.ResetLinkExpiresAfter24Hours))]
public void Token_expired_after_24_hours_is_rejected()
{
var token = ResetToken.Generate(TestUsers.Alice.Id, TimeSpan.FromHours(24));
_clock.AdvanceBy(TimeSpan.FromHours(25));
var result = _service.ValidateResetToken(token.Id);
Assert.That(result.IsSuccess, Is.False);
Assert.That(result.Error, Does.Contain("expired"));
}
[Verifies(typeof(PasswordResetFeature),
nameof(PasswordResetFeature.NewPasswordMeetsComplexityRequirements))]
public void Weak_password_is_rejected()
{
var token = CreateValidToken();
var weakPassword = new Password("123");
var result = _service.ResetPassword(token.Id, weakPassword);
Assert.That(result.IsSuccess, Is.False);
}
}[TestsFor(typeof(PasswordResetFeature))]
public class PasswordResetFeatureTests
{
[Verifies(typeof(PasswordResetFeature),
nameof(PasswordResetFeature.UserCanRequestPasswordResetEmail))]
public void Valid_email_sends_reset_link()
{
var email = new Email("user@example.com");
var result = _service.RequestPasswordReset(email);
Assert.That(result.IsSuccess, Is.True);
Assert.That(_emailService.SentEmails, Has.Count.EqualTo(1));
Assert.That(_emailService.SentEmails[0].To, Is.EqualTo(email));
}
[Verifies(typeof(PasswordResetFeature),
nameof(PasswordResetFeature.ResetLinkExpiresAfter24Hours))]
public void Token_expired_after_24_hours_is_rejected()
{
var token = ResetToken.Generate(TestUsers.Alice.Id, TimeSpan.FromHours(24));
_clock.AdvanceBy(TimeSpan.FromHours(25));
var result = _service.ValidateResetToken(token.Id);
Assert.That(result.IsSuccess, Is.False);
Assert.That(result.Error, Does.Contain("expired"));
}
[Verifies(typeof(PasswordResetFeature),
nameof(PasswordResetFeature.NewPasswordMeetsComplexityRequirements))]
public void Weak_password_is_rejected()
{
var token = CreateValidToken();
var weakPassword = new Password("123");
var result = _service.ResetPassword(token.Id, weakPassword);
Assert.That(result.IsSuccess, Is.False);
}
}The connection between tests and ACs is explicit, type-safe, and compiler-checked. If you rename ResetLinkExpiresAfter24Hours, the nameof() reference updates automatically.
Step 4: Changing a Requirement
This is where the difference becomes dramatic.
Spec-driven:
# Change: Add a new AC — "Reset link can only be used once"
# In Product-Requirements-Document.txt
DEFINE_FEATURE(password_reset)
acceptance_criteria:
- "User can request a password reset email"
- "Reset link expires after 24 hours"
- "New password must meet complexity requirements"
- "Reset link can only be used once" # <-- Added# Change: Add a new AC — "Reset link can only be used once"
# In Product-Requirements-Document.txt
DEFINE_FEATURE(password_reset)
acceptance_criteria:
- "User can request a password reset email"
- "Reset link expires after 24 hours"
- "New password must meet complexity requirements"
- "Reset link can only be used once" # <-- AddedDone. The document is updated. But:
- Is the implementation updated? Unknown until someone checks.
- Are there tests for the new AC? Unknown until coverage runs.
- Did the AI agent notice the new AC? Only if it re-reads the PRD.
The gap between document and code is silent.
Typed specifications:
// Add a new AC method to the feature
public abstract record PasswordResetFeature : Feature<UserManagementEpic>
{
// ... existing ACs ...
public abstract AcceptanceCriterionResult
ResetLinkCanOnlyBeUsedOnce(TokenId resetToken);
}// Add a new AC method to the feature
public abstract record PasswordResetFeature : Feature<UserManagementEpic>
{
// ... existing ACs ...
public abstract AcceptanceCriterionResult
ResetLinkCanOnlyBeUsedOnce(TokenId resetToken);
}The moment this compiles:
error REQ101: PasswordResetFeature.ResetLinkCanOnlyBeUsedOnce has no matching
spec method with [ForRequirement(typeof(PasswordResetFeature),
nameof(PasswordResetFeature.ResetLinkCanOnlyBeUsedOnce))]
warning REQ301: PasswordResetFeature.ResetLinkCanOnlyBeUsedOnce has no test
with [Verifies(typeof(PasswordResetFeature),
nameof(PasswordResetFeature.ResetLinkCanOnlyBeUsedOnce))]error REQ101: PasswordResetFeature.ResetLinkCanOnlyBeUsedOnce has no matching
spec method with [ForRequirement(typeof(PasswordResetFeature),
nameof(PasswordResetFeature.ResetLinkCanOnlyBeUsedOnce))]
warning REQ301: PasswordResetFeature.ResetLinkCanOnlyBeUsedOnce has no test
with [Verifies(typeof(PasswordResetFeature),
nameof(PasswordResetFeature.ResetLinkCanOnlyBeUsedOnce))]The compiler tells you exactly what's missing. The implementation cannot be "done" until the spec method exists, the domain implements it, and a test verifies it. The gap between requirement and code is impossible.
Step 5: Deleting a Requirement
Spec-driven: Delete the AC line from the PRD. But the implementation code, the tests, and the documentation for that AC remain. They become dead code and stale documentation — invisible until someone manually audits.
Typed specifications: Delete the abstract method from the feature record. Instantly:
- The spec interface method with
[ForRequirement(..., nameof(DeletedAC))]produces a compile error (thenameoftarget doesn't exist) - The implementation of the deleted spec method is an orphan interface member (compile error)
- The test with
[Verifies(..., nameof(DeletedAC))]produces a compile error - The analyzer fires
REQ302: stale [Verifies] reference
Dead code and stale tests are impossible. The compiler won't let them survive.
The Traceability Question
Both approaches claim traceability. But the depth is radically different.
Spec-driven traceability: A coverage report that says "Feature X has 80% test coverage." But which ACs are covered? Which aren't? The report counts lines, not acceptance criteria.
Typed specification traceability: A source-generated traceability matrix that says:
┌────────────────────────┬──────────────────┬──────────────────┬──────────────┐
│ Feature │ Acceptance │ Specification │ Tests │
│ │ Criterion │ │ │
├────────────────────────┼──────────────────┼──────────────────┼──────────────┤
│ UserRolesFeature │ AdminCanAssign │ IUserRolesSpec │ 3 tests ✓ │
│ │ ViewerReadOnly │ IUserRolesSpec │ 2 tests ✓ │
│ │ RoleImmediate │ IUserRolesSpec │ 1 test ✓ │
├────────────────────────┼──────────────────┼──────────────────┼──────────────┤
│ PasswordResetFeature │ RequestEmail │ IPasswordSpec │ 2 tests ✓ │
│ │ LinkExpires24h │ IPasswordSpec │ 1 test ✓ │
│ │ ComplexPassword │ IPasswordSpec │ 1 test ✓ │
│ │ UsedOnce │ ⚠ NO SPEC │ ⚠ NO TEST │
└────────────────────────┴──────────────────┴──────────────────┴──────────────┘┌────────────────────────┬──────────────────┬──────────────────┬──────────────┐
│ Feature │ Acceptance │ Specification │ Tests │
│ │ Criterion │ │ │
├────────────────────────┼──────────────────┼──────────────────┼──────────────┤
│ UserRolesFeature │ AdminCanAssign │ IUserRolesSpec │ 3 tests ✓ │
│ │ ViewerReadOnly │ IUserRolesSpec │ 2 tests ✓ │
│ │ RoleImmediate │ IUserRolesSpec │ 1 test ✓ │
├────────────────────────┼──────────────────┼──────────────────┼──────────────┤
│ PasswordResetFeature │ RequestEmail │ IPasswordSpec │ 2 tests ✓ │
│ │ LinkExpires24h │ IPasswordSpec │ 1 test ✓ │
│ │ ComplexPassword │ IPasswordSpec │ 1 test ✓ │
│ │ UsedOnce │ ⚠ NO SPEC │ ⚠ NO TEST │
└────────────────────────┴──────────────────┴──────────────────┴──────────────┘This matrix is generated at compile time from the type references. It cannot be wrong because it reads the same types the compiler checks. It's not a report about the code — it IS the code, rendered as a table.
Summary: Templates vs Types
| Aspect | PRD Template | Typed Requirements |
|---|---|---|
| Authoring | Anyone (text editor) | Developers (C# IDE) |
| Verification | Human review + CI scripts | Compiler type-checking |
| AC precision | English sentences | Method signatures with typed parameters |
| Hierarchy | Implicit (document structure) | Enforced (generic constraints) |
| Refactoring | Manual find-replace | IDE propagation via typeof/nameof |
| Change detection | Manual diff review | Compiler errors on missing implementations |
| Dead code | Possible (silent) | Impossible (compile errors) |
| Traceability | Coverage percentages | Per-AC type-linked matrix |
| Language lock-in | None | C# (or similar typed language) |
| Learning curve | Minimal | Significant |
Part IV examines how each approach handles the context engineering problem — the core of what makes AI agents succeed or fail.
The Acceptance Criteria Precision Spectrum
Not all acceptance criteria are created equal. There is a spectrum of precision, from the vaguest informal description to the most precise machine-verifiable specification. Understanding where each approach sits on this spectrum clarifies what you gain and what you lose.
Level 0: Informal (Jira Ticket Comment)
"Users should be able to reset their password somehow.""Users should be able to reset their password somehow."No structure. No inputs. No outputs. No success/failure definition. The developer reads this and interprets it. Two developers will interpret it differently. An AI agent will guess.
Precision score: ~10%. The AC communicates intent but nothing else.
Level 1: Structured Text (User Story Format)
As a registered user,
I want to reset my password via email,
So that I can regain access to my account when I forget my credentials.
Acceptance Criteria:
- User can request a password reset email
- Reset link expires after 24 hours
- New password must meet complexity requirementsAs a registered user,
I want to reset my password via email,
So that I can regain access to my account when I forget my credentials.
Acceptance Criteria:
- User can request a password reset email
- Reset link expires after 24 hours
- New password must meet complexity requirementsStandard user story format. Clear who, what, why. ACs are bullet points. Better than Level 0 — but "request a password reset email" doesn't say what inputs are needed, what the success response looks like, or what happens when the email doesn't exist.
Precision score: ~30%. The AC communicates intent and scope.
Level 2: Parameterized Template (Spec-Driven PRD)
DEFINE_FEATURE(password_reset)
description: "Allow users to reset their password via email"
acceptance_criteria:
- id: ac_request_email
given: "A registered user with a valid email"
when: "The user requests a password reset"
then: "A reset email is sent within 30 seconds"
- id: ac_link_expires
given: "A password reset link was generated"
when: "24 hours have passed since generation"
then: "The link returns an expiration error"
- id: ac_complexity
given: "A user with a valid reset token"
when: "The user submits a new password"
then: "The password is accepted only if it meets complexity rules"
priority: High
complexity: MediumDEFINE_FEATURE(password_reset)
description: "Allow users to reset their password via email"
acceptance_criteria:
- id: ac_request_email
given: "A registered user with a valid email"
when: "The user requests a password reset"
then: "A reset email is sent within 30 seconds"
- id: ac_link_expires
given: "A password reset link was generated"
when: "24 hours have passed since generation"
then: "The link returns an expiration error"
- id: ac_complexity
given: "A user with a valid reset token"
when: "The user submits a new password"
then: "The password is accepted only if it meets complexity rules"
priority: High
complexity: MediumGiven/When/Then format with IDs. This is where the spec-driven approach sits. The ACs are structured, identifiable, and testable in principle. But "a registered user with a valid email" is still a string — what type is the email? What does "registered" mean in code? What does "valid" mean?
Precision score: ~50%. The AC communicates intent, scope, and test structure.
Level 3: Typed Signature (Typed Specification Feature Record)
public abstract record PasswordResetFeature : Feature<UserManagementEpic>
{
/// <summary>
/// A registered user with a verified email address can request a
/// password reset. The system generates a time-limited token and
/// sends a reset link to the registered email. The email must be
/// sent within 30 seconds of the request.
/// </summary>
public abstract AcceptanceCriterionResult
UserCanRequestPasswordResetEmail(Email userEmail);
/// <summary>
/// A reset token expires exactly 24 hours after generation.
/// Any attempt to use an expired token returns a typed
/// TokenExpiredException, not a generic error.
/// </summary>
public abstract AcceptanceCriterionResult
ResetLinkExpiresAfter24Hours(TokenId resetToken, DateTime requestedAt);
/// <summary>
/// A new password must have: >= 12 characters, >= 1 uppercase,
/// >= 1 lowercase, >= 1 digit, >= 1 special character. Passwords
/// matching any of the last 5 passwords are rejected.
/// </summary>
public abstract AcceptanceCriterionResult
NewPasswordMeetsComplexityRequirements(Password newPassword);
}public abstract record PasswordResetFeature : Feature<UserManagementEpic>
{
/// <summary>
/// A registered user with a verified email address can request a
/// password reset. The system generates a time-limited token and
/// sends a reset link to the registered email. The email must be
/// sent within 30 seconds of the request.
/// </summary>
public abstract AcceptanceCriterionResult
UserCanRequestPasswordResetEmail(Email userEmail);
/// <summary>
/// A reset token expires exactly 24 hours after generation.
/// Any attempt to use an expired token returns a typed
/// TokenExpiredException, not a generic error.
/// </summary>
public abstract AcceptanceCriterionResult
ResetLinkExpiresAfter24Hours(TokenId resetToken, DateTime requestedAt);
/// <summary>
/// A new password must have: >= 12 characters, >= 1 uppercase,
/// >= 1 lowercase, >= 1 digit, >= 1 special character. Passwords
/// matching any of the last 5 passwords are rejected.
/// </summary>
public abstract AcceptanceCriterionResult
NewPasswordMeetsComplexityRequirements(Password newPassword);
}This is where the typed specification approach sits. The AC is a method signature with typed parameters. Email is a value object — not a string. TokenId is a strongly typed identifier. DateTime is unambiguous. The method name is the AC name. The XML doc is the human-readable description.
Precision score: ~75%. The AC communicates intent, scope, test structure, input types, and output type.
Level 4: Executable Specification (Typed Spec + Interface + Invariants)
// The requirement (what)
public abstract record PasswordResetFeature : Feature<UserManagementEpic>
{
public abstract AcceptanceCriterionResult
UserCanRequestPasswordResetEmail(Email userEmail);
public abstract AcceptanceCriterionResult
ResetLinkExpiresAfter24Hours(TokenId resetToken, DateTime requestedAt);
public abstract AcceptanceCriterionResult
NewPasswordMeetsComplexityRequirements(Password newPassword);
}
// The specification (how — contract)
[ForRequirement(typeof(PasswordResetFeature))]
public interface IPasswordResetSpec
{
[ForRequirement(typeof(PasswordResetFeature),
nameof(PasswordResetFeature.UserCanRequestPasswordResetEmail))]
Result<ResetToken, DomainException> RequestPasswordReset(Email userEmail);
[ForRequirement(typeof(PasswordResetFeature),
nameof(PasswordResetFeature.ResetLinkExpiresAfter24Hours))]
Result ValidateResetToken(TokenId token);
[ForRequirement(typeof(PasswordResetFeature),
nameof(PasswordResetFeature.NewPasswordMeetsComplexityRequirements))]
Result ResetPassword(TokenId token, Password newPassword);
}
// The test (proof)
[TestsFor(typeof(PasswordResetFeature))]
public class PasswordResetFeatureTests
{
[Verifies(typeof(PasswordResetFeature),
nameof(PasswordResetFeature.ResetLinkExpiresAfter24Hours))]
public void Token_requested_25_hours_ago_is_rejected()
{
var token = ResetToken.Generate(_testUser.Id, TimeSpan.FromHours(24));
_clock.AdvanceBy(TimeSpan.FromHours(25));
var result = _service.ValidateResetToken(token.Id);
Assert.That(result.IsSuccess, Is.False);
Assert.That(result.Error, Is.TypeOf<TokenExpiredException>());
}
}// The requirement (what)
public abstract record PasswordResetFeature : Feature<UserManagementEpic>
{
public abstract AcceptanceCriterionResult
UserCanRequestPasswordResetEmail(Email userEmail);
public abstract AcceptanceCriterionResult
ResetLinkExpiresAfter24Hours(TokenId resetToken, DateTime requestedAt);
public abstract AcceptanceCriterionResult
NewPasswordMeetsComplexityRequirements(Password newPassword);
}
// The specification (how — contract)
[ForRequirement(typeof(PasswordResetFeature))]
public interface IPasswordResetSpec
{
[ForRequirement(typeof(PasswordResetFeature),
nameof(PasswordResetFeature.UserCanRequestPasswordResetEmail))]
Result<ResetToken, DomainException> RequestPasswordReset(Email userEmail);
[ForRequirement(typeof(PasswordResetFeature),
nameof(PasswordResetFeature.ResetLinkExpiresAfter24Hours))]
Result ValidateResetToken(TokenId token);
[ForRequirement(typeof(PasswordResetFeature),
nameof(PasswordResetFeature.NewPasswordMeetsComplexityRequirements))]
Result ResetPassword(TokenId token, Password newPassword);
}
// The test (proof)
[TestsFor(typeof(PasswordResetFeature))]
public class PasswordResetFeatureTests
{
[Verifies(typeof(PasswordResetFeature),
nameof(PasswordResetFeature.ResetLinkExpiresAfter24Hours))]
public void Token_requested_25_hours_ago_is_rejected()
{
var token = ResetToken.Generate(_testUser.Id, TimeSpan.FromHours(24));
_clock.AdvanceBy(TimeSpan.FromHours(25));
var result = _service.ValidateResetToken(token.Id);
Assert.That(result.IsSuccess, Is.False);
Assert.That(result.Error, Is.TypeOf<TokenExpiredException>());
}
}The full chain: requirement defines the AC, specification defines the contract, implementation fulfills the contract, test proves the fulfillment. Every link is compiler-checked. The AC is not just a signature — it's a verified, traceable, tested specification.
Precision score: ~95%. The AC communicates intent, scope, test structure, input types, output types, error types, implementation contract, and test proof. The remaining 5% is semantic correctness — does the implementation actually do the right thing? That requires human judgment or formal verification.
The Spectrum Visualized
Precision 0% 50% 100%
├──────────┼──────────┼──────────┼──────────┤
│ │ │ │ │
Level 0: ██░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 10%
(Jira) │ │ │ │ │
│ │ │ │ │
Level 1: ██████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 30%
(Story) │ │ │ │ │
│ │ │ │ │
Level 2: ██████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 50%
(PRD) │ ↑ Spec-driven sits here │ │
│ │ │ │ │
Level 3: ███████████████░░░░░░░░░░░░░░░░░░░░░░░░░░ 75%
(Typed) │ │ ↑ Typed specs (feature record)
│ │ │ │ │
Level 4: ███████████████████░░░░░░░░░░░░░░░░░░░░░░ 95%
(Full) │ │ │ ↑ Typed specs (full chain)
│ │ │ │ │
Level 5: ████████████████████████████████████████ 100%
(Formal) │ │ │ │ ↑ Formal verification
│ │ │ │ (TLA+, Coq, Lean)Precision 0% 50% 100%
├──────────┼──────────┼──────────┼──────────┤
│ │ │ │ │
Level 0: ██░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 10%
(Jira) │ │ │ │ │
│ │ │ │ │
Level 1: ██████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 30%
(Story) │ │ │ │ │
│ │ │ │ │
Level 2: ██████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 50%
(PRD) │ ↑ Spec-driven sits here │ │
│ │ │ │ │
Level 3: ███████████████░░░░░░░░░░░░░░░░░░░░░░░░░░ 75%
(Typed) │ │ ↑ Typed specs (feature record)
│ │ │ │ │
Level 4: ███████████████████░░░░░░░░░░░░░░░░░░░░░░ 95%
(Full) │ │ │ ↑ Typed specs (full chain)
│ │ │ │ │
Level 5: ████████████████████████████████████████ 100%
(Formal) │ │ │ │ ↑ Formal verification
│ │ │ │ (TLA+, Coq, Lean)The spec-driven approach improves on Jira tickets (Level 0 to Level 2). That's a 40-percentage-point improvement. Significant.
The typed specification approach takes you from Level 2 to Level 4. That's a 45-percentage-point improvement on top of structured text. Also significant.
The question is whether the marginal precision from Level 2 to Level 4 justifies the cost of typed infrastructure. For most CRUD applications, probably not. For safety-critical, compliance-regulated, or long-lived complex systems, absolutely.
What Each Level Cannot Express
No level on this spectrum expresses non-functional acceptance criteria well. "The password reset email must be sent within 30 seconds" is a performance constraint. In the spec-driven approach, it's a string in the PRD. In the typed approach, it's an XML doc comment or a separate [PerformanceBudget(30, TimeUnit.Seconds)] attribute. Neither approach has a clean native representation for timing, throughput, latency, or resource constraints as part of the AC itself.
This is an honest gap in both approaches. Functional ACs map well to method signatures. Non-functional ACs map poorly to any code construct.
Cross-Service Requirements
Real systems are not monoliths. A single feature often spans multiple services: the API receives the request, the domain processes it, the gateway handles payment, the worker sends notifications. How does each approach handle a requirement that spans multiple microservices?
The Scenario
OrderProcessingFeature spans three services:
- OrderService — receives the order, validates it, persists it
- PaymentGateway — charges the customer's payment method
- NotificationWorker — sends order confirmation email
The feature has four acceptance criteria:
OrderCanBePlaced— OrderService creates the orderPaymentIsCharged— PaymentGateway charges the customerConfirmationEmailSent— NotificationWorker sends the emailOrderFailsIfPaymentDeclined— Orchestrated rollback across services
Spec-Driven: Per-Service PRD Sections
In the spec-driven approach, cross-service requirements are a pain point. You have several options, none ideal:
Option A: One PRD, cross-service sections
DEFINE_FEATURE(order_processing)
acceptance_criteria:
- "Order can be placed"
- "Payment is charged"
- "Confirmation email is sent"
- "Order fails if payment is declined"
services_involved:
- OrderService
- PaymentGateway
- NotificationWorkerDEFINE_FEATURE(order_processing)
acceptance_criteria:
- "Order can be placed"
- "Payment is charged"
- "Confirmation email is sent"
- "Order fails if payment is declined"
services_involved:
- OrderService
- PaymentGateway
- NotificationWorkerThe feature is defined once, but the implementation spans three services. The AI agent generating code for OrderService sees the full feature definition but must figure out which ACs apply to which service. "Payment is charged" — does the OrderService implement this? Or does it delegate to the PaymentGateway? The text doesn't say.
Option B: Per-service PRDs
# OrderService-PRD.txt
DEFINE_FEATURE(order_processing_order_service)
acceptance_criteria:
- "Order can be placed"
- "Order fails if payment is declined"
# PaymentGateway-PRD.txt
DEFINE_FEATURE(order_processing_payment)
acceptance_criteria:
- "Payment is charged"
- "Payment declined returns typed error"
# NotificationWorker-PRD.txt
DEFINE_FEATURE(order_processing_notification)
acceptance_criteria:
- "Confirmation email is sent after successful payment"# OrderService-PRD.txt
DEFINE_FEATURE(order_processing_order_service)
acceptance_criteria:
- "Order can be placed"
- "Order fails if payment is declined"
# PaymentGateway-PRD.txt
DEFINE_FEATURE(order_processing_payment)
acceptance_criteria:
- "Payment is charged"
- "Payment declined returns typed error"
# NotificationWorker-PRD.txt
DEFINE_FEATURE(order_processing_notification)
acceptance_criteria:
- "Confirmation email is sent after successful payment"Now each service has its own PRD. But the feature is split across three documents. There's no single place that shows the full feature. If you add an AC to the OrderService PRD, how do you know if it affects the PaymentGateway PRD? You read both. Manually. And hope.
Option C: Traceability document
You add a separate traceability.yaml that maps features to services:
order_processing:
order_service:
- ac_order_placed
- ac_order_fails_payment_declined
payment_gateway:
- ac_payment_charged
notification_worker:
- ac_confirmation_email_sentorder_processing:
order_service:
- ac_order_placed
- ac_order_fails_payment_declined
payment_gateway:
- ac_payment_charged
notification_worker:
- ac_confirmation_email_sentNow you have a cross-reference document. But it's a third artifact that can drift from both the PRD and the code. You're maintaining three things that must stay consistent: the PRD, the code, and the traceability mapping.
Typed: Shared Requirements Project
In the typed approach, cross-service requirements are natural. The feature is defined once in a shared Requirements project. Each service references that project and implements its share of the ACs.
Solution structure:
MyCompany.sln
├── shared/
│ └── MyCompany.Requirements/ ← THE source of truth
│ ├── Features/
│ │ └── OrderProcessingFeature.cs ← Feature with 4 ACs
│ └── SharedKernel/
│ ├── OrderId.cs
│ ├── PaymentId.cs
│ └── Money.cs
│
├── services/
│ ├── MyCompany.OrderService/
│ │ ├── Specifications/
│ │ │ └── IOrderSpec.cs ← [ForRequirement] 2 ACs
│ │ ├── Domain/
│ │ │ └── OrderService.cs ← Implements IOrderSpec
│ │ └── MyCompany.OrderService.csproj
│ │ → <ProjectReference Include="..\..\shared\MyCompany.Requirements" />
│ │
│ ├── MyCompany.PaymentGateway/
│ │ ├── Specifications/
│ │ │ └── IPaymentSpec.cs ← [ForRequirement] 1 AC
│ │ ├── Domain/
│ │ │ └── PaymentService.cs ← Implements IPaymentSpec
│ │ └── MyCompany.PaymentGateway.csproj
│ │ → <ProjectReference Include="..\..\shared\MyCompany.Requirements" />
│ │
│ └── MyCompany.NotificationWorker/
│ ├── Specifications/
│ │ └── INotificationSpec.cs ← [ForRequirement] 1 AC
│ ├── Domain/
│ │ └── NotificationService.cs ← Implements INotificationSpec
│ └── MyCompany.NotificationWorker.csproj
│ → <ProjectReference Include="..\..\shared\MyCompany.Requirements" />
│
└── test/
├── MyCompany.OrderService.Tests/
│ └── OrderProcessingTests.cs ← [Verifies] 2 ACs
├── MyCompany.PaymentGateway.Tests/
│ └── PaymentProcessingTests.cs ← [Verifies] 1 AC
├── MyCompany.NotificationWorker.Tests/
│ └── NotificationTests.cs ← [Verifies] 1 AC
└── MyCompany.Integration.Tests/
└── OrderE2ETests.cs ← [Verifies] all 4 ACs (end-to-end)Solution structure:
MyCompany.sln
├── shared/
│ └── MyCompany.Requirements/ ← THE source of truth
│ ├── Features/
│ │ └── OrderProcessingFeature.cs ← Feature with 4 ACs
│ └── SharedKernel/
│ ├── OrderId.cs
│ ├── PaymentId.cs
│ └── Money.cs
│
├── services/
│ ├── MyCompany.OrderService/
│ │ ├── Specifications/
│ │ │ └── IOrderSpec.cs ← [ForRequirement] 2 ACs
│ │ ├── Domain/
│ │ │ └── OrderService.cs ← Implements IOrderSpec
│ │ └── MyCompany.OrderService.csproj
│ │ → <ProjectReference Include="..\..\shared\MyCompany.Requirements" />
│ │
│ ├── MyCompany.PaymentGateway/
│ │ ├── Specifications/
│ │ │ └── IPaymentSpec.cs ← [ForRequirement] 1 AC
│ │ ├── Domain/
│ │ │ └── PaymentService.cs ← Implements IPaymentSpec
│ │ └── MyCompany.PaymentGateway.csproj
│ │ → <ProjectReference Include="..\..\shared\MyCompany.Requirements" />
│ │
│ └── MyCompany.NotificationWorker/
│ ├── Specifications/
│ │ └── INotificationSpec.cs ← [ForRequirement] 1 AC
│ ├── Domain/
│ │ └── NotificationService.cs ← Implements INotificationSpec
│ └── MyCompany.NotificationWorker.csproj
│ → <ProjectReference Include="..\..\shared\MyCompany.Requirements" />
│
└── test/
├── MyCompany.OrderService.Tests/
│ └── OrderProcessingTests.cs ← [Verifies] 2 ACs
├── MyCompany.PaymentGateway.Tests/
│ └── PaymentProcessingTests.cs ← [Verifies] 1 AC
├── MyCompany.NotificationWorker.Tests/
│ └── NotificationTests.cs ← [Verifies] 1 AC
└── MyCompany.Integration.Tests/
└── OrderE2ETests.cs ← [Verifies] all 4 ACs (end-to-end)The feature definition:
namespace MyCompany.Requirements.Features;
public abstract record OrderProcessingFeature : Feature<EcommercePlatformEpic>
{
public override string Title => "End-to-end order processing";
public override RequirementPriority Priority => RequirementPriority.Critical;
public override string Owner => "Commerce Team";
/// <summary>
/// A customer can place an order with valid items, quantities, and shipping
/// address. The order is persisted with status Placed and an OrderId is returned.
/// </summary>
public abstract AcceptanceCriterionResult
OrderCanBePlaced(CustomerId customer, IReadOnlyList<OrderLineDto> lines,
Address shippingAddress);
/// <summary>
/// The customer's payment method is charged for the order total.
/// The charge is idempotent — retrying with the same OrderId does not
/// double-charge.
/// </summary>
public abstract AcceptanceCriterionResult
PaymentIsCharged(OrderId orderId, PaymentMethodId paymentMethod, Money amount);
/// <summary>
/// After successful payment, a confirmation email is sent to the customer's
/// registered email address within 60 seconds.
/// </summary>
public abstract AcceptanceCriterionResult
ConfirmationEmailSent(OrderId orderId, Email customerEmail);
/// <summary>
/// If the payment gateway declines the charge, the order status is set to
/// PaymentFailed, no email is sent, and the customer receives a typed error
/// with the decline reason.
/// </summary>
public abstract AcceptanceCriterionResult
OrderFailsIfPaymentDeclined(OrderId orderId, PaymentDeclineReason reason);
}namespace MyCompany.Requirements.Features;
public abstract record OrderProcessingFeature : Feature<EcommercePlatformEpic>
{
public override string Title => "End-to-end order processing";
public override RequirementPriority Priority => RequirementPriority.Critical;
public override string Owner => "Commerce Team";
/// <summary>
/// A customer can place an order with valid items, quantities, and shipping
/// address. The order is persisted with status Placed and an OrderId is returned.
/// </summary>
public abstract AcceptanceCriterionResult
OrderCanBePlaced(CustomerId customer, IReadOnlyList<OrderLineDto> lines,
Address shippingAddress);
/// <summary>
/// The customer's payment method is charged for the order total.
/// The charge is idempotent — retrying with the same OrderId does not
/// double-charge.
/// </summary>
public abstract AcceptanceCriterionResult
PaymentIsCharged(OrderId orderId, PaymentMethodId paymentMethod, Money amount);
/// <summary>
/// After successful payment, a confirmation email is sent to the customer's
/// registered email address within 60 seconds.
/// </summary>
public abstract AcceptanceCriterionResult
ConfirmationEmailSent(OrderId orderId, Email customerEmail);
/// <summary>
/// If the payment gateway declines the charge, the order status is set to
/// PaymentFailed, no email is sent, and the customer receives a typed error
/// with the decline reason.
/// </summary>
public abstract AcceptanceCriterionResult
OrderFailsIfPaymentDeclined(OrderId orderId, PaymentDeclineReason reason);
}Each service implements its ACs:
// OrderService — implements 2 ACs
[ForRequirement(typeof(OrderProcessingFeature))]
public interface IOrderSpec
{
[ForRequirement(typeof(OrderProcessingFeature),
nameof(OrderProcessingFeature.OrderCanBePlaced))]
Result<OrderId, DomainException> PlaceOrder(
CustomerId customer, IReadOnlyList<OrderLineDto> lines, Address address);
[ForRequirement(typeof(OrderProcessingFeature),
nameof(OrderProcessingFeature.OrderFailsIfPaymentDeclined))]
Result MarkPaymentFailed(OrderId orderId, PaymentDeclineReason reason);
}
// PaymentGateway — implements 1 AC
[ForRequirement(typeof(OrderProcessingFeature))]
public interface IPaymentSpec
{
[ForRequirement(typeof(OrderProcessingFeature),
nameof(OrderProcessingFeature.PaymentIsCharged))]
Result<PaymentConfirmation, PaymentException> ChargePayment(
OrderId orderId, PaymentMethodId method, Money amount);
}
// NotificationWorker — implements 1 AC
[ForRequirement(typeof(OrderProcessingFeature))]
public interface INotificationSpec
{
[ForRequirement(typeof(OrderProcessingFeature),
nameof(OrderProcessingFeature.ConfirmationEmailSent))]
Result SendOrderConfirmation(OrderId orderId, Email customerEmail);
}// OrderService — implements 2 ACs
[ForRequirement(typeof(OrderProcessingFeature))]
public interface IOrderSpec
{
[ForRequirement(typeof(OrderProcessingFeature),
nameof(OrderProcessingFeature.OrderCanBePlaced))]
Result<OrderId, DomainException> PlaceOrder(
CustomerId customer, IReadOnlyList<OrderLineDto> lines, Address address);
[ForRequirement(typeof(OrderProcessingFeature),
nameof(OrderProcessingFeature.OrderFailsIfPaymentDeclined))]
Result MarkPaymentFailed(OrderId orderId, PaymentDeclineReason reason);
}
// PaymentGateway — implements 1 AC
[ForRequirement(typeof(OrderProcessingFeature))]
public interface IPaymentSpec
{
[ForRequirement(typeof(OrderProcessingFeature),
nameof(OrderProcessingFeature.PaymentIsCharged))]
Result<PaymentConfirmation, PaymentException> ChargePayment(
OrderId orderId, PaymentMethodId method, Money amount);
}
// NotificationWorker — implements 1 AC
[ForRequirement(typeof(OrderProcessingFeature))]
public interface INotificationSpec
{
[ForRequirement(typeof(OrderProcessingFeature),
nameof(OrderProcessingFeature.ConfirmationEmailSent))]
Result SendOrderConfirmation(OrderId orderId, Email customerEmail);
}The source-generated traceability matrix shows the cross-service picture:
┌──────────────────────────────┬───────────────────┬────────────────────────┬───────┐
│ AC │ Service │ Spec Interface │ Tests │
├──────────────────────────────┼───────────────────┼────────────────────────┼───────┤
│ OrderCanBePlaced │ OrderService │ IOrderSpec │ 3 ✓ │
│ PaymentIsCharged │ PaymentGateway │ IPaymentSpec │ 2 ✓ │
│ ConfirmationEmailSent │ NotificationWorker│ INotificationSpec │ 2 ✓ │
│ OrderFailsIfPaymentDeclined │ OrderService │ IOrderSpec │ 2 ✓ │
├──────────────────────────────┼───────────────────┼────────────────────────┼───────┤
│ (E2E: all 4 ACs) │ Integration │ — │ 1 ✓ │
└──────────────────────────────┴───────────────────┴────────────────────────┴───────┘┌──────────────────────────────┬───────────────────┬────────────────────────┬───────┐
│ AC │ Service │ Spec Interface │ Tests │
├──────────────────────────────┼───────────────────┼────────────────────────┼───────┤
│ OrderCanBePlaced │ OrderService │ IOrderSpec │ 3 ✓ │
│ PaymentIsCharged │ PaymentGateway │ IPaymentSpec │ 2 ✓ │
│ ConfirmationEmailSent │ NotificationWorker│ INotificationSpec │ 2 ✓ │
│ OrderFailsIfPaymentDeclined │ OrderService │ IOrderSpec │ 2 ✓ │
├──────────────────────────────┼───────────────────┼────────────────────────┼───────┤
│ (E2E: all 4 ACs) │ Integration │ — │ 1 ✓ │
└──────────────────────────────┴───────────────────┴────────────────────────┴───────┘Why Shared Requirements Matter at Scale
The shared Requirements project solves three problems that the spec-driven approach struggles with:
Problem 1: Single source of truth. The feature is defined once. Every service references the same type. If you rename an AC method, every nameof() reference across all services updates atomically. There is no "sync the PRDs" step.
Problem 2: Cross-service coverage visibility. The traceability matrix is generated from all services' [ForRequirement] attributes. It shows which service implements which AC. If no service claims ConfirmationEmailSent, the REQ101 diagnostic fires — even though the OrderService and PaymentGateway compile fine. The gap is visible at the solution level.
Problem 3: Contract types are shared. OrderId, PaymentMethodId, Money, Email — these value types live in SharedKernel and are referenced by the feature's AC method signatures. The PaymentGateway's ChargePayment method takes the same OrderId type that the OrderService's PlaceOrder method returns. The type system enforces that both services agree on what an OrderId is. In the spec-driven approach, both services might use string for order IDs — and one might use GUIDs while the other uses sequential integers.
// This cannot happen in the typed approach:
// OrderService returns OrderId (GUID-backed)
// PaymentGateway expects orderId as string (int-backed)
// The shared value type prevents the mismatch at compile time.// This cannot happen in the typed approach:
// OrderService returns OrderId (GUID-backed)
// PaymentGateway expects orderId as string (int-backed)
// The shared value type prevents the mismatch at compile time.The Cross-Service Comparison
| Aspect | Spec-Driven | Typed Specifications |
|---|---|---|
| Feature definition | Split across per-service PRDs or duplicated | Single feature type in shared project |
| AC-to-service mapping | Manual traceability document | Automatic from [ForRequirement] attributes |
| Shared types | Convention-based (both use "orderId") | Type-enforced (both use OrderId) |
| Coverage gaps | Invisible until manual audit | Compiler diagnostic across solution |
| Rename propagation | Manual across all PRDs | Automatic via nameof() |
| New AC added | Must update multiple PRDs | Add method once; compiler shows what's missing |
| Service decomposition | Rewrite PRD sections | Move [ForRequirement] attributes to new service |