Skip to main content
Welcome. This site supports keyboard navigation and screen readers. Press ? at any time for keyboard shortcuts. Press [ to focus the sidebar, ] to focus the content. High-contrast themes are available via the toolbar.
serard@dev00:~/cv

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(), and InternalsVisibleTo. 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)

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 Implementation

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

Diagram

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.

Diagram

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")]

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>

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

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.

Diagram

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")]

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"

CI implications:

  • The Requirements project must be signed with the same key management process as production
  • The key pair (.snk file) 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>

For InternalsVisibleTo to work across NuGet boundaries:

  1. The [InternalsVisibleTo] must be in the NuGet package source — it's baked into the assembly at compile time, before packing
  2. You can't add InternalsVisibleTo retroactively to a published NuGet package
  3. For internal NuGet feeds: add InternalsVisibleTo("MegaCorp.Requirements") to production source before dotnet pack
  4. For external/vendor NuGet packages: Requirements can only track public types (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>

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-generated

Advantages:

  • 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.cs

Advantages:

  • 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

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 ← Requirements

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

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 { }

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

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

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

The Fluent Builder Pipeline

Diagram

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

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

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

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.

Diagram

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>

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>

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

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:

  1. Define AC in Requirements: public abstract AcceptanceCriterionResult LoyaltyPointsCredited(...)
  2. Write test with [Verifies]: the typeof(OrderProcessingFeature) compiles (it's in Requirements). The nameof(OrderProcessingFeature.LoyaltyPointsCredited) compiles (the AC exists). But the test body calls _service.CreditLoyaltyPoints() which doesn't exist yet → test doesn't compile.
  3. Write production code: add CreditLoyaltyPoints to LoyaltyPointService → test compiles and passes.
  4. 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: ✓ covered

7f. 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() { ... }

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?"

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.

Diagram

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,
                    // ...
                }),
        };
}

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",
                    // ...
                }),
        };
}

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() { /* ... */ }
}

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

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 JsonSerializerContext declarations

8c. The Combined Pipeline

Diagram

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))
                                                                           ^^^^^^^^^^^^^^^^

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
Diagram

This 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

IDE 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
    }
}

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
}

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) { ... }
}

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

Diagram
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

Diagram

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.cs9 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.sln

12. 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);

Build Requirements: failsREQ101: 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));
}

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

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

Build Requirements: succeeds. REQ101 resolved. TraceabilityMatrix updated.

Diagram

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/
Diagram

13b. The Workflow

  1. Production team ships MegaCorp.OrderService v3.2.1 to internal NuGet feed
  2. Requirements team updates <PackageReference> version
  3. Requirements CI builds — validates all mappings compile against v3.2.1
  4. 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

Diagram

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

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

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"]

15c. Decision Guide

Diagram
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