Inverted Dependencies: Production-Clean Requirement Tracking
Industrial Monorepo Series — Part 7 of 7 1. The Problem · 2. Physical vs Logical · 3. Requirements as Projects · 4. At Scale · 5. Migration · 6. ROI · 7. Inverted Dependencies
Don't ship your requirements. Ship your code. Let the requirements project watch from the outside — with
typeof(),nameof(), andInternalsVisibleTo. The compiler still enforces the chain. Production stays clean.
1. Don't Ship Your Requirements
Parts 1-6 of this series established a powerful architecture: Features as types, acceptance criteria as abstract methods, specification interfaces enforced by the compiler, traceability matrices generated by Roslyn. It works. It scales. It saves $740K-$2.4M per year.
But it has a problem.
In the Parts 1-6 design, every production DLL depends on Requirements.dll. The OrderProcessingService class carries [ForRequirement(typeof(OrderProcessingFeature))] attributes. The IOrderProcessingSpec interface lives in MegaCorp.Specifications, which references MegaCorp.Requirements. Every DLL in the dotnet publish output includes requirement metadata.
For many projects, this is fine. For others, it's a dealbreaker:
- Defense contractors where binary analysis of shipped DLLs could expose classified capability descriptions embedded in requirement type names and AC method signatures
- Medical device firmware (IEC 62304) where every DLL in the production image must be individually certified — adding Requirements.dll means certifying the requirement tracking infrastructure alongside the medical logic
- Financial institutions where regulatory auditors inspect production binaries — requirement metadata in production DLLs raises questions about what else might be embedded
- SaaS vendors shipping on-premise installations where customers could decompile DLLs and discover the internal feature roadmap from requirement type names
- Any environment where the principle is: production artifacts should contain production code, nothing else
The fix is not to abandon requirement tracking. The fix is to invert the dependency direction.
2. The Dependency Inversion
The Original Direction (Parts 1-6)
Requirements (0 deps) → Specifications → Implementation (depends on Specs)Requirements (0 deps) → Specifications → Implementation (depends on Specs)Production code references Requirements. Production DLLs carry [ForRequirement] attributes. Production builds include the Requirements assembly.
The Inverted Direction (This Post)
Implementation (0 knowledge of Requirements) ← Requirements references ImplementationImplementation (0 knowledge of Requirements) ← Requirements references ImplementationRequirements references production code. Production code has zero knowledge that Requirements exists. Production DLLs are clean — no attributes, no metadata, no transitive dependency on Requirements.dll.
The key insight: typeof(OrderProcessingService) works in Requirements because Requirements has a <ProjectReference> to the production project. The compiler resolves the type at compile time. At runtime, production never loads Requirements.dll — the reference is one-directional.
Production projects build first (they have no dependency on Requirements). Requirements builds last — it references all production assemblies. Tests build after both and reference both worlds.
3. InternalsVisibleTo — Seeing Without Being Seen
3a. The Mechanism
The [InternalsVisibleTo] attribute is a .NET assembly-level attribute that grants another assembly access to internal types and members. It's declared in the production assembly and names the friend assembly as a string — not a type reference, not a <ProjectReference>, not a dependency.
// MegaCorp.OrderService/Properties/AssemblyInfo.cs
// (or in the .csproj via <InternalsVisibleTo>)
using System.Runtime.CompilerServices;
// Grant Requirements project access to internal types.
// This is a STRING — not a type reference.
// OrderService has NO dependency on Requirements.
// OrderService doesn't know Requirements exists at runtime.
[assembly: InternalsVisibleTo("MegaCorp.Requirements")]
// Also grant test project access (standard practice)
[assembly: InternalsVisibleTo("MegaCorp.OrderService.Tests")]// MegaCorp.OrderService/Properties/AssemblyInfo.cs
// (or in the .csproj via <InternalsVisibleTo>)
using System.Runtime.CompilerServices;
// Grant Requirements project access to internal types.
// This is a STRING — not a type reference.
// OrderService has NO dependency on Requirements.
// OrderService doesn't know Requirements exists at runtime.
[assembly: InternalsVisibleTo("MegaCorp.Requirements")]
// Also grant test project access (standard practice)
[assembly: InternalsVisibleTo("MegaCorp.OrderService.Tests")]Or in the .csproj (cleaner, no AssemblyInfo.cs needed):
<!-- MegaCorp.OrderService/MegaCorp.OrderService.csproj -->
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<!-- Standard production references — NO reference to Requirements -->
<ProjectReference Include="..\MegaCorp.SharedKernel\MegaCorp.SharedKernel.csproj" />
<ProjectReference Include="..\MegaCorp.Data\MegaCorp.Data.csproj" />
</ItemGroup>
<ItemGroup>
<!-- Friend assemblies — these are STRINGS, not project references -->
<InternalsVisibleTo Include="MegaCorp.Requirements" />
<InternalsVisibleTo Include="MegaCorp.OrderService.Tests" />
</ItemGroup>
</Project><!-- MegaCorp.OrderService/MegaCorp.OrderService.csproj -->
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<!-- Standard production references — NO reference to Requirements -->
<ProjectReference Include="..\MegaCorp.SharedKernel\MegaCorp.SharedKernel.csproj" />
<ProjectReference Include="..\MegaCorp.Data\MegaCorp.Data.csproj" />
</ItemGroup>
<ItemGroup>
<!-- Friend assemblies — these are STRINGS, not project references -->
<InternalsVisibleTo Include="MegaCorp.Requirements" />
<InternalsVisibleTo Include="MegaCorp.OrderService.Tests" />
</ItemGroup>
</Project>Now, in the Requirements project:
// MegaCorp.Requirements/Mappings/OrderProcessingMapping.cs
using MegaCorp.OrderService; // ← This works because Requirements has <ProjectReference> to OrderService
public class OrderProcessingMapping : FeatureMapping<OrderProcessingFeature>
{
protected override void Configure()
{
// typeof() and nameof() work on public AND internal types
// because OrderService declared [InternalsVisibleTo("MegaCorp.Requirements")]
Ac(f => f.OrderTotalMustBePositive)
.ImplementedBy<OrderProcessingService>( // public class — always visible
s => s.ValidateOrderTotal); // public method
Ac(f => f.InventoryReservedBeforePayment)
.ImplementedBy<InternalInventoryOrchestrator>( // INTERNAL class — visible via IVT
s => s.ReserveAndValidate); // INTERNAL method — visible via IVT
}
}// MegaCorp.Requirements/Mappings/OrderProcessingMapping.cs
using MegaCorp.OrderService; // ← This works because Requirements has <ProjectReference> to OrderService
public class OrderProcessingMapping : FeatureMapping<OrderProcessingFeature>
{
protected override void Configure()
{
// typeof() and nameof() work on public AND internal types
// because OrderService declared [InternalsVisibleTo("MegaCorp.Requirements")]
Ac(f => f.OrderTotalMustBePositive)
.ImplementedBy<OrderProcessingService>( // public class — always visible
s => s.ValidateOrderTotal); // public method
Ac(f => f.InventoryReservedBeforePayment)
.ImplementedBy<InternalInventoryOrchestrator>( // INTERNAL class — visible via IVT
s => s.ReserveAndValidate); // INTERNAL method — visible via IVT
}
}Without InternalsVisibleTo, Requirements could only reference public types and methods. With it, Requirements can track internal domain services — the classes that aren't exposed in the API surface but contain the core business logic. This is often exactly what you want to track: the internal implementation details that satisfy acceptance criteria.
3b. Strong-Named Assemblies
If production assemblies are strong-named (signed with a cryptographic key), InternalsVisibleTo requires the full public key of the friend assembly. A bare assembly name isn't enough — the CLR enforces key-based identity for strong-named assemblies.
// For strong-named assemblies:
[assembly: InternalsVisibleTo(
"MegaCorp.Requirements, PublicKey=" +
"0024000004800000940000000602000000240000525341310004000001000100" +
"b5fc90e7027f67871e773a8fde8938c81dd402ba65b9201d60593e96c492651e" +
"889cc13f1415ebb53fac1131ae0bd333c5ee6021672d9718ea31a8aebd0da007" +
"3b4f0f0e6dda8c68f940364e5a0c4f538e0c1d4c1c3e5ff2b3e8b4e6c0e3d1a" +
"f8d3c8a7e2b9f4d6a0c5e1b7f3d9a2c4e8b6f0d2a4c6e8b0f2d4a6c8e0b2f4")]// For strong-named assemblies:
[assembly: InternalsVisibleTo(
"MegaCorp.Requirements, PublicKey=" +
"0024000004800000940000000602000000240000525341310004000001000100" +
"b5fc90e7027f67871e773a8fde8938c81dd402ba65b9201d60593e96c492651e" +
"889cc13f1415ebb53fac1131ae0bd333c5ee6021672d9718ea31a8aebd0da007" +
"3b4f0f0e6dda8c68f940364e5a0c4f538e0c1d4c1c3e5ff2b3e8b4e6c0e3d1a" +
"f8d3c8a7e2b9f4d6a0c5e1b7f3d9a2c4e8b6f0d2a4c6e8b0f2d4a6c8e0b2f4")]To extract the public key from the Requirements assembly:
# Extract public key token
sn -Tp MegaCorp.Requirements.dll
# Or extract the full public key
sn -Tp MegaCorp.Requirements.dll | grep "Public key"# Extract public key token
sn -Tp MegaCorp.Requirements.dll
# Or extract the full public key
sn -Tp MegaCorp.Requirements.dll | grep "Public key"CI implications:
- The Requirements project must be signed with the same key management process as production
- The key pair (
.snkfile) must be available in CI - Both production and Requirements assemblies must be signed — you can't have a strong-named assembly grant access to an unsigned one
When strong naming is required:
- GAC (Global Assembly Cache) deployment
- COM interop scenarios
- Security-critical environments where assembly identity verification is mandated
- .NET Framework (not .NET Core/5+, where strong naming is less common)
For most modern .NET 8+ projects, strong naming is not required. InternalsVisibleTo works with just the assembly name.
3c. NuGet Package Scenario
If production code is distributed as NuGet packages (not ProjectReference within the same solution):
<!-- MegaCorp.Requirements/MegaCorp.Requirements.csproj -->
<ItemGroup>
<!-- Reference production code as NuGet packages from internal feed -->
<PackageReference Include="MegaCorp.OrderService" Version="3.2.1" />
<PackageReference Include="MegaCorp.PaymentGateway" Version="3.2.1" />
<PackageReference Include="MegaCorp.InventoryService" Version="3.2.1" />
</ItemGroup><!-- MegaCorp.Requirements/MegaCorp.Requirements.csproj -->
<ItemGroup>
<!-- Reference production code as NuGet packages from internal feed -->
<PackageReference Include="MegaCorp.OrderService" Version="3.2.1" />
<PackageReference Include="MegaCorp.PaymentGateway" Version="3.2.1" />
<PackageReference Include="MegaCorp.InventoryService" Version="3.2.1" />
</ItemGroup>For InternalsVisibleTo to work across NuGet boundaries:
- The
[InternalsVisibleTo]must be in the NuGet package source — it's baked into the assembly at compile time, before packing - You can't add
InternalsVisibleToretroactively to a published NuGet package - For internal NuGet feeds: add
InternalsVisibleTo("MegaCorp.Requirements")to production source beforedotnet pack - For external/vendor NuGet packages: Requirements can only track
publictypes (which is usually sufficient for external APIs)
Directory.Build.props pattern to auto-inject InternalsVisibleTo into all production projects:
<!-- Directory.Build.props (at solution root) -->
<Project>
<ItemGroup Condition="'$(IsTestProject)' != 'true' AND '$(IsRequirementsProject)' != 'true'">
<!-- Every production project grants access to Requirements and its own test project -->
<InternalsVisibleTo Include="MegaCorp.Requirements" />
<InternalsVisibleTo Include="$(AssemblyName).Tests" />
</ItemGroup>
</Project><!-- Directory.Build.props (at solution root) -->
<Project>
<ItemGroup Condition="'$(IsTestProject)' != 'true' AND '$(IsRequirementsProject)' != 'true'">
<!-- Every production project grants access to Requirements and its own test project -->
<InternalsVisibleTo Include="MegaCorp.Requirements" />
<InternalsVisibleTo Include="$(AssemblyName).Tests" />
</ItemGroup>
</Project>This ensures that every new production project automatically grants InternalsVisibleTo to Requirements — no per-project configuration needed.
3d. What InternalsVisibleTo Does NOT Grant
This is critical to understand — InternalsVisibleTo is a compile-time visibility grant, not a runtime dependency:
| What IVT Does | What IVT Does NOT Do |
|---|---|
Lets Requirements see internal types at compile time |
Create a runtime dependency — Requirements.dll is NOT loaded when production runs |
Lets Requirements use typeof(InternalClass) |
Appear in the production assembly's dependency manifest |
Lets Requirements use nameof(InternalClass.Method) |
Affect dotnet publish output — production bin/ has no trace of Requirements |
Works with both internal and internal protected |
Grant access to private or private protected members |
| Is a string attribute on the production assembly | Create a <ProjectReference> or NuGet dependency |
The production assembly at runtime has no idea that Requirements exists. The CLR doesn't load Requirements.dll. The assembly manifest doesn't reference it. The dotnet publish folder doesn't contain it. The Docker image doesn't include it. InternalsVisibleTo is a one-way compile-time handshake that leaves no runtime trace.
4. The Specifications Question: Merge or Keep Separate?
In Parts 1-6, .Specifications was a separate project holding interfaces like IOrderProcessingSpec. In inverted mode, Requirements already references production assemblies — so it CAN define specification interfaces and reference production types. Should Specifications merge into Requirements?
4a. Option A: Merge Specifications into Requirements
MegaCorp.Requirements/
├── Features/
│ └── OrderProcessingFeature.cs ← AC definitions (abstract methods)
├── Specifications/
│ ├── IOrderProcessingSpec.cs ← Interface referencing production types
│ └── IInventoryIntegrationSpec.cs
├── Mappings/
│ └── OrderProcessingMapping.cs ← Fluent builder linking ACs to production code
└── Generated/
└── TraceabilityMatrix.g.cs ← Roslyn-generatedMegaCorp.Requirements/
├── Features/
│ └── OrderProcessingFeature.cs ← AC definitions (abstract methods)
├── Specifications/
│ ├── IOrderProcessingSpec.cs ← Interface referencing production types
│ └── IInventoryIntegrationSpec.cs
├── Mappings/
│ └── OrderProcessingMapping.cs ← Fluent builder linking ACs to production code
└── Generated/
└── TraceabilityMatrix.g.cs ← Roslyn-generatedAdvantages:
- One project instead of two — simpler dependency graph
- Specifications can directly reference production types via
typeof()(since Requirements has the ProjectReference) - Single source of truth: features, specs, and mappings all in one place
Trade-off:
- Requirements is larger and has multiple responsibilities (feature definitions + spec interfaces + mappings)
4b. Option B: Keep Specifications Separate
MegaCorp.Requirements/ ← Pure: Feature types + ACs only
├── Features/
│ └── OrderProcessingFeature.cs
MegaCorp.Specifications/ ← Bridge: references both Requirements AND production
├── IOrderProcessingSpec.cs
├── Mappings/
│ └── OrderProcessingMapping.csMegaCorp.Requirements/ ← Pure: Feature types + ACs only
├── Features/
│ └── OrderProcessingFeature.cs
MegaCorp.Specifications/ ← Bridge: references both Requirements AND production
├── IOrderProcessingSpec.cs
├── Mappings/
│ └── OrderProcessingMapping.csAdvantages:
- Requirements stays pure (zero references — feature types and ACs only)
- Specifications is the explicit "bridge" between requirement world and production world
Trade-off:
- Two projects that both reference production assemblies — redundant
- Requirements can no longer do
typeof(OrderProcessingService)directly
4c. Recommended: Merge
In inverted mode, the whole point is that Requirements references production. Having a separate Specifications project that ALSO references production is redundant. Merge them:
Requirements = Feature types + AC methods + Spec interfaces + Fluent mappings + Generated artifacts
The project is larger but has a single clear purpose: "Everything the business requires and how it maps to production code."
This also simplifies the dependency graph:
Original (Parts 1-6): Requirements → Specifications → Implementation → Tests
Inverted + separate: Implementation ← Requirements, Implementation ← Specifications ← Requirements
Inverted + merged: Implementation ← Requirements, Implementation ← Tests ← RequirementsOriginal (Parts 1-6): Requirements → Specifications → Implementation → Tests
Inverted + separate: Implementation ← Requirements, Implementation ← Specifications ← Requirements
Inverted + merged: Implementation ← Requirements, Implementation ← Tests ← RequirementsThe merged version has fewer edges. Fewer projects. Fewer things to understand.
5. Attribute-Based Mapping
The simplest way to declare the AC-to-production mapping is with attributes on dedicated mapping classes in Requirements:
Attribute Definitions
// MegaCorp.Requirements/Mapping/Attributes.cs
namespace MegaCorp.Requirements.Mapping;
/// <summary>
/// Declares that a feature is implemented by a specific production class.
/// Multiple [ImplementedBy] attributes are allowed (feature spans multiple services).
/// </summary>
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)]
public sealed class ImplementedByAttribute : Attribute
{
public Type ImplementationType { get; }
public ImplementedByAttribute(Type implementationType)
=> ImplementationType = implementationType;
}
/// <summary>
/// Maps a specific acceptance criterion to its production implementation.
/// The AC is identified by nameof(Feature.ACMethod).
/// The implementation is identified by typeof(Service) + nameof(Service.Method).
/// </summary>
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)]
public sealed class AcMapAttribute : Attribute
{
public string AcceptanceCriterion { get; }
public Type ImplementationType { get; }
public string ImplementationMethod { get; }
public AcMapAttribute(string acceptanceCriterion, Type implementationType,
string implementationMethod)
{
AcceptanceCriterion = acceptanceCriterion;
ImplementationType = implementationType;
ImplementationMethod = implementationMethod;
}
}
/// <summary>
/// Maps an AC to a test method (alternative to [Verifies] on the test itself).
/// Useful when you want all traceability declarations in Requirements.
/// </summary>
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)]
public sealed class AcTestedByAttribute : Attribute
{
public string AcceptanceCriterion { get; }
public Type TestClass { get; }
public string TestMethod { get; }
public AcTestedByAttribute(string acceptanceCriterion, Type testClass,
string testMethod)
{
AcceptanceCriterion = acceptanceCriterion;
TestClass = testClass;
TestMethod = testMethod;
}
}// MegaCorp.Requirements/Mapping/Attributes.cs
namespace MegaCorp.Requirements.Mapping;
/// <summary>
/// Declares that a feature is implemented by a specific production class.
/// Multiple [ImplementedBy] attributes are allowed (feature spans multiple services).
/// </summary>
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)]
public sealed class ImplementedByAttribute : Attribute
{
public Type ImplementationType { get; }
public ImplementedByAttribute(Type implementationType)
=> ImplementationType = implementationType;
}
/// <summary>
/// Maps a specific acceptance criterion to its production implementation.
/// The AC is identified by nameof(Feature.ACMethod).
/// The implementation is identified by typeof(Service) + nameof(Service.Method).
/// </summary>
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)]
public sealed class AcMapAttribute : Attribute
{
public string AcceptanceCriterion { get; }
public Type ImplementationType { get; }
public string ImplementationMethod { get; }
public AcMapAttribute(string acceptanceCriterion, Type implementationType,
string implementationMethod)
{
AcceptanceCriterion = acceptanceCriterion;
ImplementationType = implementationType;
ImplementationMethod = implementationMethod;
}
}
/// <summary>
/// Maps an AC to a test method (alternative to [Verifies] on the test itself).
/// Useful when you want all traceability declarations in Requirements.
/// </summary>
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)]
public sealed class AcTestedByAttribute : Attribute
{
public string AcceptanceCriterion { get; }
public Type TestClass { get; }
public string TestMethod { get; }
public AcTestedByAttribute(string acceptanceCriterion, Type testClass,
string testMethod)
{
AcceptanceCriterion = acceptanceCriterion;
TestClass = testClass;
TestMethod = testMethod;
}
}Concrete Mapping
// MegaCorp.Requirements/Mappings/OrderProcessingMapping.cs
namespace MegaCorp.Requirements.Mappings;
using MegaCorp.Requirements.Features;
using MegaCorp.OrderService;
using MegaCorp.InventoryService;
using MegaCorp.PaymentGateway;
using MegaCorp.NotificationService;
using MegaCorp.AuditService;
[ImplementedBy(typeof(OrderProcessingService))]
[ImplementedBy(typeof(InventoryReservationService))]
[ImplementedBy(typeof(StripePaymentService))]
[ImplementedBy(typeof(OrderNotificationService))]
[ImplementedBy(typeof(AuditService))]
[AcMap(nameof(OrderProcessingFeature.OrderTotalMustBePositive),
typeof(OrderProcessingService), nameof(OrderProcessingService.ValidateOrderTotal))]
[AcMap(nameof(OrderProcessingFeature.InventoryReservedBeforePayment),
typeof(InventoryReservationService), nameof(InventoryReservationService.ReserveInventory))]
[AcMap(nameof(OrderProcessingFeature.PaymentCapturedAfterReservation),
typeof(StripePaymentService), nameof(StripePaymentService.CapturePayment))]
[AcMap(nameof(OrderProcessingFeature.ConfirmationSentAfterPayment),
typeof(OrderNotificationService), nameof(OrderNotificationService.SendOrderConfirmation))]
[AcMap(nameof(OrderProcessingFeature.AllOperationsAudited),
typeof(AuditService), nameof(AuditService.RecordAuditTrail))]
[AcMap(nameof(OrderProcessingFeature.FailedPaymentReleasesInventory),
typeof(InventoryReservationService), nameof(InventoryReservationService.ReleaseReservations))]
[AcMap(nameof(OrderProcessingFeature.BulkOrderDiscountApplied),
typeof(OrderProcessingService), nameof(OrderProcessingService.ApplyBulkDiscount))]
[AcMap(nameof(OrderProcessingFeature.OrderPersistedWithFullDetail),
typeof(OrderProcessingService), nameof(OrderProcessingService.PersistOrder))]
public class OrderProcessingTraceability { }// MegaCorp.Requirements/Mappings/OrderProcessingMapping.cs
namespace MegaCorp.Requirements.Mappings;
using MegaCorp.Requirements.Features;
using MegaCorp.OrderService;
using MegaCorp.InventoryService;
using MegaCorp.PaymentGateway;
using MegaCorp.NotificationService;
using MegaCorp.AuditService;
[ImplementedBy(typeof(OrderProcessingService))]
[ImplementedBy(typeof(InventoryReservationService))]
[ImplementedBy(typeof(StripePaymentService))]
[ImplementedBy(typeof(OrderNotificationService))]
[ImplementedBy(typeof(AuditService))]
[AcMap(nameof(OrderProcessingFeature.OrderTotalMustBePositive),
typeof(OrderProcessingService), nameof(OrderProcessingService.ValidateOrderTotal))]
[AcMap(nameof(OrderProcessingFeature.InventoryReservedBeforePayment),
typeof(InventoryReservationService), nameof(InventoryReservationService.ReserveInventory))]
[AcMap(nameof(OrderProcessingFeature.PaymentCapturedAfterReservation),
typeof(StripePaymentService), nameof(StripePaymentService.CapturePayment))]
[AcMap(nameof(OrderProcessingFeature.ConfirmationSentAfterPayment),
typeof(OrderNotificationService), nameof(OrderNotificationService.SendOrderConfirmation))]
[AcMap(nameof(OrderProcessingFeature.AllOperationsAudited),
typeof(AuditService), nameof(AuditService.RecordAuditTrail))]
[AcMap(nameof(OrderProcessingFeature.FailedPaymentReleasesInventory),
typeof(InventoryReservationService), nameof(InventoryReservationService.ReleaseReservations))]
[AcMap(nameof(OrderProcessingFeature.BulkOrderDiscountApplied),
typeof(OrderProcessingService), nameof(OrderProcessingService.ApplyBulkDiscount))]
[AcMap(nameof(OrderProcessingFeature.OrderPersistedWithFullDetail),
typeof(OrderProcessingService), nameof(OrderProcessingService.PersistOrder))]
public class OrderProcessingTraceability { }Every typeof() and nameof() is compiler-checked. If someone renames OrderProcessingService.ValidateOrderTotal, this mapping class fails to compile. The compiler catches the drift — at Requirements build time, not at production runtime.
When to Use Attributes vs Fluent
| Scenario | Use Attributes | Use Fluent Builder |
|---|---|---|
| Simple 1:1 AC → method | Yes | Overkill |
| Multiple implementations per AC | Verbose but works | Cleaner |
| Conditional mappings (platform-specific) | Can't do | Yes |
| Need to test the mapping itself | Harder | Easy (unit testable) |
| Large features (10+ ACs) | Gets noisy | More readable |
| Team prefers declarative style | Yes | No |
| Team prefers imperative style | No | Yes |
You can compose both. A mapping class can have [AcMap] attributes AND override Configure() from FeatureMapping<T>. The Roslyn analyzer merges both sources into a single TraceabilityMatrix.
6. Fluent Builder Mapping
The fluent builder is the more expressive mapping approach. It uses lambda expressions to create compile-time-checked links from ACs to production methods.
The Base Class
// MegaCorp.Requirements/Mapping/FeatureMapping.cs
namespace MegaCorp.Requirements.Mapping;
/// <summary>
/// Base class for fluent feature-to-implementation mappings.
/// Subclasses override Configure() to declare AC → production method links.
///
/// The Roslyn source generator reads Configure() at compile time
/// (via semantic model, not execution) to build the TraceabilityMatrix.
/// </summary>
public abstract class FeatureMapping<TFeature> where TFeature : RequirementMetadata
{
private readonly List<AcMappingEntry> _entries = new();
/// <summary>
/// Override to declare AC → implementation mappings.
/// Called by the mapping registry at startup (for validation).
/// Also analyzed by Roslyn at compile time (for code generation).
/// </summary>
protected abstract void Configure();
/// <summary>
/// Start mapping an acceptance criterion.
/// The lambda selects the AC method on the Feature type.
/// </summary>
protected AcBuilder<TFeature> Ac(
Func<TFeature, AcceptanceCriterionResult> acSelector,
[System.Runtime.CompilerServices.CallerArgumentExpression("acSelector")]
string? acExpression = null)
{
// Extract the method name from the expression (e.g., "f => f.OrderTotalMustBePositive")
var methodName = ExtractMethodName(acExpression);
return new AcBuilder<TFeature>(methodName, _entries);
}
/// <summary>
/// Overload for ACs with parameters (the lambda ignores them — we only need the method name).
/// </summary>
protected AcBuilder<TFeature> Ac<T1>(
Func<TFeature, T1, AcceptanceCriterionResult> acSelector,
[System.Runtime.CompilerServices.CallerArgumentExpression("acSelector")]
string? acExpression = null)
{
var methodName = ExtractMethodName(acExpression);
return new AcBuilder<TFeature>(methodName, _entries);
}
protected AcBuilder<TFeature> Ac<T1, T2>(
Func<TFeature, T1, T2, AcceptanceCriterionResult> acSelector,
[System.Runtime.CompilerServices.CallerArgumentExpression("acSelector")]
string? acExpression = null)
{
var methodName = ExtractMethodName(acExpression);
return new AcBuilder<TFeature>(methodName, _entries);
}
protected AcBuilder<TFeature> Ac<T1, T2, T3>(
Func<TFeature, T1, T2, T3, AcceptanceCriterionResult> acSelector,
[System.Runtime.CompilerServices.CallerArgumentExpression("acSelector")]
string? acExpression = null)
{
var methodName = ExtractMethodName(acExpression);
return new AcBuilder<TFeature>(methodName, _entries);
}
protected AcBuilder<TFeature> Ac<T1, T2, T3, T4>(
Func<TFeature, T1, T2, T3, T4, AcceptanceCriterionResult> acSelector,
[System.Runtime.CompilerServices.CallerArgumentExpression("acSelector")]
string? acExpression = null)
{
var methodName = ExtractMethodName(acExpression);
return new AcBuilder<TFeature>(methodName, _entries);
}
/// <summary>
/// Get all registered mappings (after Configure() has been called).
/// </summary>
public IReadOnlyList<AcMappingEntry> GetMappings()
{
if (_entries.Count == 0) Configure();
return _entries.AsReadOnly();
}
private static string ExtractMethodName(string? expression)
{
if (string.IsNullOrEmpty(expression))
throw new InvalidOperationException("CallerArgumentExpression not available");
// "f => f.OrderTotalMustBePositive" → "OrderTotalMustBePositive"
// "(f, _, _) => f.OrderTotalMustBePositive" → "OrderTotalMustBePositive"
var lastDot = expression.LastIndexOf('.');
if (lastDot < 0)
throw new InvalidOperationException($"Cannot extract method name from: {expression}");
var name = expression[(lastDot + 1)..].Trim();
// Remove trailing parentheses if present
var parenIdx = name.IndexOf('(');
if (parenIdx >= 0) name = name[..parenIdx];
return name;
}
}// MegaCorp.Requirements/Mapping/FeatureMapping.cs
namespace MegaCorp.Requirements.Mapping;
/// <summary>
/// Base class for fluent feature-to-implementation mappings.
/// Subclasses override Configure() to declare AC → production method links.
///
/// The Roslyn source generator reads Configure() at compile time
/// (via semantic model, not execution) to build the TraceabilityMatrix.
/// </summary>
public abstract class FeatureMapping<TFeature> where TFeature : RequirementMetadata
{
private readonly List<AcMappingEntry> _entries = new();
/// <summary>
/// Override to declare AC → implementation mappings.
/// Called by the mapping registry at startup (for validation).
/// Also analyzed by Roslyn at compile time (for code generation).
/// </summary>
protected abstract void Configure();
/// <summary>
/// Start mapping an acceptance criterion.
/// The lambda selects the AC method on the Feature type.
/// </summary>
protected AcBuilder<TFeature> Ac(
Func<TFeature, AcceptanceCriterionResult> acSelector,
[System.Runtime.CompilerServices.CallerArgumentExpression("acSelector")]
string? acExpression = null)
{
// Extract the method name from the expression (e.g., "f => f.OrderTotalMustBePositive")
var methodName = ExtractMethodName(acExpression);
return new AcBuilder<TFeature>(methodName, _entries);
}
/// <summary>
/// Overload for ACs with parameters (the lambda ignores them — we only need the method name).
/// </summary>
protected AcBuilder<TFeature> Ac<T1>(
Func<TFeature, T1, AcceptanceCriterionResult> acSelector,
[System.Runtime.CompilerServices.CallerArgumentExpression("acSelector")]
string? acExpression = null)
{
var methodName = ExtractMethodName(acExpression);
return new AcBuilder<TFeature>(methodName, _entries);
}
protected AcBuilder<TFeature> Ac<T1, T2>(
Func<TFeature, T1, T2, AcceptanceCriterionResult> acSelector,
[System.Runtime.CompilerServices.CallerArgumentExpression("acSelector")]
string? acExpression = null)
{
var methodName = ExtractMethodName(acExpression);
return new AcBuilder<TFeature>(methodName, _entries);
}
protected AcBuilder<TFeature> Ac<T1, T2, T3>(
Func<TFeature, T1, T2, T3, AcceptanceCriterionResult> acSelector,
[System.Runtime.CompilerServices.CallerArgumentExpression("acSelector")]
string? acExpression = null)
{
var methodName = ExtractMethodName(acExpression);
return new AcBuilder<TFeature>(methodName, _entries);
}
protected AcBuilder<TFeature> Ac<T1, T2, T3, T4>(
Func<TFeature, T1, T2, T3, T4, AcceptanceCriterionResult> acSelector,
[System.Runtime.CompilerServices.CallerArgumentExpression("acSelector")]
string? acExpression = null)
{
var methodName = ExtractMethodName(acExpression);
return new AcBuilder<TFeature>(methodName, _entries);
}
/// <summary>
/// Get all registered mappings (after Configure() has been called).
/// </summary>
public IReadOnlyList<AcMappingEntry> GetMappings()
{
if (_entries.Count == 0) Configure();
return _entries.AsReadOnly();
}
private static string ExtractMethodName(string? expression)
{
if (string.IsNullOrEmpty(expression))
throw new InvalidOperationException("CallerArgumentExpression not available");
// "f => f.OrderTotalMustBePositive" → "OrderTotalMustBePositive"
// "(f, _, _) => f.OrderTotalMustBePositive" → "OrderTotalMustBePositive"
var lastDot = expression.LastIndexOf('.');
if (lastDot < 0)
throw new InvalidOperationException($"Cannot extract method name from: {expression}");
var name = expression[(lastDot + 1)..].Trim();
// Remove trailing parentheses if present
var parenIdx = name.IndexOf('(');
if (parenIdx >= 0) name = name[..parenIdx];
return name;
}
}The AcBuilder
// MegaCorp.Requirements/Mapping/AcBuilder.cs
namespace MegaCorp.Requirements.Mapping;
/// <summary>
/// Fluent builder for mapping an AC to its implementation.
/// </summary>
public class AcBuilder<TFeature> where TFeature : RequirementMetadata
{
private readonly string _acName;
private readonly List<AcMappingEntry> _entries;
internal AcBuilder(string acName, List<AcMappingEntry> entries)
{
_acName = acName;
_entries = entries;
}
/// <summary>
/// Declare which production class and method implement this AC.
/// </summary>
public AcBuilder<TFeature> ImplementedBy<TService>(
Func<TService, Delegate> methodSelector,
[System.Runtime.CompilerServices.CallerArgumentExpression("methodSelector")]
string? methodExpression = null)
{
var methodName = ExtractMethodName(methodExpression);
_entries.Add(new AcMappingEntry(
FeatureType: typeof(TFeature),
AcceptanceCriterion: _acName,
ImplementationType: typeof(TService),
ImplementationMethod: methodName));
return this; // Allow chaining for multi-impl ACs
}
/// <summary>
/// Overload for methods that return non-delegate types.
/// Uses expression to extract the method name.
/// </summary>
public AcBuilder<TFeature> ImplementedBy<TService>(
string methodName)
{
_entries.Add(new AcMappingEntry(
FeatureType: typeof(TFeature),
AcceptanceCriterion: _acName,
ImplementationType: typeof(TService),
ImplementationMethod: methodName));
return this;
}
private static string ExtractMethodName(string? expression)
{
if (string.IsNullOrEmpty(expression))
throw new InvalidOperationException("CallerArgumentExpression not available");
var lastDot = expression.LastIndexOf('.');
if (lastDot < 0) return expression.Trim();
var name = expression[(lastDot + 1)..].Trim();
var parenIdx = name.IndexOf('(');
if (parenIdx >= 0) name = name[..parenIdx];
return name;
}
}
/// <summary>
/// A single AC → implementation mapping entry.
/// </summary>
public record AcMappingEntry(
Type FeatureType,
string AcceptanceCriterion,
Type ImplementationType,
string ImplementationMethod);// MegaCorp.Requirements/Mapping/AcBuilder.cs
namespace MegaCorp.Requirements.Mapping;
/// <summary>
/// Fluent builder for mapping an AC to its implementation.
/// </summary>
public class AcBuilder<TFeature> where TFeature : RequirementMetadata
{
private readonly string _acName;
private readonly List<AcMappingEntry> _entries;
internal AcBuilder(string acName, List<AcMappingEntry> entries)
{
_acName = acName;
_entries = entries;
}
/// <summary>
/// Declare which production class and method implement this AC.
/// </summary>
public AcBuilder<TFeature> ImplementedBy<TService>(
Func<TService, Delegate> methodSelector,
[System.Runtime.CompilerServices.CallerArgumentExpression("methodSelector")]
string? methodExpression = null)
{
var methodName = ExtractMethodName(methodExpression);
_entries.Add(new AcMappingEntry(
FeatureType: typeof(TFeature),
AcceptanceCriterion: _acName,
ImplementationType: typeof(TService),
ImplementationMethod: methodName));
return this; // Allow chaining for multi-impl ACs
}
/// <summary>
/// Overload for methods that return non-delegate types.
/// Uses expression to extract the method name.
/// </summary>
public AcBuilder<TFeature> ImplementedBy<TService>(
string methodName)
{
_entries.Add(new AcMappingEntry(
FeatureType: typeof(TFeature),
AcceptanceCriterion: _acName,
ImplementationType: typeof(TService),
ImplementationMethod: methodName));
return this;
}
private static string ExtractMethodName(string? expression)
{
if (string.IsNullOrEmpty(expression))
throw new InvalidOperationException("CallerArgumentExpression not available");
var lastDot = expression.LastIndexOf('.');
if (lastDot < 0) return expression.Trim();
var name = expression[(lastDot + 1)..].Trim();
var parenIdx = name.IndexOf('(');
if (parenIdx >= 0) name = name[..parenIdx];
return name;
}
}
/// <summary>
/// A single AC → implementation mapping entry.
/// </summary>
public record AcMappingEntry(
Type FeatureType,
string AcceptanceCriterion,
Type ImplementationType,
string ImplementationMethod);Concrete Mappings — All 8 Features
// MegaCorp.Requirements/Mappings/OrderProcessingMapping.cs
namespace MegaCorp.Requirements.Mappings;
using MegaCorp.Requirements.Features;
using MegaCorp.Requirements.Mapping;
using MegaCorp.OrderService;
using MegaCorp.InventoryService;
using MegaCorp.PaymentGateway;
using MegaCorp.NotificationService;
using MegaCorp.AuditService;
using MegaCorp.UserService;
public class OrderProcessingMapping : FeatureMapping<OrderProcessingFeature>
{
protected override void Configure()
{
// AC-1: Order total validation → OrderService
Ac(f => f.OrderTotalMustBePositive)
.ImplementedBy<OrderProcessingService>(nameof(OrderProcessingService.ValidateOrderTotal));
// AC-2: Inventory reservation → InventoryService
Ac(f => f.InventoryReservedBeforePayment)
.ImplementedBy<InventoryReservationService>(nameof(InventoryReservationService.ReserveInventory));
// AC-3: Payment capture → PaymentGateway
Ac(f => f.PaymentCapturedAfterReservation)
.ImplementedBy<StripePaymentService>(nameof(StripePaymentService.CapturePayment));
// AC-4: Notification → NotificationService
Ac(f => f.ConfirmationSentAfterPayment)
.ImplementedBy<OrderNotificationService>(nameof(OrderNotificationService.SendOrderConfirmation));
// AC-5: Audit → AuditService
Ac(f => f.AllOperationsAudited)
.ImplementedBy<AuditService>(nameof(AuditService.RecordAuditTrail));
// AC-6: Inventory release on failure → InventoryService
Ac(f => f.FailedPaymentReleasesInventory)
.ImplementedBy<InventoryReservationService>(nameof(InventoryReservationService.ReleaseReservations));
// AC-7: Bulk discount → OrderService
Ac(f => f.BulkOrderDiscountApplied)
.ImplementedBy<OrderProcessingService>(nameof(OrderProcessingService.ApplyBulkDiscount));
// AC-8: Persistence → OrderService
Ac(f => f.OrderPersistedWithFullDetail)
.ImplementedBy<OrderProcessingService>(nameof(OrderProcessingService.PersistOrder));
// AC-9: Loyalty points → UserService
Ac(f => f.LoyaltyPointsCredited)
.ImplementedBy<LoyaltyPointService>(nameof(LoyaltyPointService.CreditLoyaltyPoints));
}
}// MegaCorp.Requirements/Mappings/OrderProcessingMapping.cs
namespace MegaCorp.Requirements.Mappings;
using MegaCorp.Requirements.Features;
using MegaCorp.Requirements.Mapping;
using MegaCorp.OrderService;
using MegaCorp.InventoryService;
using MegaCorp.PaymentGateway;
using MegaCorp.NotificationService;
using MegaCorp.AuditService;
using MegaCorp.UserService;
public class OrderProcessingMapping : FeatureMapping<OrderProcessingFeature>
{
protected override void Configure()
{
// AC-1: Order total validation → OrderService
Ac(f => f.OrderTotalMustBePositive)
.ImplementedBy<OrderProcessingService>(nameof(OrderProcessingService.ValidateOrderTotal));
// AC-2: Inventory reservation → InventoryService
Ac(f => f.InventoryReservedBeforePayment)
.ImplementedBy<InventoryReservationService>(nameof(InventoryReservationService.ReserveInventory));
// AC-3: Payment capture → PaymentGateway
Ac(f => f.PaymentCapturedAfterReservation)
.ImplementedBy<StripePaymentService>(nameof(StripePaymentService.CapturePayment));
// AC-4: Notification → NotificationService
Ac(f => f.ConfirmationSentAfterPayment)
.ImplementedBy<OrderNotificationService>(nameof(OrderNotificationService.SendOrderConfirmation));
// AC-5: Audit → AuditService
Ac(f => f.AllOperationsAudited)
.ImplementedBy<AuditService>(nameof(AuditService.RecordAuditTrail));
// AC-6: Inventory release on failure → InventoryService
Ac(f => f.FailedPaymentReleasesInventory)
.ImplementedBy<InventoryReservationService>(nameof(InventoryReservationService.ReleaseReservations));
// AC-7: Bulk discount → OrderService
Ac(f => f.BulkOrderDiscountApplied)
.ImplementedBy<OrderProcessingService>(nameof(OrderProcessingService.ApplyBulkDiscount));
// AC-8: Persistence → OrderService
Ac(f => f.OrderPersistedWithFullDetail)
.ImplementedBy<OrderProcessingService>(nameof(OrderProcessingService.PersistOrder));
// AC-9: Loyalty points → UserService
Ac(f => f.LoyaltyPointsCredited)
.ImplementedBy<LoyaltyPointService>(nameof(LoyaltyPointService.CreditLoyaltyPoints));
}
}// MegaCorp.Requirements/Mappings/PaymentProcessingMapping.cs
public class PaymentProcessingMapping : FeatureMapping<PaymentProcessingFeature>
{
protected override void Configure()
{
Ac(f => f.PaymentAmountMatchesOrderTotal)
.ImplementedBy<StripePaymentService>(nameof(StripePaymentService.ValidateAmount));
Ac(f => f.RefundDoesNotExceedOriginal)
.ImplementedBy<RefundService>(nameof(RefundService.ValidateRefundAmount));
Ac(f => f.PaymentFailureIsStructured)
.ImplementedBy<StripePaymentService>(nameof(StripePaymentService.HandleFailure));
Ac(f => f.PaymentIsIdempotent)
.ImplementedBy<StripePaymentService>(nameof(StripePaymentService.CapturePayment));
Ac(f => f.NoPciDataStored)
.ImplementedBy<StripePaymentService>(nameof(StripePaymentService.SanitizeLogEntry));
}
}// MegaCorp.Requirements/Mappings/PaymentProcessingMapping.cs
public class PaymentProcessingMapping : FeatureMapping<PaymentProcessingFeature>
{
protected override void Configure()
{
Ac(f => f.PaymentAmountMatchesOrderTotal)
.ImplementedBy<StripePaymentService>(nameof(StripePaymentService.ValidateAmount));
Ac(f => f.RefundDoesNotExceedOriginal)
.ImplementedBy<RefundService>(nameof(RefundService.ValidateRefundAmount));
Ac(f => f.PaymentFailureIsStructured)
.ImplementedBy<StripePaymentService>(nameof(StripePaymentService.HandleFailure));
Ac(f => f.PaymentIsIdempotent)
.ImplementedBy<StripePaymentService>(nameof(StripePaymentService.CapturePayment));
Ac(f => f.NoPciDataStored)
.ImplementedBy<StripePaymentService>(nameof(StripePaymentService.SanitizeLogEntry));
}
}// MegaCorp.Requirements/Mappings/InventoryManagementMapping.cs
public class InventoryManagementMapping : FeatureMapping<InventoryManagementFeature>
{
protected override void Configure()
{
Ac(f => f.StockDecrementedAtomically)
.ImplementedBy<InventoryReservationService>(
nameof(InventoryReservationService.ReserveInventory));
Ac(f => f.ConcurrentReservationsSerialized)
.ImplementedBy<InventoryReservationService>(
nameof(InventoryReservationService.ReserveInventory));
Ac(f => f.ReleasedReservationsRestoreStock)
.ImplementedBy<InventoryReservationService>(
nameof(InventoryReservationService.ReleaseReservations));
Ac(f => f.OutOfStockReturnsAlternatives)
.ImplementedBy<StockQueryService>(
nameof(StockQueryService.FindAlternatives));
}
}// MegaCorp.Requirements/Mappings/InventoryManagementMapping.cs
public class InventoryManagementMapping : FeatureMapping<InventoryManagementFeature>
{
protected override void Configure()
{
Ac(f => f.StockDecrementedAtomically)
.ImplementedBy<InventoryReservationService>(
nameof(InventoryReservationService.ReserveInventory));
Ac(f => f.ConcurrentReservationsSerialized)
.ImplementedBy<InventoryReservationService>(
nameof(InventoryReservationService.ReserveInventory));
Ac(f => f.ReleasedReservationsRestoreStock)
.ImplementedBy<InventoryReservationService>(
nameof(InventoryReservationService.ReleaseReservations));
Ac(f => f.OutOfStockReturnsAlternatives)
.ImplementedBy<StockQueryService>(
nameof(StockQueryService.FindAlternatives));
}
}The Fluent Builder Pipeline
Unit Testing the Mappings
The fluent builder can be tested — verify that all ACs are mapped:
// MegaCorp.Requirements.Tests/MappingTests.cs
[TestFixture]
public class MappingTests
{
[Test]
public void OrderProcessing_all_ACs_are_mapped()
{
var mapping = new OrderProcessingMapping();
var entries = mapping.GetMappings();
// Get all AC method names from the Feature type
var acMethods = typeof(OrderProcessingFeature)
.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)
.Where(m => m.ReturnType == typeof(AcceptanceCriterionResult))
.Select(m => m.Name)
.ToHashSet();
var mappedAcs = entries.Select(e => e.AcceptanceCriterion).ToHashSet();
var unmapped = acMethods.Except(mappedAcs).ToList();
Assert.That(unmapped, Is.Empty,
$"Unmapped ACs: {string.Join(", ", unmapped)}");
}
[Test]
public void OrderProcessing_all_implementation_methods_exist()
{
var mapping = new OrderProcessingMapping();
var entries = mapping.GetMappings();
foreach (var entry in entries)
{
var method = entry.ImplementationType.GetMethod(entry.ImplementationMethod,
BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
Assert.That(method, Is.Not.Null,
$"Method {entry.ImplementationType.Name}.{entry.ImplementationMethod} not found " +
$"(mapped from AC {entry.AcceptanceCriterion})");
}
}
[Test]
public void All_features_have_mappings()
{
// Discover all FeatureMapping<T> subclasses via reflection
var mappingTypes = typeof(OrderProcessingMapping).Assembly
.GetTypes()
.Where(t => !t.IsAbstract && t.BaseType?.IsGenericType == true
&& t.BaseType.GetGenericTypeDefinition() == typeof(FeatureMapping<>))
.ToList();
// Discover all Feature types
var featureTypes = typeof(OrderProcessingFeature).Assembly
.GetTypes()
.Where(t => !t.IsAbstract && IsFeatureType(t))
.ToList();
var mappedFeatures = mappingTypes
.Select(t => t.BaseType!.GetGenericArguments()[0])
.ToHashSet();
var unmappedFeatures = featureTypes.Except(mappedFeatures).ToList();
Assert.That(unmappedFeatures, Is.Empty,
$"Features without mappings: {string.Join(", ", unmappedFeatures.Select(t => t.Name))}");
}
private static bool IsFeatureType(Type t)
{
var current = t;
while (current != null)
{
if (current == typeof(RequirementMetadata)) return true;
if (current.IsGenericType)
{
var def = current.GetGenericTypeDefinition();
if (def.Name.StartsWith("Feature")) return true;
}
current = current.BaseType;
}
return false;
}
}// MegaCorp.Requirements.Tests/MappingTests.cs
[TestFixture]
public class MappingTests
{
[Test]
public void OrderProcessing_all_ACs_are_mapped()
{
var mapping = new OrderProcessingMapping();
var entries = mapping.GetMappings();
// Get all AC method names from the Feature type
var acMethods = typeof(OrderProcessingFeature)
.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)
.Where(m => m.ReturnType == typeof(AcceptanceCriterionResult))
.Select(m => m.Name)
.ToHashSet();
var mappedAcs = entries.Select(e => e.AcceptanceCriterion).ToHashSet();
var unmapped = acMethods.Except(mappedAcs).ToList();
Assert.That(unmapped, Is.Empty,
$"Unmapped ACs: {string.Join(", ", unmapped)}");
}
[Test]
public void OrderProcessing_all_implementation_methods_exist()
{
var mapping = new OrderProcessingMapping();
var entries = mapping.GetMappings();
foreach (var entry in entries)
{
var method = entry.ImplementationType.GetMethod(entry.ImplementationMethod,
BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
Assert.That(method, Is.Not.Null,
$"Method {entry.ImplementationType.Name}.{entry.ImplementationMethod} not found " +
$"(mapped from AC {entry.AcceptanceCriterion})");
}
}
[Test]
public void All_features_have_mappings()
{
// Discover all FeatureMapping<T> subclasses via reflection
var mappingTypes = typeof(OrderProcessingMapping).Assembly
.GetTypes()
.Where(t => !t.IsAbstract && t.BaseType?.IsGenericType == true
&& t.BaseType.GetGenericTypeDefinition() == typeof(FeatureMapping<>))
.ToList();
// Discover all Feature types
var featureTypes = typeof(OrderProcessingFeature).Assembly
.GetTypes()
.Where(t => !t.IsAbstract && IsFeatureType(t))
.ToList();
var mappedFeatures = mappingTypes
.Select(t => t.BaseType!.GetGenericArguments()[0])
.ToHashSet();
var unmappedFeatures = featureTypes.Except(mappedFeatures).ToList();
Assert.That(unmappedFeatures, Is.Empty,
$"Features without mappings: {string.Join(", ", unmappedFeatures.Select(t => t.Name))}");
}
private static bool IsFeatureType(Type t)
{
var current = t;
while (current != null)
{
if (current == typeof(RequirementMetadata)) return true;
if (current.IsGenericType)
{
var def = current.GetGenericTypeDefinition();
if (def.Name.StartsWith("Feature")) return true;
}
current = current.BaseType;
}
return false;
}
}Edge Cases
Multiple implementations per AC:
// An AC implemented by two services (cross-service feature)
Ac(f => f.InventoryReservedBeforePayment)
.ImplementedBy<InventoryReservationService>(nameof(InventoryReservationService.ReserveInventory))
.ImplementedBy<WarehouseService>(nameof(WarehouseService.ValidateWarehouseCapacity));// An AC implemented by two services (cross-service feature)
Ac(f => f.InventoryReservedBeforePayment)
.ImplementedBy<InventoryReservationService>(nameof(InventoryReservationService.ReserveInventory))
.ImplementedBy<WarehouseService>(nameof(WarehouseService.ValidateWarehouseCapacity));Deprecated AC (still tracked but marked as legacy):
// AC exists in the Feature type but implementation is being phased out
Ac(f => f.LegacyDiscountCalculation)
.ImplementedBy<LegacyPricingEngine>(nameof(LegacyPricingEngine.CalculateDiscount))
.Deprecated("Replaced by AC-7 BulkOrderDiscountApplied — remove in v4.0");// AC exists in the Feature type but implementation is being phased out
Ac(f => f.LegacyDiscountCalculation)
.ImplementedBy<LegacyPricingEngine>(nameof(LegacyPricingEngine.CalculateDiscount))
.Deprecated("Replaced by AC-7 BulkOrderDiscountApplied — remove in v4.0");7. Test Mapping — [Verifies] Stays in Tests
Tests are not production artifacts. They don't ship. They don't enter Docker images. So tests CAN reference Requirements — and that's where [Verifies] lives.
7a. Why Tests Are the Exception
In the inverted model:
- Production code: NO reference to Requirements. Zero knowledge. Clean DLLs.
- Test code: YES reference to Requirements. Tests aren't shipped, so no sensitivity concern.
This creates a natural split: production is clean, tests carry the requirement metadata. The test project is the only place where production types and requirement types coexist in the same compilation.
The test project is the "meeting point" — it sees both production types (to call them) and requirement types (to link tests to ACs via [Verifies]).
7b. The Test .csproj — Dual References
<!-- MegaCorp.OrderService.Tests/MegaCorp.OrderService.Tests.csproj -->
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<!-- Production code to test -->
<ProjectReference Include="..\MegaCorp.OrderService\MegaCorp.OrderService.csproj" />
<!-- Requirements for [Verifies] and typeof(Feature) -->
<ProjectReference Include="..\MegaCorp.Requirements\MegaCorp.Requirements.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="NUnit" Version="4.0.1" />
<PackageReference Include="NUnit3TestAdapter" Version="4.5.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
</ItemGroup>
</Project><!-- MegaCorp.OrderService.Tests/MegaCorp.OrderService.Tests.csproj -->
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<!-- Production code to test -->
<ProjectReference Include="..\MegaCorp.OrderService\MegaCorp.OrderService.csproj" />
<!-- Requirements for [Verifies] and typeof(Feature) -->
<ProjectReference Include="..\MegaCorp.Requirements\MegaCorp.Requirements.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="NUnit" Version="4.0.1" />
<PackageReference Include="NUnit3TestAdapter" Version="4.5.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
</ItemGroup>
</Project>This is safe: test assemblies are excluded from dotnet publish, Docker COPY, and NuGet pack. They exist only in the build environment.
Auto-inject with Directory.Build.props:
<!-- Directory.Build.props -->
<Project>
<ItemGroup Condition="'$(IsTestProject)' == 'true'">
<!-- All test projects automatically reference Requirements -->
<ProjectReference Include="$(SolutionDir)src\MegaCorp.Requirements\MegaCorp.Requirements.csproj" />
</ItemGroup>
</Project><!-- Directory.Build.props -->
<Project>
<ItemGroup Condition="'$(IsTestProject)' == 'true'">
<!-- All test projects automatically reference Requirements -->
<ProjectReference Include="$(SolutionDir)src\MegaCorp.Requirements\MegaCorp.Requirements.csproj" />
</ItemGroup>
</Project>7c. Full Test Code — Inverted Mode
The test body is identical to Parts 1-6. Only the imports and .csproj differ:
// MegaCorp.OrderService.Tests/OrderProcessingTests.cs
namespace MegaCorp.OrderService.Tests;
using MegaCorp.Requirements.Features; // ← from Requirements (for [Verifies])
using MegaCorp.Requirements; // ← from Requirements (for typeof, nameof)
using MegaCorp.OrderService; // ← from production (code under test)
using MegaCorp.SharedKernel;
[TestFixture]
[TestsFor(typeof(OrderProcessingFeature))]
public class OrderProcessingTests
{
private OrderProcessingService _service;
// ... setup with real or in-memory dependencies
[Test]
[Verifies(typeof(OrderProcessingFeature),
nameof(OrderProcessingFeature.OrderTotalMustBePositive))]
public void Positive_total_is_accepted()
{
var order = CreateOrder(total: 99.99m);
var result = _service.ValidateOrderTotal(order);
Assert.That(result.IsSuccess, Is.True);
}
[Test]
[Verifies(typeof(OrderProcessingFeature),
nameof(OrderProcessingFeature.OrderTotalMustBePositive))]
public void Negative_total_is_rejected()
{
var order = CreateOrder(total: -50m);
var result = _service.ValidateOrderTotal(order);
Assert.That(result.IsSuccess, Is.False);
}
[Test]
[Verifies(typeof(OrderProcessingFeature),
nameof(OrderProcessingFeature.InventoryReservedBeforePayment))]
public async Task Inventory_reserved_before_payment()
{
var command = CreateOrderCommand(items: new[] { ("SKU-001", 5) });
var result = await _service.ProcessOrder(command);
Assert.That(result.IsSuccess, Is.True);
Assert.That(_reservationRepo.Reservations.Count, Is.GreaterThan(0));
Assert.That(_stripeClient.ChargeAttempts.First().AttemptedAt,
Is.GreaterThan(_reservationRepo.Reservations.First().CreatedAt));
}
[Test]
[Verifies(typeof(OrderProcessingFeature),
nameof(OrderProcessingFeature.PaymentCapturedAfterReservation))]
public async Task Payment_amount_matches_order_total()
{
_stripeClient.Configure(succeed: true);
var command = CreateOrderCommand(items: new[] { ("SKU-001", 3) }, unitPrice: 25.00m);
var result = await _service.ProcessOrder(command);
Assert.That(result.IsSuccess, Is.True);
Assert.That(_stripeClient.ChargeAttempts.First().Amount, Is.EqualTo(7500L));
}
[Test]
[Verifies(typeof(OrderProcessingFeature),
nameof(OrderProcessingFeature.FailedPaymentReleasesInventory))]
public async Task Failed_payment_releases_inventory()
{
_stripeClient.Configure(succeed: false, failureReason: "Card declined");
var initialStock = _stockRepo.GetAvailable("SKU-001");
var command = CreateOrderCommand(items: new[] { ("SKU-001", 5) });
var result = await _service.ProcessOrder(command);
Assert.That(result.IsSuccess, Is.False);
Assert.That(_stockRepo.GetAvailable("SKU-001"), Is.EqualTo(initialStock));
}
[Test]
[Verifies(typeof(OrderProcessingFeature),
nameof(OrderProcessingFeature.BulkOrderDiscountApplied))]
public void Bulk_order_receives_discount()
{
var order = CreateOrderWithLines(
Enumerable.Range(1, 12).Select(i => ($"SKU-{i:000}", 1, 10.00m)).ToArray());
var result = _service.ApplyBulkDiscount(order);
Assert.That(result.IsSuccess, Is.True);
Assert.That(result.Value.Total.Amount, Is.EqualTo(114.00m)); // 120 * 0.95
}
[Test]
[Verifies(typeof(OrderProcessingFeature),
nameof(OrderProcessingFeature.LoyaltyPointsCredited))]
public async Task Loyalty_points_credited_after_order()
{
_stripeClient.Configure(succeed: true);
var command = CreateOrderCommand(items: new[] { ("SKU-001", 5) }, unitPrice: 20.00m);
var result = await _service.ProcessOrder(command);
Assert.That(result.IsSuccess, Is.True);
Assert.That(_loyaltyRepo.GetPoints(command.CustomerId), Is.EqualTo(10));
}
}// MegaCorp.OrderService.Tests/OrderProcessingTests.cs
namespace MegaCorp.OrderService.Tests;
using MegaCorp.Requirements.Features; // ← from Requirements (for [Verifies])
using MegaCorp.Requirements; // ← from Requirements (for typeof, nameof)
using MegaCorp.OrderService; // ← from production (code under test)
using MegaCorp.SharedKernel;
[TestFixture]
[TestsFor(typeof(OrderProcessingFeature))]
public class OrderProcessingTests
{
private OrderProcessingService _service;
// ... setup with real or in-memory dependencies
[Test]
[Verifies(typeof(OrderProcessingFeature),
nameof(OrderProcessingFeature.OrderTotalMustBePositive))]
public void Positive_total_is_accepted()
{
var order = CreateOrder(total: 99.99m);
var result = _service.ValidateOrderTotal(order);
Assert.That(result.IsSuccess, Is.True);
}
[Test]
[Verifies(typeof(OrderProcessingFeature),
nameof(OrderProcessingFeature.OrderTotalMustBePositive))]
public void Negative_total_is_rejected()
{
var order = CreateOrder(total: -50m);
var result = _service.ValidateOrderTotal(order);
Assert.That(result.IsSuccess, Is.False);
}
[Test]
[Verifies(typeof(OrderProcessingFeature),
nameof(OrderProcessingFeature.InventoryReservedBeforePayment))]
public async Task Inventory_reserved_before_payment()
{
var command = CreateOrderCommand(items: new[] { ("SKU-001", 5) });
var result = await _service.ProcessOrder(command);
Assert.That(result.IsSuccess, Is.True);
Assert.That(_reservationRepo.Reservations.Count, Is.GreaterThan(0));
Assert.That(_stripeClient.ChargeAttempts.First().AttemptedAt,
Is.GreaterThan(_reservationRepo.Reservations.First().CreatedAt));
}
[Test]
[Verifies(typeof(OrderProcessingFeature),
nameof(OrderProcessingFeature.PaymentCapturedAfterReservation))]
public async Task Payment_amount_matches_order_total()
{
_stripeClient.Configure(succeed: true);
var command = CreateOrderCommand(items: new[] { ("SKU-001", 3) }, unitPrice: 25.00m);
var result = await _service.ProcessOrder(command);
Assert.That(result.IsSuccess, Is.True);
Assert.That(_stripeClient.ChargeAttempts.First().Amount, Is.EqualTo(7500L));
}
[Test]
[Verifies(typeof(OrderProcessingFeature),
nameof(OrderProcessingFeature.FailedPaymentReleasesInventory))]
public async Task Failed_payment_releases_inventory()
{
_stripeClient.Configure(succeed: false, failureReason: "Card declined");
var initialStock = _stockRepo.GetAvailable("SKU-001");
var command = CreateOrderCommand(items: new[] { ("SKU-001", 5) });
var result = await _service.ProcessOrder(command);
Assert.That(result.IsSuccess, Is.False);
Assert.That(_stockRepo.GetAvailable("SKU-001"), Is.EqualTo(initialStock));
}
[Test]
[Verifies(typeof(OrderProcessingFeature),
nameof(OrderProcessingFeature.BulkOrderDiscountApplied))]
public void Bulk_order_receives_discount()
{
var order = CreateOrderWithLines(
Enumerable.Range(1, 12).Select(i => ($"SKU-{i:000}", 1, 10.00m)).ToArray());
var result = _service.ApplyBulkDiscount(order);
Assert.That(result.IsSuccess, Is.True);
Assert.That(result.Value.Total.Amount, Is.EqualTo(114.00m)); // 120 * 0.95
}
[Test]
[Verifies(typeof(OrderProcessingFeature),
nameof(OrderProcessingFeature.LoyaltyPointsCredited))]
public async Task Loyalty_points_credited_after_order()
{
_stripeClient.Configure(succeed: true);
var command = CreateOrderCommand(items: new[] { ("SKU-001", 5) }, unitPrice: 20.00m);
var result = await _service.ProcessOrder(command);
Assert.That(result.IsSuccess, Is.True);
Assert.That(_loyaltyRepo.GetPoints(command.CustomerId), Is.EqualTo(10));
}
}What changed from Parts 1-6? Almost nothing. The [Verifies] attribute, the typeof(), the nameof() — all identical. The only difference is the .csproj: the test references Requirements directly instead of through a Specifications chain.
7d. Test-First Workflow
The TDD cycle in inverted mode:
- Define AC in Requirements:
public abstract AcceptanceCriterionResult LoyaltyPointsCredited(...) - Write test with
[Verifies]: thetypeof(OrderProcessingFeature)compiles (it's in Requirements). Thenameof(OrderProcessingFeature.LoyaltyPointsCredited)compiles (the AC exists). But the test body calls_service.CreditLoyaltyPoints()which doesn't exist yet → test doesn't compile. - Write production code: add
CreditLoyaltyPointstoLoyaltyPointService→ test compiles and passes. - Add fluent mapping in Requirements:
Ac(f => f.LoyaltyPointsCredited).ImplementedBy<LoyaltyPointService>(...)→ TraceabilityMatrix updated.
The key insight: the [Verifies] attribute compiles before the production code exists because it references the Feature type (in Requirements), not the production method. The test body fails to compile until the production method exists — which is exactly the TDD red-green cycle.
7e. Test Coverage Reporting
The Roslyn analyzer scans test assemblies for [Verifies] attributes and cross-references with the fluent builder mappings:
| Diagnostic | Severity | Trigger |
|---|---|---|
| REQ301 | Warning | AC has a fluent mapping but no [Verifies] test |
| REQ302 | Error | [Verifies] references an AC that doesn't exist (stale test) |
| REQ303 | Info | Feature fully tested — all ACs have [Verifies] |
The TraceabilityMatrix includes test coverage:
AC-7 BulkOrderDiscountApplied:
Mapped to: OrderProcessingService.ApplyBulkDiscount
Tests: Bulk_order_receives_discount, Small_order_no_discount (2 tests)
Status: ✓ coveredAC-7 BulkOrderDiscountApplied:
Mapped to: OrderProcessingService.ApplyBulkDiscount
Tests: Bulk_order_receives_discount, Small_order_no_discount (2 tests)
Status: ✓ covered7f. Framework Integration
[Verifies] is a plain C# attribute — not a test framework extension:
// Works with NUnit
[Test]
[Verifies(typeof(OrderProcessingFeature), nameof(OrderProcessingFeature.OrderTotalMustBePositive))]
public void Nunit_test() { ... }
// Works with xUnit
[Fact]
[Verifies(typeof(OrderProcessingFeature), nameof(OrderProcessingFeature.OrderTotalMustBePositive))]
public void Xunit_test() { ... }
// Works with MSTest
[TestMethod]
[Verifies(typeof(OrderProcessingFeature), nameof(OrderProcessingFeature.OrderTotalMustBePositive))]
public void Mstest_test() { ... }// Works with NUnit
[Test]
[Verifies(typeof(OrderProcessingFeature), nameof(OrderProcessingFeature.OrderTotalMustBePositive))]
public void Nunit_test() { ... }
// Works with xUnit
[Fact]
[Verifies(typeof(OrderProcessingFeature), nameof(OrderProcessingFeature.OrderTotalMustBePositive))]
public void Xunit_test() { ... }
// Works with MSTest
[TestMethod]
[Verifies(typeof(OrderProcessingFeature), nameof(OrderProcessingFeature.OrderTotalMustBePositive))]
public void Mstest_test() { ... }No test runner plugin needed. The attribute is read by the Roslyn analyzer at compile time, not by the test runner at execution time.
8. The Roslyn Analyzer and Source Generator — Inverted Mode
This is the technical heart of the inverted approach. The analyzer and SG work together, feeding off the fluent builder mappings from Section 6.
8a. The Analyzer: Validation
The analyzer runs on the Requirements compilation only — not on production, not on tests. This is a key performance advantage over the original model.
In the original model (Parts 1-6), the analyzer ran on every project that had [ForRequirement] attributes — potentially 50 projects in a large monorepo. In inverted mode, it runs on one compilation (Requirements) that references all production assemblies. Same validation, one context.
What the analyzer validates:
// The analyzer discovers all FeatureMapping<T> subclasses in the Requirements compilation
// and validates the mappings against the referenced production assemblies.
// REQ101: Every AC must have a mapping
// Scan: all abstract methods returning AcceptanceCriterionResult on Feature types
// Check: each one appears in at least one Configure() call
// Error: "OrderProcessingFeature.LoyaltyPointsCredited has no mapping"
// REQ102: Referenced types must exist
// Scan: all typeof(T) references in ImplementedBy<T>() calls
// Check: T exists in a referenced assembly
// Error: "typeof(OrderProcessingService) — type not found in referenced assemblies"
// REQ103: Referenced methods must exist and be accessible
// Scan: all nameof(T.Method) references in ImplementedBy<T>(nameof(...)) calls
// Check: Method exists on T, is public or internal (with IVT)
// Error: "nameof(OrderProcessingService.ValidateOrderTotal) — method not found or inaccessible"
// REQ104: Method signature compatibility
// Scan: AC parameter types vs implementation method parameter types
// Check: parameter count and types are compatible
// Warning: "AC OrderTotalMustBePositive takes OrderSummary, but ValidateOrderTotal takes Order"
// REQ105: No unintended duplicate mappings
// Scan: same AC mapped to multiple implementations
// Check: if multiple, require explicit [MultiImpl] or .ImplementedBy().ImplementedBy() chain
// Warning: "AC InventoryReservedBeforePayment mapped to 2 implementations — intentional?"// The analyzer discovers all FeatureMapping<T> subclasses in the Requirements compilation
// and validates the mappings against the referenced production assemblies.
// REQ101: Every AC must have a mapping
// Scan: all abstract methods returning AcceptanceCriterionResult on Feature types
// Check: each one appears in at least one Configure() call
// Error: "OrderProcessingFeature.LoyaltyPointsCredited has no mapping"
// REQ102: Referenced types must exist
// Scan: all typeof(T) references in ImplementedBy<T>() calls
// Check: T exists in a referenced assembly
// Error: "typeof(OrderProcessingService) — type not found in referenced assemblies"
// REQ103: Referenced methods must exist and be accessible
// Scan: all nameof(T.Method) references in ImplementedBy<T>(nameof(...)) calls
// Check: Method exists on T, is public or internal (with IVT)
// Error: "nameof(OrderProcessingService.ValidateOrderTotal) — method not found or inaccessible"
// REQ104: Method signature compatibility
// Scan: AC parameter types vs implementation method parameter types
// Check: parameter count and types are compatible
// Warning: "AC OrderTotalMustBePositive takes OrderSummary, but ValidateOrderTotal takes Order"
// REQ105: No unintended duplicate mappings
// Scan: same AC mapped to multiple implementations
// Check: if multiple, require explicit [MultiImpl] or .ImplementedBy().ImplementedBy() chain
// Warning: "AC InventoryReservedBeforePayment mapped to 2 implementations — intentional?"Full diagnostic table — inverted mode vs original:
| Diagnostic | Original (Parts 1-6) | Inverted (Part 7) | Change |
|---|---|---|---|
| REQ100 | Feature has no [ForRequirement] interface | Feature has no FeatureMapping |
Same concept, different source |
| REQ101 | AC has no matching spec method | AC has no mapping in Configure() | Same concept, different source |
| REQ102 | N/A | Referenced typeof(T) not found | New — inverted-specific |
| REQ103 | N/A | Referenced nameof(T.M) not found | New — inverted-specific |
| REQ104 | N/A | Method signature mismatch | New — inverted-specific |
| REQ105 | N/A | Duplicate mapping without [MultiImpl] | New — inverted-specific |
| REQ200 | Spec interface not implemented | N/A (no spec interfaces in inverted) | Removed |
| REQ201 | Implementation missing [ForRequirement] | N/A (no attributes on production) | Removed |
| REQ300 | Feature has no [TestsFor] | Feature has no [TestsFor] | Unchanged |
| REQ301 | AC has no [Verifies] test | AC has no [Verifies] test | Unchanged |
| REQ302 | [Verifies] references nonexistent AC | [Verifies] references nonexistent AC | Unchanged |
The REQ2xx family is entirely removed — there are no specification interfaces in the inverted model (they're merged into Requirements, and the mapping replaces them). The REQ1xx family gains three new diagnostics for type/method resolution.
8b. The Source Generator: Artifacts from Fluent Builders
The SG reads the same fluent builder registrations the analyzer validates. It generates from the FeatureMapping<T>.Configure() calls:
1. TraceabilityMatrix.g.cs
// Generated in MegaCorp.Requirements/obj/
public static class TraceabilityMatrix
{
public static IReadOnlyDictionary<Type, TraceabilityEntry> Entries { get; } =
new Dictionary<Type, TraceabilityEntry>
{
[typeof(OrderProcessingFeature)] = new(
FeatureType: typeof(OrderProcessingFeature),
Mappings: new[]
{
new MappingRef("OrderTotalMustBePositive",
typeof(OrderProcessingService), "ValidateOrderTotal"),
new MappingRef("InventoryReservedBeforePayment",
typeof(InventoryReservationService), "ReserveInventory"),
new MappingRef("PaymentCapturedAfterReservation",
typeof(StripePaymentService), "CapturePayment"),
// ... all 9 ACs
},
Tests: new[]
{
new TestRef(typeof(OrderProcessingTests),
"Positive_total_is_accepted", "OrderTotalMustBePositive"),
new TestRef(typeof(OrderProcessingTests),
"Negative_total_is_rejected", "OrderTotalMustBePositive"),
// ... all test refs discovered from [Verifies]
},
AcceptanceCriteriaCoverage: new Dictionary<string, int>
{
["OrderTotalMustBePositive"] = 2,
["InventoryReservedBeforePayment"] = 1,
// ...
}),
};
}// Generated in MegaCorp.Requirements/obj/
public static class TraceabilityMatrix
{
public static IReadOnlyDictionary<Type, TraceabilityEntry> Entries { get; } =
new Dictionary<Type, TraceabilityEntry>
{
[typeof(OrderProcessingFeature)] = new(
FeatureType: typeof(OrderProcessingFeature),
Mappings: new[]
{
new MappingRef("OrderTotalMustBePositive",
typeof(OrderProcessingService), "ValidateOrderTotal"),
new MappingRef("InventoryReservedBeforePayment",
typeof(InventoryReservationService), "ReserveInventory"),
new MappingRef("PaymentCapturedAfterReservation",
typeof(StripePaymentService), "CapturePayment"),
// ... all 9 ACs
},
Tests: new[]
{
new TestRef(typeof(OrderProcessingTests),
"Positive_total_is_accepted", "OrderTotalMustBePositive"),
new TestRef(typeof(OrderProcessingTests),
"Negative_total_is_rejected", "OrderTotalMustBePositive"),
// ... all test refs discovered from [Verifies]
},
AcceptanceCriteriaCoverage: new Dictionary<string, int>
{
["OrderTotalMustBePositive"] = 2,
["InventoryReservedBeforePayment"] = 1,
// ...
}),
};
}2. RequirementRegistry.g.cs — same as Parts 1-6:
public static class RequirementRegistry
{
public static IReadOnlyDictionary<Type, RequirementInfo> All { get; } =
new Dictionary<Type, RequirementInfo>
{
[typeof(OrderProcessingFeature)] = new(
Type: typeof(OrderProcessingFeature),
Kind: RequirementKind.Feature,
Title: "Order Processing",
Parent: typeof(ECommerceEpic),
AcceptanceCriteria: new[]
{
"OrderTotalMustBePositive",
"InventoryReservedBeforePayment",
"PaymentCapturedAfterReservation",
// ...
}),
};
}public static class RequirementRegistry
{
public static IReadOnlyDictionary<Type, RequirementInfo> All { get; } =
new Dictionary<Type, RequirementInfo>
{
[typeof(OrderProcessingFeature)] = new(
Type: typeof(OrderProcessingFeature),
Kind: RequirementKind.Feature,
Title: "Order Processing",
Parent: typeof(ECommerceEpic),
AcceptanceCriteria: new[]
{
"OrderTotalMustBePositive",
"InventoryReservedBeforePayment",
"PaymentCapturedAfterReservation",
// ...
}),
};
}3. ComplianceReport.g.cs — generates markdown/JSON/CSV from the registry:
public static class ComplianceReport
{
public static string GenerateMarkdown()
{
var sb = new StringBuilder();
sb.AppendLine("# Requirement Compliance Report");
sb.AppendLine($"Generated: {DateTimeOffset.UtcNow:O}");
sb.AppendLine();
foreach (var (type, entry) in TraceabilityMatrix.Entries)
{
var info = RequirementRegistry.All[type];
var totalACs = info.AcceptanceCriteria.Length;
var testedACs = entry.AcceptanceCriteriaCoverage.Count;
var percent = totalACs > 0 ? (testedACs * 100 / totalACs) : 0;
sb.AppendLine($"## {info.Title} ({testedACs}/{totalACs} ACs — {percent}%)");
foreach (var ac in info.AcceptanceCriteria)
{
var mapping = entry.Mappings.FirstOrDefault(m => m.AcceptanceCriterion == ac);
var tests = entry.Tests.Where(t => t.AcceptanceCriterion == ac).ToList();
var status = tests.Count > 0 ? "✓" : "✗";
sb.AppendLine($" {status} {ac}");
if (mapping is not null)
sb.AppendLine($" Impl: {mapping.ImplementationType.Name}.{mapping.ImplementationMethod}");
sb.AppendLine($" Tests: {(tests.Count > 0 ? string.Join(", ", tests.Select(t => t.TestMethod)) : "NONE")}");
}
sb.AppendLine();
}
return sb.ToString();
}
public static string GenerateJson() { /* ... */ }
public static string GenerateCsv() { /* ... */ }
}public static class ComplianceReport
{
public static string GenerateMarkdown()
{
var sb = new StringBuilder();
sb.AppendLine("# Requirement Compliance Report");
sb.AppendLine($"Generated: {DateTimeOffset.UtcNow:O}");
sb.AppendLine();
foreach (var (type, entry) in TraceabilityMatrix.Entries)
{
var info = RequirementRegistry.All[type];
var totalACs = info.AcceptanceCriteria.Length;
var testedACs = entry.AcceptanceCriteriaCoverage.Count;
var percent = totalACs > 0 ? (testedACs * 100 / totalACs) : 0;
sb.AppendLine($"## {info.Title} ({testedACs}/{totalACs} ACs — {percent}%)");
foreach (var ac in info.AcceptanceCriteria)
{
var mapping = entry.Mappings.FirstOrDefault(m => m.AcceptanceCriterion == ac);
var tests = entry.Tests.Where(t => t.AcceptanceCriterion == ac).ToList();
var status = tests.Count > 0 ? "✓" : "✗";
sb.AppendLine($" {status} {ac}");
if (mapping is not null)
sb.AppendLine($" Impl: {mapping.ImplementationType.Name}.{mapping.ImplementationMethod}");
sb.AppendLine($" Tests: {(tests.Count > 0 ? string.Join(", ", tests.Select(t => t.TestMethod)) : "NONE")}");
}
sb.AppendLine();
}
return sb.ToString();
}
public static string GenerateJson() { /* ... */ }
public static string GenerateCsv() { /* ... */ }
}How the SG reads Configure() at compile time:
The SG does NOT execute Configure(). It uses Roslyn's semantic model to analyze the method body:
// Simplified: how the SG walks Configure()
public class MappingDiscoverer
{
public List<DiscoveredMapping> Discover(Compilation compilation)
{
var results = new List<DiscoveredMapping>();
// Find all classes inheriting FeatureMapping<T>
var featureMappingType = compilation.GetTypeByMetadataName(
"MegaCorp.Requirements.Mapping.FeatureMapping`1");
foreach (var tree in compilation.SyntaxTrees)
{
var model = compilation.GetSemanticModel(tree);
var root = tree.GetRoot();
// Find Configure() method overrides
var configMethods = root.DescendantNodes()
.OfType<MethodDeclarationSyntax>()
.Where(m => m.Identifier.Text == "Configure");
foreach (var method in configMethods)
{
var containingType = model.GetDeclaredSymbol(method)?.ContainingType;
if (containingType?.BaseType?.OriginalDefinition?.Equals(
featureMappingType, SymbolEqualityComparer.Default) != true)
continue;
// Extract the TFeature type argument
var featureType = containingType.BaseType.TypeArguments[0];
// Walk the method body for Ac().ImplementedBy() chains
var invocations = method.DescendantNodes()
.OfType<InvocationExpressionSyntax>();
foreach (var invocation in invocations)
{
if (IsImplementedByCall(invocation, model, out var acName,
out var implType, out var implMethod))
{
results.Add(new DiscoveredMapping(
featureType, acName, implType, implMethod));
}
}
}
}
return results;
}
}// Simplified: how the SG walks Configure()
public class MappingDiscoverer
{
public List<DiscoveredMapping> Discover(Compilation compilation)
{
var results = new List<DiscoveredMapping>();
// Find all classes inheriting FeatureMapping<T>
var featureMappingType = compilation.GetTypeByMetadataName(
"MegaCorp.Requirements.Mapping.FeatureMapping`1");
foreach (var tree in compilation.SyntaxTrees)
{
var model = compilation.GetSemanticModel(tree);
var root = tree.GetRoot();
// Find Configure() method overrides
var configMethods = root.DescendantNodes()
.OfType<MethodDeclarationSyntax>()
.Where(m => m.Identifier.Text == "Configure");
foreach (var method in configMethods)
{
var containingType = model.GetDeclaredSymbol(method)?.ContainingType;
if (containingType?.BaseType?.OriginalDefinition?.Equals(
featureMappingType, SymbolEqualityComparer.Default) != true)
continue;
// Extract the TFeature type argument
var featureType = containingType.BaseType.TypeArguments[0];
// Walk the method body for Ac().ImplementedBy() chains
var invocations = method.DescendantNodes()
.OfType<InvocationExpressionSyntax>();
foreach (var invocation in invocations)
{
if (IsImplementedByCall(invocation, model, out var acName,
out var implType, out var implMethod))
{
results.Add(new DiscoveredMapping(
featureType, acName, implType, implMethod));
}
}
}
}
return results;
}
}This is the same technique used by:
- EF Core compiled models — reads
OnModelCreating()at compile time - ASP.NET Minimal API source generators — reads
MapGet()/MapPost()calls - System.Text.Json source generation — reads
JsonSerializerContextdeclarations
8c. The Combined Pipeline
Both the analyzer and SG share MappingDiscoverer. The analyzer validates and emits diagnostics. The SG generates code. They run in the same Roslyn pipeline, on the same compilation, with the same discovered mappings.
Generated artifacts location:
MegaCorp.Requirements/obj/Debug/net8.0/generated/— TraceabilityMatrix.g.cs, etc.- These are in Requirements' output, NOT in production bins
- CI can copy them to an artifacts folder for audit purposes
9. Drift Detection — When Production Changes
The #1 operational risk of the inverted model: production code changes freely (it doesn't depend on Requirements). If someone renames a method, the fluent mapping breaks — but only in the Requirements build.
9a. The Drift Scenario
Developer renames: OrderProcessingService.ValidateOrderTotal
→ OrderProcessingService.CheckOrderTotal
Production build: ✓ SUCCESS (no dependency on Requirements)
Production tests: ✓ SUCCESS (tests call the new method name)
Requirements build: ✗ FAIL
error CS0117: 'OrderProcessingService' does not contain a definition for 'ValidateOrderTotal'
→ in OrderProcessingMapping.Configure():
Ac(f => f.OrderTotalMustBePositive)
.ImplementedBy<OrderProcessingService>(nameof(OrderProcessingService.ValidateOrderTotal))
^^^^^^^^^^^^^^^^Developer renames: OrderProcessingService.ValidateOrderTotal
→ OrderProcessingService.CheckOrderTotal
Production build: ✓ SUCCESS (no dependency on Requirements)
Production tests: ✓ SUCCESS (tests call the new method name)
Requirements build: ✗ FAIL
error CS0117: 'OrderProcessingService' does not contain a definition for 'ValidateOrderTotal'
→ in OrderProcessingMapping.Configure():
Ac(f => f.OrderTotalMustBePositive)
.ImplementedBy<OrderProcessingService>(nameof(OrderProcessingService.ValidateOrderTotal))
^^^^^^^^^^^^^^^^Production is fine. Tests are fine. Requirements is broken. If CI only builds production, the drift goes undetected until someone builds Requirements — which could be days later.
9b. CI Must Build Requirements on Every Commit
The fix: every CI pipeline builds Requirements, even when only production code changed.
# .github/workflows/build.yml
jobs:
build:
steps:
# 1. Build production (fast — no analyzer overhead)
- run: dotnet build src/MegaCorp.OrderService/
- run: dotnet build src/MegaCorp.PaymentGateway/
# ... all production projects
# 2. Build Requirements (catches drift)
- run: dotnet build src/MegaCorp.Requirements/
# If this fails, a production rename broke a mapping.
# The PR is blocked until the mapping is updated.
# 3. Run tests
- run: dotnet test# .github/workflows/build.yml
jobs:
build:
steps:
# 1. Build production (fast — no analyzer overhead)
- run: dotnet build src/MegaCorp.OrderService/
- run: dotnet build src/MegaCorp.PaymentGateway/
# ... all production projects
# 2. Build Requirements (catches drift)
- run: dotnet build src/MegaCorp.Requirements/
# If this fails, a production rename broke a mapping.
# The PR is blocked until the mapping is updated.
# 3. Run tests
- run: dotnet testThis is the inverted equivalent of "the compiler as coordination mechanism" from Part 4. In the original model, production catches requirement drift. In the inverted model, Requirements catches production drift. Same guarantee, opposite direction.
9c. Pre-commit Hooks and IDE Integration
For faster feedback than CI:
# .husky/pre-commit (or equivalent)
# If any production .cs file changed, verify Requirements still compiles
CHANGED_CS=$(git diff --cached --name-only --diff-filter=ACMR -- '*.cs' | grep -v 'Requirements' | grep -v 'Tests')
if [ -n "$CHANGED_CS" ]; then
echo "Production code changed — verifying Requirements mapping..."
dotnet build src/MegaCorp.Requirements/ --no-restore -q || {
echo "ERROR: Requirements mapping broken by production change."
echo "Update the fluent mapping in MegaCorp.Requirements/Mappings/"
exit 1
}
fi# .husky/pre-commit (or equivalent)
# If any production .cs file changed, verify Requirements still compiles
CHANGED_CS=$(git diff --cached --name-only --diff-filter=ACMR -- '*.cs' | grep -v 'Requirements' | grep -v 'Tests')
if [ -n "$CHANGED_CS" ]; then
echo "Production code changed — verifying Requirements mapping..."
dotnet build src/MegaCorp.Requirements/ --no-restore -q || {
echo "ERROR: Requirements mapping broken by production change."
echo "Update the fluent mapping in MegaCorp.Requirements/Mappings/"
exit 1
}
fiIDE integration: In Rider/VS, configure the build scope to always include MegaCorp.Requirements when editing production files. The cost is ~1-2 seconds extra per build — negligible.
10. What Changes vs Parts 1-6
The Same OrderProcessingService — Three Architectures
ServiceLocator (Part 1):
// No attributes. No interfaces. 9 hidden dependencies.
public class OrderService
{
public async Task<OrderResult> ProcessOrder(CreateOrderCommand command)
{
var validator = ServiceLocator.GetService<IOrderValidator>();
var inventory = ServiceLocator.GetService<IInventoryChecker>();
var payment = ServiceLocator.GetService<IPaymentProcessor>();
// ... 6 more ServiceLocator calls
}
}// No attributes. No interfaces. 9 hidden dependencies.
public class OrderService
{
public async Task<OrderResult> ProcessOrder(CreateOrderCommand command)
{
var validator = ServiceLocator.GetService<IOrderValidator>();
var inventory = ServiceLocator.GetService<IInventoryChecker>();
var payment = ServiceLocator.GetService<IPaymentProcessor>();
// ... 6 more ServiceLocator calls
}
}Original typed (Parts 1-6):
// [ForRequirement] attributes on class and every method.
// Depends on MegaCorp.Specifications (which depends on MegaCorp.Requirements).
[ForRequirement(typeof(OrderProcessingFeature))]
public class OrderProcessingService : IOrderProcessingSpec
{
[ForRequirement(typeof(OrderProcessingFeature),
nameof(OrderProcessingFeature.OrderTotalMustBePositive))]
public Result ValidateOrderTotal(Order order) { ... }
[ForRequirement(typeof(OrderProcessingFeature),
nameof(OrderProcessingFeature.BulkOrderDiscountApplied))]
public Result<Order> ApplyBulkDiscount(Order order) { ... }
// ... all methods decorated
}// [ForRequirement] attributes on class and every method.
// Depends on MegaCorp.Specifications (which depends on MegaCorp.Requirements).
[ForRequirement(typeof(OrderProcessingFeature))]
public class OrderProcessingService : IOrderProcessingSpec
{
[ForRequirement(typeof(OrderProcessingFeature),
nameof(OrderProcessingFeature.OrderTotalMustBePositive))]
public Result ValidateOrderTotal(Order order) { ... }
[ForRequirement(typeof(OrderProcessingFeature),
nameof(OrderProcessingFeature.BulkOrderDiscountApplied))]
public Result<Order> ApplyBulkDiscount(Order order) { ... }
// ... all methods decorated
}Inverted (Part 7):
// ZERO attributes. ZERO knowledge of Requirements. Clean production code.
// Constructor injection with domain interfaces (not spec interfaces).
public class OrderProcessingService
{
private readonly IInventoryRepository _inventory;
private readonly IPaymentGateway _payment;
private readonly INotificationSender _notification;
private readonly IAuditRepository _audit;
private readonly IOrderRepository _orderRepo;
public OrderProcessingService(
IInventoryRepository inventory, IPaymentGateway payment,
INotificationSender notification, IAuditRepository audit,
IOrderRepository orderRepo)
{
_inventory = inventory;
_payment = payment;
_notification = notification;
_audit = audit;
_orderRepo = orderRepo;
}
public Result ValidateOrderTotal(Order order) { ... }
public Result<Order> ApplyBulkDiscount(Order order) { ... }
public async Task<Result> PersistOrder(Order order, Payment payment,
IReadOnlyList<Reservation> reservations) { ... }
public async Task<Result<OrderConfirmation>> ProcessOrder(
CreateOrderCommand command) { ... }
}// ZERO attributes. ZERO knowledge of Requirements. Clean production code.
// Constructor injection with domain interfaces (not spec interfaces).
public class OrderProcessingService
{
private readonly IInventoryRepository _inventory;
private readonly IPaymentGateway _payment;
private readonly INotificationSender _notification;
private readonly IAuditRepository _audit;
private readonly IOrderRepository _orderRepo;
public OrderProcessingService(
IInventoryRepository inventory, IPaymentGateway payment,
INotificationSender notification, IAuditRepository audit,
IOrderRepository orderRepo)
{
_inventory = inventory;
_payment = payment;
_notification = notification;
_audit = audit;
_orderRepo = orderRepo;
}
public Result ValidateOrderTotal(Order order) { ... }
public Result<Order> ApplyBulkDiscount(Order order) { ... }
public async Task<Result> PersistOrder(Order order, Payment payment,
IReadOnlyList<Reservation> reservations) { ... }
public async Task<Result<OrderConfirmation>> ProcessOrder(
CreateOrderCommand command) { ... }
}The inverted version is the cleanest production code. No attributes. No requirement metadata. No dependency on Requirements or Specifications. Just domain interfaces, constructor injection, and business logic.
The Blast Radius Comparison
| Blast radius | Original | Inverted |
|---|---|---|
| Requirements project | Compiles (no change needed) | Breaks (needs mapping) |
| Specifications project | Breaks (needs spec method) | N/A (merged into Requirements) |
| Production projects | Break (must implement new spec method) | Untouched |
| Test projects | Break (need [Verifies]) | Break (need [Verifies]) |
The killer advantage: in inverted mode, adding an AC never breaks the production build. Production teams can continue shipping while the requirement mapping catches up. In the original model, adding an AC blocks ALL production builds until every team implements the new spec method.
Full Comparison Table
| Aspect | ServiceLocator | Original (Parts 1-6) | Inverted (Part 7) |
|---|---|---|---|
| Production DLL dependencies | Hidden (520 runtime) | Requirements + Specifications | None (clean) |
[ForRequirement] on production code |
None | Yes (every class + method) | None |
| Requirements.dll ships to production | No (doesn't exist) | Yes | No |
| typeof/nameof in production | No | Yes (references Feature types) | No |
| Add AC → production build breaks? | No (Jira only) | Yes (compiler cascade) | No (only Requirements breaks) |
| Rename prod method → detected? | No | Yes (compile error in production) | Yes (compile error in Requirements) |
| Runtime AC evaluation | No | Yes (validator bridge) | No (requires separate Compliance project) |
| OpenAPI requirement metadata | No | Yes ([ForRequirement] on controllers) | No |
| IDE: Find All Refs from production → requirements | No | Yes | No (one-way only) |
| IDE: Find All Refs from requirements → production | No | Yes | Yes |
| Build performance (analyzer overhead on production) | None | Analyzer on every project | None (analyzer on Requirements only) |
11. The Csproj Layout and Build Order
MSBuild Order — Inverted
In the original model, Requirements builds first (it has zero deps). In the inverted model, Requirements builds last (it references all production projects). This is the opposite build order — and it means production builds are never blocked by Requirements compilation.
Full Solution Directory
MegaCorp.sln
├── src/
│ ├── MegaCorp.SharedKernel/ ← Domain types (no change)
│ ├── MegaCorp.Data/ ← EF repositories (no change)
│ ├── MegaCorp.OrderService/ ← CLEAN: no [ForRequirement], no Req reference
│ │ ├── OrderProcessingService.cs
│ │ └── MegaCorp.OrderService.csproj ← only refs SharedKernel + Data
│ ├── MegaCorp.PaymentGateway/ ← CLEAN
│ ├── MegaCorp.InventoryService/ ← CLEAN
│ ├── MegaCorp.NotificationService/ ← CLEAN
│ ├── MegaCorp.BillingService/ ← CLEAN
│ ├── MegaCorp.AuditService/ ← CLEAN
│ ├── MegaCorp.UserService/ ← CLEAN
│ ├── MegaCorp.Web/ ← API host (no change)
│ ├── MegaCorp.Worker/ ← Background jobs (no change)
│ │
│ └── MegaCorp.Requirements/ ← INVERTED: references ALL production projects
│ ├── Features/
│ │ ├── OrderProcessingFeature.cs ← 9 ACs (abstract methods)
│ │ ├── PaymentProcessingFeature.cs
│ │ ├── InventoryManagementFeature.cs
│ │ └── ...
│ ├── Mapping/
│ │ ├── FeatureMapping.cs ← Base class (fluent builder)
│ │ ├── AcBuilder.cs
│ │ ├── Attributes.cs ← [ImplementedBy], [AcMap]
│ │ └── AcMappingEntry.cs
│ ├── Mappings/
│ │ ├── OrderProcessingMapping.cs ← Fluent: Ac(f => ...).ImplementedBy<T>(...)
│ │ ├── PaymentProcessingMapping.cs
│ │ ├── InventoryManagementMapping.cs
│ │ └── ...
│ ├── Generated/ ← Roslyn SG output (obj/)
│ │ ├── TraceabilityMatrix.g.cs
│ │ ├── RequirementRegistry.g.cs
│ │ └── ComplianceReport.g.cs
│ └── MegaCorp.Requirements.csproj ← References all production projects
│
├── test/
│ ├── MegaCorp.OrderService.Tests/ ← Refs OrderService + Requirements
│ │ └── OrderProcessingTests.cs ← [Verifies] links to ACs
│ ├── MegaCorp.PaymentGateway.Tests/
│ ├── MegaCorp.Requirements.Tests/ ← Tests the mappings themselves
│ │ └── MappingTests.cs
│ └── ...
│
├── tools/
│ └── MegaCorp.Requirements.Analyzers/ ← Roslyn analyzer + SG
│
├── Directory.Build.props ← Auto-inject InternalsVisibleTo + Requirements ref
└── MegaCorp.slnMegaCorp.sln
├── src/
│ ├── MegaCorp.SharedKernel/ ← Domain types (no change)
│ ├── MegaCorp.Data/ ← EF repositories (no change)
│ ├── MegaCorp.OrderService/ ← CLEAN: no [ForRequirement], no Req reference
│ │ ├── OrderProcessingService.cs
│ │ └── MegaCorp.OrderService.csproj ← only refs SharedKernel + Data
│ ├── MegaCorp.PaymentGateway/ ← CLEAN
│ ├── MegaCorp.InventoryService/ ← CLEAN
│ ├── MegaCorp.NotificationService/ ← CLEAN
│ ├── MegaCorp.BillingService/ ← CLEAN
│ ├── MegaCorp.AuditService/ ← CLEAN
│ ├── MegaCorp.UserService/ ← CLEAN
│ ├── MegaCorp.Web/ ← API host (no change)
│ ├── MegaCorp.Worker/ ← Background jobs (no change)
│ │
│ └── MegaCorp.Requirements/ ← INVERTED: references ALL production projects
│ ├── Features/
│ │ ├── OrderProcessingFeature.cs ← 9 ACs (abstract methods)
│ │ ├── PaymentProcessingFeature.cs
│ │ ├── InventoryManagementFeature.cs
│ │ └── ...
│ ├── Mapping/
│ │ ├── FeatureMapping.cs ← Base class (fluent builder)
│ │ ├── AcBuilder.cs
│ │ ├── Attributes.cs ← [ImplementedBy], [AcMap]
│ │ └── AcMappingEntry.cs
│ ├── Mappings/
│ │ ├── OrderProcessingMapping.cs ← Fluent: Ac(f => ...).ImplementedBy<T>(...)
│ │ ├── PaymentProcessingMapping.cs
│ │ ├── InventoryManagementMapping.cs
│ │ └── ...
│ ├── Generated/ ← Roslyn SG output (obj/)
│ │ ├── TraceabilityMatrix.g.cs
│ │ ├── RequirementRegistry.g.cs
│ │ └── ComplianceReport.g.cs
│ └── MegaCorp.Requirements.csproj ← References all production projects
│
├── test/
│ ├── MegaCorp.OrderService.Tests/ ← Refs OrderService + Requirements
│ │ └── OrderProcessingTests.cs ← [Verifies] links to ACs
│ ├── MegaCorp.PaymentGateway.Tests/
│ ├── MegaCorp.Requirements.Tests/ ← Tests the mappings themselves
│ │ └── MappingTests.cs
│ └── ...
│
├── tools/
│ └── MegaCorp.Requirements.Analyzers/ ← Roslyn analyzer + SG
│
├── Directory.Build.props ← Auto-inject InternalsVisibleTo + Requirements ref
└── MegaCorp.sln12. The Developer Workflow: Requirements → Tests → Code
The Full TDD Cycle — Adding AC-9
Step 1: Define the AC in Requirements
// MegaCorp.Requirements/Features/OrderProcessingFeature.cs
// Developer adds:
/// AC-9: Customer loyalty points are credited after successful order.
public abstract AcceptanceCriterionResult LoyaltyPointsCredited(
OrderSummary order, CustomerId customer, int pointsEarned, TimeSpan elapsed);// MegaCorp.Requirements/Features/OrderProcessingFeature.cs
// Developer adds:
/// AC-9: Customer loyalty points are credited after successful order.
public abstract AcceptanceCriterionResult LoyaltyPointsCredited(
OrderSummary order, CustomerId customer, int pointsEarned, TimeSpan elapsed);Build Requirements: fails — REQ101: LoyaltyPointsCredited has no mapping.
Step 2: Write the test
// MegaCorp.OrderService.Tests/OrderProcessingTests.cs
[Test]
[Verifies(typeof(OrderProcessingFeature),
nameof(OrderProcessingFeature.LoyaltyPointsCredited))]
public async Task Loyalty_points_credited_after_order()
{
_stripeClient.Configure(succeed: true);
var command = CreateOrderCommand(items: new[] { ("SKU-001", 5) }, unitPrice: 20.00m);
var result = await _service.ProcessOrder(command);
Assert.That(result.IsSuccess, Is.True);
Assert.That(_loyaltyRepo.GetPoints(command.CustomerId), Is.EqualTo(10));
}// MegaCorp.OrderService.Tests/OrderProcessingTests.cs
[Test]
[Verifies(typeof(OrderProcessingFeature),
nameof(OrderProcessingFeature.LoyaltyPointsCredited))]
public async Task Loyalty_points_credited_after_order()
{
_stripeClient.Configure(succeed: true);
var command = CreateOrderCommand(items: new[] { ("SKU-001", 5) }, unitPrice: 20.00m);
var result = await _service.ProcessOrder(command);
Assert.That(result.IsSuccess, Is.True);
Assert.That(_loyaltyRepo.GetPoints(command.CustomerId), Is.EqualTo(10));
}Build test: fails — _service.ProcessOrder doesn't credit loyalty points yet. _loyaltyRepo doesn't exist yet. This is the red phase of TDD.
Step 3: Write the production code
// MegaCorp.UserService/LoyaltyPointService.cs (NEW)
public class LoyaltyPointService
{
private readonly ILoyaltyRepository _repo;
public LoyaltyPointService(ILoyaltyRepository repo) => _repo = repo;
public async Task<Result> CreditLoyaltyPoints(Order order, int points)
{
if (points <= 0) return Result.Success();
await _repo.AddPoints(order.Customer, points, $"Order {order.Id.Value}");
return Result.Success();
}
}// MegaCorp.UserService/LoyaltyPointService.cs (NEW)
public class LoyaltyPointService
{
private readonly ILoyaltyRepository _repo;
public LoyaltyPointService(ILoyaltyRepository repo) => _repo = repo;
public async Task<Result> CreditLoyaltyPoints(Order order, int points)
{
if (points <= 0) return Result.Success();
await _repo.AddPoints(order.Customer, points, $"Order {order.Id.Value}");
return Result.Success();
}
}Update OrderProcessingService.ProcessOrder() to call LoyaltyPointService. Build and run test: passes. This is the green phase.
Step 4: Add the mapping
// MegaCorp.Requirements/Mappings/OrderProcessingMapping.cs
// Add to Configure():
Ac(f => f.LoyaltyPointsCredited)
.ImplementedBy<LoyaltyPointService>(nameof(LoyaltyPointService.CreditLoyaltyPoints));// MegaCorp.Requirements/Mappings/OrderProcessingMapping.cs
// Add to Configure():
Ac(f => f.LoyaltyPointsCredited)
.ImplementedBy<LoyaltyPointService>(nameof(LoyaltyPointService.CreditLoyaltyPoints));Build Requirements: succeeds. REQ101 resolved. TraceabilityMatrix updated.
Total time: ~2 hours. Same as the original model (Part 4), but production was never broken by the requirement change.
13. Cross-Repo Requirement Tracking
The inverted model enables a use case that's impossible with the original: production in one repo, requirements in another.
13a. The Scenario
- Production repo (50 projects): managed by dev teams, CI builds and ships NuGet packages to internal feed
- Requirements repo: managed by architecture/product team, CI validates traceability against production packages
Repo A: megacorp-production/
├── src/MegaCorp.OrderService/
├── src/MegaCorp.PaymentGateway/
└── ... (50 projects)
→ publishes: MegaCorp.OrderService.3.2.1.nupkg to internal NuGet feed
Repo B: megacorp-requirements/
├── src/MegaCorp.Requirements/
│ ├── Features/OrderProcessingFeature.cs
│ ├── Mappings/OrderProcessingMapping.cs
│ └── MegaCorp.Requirements.csproj
│ → <PackageReference Include="MegaCorp.OrderService" Version="3.2.1" />
└── test/MegaCorp.Requirements.Tests/Repo A: megacorp-production/
├── src/MegaCorp.OrderService/
├── src/MegaCorp.PaymentGateway/
└── ... (50 projects)
→ publishes: MegaCorp.OrderService.3.2.1.nupkg to internal NuGet feed
Repo B: megacorp-requirements/
├── src/MegaCorp.Requirements/
│ ├── Features/OrderProcessingFeature.cs
│ ├── Mappings/OrderProcessingMapping.cs
│ └── MegaCorp.Requirements.csproj
│ → <PackageReference Include="MegaCorp.OrderService" Version="3.2.1" />
└── test/MegaCorp.Requirements.Tests/13b. The Workflow
- Production team ships
MegaCorp.OrderServicev3.2.1 to internal NuGet feed - Requirements team updates
<PackageReference>version - Requirements CI builds — validates all mappings compile against v3.2.1
- If production renamed a method → Requirements CI fails → blocks package promotion to "stable" feed
13c. When This Matters
- Multi-vendor projects: vendor delivers production DLLs, client organization tracks requirements separately
- Regulated industries: requirement tracking team is organizationally separate from development (auditor independence)
- Legacy systems: you can't modify production source (vendor lock-in, binary-only delivery) but need traceability against public APIs
- Microservice federation: each team owns their service repo, a central requirements repo tracks the cross-service feature chain
This is impossible with the original model — production code can't reference a Requirements project in a different repo. With the inverted model, Requirements references production, so it can live anywhere.
14. Security and Deployment Implications
Production Docker Image — Before and After
dotnet publish output comparison:
# Original (Parts 1-6)
$ ls publish/
MegaCorp.Web.dll
MegaCorp.OrderService.dll
MegaCorp.PaymentGateway.dll
MegaCorp.InventoryService.dll
MegaCorp.SharedKernel.dll
MegaCorp.Data.dll
MegaCorp.Requirements.dll ← SENSITIVE: contains feature roadmap
MegaCorp.Specifications.dll ← SENSITIVE: contains API surface expectations
# Total: 8 DLLs
# Inverted (Part 7)
$ ls publish/
MegaCorp.Web.dll
MegaCorp.OrderService.dll
MegaCorp.PaymentGateway.dll
MegaCorp.InventoryService.dll
MegaCorp.SharedKernel.dll
MegaCorp.Data.dll
# Total: 6 DLLs — no requirement metadata# Original (Parts 1-6)
$ ls publish/
MegaCorp.Web.dll
MegaCorp.OrderService.dll
MegaCorp.PaymentGateway.dll
MegaCorp.InventoryService.dll
MegaCorp.SharedKernel.dll
MegaCorp.Data.dll
MegaCorp.Requirements.dll ← SENSITIVE: contains feature roadmap
MegaCorp.Specifications.dll ← SENSITIVE: contains API surface expectations
# Total: 8 DLLs
# Inverted (Part 7)
$ ls publish/
MegaCorp.Web.dll
MegaCorp.OrderService.dll
MegaCorp.PaymentGateway.dll
MegaCorp.InventoryService.dll
MegaCorp.SharedKernel.dll
MegaCorp.Data.dll
# Total: 6 DLLs — no requirement metadataTwo fewer DLLs. No feature names decompilable from the production image. No AC method signatures revealing business rules. No requirement hierarchy exposing the product roadmap.
For defense contractors, medical devices, financial institutions: this is the difference between a clean audit and a findings report.
15. Trade-offs, Runtime Compliance, and Decision Guide
15a. What's Lost and What's Gained
| Lost | Impact | Workaround |
|---|---|---|
| Runtime AC evaluation | Can't call Requirements types from production | Separate Compliance project (see 15b) |
[ForRequirement] in OpenAPI |
API docs don't show requirement links | Generate OpenAPI extension from TraceabilityMatrix as post-build step |
| IDE: Find All Refs from production → requirements | Production developers can't Ctrl+Click to requirements | Use "Find All References" from Requirements side instead |
| Specification interfaces on production code | Production classes don't implement ISpec | Domain interfaces serve the same purpose for DI |
| Gained | Impact |
|---|---|
| Clean production artifacts | No Requirements.dll in production images |
| No sensitive DLL exposure | Feature roadmap not decompilable from binaries |
| Smaller deployment footprint | 2 fewer DLLs per service |
| AC changes don't break production builds | Requirement changes only affect Requirements + Tests |
| Faster production builds | No analyzer overhead on production compilation |
| Cross-repo tracking | Requirements can live in a separate repo (Section 13) |
15b. Runtime Compliance Without Polluting Production
The "Lost: runtime AC evaluation" CAN be recovered:
// MegaCorp.Compliance/ — a SEPARATE project, NOT shipped to production
// References both Requirements AND Production
// Deployed ONLY to audit/compliance environments
public class ComplianceHost : IHostedService
{
private readonly IServiceProvider _sp;
public async Task StartAsync(CancellationToken ct)
{
// Evaluate ACs at runtime using the validator bridge pattern from Part 3
// But this code lives in Compliance, not in Production
var orderService = _sp.GetRequiredService<OrderProcessingService>();
var validator = new OrderProcessingValidator(orderService);
var result = validator.OrderTotalMustBePositive(testOrder);
await _auditDb.Record("AC-1", result.IsSatisfied, result.FailureReason);
}
}// MegaCorp.Compliance/ — a SEPARATE project, NOT shipped to production
// References both Requirements AND Production
// Deployed ONLY to audit/compliance environments
public class ComplianceHost : IHostedService
{
private readonly IServiceProvider _sp;
public async Task StartAsync(CancellationToken ct)
{
// Evaluate ACs at runtime using the validator bridge pattern from Part 3
// But this code lives in Compliance, not in Production
var orderService = _sp.GetRequiredService<OrderProcessingService>();
var validator = new OrderProcessingValidator(orderService);
var result = validator.OrderTotalMustBePositive(testOrder);
await _auditDb.Record("AC-1", result.IsSatisfied, result.FailureReason);
}
}Deploy as a separate Docker image to the audit environment:
# Dockerfile.compliance (separate from Dockerfile.production)
FROM mcr.microsoft.com/dotnet/aspnet:8.0
COPY publish-compliance/ /app/
# This image contains Requirements.dll + Production DLLs
# Deployed to audit namespace only, never to production
ENTRYPOINT ["dotnet", "MegaCorp.Compliance.dll"]# Dockerfile.compliance (separate from Dockerfile.production)
FROM mcr.microsoft.com/dotnet/aspnet:8.0
COPY publish-compliance/ /app/
# This image contains Requirements.dll + Production DLLs
# Deployed to audit namespace only, never to production
ENTRYPOINT ["dotnet", "MegaCorp.Compliance.dll"]15c. Decision Guide
| Scenario | Recommended | Why |
|---|---|---|
| SaaS (internal deployment only) | Original | Runtime AC eval + OpenAPI metadata are valuable; no DLL sensitivity |
| Medical device (IEC 62304) | Inverted | Production firmware must be certified DLL-by-DLL; Requirements.dll can't be on the device |
| Defense contractor (ITAR) | Inverted | Classified capability descriptions in requirement types must not ship |
| Financial institution (on-prem) | Inverted | Customers could decompile DLLs; feature roadmap must not be embedded |
| Startup (move fast) | Original | Simplicity; no DLL sensitivity; runtime compliance is nice to have |
| Enterprise (hybrid) | Mix both | Sensitive features inverted, internal features original |
Can you mix both? Yes. Some features use [ForRequirement] directly on production code (original). Others use fluent mappings in Requirements (inverted). Both mapping sources feed the same TraceabilityMatrix.g.cs.
16. Closing
The dependency direction between requirements and production is a design choice, not a dogma.
If production cleanliness matters — regulated binaries, shipped DLLs, separate tracking teams, cross-repo — invert. Requirements references production. Production stays clean. The compiler still enforces the chain, just from the opposite direction.
If runtime compliance and OpenAPI traceability matter — internal SaaS, audit endpoints, developer convenience — keep the original. Production references Requirements. The chain is tighter. The IDE navigation is bidirectional.
Both share the same foundation:
- Features are types
- Acceptance criteria are abstract methods
- The compiler verifies the chain
- The TraceabilityMatrix is source-generated
[Verifies]links tests to ACs
The inverted approach is the "mature" version of the architecture. It acknowledges that requirement tracking is a development concern, not a production concern. Production code should produce business value. Requirement tracking should verify that it does — without leaving fingerprints on the artifacts it monitors.
The cross-repo capability (Section 13) is unique to the inverted model. The original model can't do it — production can't reference a Requirements project in a different repo. With the inverted model, Requirements is a separate compilation unit that observes production from outside. It can live in the same repo, a different repo, or a different organization entirely.
Physical boundaries are packaging. Logical boundaries are architecture. The compiler enforces both. The direction of the arrows is up to you.
| Previous Posts | |
|---|---|
| Part 1: The Problem | The industrial monorepo nobody planned |
| Part 2: Physical vs Logical | DLLs are packaging, not architecture |
| Part 3: Requirements as Projects | The original typed chain |
| Part 4: At Scale | The AC cascade across 15 teams |
| Part 5: Migration | One feature at a time |
| Part 6: ROI | The business case |
Previous: Part 6 — ROI and Maintenance Costs
Back to: Series Index