What Changes at 50+ Projects
Industrial Monorepo Series — Part 4 of 7 1. The Problem · 2. Physical vs Logical · 3. Requirements as Projects · 4. At Scale · 5. Migration · 6. ROI · 7. Inverted Deps
Add one abstract method to a Feature type. The build breaks across 5 teams. The compiler just became your cross-team coordination mechanism.
Part 3 showed how .Requirements and .Specifications projects create typed boundaries in a monorepo. This post answers the question that matters at industrial scale: what actually happens when 15 teams, 50 projects, and 200 features operate under this architecture?
The answer centers on three phenomena:
- The AC Cascade — how a single acceptance criterion change propagates through the entire monorepo
- Feature Traceability — how you trace a business feature across 15+ projects
- Multi-Team Ownership — how teams coordinate through the compiler instead of Jira tickets
The AC Cascade
This is the defining characteristic of Requirements as Projects at scale. When a PM adds a new acceptance criterion to a Feature, the build breaks — deliberately, precisely, and informatively — across every project that needs to change.
Scenario: PM Adds AC-9 to Order Processing
The PM says: "We need loyalty points credited after every order." The developer adds an abstract method to OrderProcessingFeature:
// MegaCorp.Requirements/Features/OrderProcessingFeature.cs
// New AC added by the order-team:
/// <summary>
/// AC-9: Customer loyalty points are credited after successful order.
/// Points = floor(order total / 10). Credited within 5 seconds of payment capture.
/// </summary>
public abstract AcceptanceCriterionResult LoyaltyPointsCredited(
OrderSummary order,
CustomerId customer,
int pointsEarned,
TimeSpan elapsed);// MegaCorp.Requirements/Features/OrderProcessingFeature.cs
// New AC added by the order-team:
/// <summary>
/// AC-9: Customer loyalty points are credited after successful order.
/// Points = floor(order total / 10). Credited within 5 seconds of payment capture.
/// </summary>
public abstract AcceptanceCriterionResult LoyaltyPointsCredited(
OrderSummary order,
CustomerId customer,
int pointsEarned,
TimeSpan elapsed);The developer commits this change to a PR. The CI build runs. Here's what happens:
The Build Output
Build started...
MegaCorp.Requirements → Build succeeded.
MegaCorp.SharedKernel → Build succeeded.
MegaCorp.Specifications →
error REQ101: OrderProcessingFeature.LoyaltyPointsCredited has no matching
specification method with [ForRequirement(typeof(OrderProcessingFeature),
nameof(OrderProcessingFeature.LoyaltyPointsCredited))]
→ Add a method to a spec interface (e.g., IOrderProcessingSpec or a new
ILoyaltyIntegrationSpec) with the matching [ForRequirement] attribute.
Build FAILED.
MegaCorp.OrderService →
(skipped — depends on MegaCorp.Specifications which failed)
MegaCorp.OrderService.Tests →
(skipped — depends on MegaCorp.OrderService which was skipped)
Build: 2 succeeded, 1 failed, 3 skipped.Build started...
MegaCorp.Requirements → Build succeeded.
MegaCorp.SharedKernel → Build succeeded.
MegaCorp.Specifications →
error REQ101: OrderProcessingFeature.LoyaltyPointsCredited has no matching
specification method with [ForRequirement(typeof(OrderProcessingFeature),
nameof(OrderProcessingFeature.LoyaltyPointsCredited))]
→ Add a method to a spec interface (e.g., IOrderProcessingSpec or a new
ILoyaltyIntegrationSpec) with the matching [ForRequirement] attribute.
Build FAILED.
MegaCorp.OrderService →
(skipped — depends on MegaCorp.Specifications which failed)
MegaCorp.OrderService.Tests →
(skipped — depends on MegaCorp.OrderService which was skipped)
Build: 2 succeeded, 1 failed, 3 skipped.The build fails in MegaCorp.Specifications — the first project downstream of MegaCorp.Requirements. The error is precise: it names the feature, the AC, and tells you exactly what to do.
Step-by-Step Resolution
Step 1: Create the spec method.
The team decides loyalty points need a new spec interface (because the implementation will be in a separate service):
// MegaCorp.Specifications/OrderProcessing/ILoyaltyIntegrationSpec.cs
namespace MegaCorp.Specifications.OrderProcessing;
using MegaCorp.Requirements.Features;
using MegaCorp.SharedKernel;
/// <summary>
/// Specification for loyalty point operations within order processing.
/// Implemented by: MegaCorp.UserService (loyalty is a user-domain concept).
/// </summary>
[ForRequirement(typeof(OrderProcessingFeature))]
public interface ILoyaltyIntegrationSpec
{
[ForRequirement(typeof(OrderProcessingFeature),
nameof(OrderProcessingFeature.LoyaltyPointsCredited))]
Task<Result> CreditLoyaltyPoints(Order order, int points);
}// MegaCorp.Specifications/OrderProcessing/ILoyaltyIntegrationSpec.cs
namespace MegaCorp.Specifications.OrderProcessing;
using MegaCorp.Requirements.Features;
using MegaCorp.SharedKernel;
/// <summary>
/// Specification for loyalty point operations within order processing.
/// Implemented by: MegaCorp.UserService (loyalty is a user-domain concept).
/// </summary>
[ForRequirement(typeof(OrderProcessingFeature))]
public interface ILoyaltyIntegrationSpec
{
[ForRequirement(typeof(OrderProcessingFeature),
nameof(OrderProcessingFeature.LoyaltyPointsCredited))]
Task<Result> CreditLoyaltyPoints(Order order, int points);
}Build again:
MegaCorp.Specifications → Build succeeded. (REQ101 resolved)
MegaCorp.OrderService →
error CS0535: 'OrderProcessingService' does not implement interface member
'IOrderProcessingSpec.ProcessOrder' — wait, that's already implemented.
Actually, the build succeeds for OrderService because it doesn't implement
ILoyaltyIntegrationSpec. But:
error REQ200: ILoyaltyIntegrationSpec has no implementing class.
→ Create a class that implements ILoyaltyIntegrationSpec.
Build FAILED.MegaCorp.Specifications → Build succeeded. (REQ101 resolved)
MegaCorp.OrderService →
error CS0535: 'OrderProcessingService' does not implement interface member
'IOrderProcessingSpec.ProcessOrder' — wait, that's already implemented.
Actually, the build succeeds for OrderService because it doesn't implement
ILoyaltyIntegrationSpec. But:
error REQ200: ILoyaltyIntegrationSpec has no implementing class.
→ Create a class that implements ILoyaltyIntegrationSpec.
Build FAILED.Step 2: Implement the spec.
The user-team creates the implementation:
// MegaCorp.UserService/LoyaltyPointService.cs
namespace MegaCorp.UserService;
using MegaCorp.Requirements.Features;
using MegaCorp.Specifications.OrderProcessing;
using MegaCorp.SharedKernel;
[ForRequirement(typeof(OrderProcessingFeature))]
public class LoyaltyPointService : ILoyaltyIntegrationSpec
{
private readonly ILoyaltyRepository _repo;
public LoyaltyPointService(ILoyaltyRepository repo) => _repo = repo;
[ForRequirement(typeof(OrderProcessingFeature),
nameof(OrderProcessingFeature.LoyaltyPointsCredited))]
public async Task<Result> CreditLoyaltyPoints(Order order, int points)
{
if (points <= 0)
return Result.Success(); // No points to credit
await _repo.AddPoints(order.Customer, points, $"Order {order.Id.Value}");
return Result.Success();
}
}// MegaCorp.UserService/LoyaltyPointService.cs
namespace MegaCorp.UserService;
using MegaCorp.Requirements.Features;
using MegaCorp.Specifications.OrderProcessing;
using MegaCorp.SharedKernel;
[ForRequirement(typeof(OrderProcessingFeature))]
public class LoyaltyPointService : ILoyaltyIntegrationSpec
{
private readonly ILoyaltyRepository _repo;
public LoyaltyPointService(ILoyaltyRepository repo) => _repo = repo;
[ForRequirement(typeof(OrderProcessingFeature),
nameof(OrderProcessingFeature.LoyaltyPointsCredited))]
public async Task<Result> CreditLoyaltyPoints(Order order, int points)
{
if (points <= 0)
return Result.Success(); // No points to credit
await _repo.AddPoints(order.Customer, points, $"Order {order.Id.Value}");
return Result.Success();
}
}Step 3: Wire it into the orchestrator.
The order-team updates OrderProcessingService to call the new spec:
// MegaCorp.OrderService/OrderProcessingService.cs — updated constructor
public OrderProcessingService(
IInventoryIntegrationSpec inventory,
IPaymentIntegrationSpec payment,
INotificationIntegrationSpec notification,
IAuditIntegrationSpec audit,
ILoyaltyIntegrationSpec loyalty, // ← NEW
IOrderRepository orderRepo)
{
// ...
_loyalty = loyalty;
}
// In ProcessOrder, after payment capture:
// AC-9: Credit loyalty points
var points = (int)Math.Floor(order.Total.Amount / 10m);
await _loyalty.CreditLoyaltyPoints(order, points);// MegaCorp.OrderService/OrderProcessingService.cs — updated constructor
public OrderProcessingService(
IInventoryIntegrationSpec inventory,
IPaymentIntegrationSpec payment,
INotificationIntegrationSpec notification,
IAuditIntegrationSpec audit,
ILoyaltyIntegrationSpec loyalty, // ← NEW
IOrderRepository orderRepo)
{
// ...
_loyalty = loyalty;
}
// In ProcessOrder, after payment capture:
// AC-9: Credit loyalty points
var points = (int)Math.Floor(order.Total.Amount / 10m);
await _loyalty.CreditLoyaltyPoints(order, points);Step 4: Register in DI.
// MegaCorp.Web/Program.cs
builder.Services.AddScoped<ILoyaltyIntegrationSpec, LoyaltyPointService>();// MegaCorp.Web/Program.cs
builder.Services.AddScoped<ILoyaltyIntegrationSpec, LoyaltyPointService>();Step 5: 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); // Total: 100 → 10 points
var result = await _service.ProcessOrder(command);
Assert.That(result.IsSuccess, Is.True);
Assert.That(_loyaltyRepo.GetPoints(command.CustomerId), Is.EqualTo(10));
}
[Test]
[Verifies(typeof(OrderProcessingFeature),
nameof(OrderProcessingFeature.LoyaltyPointsCredited))]
public async Task No_loyalty_points_for_small_order()
{
_stripeClient.Configure(succeed: true);
var command = CreateOrderCommand(items: new[] { ("SKU-001", 1) },
unitPrice: 5.00m); // Total: 5 → 0 points
var result = await _service.ProcessOrder(command);
Assert.That(result.IsSuccess, Is.True);
Assert.That(_loyaltyRepo.GetPoints(command.CustomerId), Is.EqualTo(0));
}// 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); // Total: 100 → 10 points
var result = await _service.ProcessOrder(command);
Assert.That(result.IsSuccess, Is.True);
Assert.That(_loyaltyRepo.GetPoints(command.CustomerId), Is.EqualTo(10));
}
[Test]
[Verifies(typeof(OrderProcessingFeature),
nameof(OrderProcessingFeature.LoyaltyPointsCredited))]
public async Task No_loyalty_points_for_small_order()
{
_stripeClient.Configure(succeed: true);
var command = CreateOrderCommand(items: new[] { ("SKU-001", 1) },
unitPrice: 5.00m); // Total: 5 → 0 points
var result = await _service.ProcessOrder(command);
Assert.That(result.IsSuccess, Is.True);
Assert.That(_loyaltyRepo.GetPoints(command.CustomerId), Is.EqualTo(0));
}Final build:
MegaCorp.Requirements → Build succeeded.
MegaCorp.SharedKernel → Build succeeded.
MegaCorp.Specifications → Build succeeded. info REQ103: OrderProcessingFeature — 9/9 ACs specified
MegaCorp.UserService → Build succeeded. info REQ203: ILoyaltyIntegrationSpec — implemented
MegaCorp.OrderService → Build succeeded.
MegaCorp.OrderService.Tests → Build succeeded. info REQ303: OrderProcessingFeature — 9/9 ACs tested
Build: 6 succeeded, 0 failed.MegaCorp.Requirements → Build succeeded.
MegaCorp.SharedKernel → Build succeeded.
MegaCorp.Specifications → Build succeeded. info REQ103: OrderProcessingFeature — 9/9 ACs specified
MegaCorp.UserService → Build succeeded. info REQ203: ILoyaltyIntegrationSpec — implemented
MegaCorp.OrderService → Build succeeded.
MegaCorp.OrderService.Tests → Build succeeded. info REQ303: OrderProcessingFeature — 9/9 ACs tested
Build: 6 succeeded, 0 failed.The Cascade Timeline
Total time from PM request to verified production code: ~2 hours. Two teams worked in parallel (user-team on LoyaltyPointService, order-team on OrderProcessingService update). The compiler coordinated them — no Jira ticket handoffs, no Slack messages asking "are you done with your part?"
What Didn't Happen
In the ServiceLocator monorepo, the same change would have gone like this:
- PM creates Jira ticket MEGA-6001 assigned to order-team
- Order-team adds loyalty logic to
OrderService.ProcessOrdervia ServiceLocator - Order-team resolves
ILoyaltyService— but who implements it? - Order-team creates MEGA-6002 sub-task for user-team to implement
LoyaltyService - User-team picks it up next sprint (1-2 week delay)
- User-team implements it, doesn't know about the mock in OrderService tests
- QA finds that the test mocks
ILoyaltyServiceand always returns success - Integration bug discovered in staging 3 weeks later
- Hotfix, rollback, post-mortem
Total time: 3-4 weeks. With compiler coordination: 2 hours.
Feature Traceability Across 50+ Projects
In a 50-project monorepo, a single feature like Order Processing touches many projects. With Requirements as Projects, every touch point is compiler-verified and IDE-navigable.
The Full Traceability Chain for Order Processing
One feature. Six specification interfaces. Six implementation classes in six different projects. One API controller. One background worker. Three test projects. Three generated artifacts. 15 touch points across the monorepo — every one of them linked by typeof() and nameof(), compiler-verified, IDE-navigable.
"Find All References" at Scale
In a 50-project monorepo with 8 features and 37 ACs, "Find All References" on a single Feature type produces:
OrderProcessingFeature (37 references across 12 files in 8 projects)
│
├── MegaCorp.Specifications/OrderProcessing/IOrderProcessingSpec.cs
│ ├── [ForRequirement(typeof(OrderProcessingFeature))] on interface
│ ├── [ForRequirement(..., nameof(...OrderTotalMustBePositive))] on method
│ ├── [ForRequirement(..., nameof(...BulkOrderDiscountApplied))] on method
│ └── [ForRequirement(..., nameof(...OrderPersistedWithFullDetail))] on method
│
├── MegaCorp.Specifications/OrderProcessing/IInventoryIntegrationSpec.cs
│ ├── [ForRequirement(typeof(OrderProcessingFeature))] on interface
│ ├── [ForRequirement(..., nameof(...InventoryReservedBeforePayment))] on method
│ └── [ForRequirement(..., nameof(...FailedPaymentReleasesInventory))] on method
│
├── MegaCorp.Specifications/OrderProcessing/IPaymentIntegrationSpec.cs
│ ├── [ForRequirement(typeof(OrderProcessingFeature))] on interface
│ └── [ForRequirement(..., nameof(...PaymentCapturedAfterReservation))] on method
│
├── MegaCorp.Specifications/OrderProcessing/INotificationIntegrationSpec.cs
│ └── [ForRequirement(..., nameof(...ConfirmationSentAfterPayment))] on method
│
├── MegaCorp.Specifications/OrderProcessing/IAuditIntegrationSpec.cs
│ └── [ForRequirement(..., nameof(...AllOperationsAudited))] on method
│
├── MegaCorp.Specifications/OrderProcessing/ILoyaltyIntegrationSpec.cs
│ └── [ForRequirement(..., nameof(...LoyaltyPointsCredited))] on method
│
├── MegaCorp.OrderService/OrderProcessingService.cs
│ └── [ForRequirement(typeof(OrderProcessingFeature))] on class
│
├── MegaCorp.InventoryService/InventoryReservationService.cs
│ └── [ForRequirement(typeof(OrderProcessingFeature))] on class
│
├── MegaCorp.PaymentGateway/StripePaymentService.cs
│ └── [ForRequirement(typeof(OrderProcessingFeature))] on class
│
├── MegaCorp.NotificationService/OrderNotificationService.cs
│ └── [ForRequirement(typeof(OrderProcessingFeature))] on class
│
├── MegaCorp.UserService/LoyaltyPointService.cs
│ └── [ForRequirement(typeof(OrderProcessingFeature))] on class
│
├── MegaCorp.Web/Controllers/OrderController.cs
│ └── [ForRequirement(typeof(OrderProcessingFeature))] on controller + methods
│
└── MegaCorp.OrderService.Tests/OrderProcessingTests.cs
├── [TestsFor(typeof(OrderProcessingFeature))] on class
├── [Verifies(..., nameof(...OrderTotalMustBePositive))] × 4 tests
├── [Verifies(..., nameof(...InventoryReservedBeforePayment))] × 2 tests
├── [Verifies(..., nameof(...PaymentCapturedAfterReservation))] × 2 tests
├── [Verifies(..., nameof(...ConfirmationSentAfterPayment))] × 1 test
├── [Verifies(..., nameof(...AllOperationsAudited))] × 1 test
├── [Verifies(..., nameof(...FailedPaymentReleasesInventory))] × 1 test
├── [Verifies(..., nameof(...BulkOrderDiscountApplied))] × 2 tests
├── [Verifies(..., nameof(...OrderPersistedWithFullDetail))] × 1 test
└── [Verifies(..., nameof(...LoyaltyPointsCredited))] × 2 testsOrderProcessingFeature (37 references across 12 files in 8 projects)
│
├── MegaCorp.Specifications/OrderProcessing/IOrderProcessingSpec.cs
│ ├── [ForRequirement(typeof(OrderProcessingFeature))] on interface
│ ├── [ForRequirement(..., nameof(...OrderTotalMustBePositive))] on method
│ ├── [ForRequirement(..., nameof(...BulkOrderDiscountApplied))] on method
│ └── [ForRequirement(..., nameof(...OrderPersistedWithFullDetail))] on method
│
├── MegaCorp.Specifications/OrderProcessing/IInventoryIntegrationSpec.cs
│ ├── [ForRequirement(typeof(OrderProcessingFeature))] on interface
│ ├── [ForRequirement(..., nameof(...InventoryReservedBeforePayment))] on method
│ └── [ForRequirement(..., nameof(...FailedPaymentReleasesInventory))] on method
│
├── MegaCorp.Specifications/OrderProcessing/IPaymentIntegrationSpec.cs
│ ├── [ForRequirement(typeof(OrderProcessingFeature))] on interface
│ └── [ForRequirement(..., nameof(...PaymentCapturedAfterReservation))] on method
│
├── MegaCorp.Specifications/OrderProcessing/INotificationIntegrationSpec.cs
│ └── [ForRequirement(..., nameof(...ConfirmationSentAfterPayment))] on method
│
├── MegaCorp.Specifications/OrderProcessing/IAuditIntegrationSpec.cs
│ └── [ForRequirement(..., nameof(...AllOperationsAudited))] on method
│
├── MegaCorp.Specifications/OrderProcessing/ILoyaltyIntegrationSpec.cs
│ └── [ForRequirement(..., nameof(...LoyaltyPointsCredited))] on method
│
├── MegaCorp.OrderService/OrderProcessingService.cs
│ └── [ForRequirement(typeof(OrderProcessingFeature))] on class
│
├── MegaCorp.InventoryService/InventoryReservationService.cs
│ └── [ForRequirement(typeof(OrderProcessingFeature))] on class
│
├── MegaCorp.PaymentGateway/StripePaymentService.cs
│ └── [ForRequirement(typeof(OrderProcessingFeature))] on class
│
├── MegaCorp.NotificationService/OrderNotificationService.cs
│ └── [ForRequirement(typeof(OrderProcessingFeature))] on class
│
├── MegaCorp.UserService/LoyaltyPointService.cs
│ └── [ForRequirement(typeof(OrderProcessingFeature))] on class
│
├── MegaCorp.Web/Controllers/OrderController.cs
│ └── [ForRequirement(typeof(OrderProcessingFeature))] on controller + methods
│
└── MegaCorp.OrderService.Tests/OrderProcessingTests.cs
├── [TestsFor(typeof(OrderProcessingFeature))] on class
├── [Verifies(..., nameof(...OrderTotalMustBePositive))] × 4 tests
├── [Verifies(..., nameof(...InventoryReservedBeforePayment))] × 2 tests
├── [Verifies(..., nameof(...PaymentCapturedAfterReservation))] × 2 tests
├── [Verifies(..., nameof(...ConfirmationSentAfterPayment))] × 1 test
├── [Verifies(..., nameof(...AllOperationsAudited))] × 1 test
├── [Verifies(..., nameof(...FailedPaymentReleasesInventory))] × 1 test
├── [Verifies(..., nameof(...BulkOrderDiscountApplied))] × 2 tests
├── [Verifies(..., nameof(...OrderPersistedWithFullDetail))] × 1 test
└── [Verifies(..., nameof(...LoyaltyPointsCredited))] × 2 tests37 references. 12 files. 8 projects. One click.
In the ServiceLocator monorepo, the same question ("what implements Order Processing?") required 2.5 hours of manual investigation. Now it takes 1 second.
Multi-Team Ownership
In a 15-team organization, who owns what? The Requirements project makes this explicit.
Team-to-Feature Mapping
// The `Owner` property on each Feature type declares ownership:
public abstract record OrderProcessingFeature : Feature<ECommerceEpic>
{
public override string Owner => "order-team";
// ...
}
public abstract record PaymentProcessingFeature : Feature<ECommerceEpic>
{
public override string Owner => "payment-team";
// ...
}
public abstract record InventoryManagementFeature : Feature<ECommerceEpic>
{
public override string Owner => "inventory-team";
// ...
}// The `Owner` property on each Feature type declares ownership:
public abstract record OrderProcessingFeature : Feature<ECommerceEpic>
{
public override string Owner => "order-team";
// ...
}
public abstract record PaymentProcessingFeature : Feature<ECommerceEpic>
{
public override string Owner => "payment-team";
// ...
}
public abstract record InventoryManagementFeature : Feature<ECommerceEpic>
{
public override string Owner => "inventory-team";
// ...
}The generated reports include ownership:
Team Ownership Report
═══════════════════════════════════════════════════════
order-team:
Features: OrderProcessingFeature (9 ACs)
Spec implementations: OrderProcessingService
Spec dependencies:
IInventoryIntegrationSpec → inventory-team
IPaymentIntegrationSpec → payment-team
INotificationIntegrationSpec → cx-team
IAuditIntegrationSpec → compliance-team
ILoyaltyIntegrationSpec → user-team
payment-team:
Features: PaymentProcessingFeature (5 ACs)
Spec implementations: StripePaymentService
Also implements for: OrderProcessingFeature (IPaymentIntegrationSpec)
inventory-team:
Features: InventoryManagementFeature (4 ACs)
Spec implementations: InventoryReservationService
Also implements for: OrderProcessingFeature (IInventoryIntegrationSpec)
user-team:
Features: UserManagementFeature (6 ACs)
Spec implementations: UserService, LoyaltyPointService
Also implements for: OrderProcessingFeature (ILoyaltyIntegrationSpec)Team Ownership Report
═══════════════════════════════════════════════════════
order-team:
Features: OrderProcessingFeature (9 ACs)
Spec implementations: OrderProcessingService
Spec dependencies:
IInventoryIntegrationSpec → inventory-team
IPaymentIntegrationSpec → payment-team
INotificationIntegrationSpec → cx-team
IAuditIntegrationSpec → compliance-team
ILoyaltyIntegrationSpec → user-team
payment-team:
Features: PaymentProcessingFeature (5 ACs)
Spec implementations: StripePaymentService
Also implements for: OrderProcessingFeature (IPaymentIntegrationSpec)
inventory-team:
Features: InventoryManagementFeature (4 ACs)
Spec implementations: InventoryReservationService
Also implements for: OrderProcessingFeature (IInventoryIntegrationSpec)
user-team:
Features: UserManagementFeature (6 ACs)
Spec implementations: UserService, LoyaltyPointService
Also implements for: OrderProcessingFeature (ILoyaltyIntegrationSpec)Cross-Team Dependencies Are Visible
When the order-team's OrderProcessingService depends on IPaymentIntegrationSpec (owned by payment-team), this is visible in:
- The constructor —
OrderProcessingService(IPaymentIntegrationSpec payment, ...) - The spec interface —
[ForRequirement(typeof(OrderProcessingFeature))]onIPaymentIntegrationSpec - The generated report — "order-team depends on payment-team for AC-3"
If the payment-team changes IPaymentIntegrationSpec, the order-team's build breaks. The compiler tells both teams. No Slack message needed.
The Requirements Project Is PR-Gated
Changes to MegaCorp.Requirements affect the entire monorepo. It's the most impactful project in the solution. It should be:
- PR-gated — no direct pushes to
main - Multi-team review — changes require approval from all affected teams
- Architecture-team owned — a dedicated team or role manages the requirement hierarchy
# .github/CODEOWNERS (or equivalent)
/src/MegaCorp.Requirements/ @megacorp/architecture-team
/src/MegaCorp.Specifications/ @megacorp/architecture-team
/src/MegaCorp.Requirements/Features/OrderProcessing* @megacorp/order-team
/src/MegaCorp.Requirements/Features/Payment* @megacorp/payment-team
/src/MegaCorp.Requirements/Features/Inventory* @megacorp/inventory-team# .github/CODEOWNERS (or equivalent)
/src/MegaCorp.Requirements/ @megacorp/architecture-team
/src/MegaCorp.Specifications/ @megacorp/architecture-team
/src/MegaCorp.Requirements/Features/OrderProcessing* @megacorp/order-team
/src/MegaCorp.Requirements/Features/Payment* @megacorp/payment-team
/src/MegaCorp.Requirements/Features/Inventory* @megacorp/inventory-teamFeature owners can modify their own features. But adding a new feature or changing the hierarchy requires architecture-team approval. This is the governance model that scales — it's enforced by the Git hosting platform, not by human discipline.
The Compiler as Coordination Mechanism
In a traditional monorepo, cross-team coordination happens through:
| Mechanism | Speed | Reliability | Scales? |
|---|---|---|---|
| Slack messages | Minutes | Low (messages get buried) | No |
| Jira tickets | Days | Medium (tickets get stale) | Somewhat |
| Hours | Low (unread) | No | |
| Standup meetings | 1 day | Medium (verbal, not tracked) | No |
| Sprint planning | 2 weeks | Medium (plans change) | Somewhat |
| Architecture docs | Weeks | Low (docs drift from code) | No |
With Requirements as Projects, cross-team coordination happens through:
| Mechanism | Speed | Reliability | Scales? |
|---|---|---|---|
| Compiler error | Instant | 100% (it's a build failure) | Yes |
When the order-team adds AC-9, the compiler tells every affected team immediately, precisely, and unforgettably. The build is red until everyone has responded. No message gets buried. No ticket gets stale. No plan changes unnoticed.
The compiler is the fastest, most reliable, and most scalable coordination mechanism in software engineering. It doesn't take vacations. It doesn't forget. It doesn't have a backlog. It runs on every build, every PR, every commit.
Before and After: The 50-Project View
Let's compare the full 50-project monorepo before and after adopting Requirements as Projects.
Before: The ServiceLocator Monorepo
50 projects, ~520 hidden dependencies, 0 feature types.
Developer question: "What implements Order Processing?"
Answer: Unknown. Grep for "Order" → 247 files. Trace ServiceLocator calls → 2.5 hours.
PM question: "Is Order Processing complete?"
Answer: Unknown. Check Jira → 6 tickets over 5 years. Some ACs updated, some not.
QA question: "Are all ACs for Order Processing tested?"
Answer: Unknown. 87% code coverage. 0% requirement coverage.
New developer question: "What does this codebase do?"
Answer: Open the solution. Read 50 project names. Ask a senior developer. Hope they remember.
Auditor question: "Prove that all payment ACs are tested."
Answer: 3-day manual spreadsheet exercise. Results may be incomplete.
Incident response: "OrderPricingEngine was changed. What's the blast radius?"
Answer: Unknown. PricingEngine is in MegaCorp.Core, referenced by everything.
ServiceLocator hides the actual callers. Run all tests and hope.50 projects, ~520 hidden dependencies, 0 feature types.
Developer question: "What implements Order Processing?"
Answer: Unknown. Grep for "Order" → 247 files. Trace ServiceLocator calls → 2.5 hours.
PM question: "Is Order Processing complete?"
Answer: Unknown. Check Jira → 6 tickets over 5 years. Some ACs updated, some not.
QA question: "Are all ACs for Order Processing tested?"
Answer: Unknown. 87% code coverage. 0% requirement coverage.
New developer question: "What does this codebase do?"
Answer: Open the solution. Read 50 project names. Ask a senior developer. Hope they remember.
Auditor question: "Prove that all payment ACs are tested."
Answer: 3-day manual spreadsheet exercise. Results may be incomplete.
Incident response: "OrderPricingEngine was changed. What's the blast radius?"
Answer: Unknown. PricingEngine is in MegaCorp.Core, referenced by everything.
ServiceLocator hides the actual callers. Run all tests and hope.After: Requirements as Projects
52 projects (+Requirements, +Specifications), 0 hidden dependencies, 8 feature types, 37 ACs.
Developer question: "What implements Order Processing?"
Answer: Find All References on OrderProcessingFeature → 37 refs, 12 files, 8 projects. 1 second.
PM question: "Is Order Processing complete?"
Answer: dotnet build → "REQ303: OrderProcessingFeature — 9/9 ACs tested (100%)". Instant.
QA question: "Are all ACs for Order Processing tested?"
Answer: TraceabilityMatrix.g.cs → 21 tests, 9/9 ACs covered. Generated on every build.
New developer question: "What does this codebase do?"
Answer: Open MegaCorp.Requirements/Features/. See 8 feature types. Read them.
Each one has ACs with typed signatures. Ctrl+Click to implementations and tests.
Auditor question: "Prove that all payment ACs are tested."
Answer: dotnet build → TraceabilityMatrix.g.cs → PaymentProcessingFeature:
5/5 ACs specified, 5/5 implemented, 4/5 tested. Missing: NoPciDataStored.
Total time: 30 seconds.
Incident response: "OrderPricingEngine was changed. What's the blast radius?"
Answer: OrderPricingEngine implements IOrderProcessingSpec.ApplyBulkDiscount
→ [ForRequirement(typeof(OrderProcessingFeature), nameof(BulkOrderDiscountApplied))]
→ Affects AC-7. Tests: Bulk_order_receives_discount, Small_order_no_discount.
→ Run those 2 tests. If they pass, AC-7 is still satisfied.52 projects (+Requirements, +Specifications), 0 hidden dependencies, 8 feature types, 37 ACs.
Developer question: "What implements Order Processing?"
Answer: Find All References on OrderProcessingFeature → 37 refs, 12 files, 8 projects. 1 second.
PM question: "Is Order Processing complete?"
Answer: dotnet build → "REQ303: OrderProcessingFeature — 9/9 ACs tested (100%)". Instant.
QA question: "Are all ACs for Order Processing tested?"
Answer: TraceabilityMatrix.g.cs → 21 tests, 9/9 ACs covered. Generated on every build.
New developer question: "What does this codebase do?"
Answer: Open MegaCorp.Requirements/Features/. See 8 feature types. Read them.
Each one has ACs with typed signatures. Ctrl+Click to implementations and tests.
Auditor question: "Prove that all payment ACs are tested."
Answer: dotnet build → TraceabilityMatrix.g.cs → PaymentProcessingFeature:
5/5 ACs specified, 5/5 implemented, 4/5 tested. Missing: NoPciDataStored.
Total time: 30 seconds.
Incident response: "OrderPricingEngine was changed. What's the blast radius?"
Answer: OrderPricingEngine implements IOrderProcessingSpec.ApplyBulkDiscount
→ [ForRequirement(typeof(OrderProcessingFeature), nameof(BulkOrderDiscountApplied))]
→ Affects AC-7. Tests: Bulk_order_receives_discount, Small_order_no_discount.
→ Run those 2 tests. If they pass, AC-7 is still satisfied.The Numbers
| Metric | Before | After | Change |
|---|---|---|---|
| Projects | 50 | 52 | +2 (Requirements, Specifications) |
| Hidden dependencies (ServiceLocator) | ~520 | 0 | -100% |
| Visible dependencies (constructor injection) | ~80 | ~250 | +213% (all visible now) |
| Feature types | 0 | 8 | +8 (from nothing) |
| Acceptance criteria (in code) | 0 | 37 | +37 (were only in Jira) |
| ACs with specification methods | 0 | 37 | 100% coverage |
| ACs with tests | unknown | 35 | 94.6% coverage |
| Time to understand a feature | ~2.5 hours | ~1 minute | -97.5% |
| Time for compliance audit | ~3 days | ~30 seconds | -99.9% |
| Cross-team coordination | Jira + Slack | Compiler errors | Instant |
| Dead code detection | Unreliable | Reliable | No hidden refs |
Scaling Characteristics
Build Time
Adding .Requirements and .Specifications to a 50-project monorepo adds negligible build time:
MegaCorp.Requirements: ~20 source files, no dependencies → compiles in < 1 secondMegaCorp.Specifications: ~30 source files, 2 dependencies → compiles in < 1 second- Roslyn analyzer: runs incrementally per-project, caches results → < 0.5 seconds per project
Total additional build time: ~2-3 seconds on a 50-project solution that already takes 2-3 minutes.
The Roslyn analyzer is the key to scaling. It doesn't re-analyze the entire solution on every build — it uses Roslyn's incremental generation API:
// The analyzer uses ForAttributeWithMetadataName for incremental generation
[Generator]
public class RequirementAnalyzer : IIncrementalGenerator
{
public void Initialize(IncrementalGeneratorInitializationContext context)
{
// Only re-runs when types with [ForRequirement] change
var requirementTypes = context.SyntaxProvider
.ForAttributeWithMetadataName(
"MegaCorp.Requirements.ForRequirementAttribute",
predicate: (node, _) => node is ClassDeclarationSyntax
or InterfaceDeclarationSyntax
or MethodDeclarationSyntax,
transform: (ctx, _) => /* extract metadata */);
// Combine with requirement registry
var combined = requirementTypes.Collect()
.Combine(context.CompilationProvider);
// Generate only when inputs change
context.RegisterSourceOutput(combined, (ctx, source) =>
{
// Generate TraceabilityMatrix.g.cs
// Emit diagnostics
});
}
}// The analyzer uses ForAttributeWithMetadataName for incremental generation
[Generator]
public class RequirementAnalyzer : IIncrementalGenerator
{
public void Initialize(IncrementalGeneratorInitializationContext context)
{
// Only re-runs when types with [ForRequirement] change
var requirementTypes = context.SyntaxProvider
.ForAttributeWithMetadataName(
"MegaCorp.Requirements.ForRequirementAttribute",
predicate: (node, _) => node is ClassDeclarationSyntax
or InterfaceDeclarationSyntax
or MethodDeclarationSyntax,
transform: (ctx, _) => /* extract metadata */);
// Combine with requirement registry
var combined = requirementTypes.Collect()
.Combine(context.CompilationProvider);
// Generate only when inputs change
context.RegisterSourceOutput(combined, (ctx, source) =>
{
// Generate TraceabilityMatrix.g.cs
// Emit diagnostics
});
}
}If no [ForRequirement] attributes changed, the analyzer produces cached output. If only one attribute changed, it re-generates only the affected entries. The incremental API means the analyzer scales with the size of the change, not the size of the solution.
Memory Footprint
The Roslyn analyzer keeps metadata in memory during compilation:
| Data | Size (50-project monorepo) | Size (200-project monorepo) |
|---|---|---|
| Requirement types | ~8 records × 200 bytes = 1.6 KB | ~40 records × 200 bytes = 8 KB |
| AC methods | ~37 entries × 100 bytes = 3.7 KB | ~200 entries × 100 bytes = 20 KB |
| [ForRequirement] attributes | ~100 × 150 bytes = 15 KB | ~500 × 150 bytes = 75 KB |
| [Verifies] attributes | ~70 × 100 bytes = 7 KB | ~400 × 100 bytes = 40 KB |
| TraceabilityMatrix entries | ~8 × 2 KB = 16 KB | ~40 × 2 KB = 80 KB |
| Total | ~43 KB | ~223 KB |
Negligible. The analyzer's memory footprint is smaller than a single high-resolution image. It doesn't matter whether the monorepo has 50 projects or 500.
IDE Responsiveness
"Find All References" on a Feature type in a 50-project monorepo:
- Before (grep for "Order"): 5-15 seconds (text search across all files)
- After (Roslyn symbol search): < 0.5 seconds (Roslyn's symbol index is pre-built)
The typeof() and nameof() references are symbols, not strings. Roslyn indexes symbols at solution load time. "Find All References" on a symbol is a hash table lookup, not a text search. It scales to any solution size.
What Doesn't Change
It's important to be honest about what Requirements as Projects does NOT change at scale:
1. Build complexity
You still have 50+ projects. The build graph is still complex. MSBuild still needs to compile them in dependency order. Adding 2 projects doesn't simplify this.
2. Deployment complexity
You still deploy multiple services. Docker containers, Kubernetes manifests, CI/CD pipelines — none of that changes. Requirements as Projects is a compile-time architecture, not a deployment architecture.
3. Runtime behavior
At runtime, the application behaves identically. The [ForRequirement] attributes are metadata — they don't affect execution speed, memory usage, or API latency. The only runtime addition is the optional startup compliance check.
4. Team politics
If two teams disagree about who owns a feature, the compiler can't resolve that. If a PM writes bad acceptance criteria, the compiler enforces bad acceptance criteria. The tool is only as good as the humans defining the requirements.
5. Legacy code
Existing code that uses ServiceLocator continues to work. The 520 hidden dependencies don't disappear overnight. Migration is incremental (covered in Part 5).
The Scale-Independent Guarantee
Here is what DOES hold at any scale:
Every feature is a type. Whether you have 5 features or 500, each one is an abstract record with ACs as abstract methods.
Every AC has a spec. The Roslyn analyzer verifies this at compile time. Missing spec → build error.
Every spec has an implementation. The C# compiler verifies this. Missing interface method → compile error.
Every AC has a test. The Roslyn analyzer verifies this. Missing
[Verifies]→ build warning (error in CI).The traceability matrix is generated. From 8 features to 800, the matrix is source-generated on every build.
"Find All References" works. Type references are indexed by Roslyn. Symbol lookup is O(1), not O(n).
The AC cascade propagates. Add an AC → build breaks → teams implement → build green. Same at 5 teams or 50 teams.
These guarantees are structural — they come from the C# type system and the Roslyn analyzer, not from human discipline. They don't degrade with scale. They don't require more meetings. They don't need a "requirements sync" ceremony. The compiler enforces them on every build, forever.
Summary
At 50+ projects with 15 teams:
| Aspect | ServiceLocator Monorepo | Requirements as Projects |
|---|---|---|
| Add a new AC | Jira ticket → Slack → wait → implement → hope | Abstract method → compiler cascade → all teams implement → build green |
| Cross-team coordination | Jira, Slack, standups, sprint planning | Compiler errors |
| Feature traceability | 2.5 hours manual investigation | 1 second (Find All References) |
| Compliance audit | 3-day spreadsheet | 30-second build output |
| Dead code detection | Unreliable (ServiceLocator hides refs) | Reliable (all refs are typed) |
| Onboarding | 5 days to understand one feature | Read Feature type → Ctrl+Click |
| Build time overhead | N/A | +2-3 seconds |
| Memory overhead | N/A | ~43 KB |
The compiler is the coordination mechanism. The type system is the architecture. The traceability matrix is the audit trail. They scale because they're structural, not procedural.
The next post shows how to get from the ServiceLocator monorepo to this architecture — incrementally, one feature at a time, without a big-bang rewrite.
Previous: Part 3 — Requirements and Specifications ARE Projects