Migration: One Feature at a Time
Industrial Monorepo Series — Part 5 of 7 1. The Problem · 2. Physical vs Logical · 3. Requirements as Projects · 4. At Scale · 5. Migration · 6. ROI · 7. Inverted Deps
You don't rewrite the monorepo. You don't stop shipping features. You introduce two projects, migrate one feature per sprint, and let the typed half grow while the ServiceLocator half shrinks. After 6 months, the compiler enforces more than the entire Jira board ever did.
The previous posts described the target architecture. This post describes how to get there from a 50-project ServiceLocator monorepo — without a big-bang rewrite, without stopping feature work, and without asking management for a dedicated "refactoring sprint."
Migration happens along two axes:
- Space — which projects adopt the new architecture first
- Time — which features migrate in which sprint
The Two Dimensions of Migration
Space Migration: Project by Project
Not all 50 projects migrate at once. The migration spreads outward from the new Requirements and Specifications projects:
Time Migration: Feature by Feature
Not all 8 features migrate at once. Start with the most cross-cutting, highest-value feature:
Sprint 1: The Foundation
Goal: Create the two new projects, set up the Roslyn analyzer, ship zero features.
Day 1-2: Create the Projects
dotnet new classlib -n MegaCorp.Requirements -o src/MegaCorp.Requirements
dotnet new classlib -n MegaCorp.Specifications -o src/MegaCorp.Specifications
dotnet sln add src/MegaCorp.Requirements/MegaCorp.Requirements.csproj
dotnet sln add src/MegaCorp.Specifications/MegaCorp.Specifications.csprojdotnet new classlib -n MegaCorp.Requirements -o src/MegaCorp.Requirements
dotnet new classlib -n MegaCorp.Specifications -o src/MegaCorp.Specifications
dotnet sln add src/MegaCorp.Requirements/MegaCorp.Requirements.csproj
dotnet sln add src/MegaCorp.Specifications/MegaCorp.Specifications.csprojAdd the base types to Requirements:
// MegaCorp.Requirements/RequirementMetadata.cs
public abstract record RequirementMetadata
{
public abstract string Title { get; }
public abstract RequirementPriority Priority { get; }
public abstract string Owner { get; }
}
// + Epic, Feature<T>, AcceptanceCriterionResult, etc.
// (see Part 3 for complete types)// MegaCorp.Requirements/RequirementMetadata.cs
public abstract record RequirementMetadata
{
public abstract string Title { get; }
public abstract RequirementPriority Priority { get; }
public abstract string Owner { get; }
}
// + Epic, Feature<T>, AcceptanceCriterionResult, etc.
// (see Part 3 for complete types)Add the generated attributes:
// MegaCorp.Requirements/ForRequirementAttribute.cs
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Interface | AttributeTargets.Method,
AllowMultiple = true)]
public sealed class ForRequirementAttribute : Attribute { /* ... */ }
// + VerifiesAttribute, TestsForAttribute// MegaCorp.Requirements/ForRequirementAttribute.cs
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Interface | AttributeTargets.Method,
AllowMultiple = true)]
public sealed class ForRequirementAttribute : Attribute { /* ... */ }
// + VerifiesAttribute, TestsForAttributeAt this point: The two projects exist, compile, and do nothing. The rest of the solution is unaffected. No risk.
Day 3-5: Set Up the Roslyn Analyzer
Create the analyzer project with basic diagnostics:
<!-- tools/MegaCorp.Requirements.Analyzers/MegaCorp.Requirements.Analyzers.csproj -->
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.8.0" PrivateAssets="all" />
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.3.4" PrivateAssets="all" />
</ItemGroup>
</Project><!-- tools/MegaCorp.Requirements.Analyzers/MegaCorp.Requirements.Analyzers.csproj -->
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.8.0" PrivateAssets="all" />
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.3.4" PrivateAssets="all" />
</ItemGroup>
</Project>Wire it into Specifications:
<!-- MegaCorp.Specifications/MegaCorp.Specifications.csproj -->
<ItemGroup>
<ProjectReference Include="..\MegaCorp.Requirements\MegaCorp.Requirements.csproj" />
<ProjectReference Include="..\..\tools\MegaCorp.Requirements.Analyzers\MegaCorp.Requirements.Analyzers.csproj"
OutputItemType="Analyzer"
ReferenceOutputAssembly="false" />
</ItemGroup><!-- MegaCorp.Specifications/MegaCorp.Specifications.csproj -->
<ItemGroup>
<ProjectReference Include="..\MegaCorp.Requirements\MegaCorp.Requirements.csproj" />
<ProjectReference Include="..\..\tools\MegaCorp.Requirements.Analyzers\MegaCorp.Requirements.Analyzers.csproj"
OutputItemType="Analyzer"
ReferenceOutputAssembly="false" />
</ItemGroup>At the end of Sprint 1: Two empty projects, one Roslyn analyzer skeleton. The rest of the solution doesn't know they exist. Zero risk. Zero disruption.
Sprint 2: The First Feature
Goal: Migrate Order Processing — the most cross-cutting feature — to the new architecture.
This is the most important sprint. It establishes the pattern that all subsequent features follow.
Step 1: Define the Feature
// MegaCorp.Requirements/Features/OrderProcessingFeature.cs
public abstract record OrderProcessingFeature : Feature<ECommerceEpic>
{
public override string Title => "Order Processing";
public override RequirementPriority Priority => RequirementPriority.Critical;
public override string Owner => "order-team";
public abstract AcceptanceCriterionResult OrderTotalMustBePositive(OrderSummary order);
public abstract AcceptanceCriterionResult InventoryReservedBeforePayment(
OrderSummary order, IReadOnlyList<StockReservation> reservations);
// ... (8 ACs total — see Part 3)
}// MegaCorp.Requirements/Features/OrderProcessingFeature.cs
public abstract record OrderProcessingFeature : Feature<ECommerceEpic>
{
public override string Title => "Order Processing";
public override RequirementPriority Priority => RequirementPriority.Critical;
public override string Owner => "order-team";
public abstract AcceptanceCriterionResult OrderTotalMustBePositive(OrderSummary order);
public abstract AcceptanceCriterionResult InventoryReservedBeforePayment(
OrderSummary order, IReadOnlyList<StockReservation> reservations);
// ... (8 ACs total — see Part 3)
}Step 2: Create the Spec Interfaces
// MegaCorp.Specifications/OrderProcessing/IOrderProcessingSpec.cs
[ForRequirement(typeof(OrderProcessingFeature))]
public interface IOrderProcessingSpec
{
[ForRequirement(typeof(OrderProcessingFeature),
nameof(OrderProcessingFeature.OrderTotalMustBePositive))]
Result ValidateOrderTotal(Order order);
// ... (see Part 3)
}
// + IInventoryIntegrationSpec, IPaymentIntegrationSpec, etc.// MegaCorp.Specifications/OrderProcessing/IOrderProcessingSpec.cs
[ForRequirement(typeof(OrderProcessingFeature))]
public interface IOrderProcessingSpec
{
[ForRequirement(typeof(OrderProcessingFeature),
nameof(OrderProcessingFeature.OrderTotalMustBePositive))]
Result ValidateOrderTotal(Order order);
// ... (see Part 3)
}
// + IInventoryIntegrationSpec, IPaymentIntegrationSpec, etc.Step 3: Make the Implementation Implement the Spec
This is where the migration touches existing code. The existing OrderService in MegaCorp.Core already has the logic — it just doesn't implement an interface.
Option A: Refactor in place (preferred for small changes)
// MegaCorp.Core/Orders/OrderService.cs — BEFORE
public class OrderService
{
public async Task<OrderResult> ProcessOrder(CreateOrderCommand command)
{
var validator = ServiceLocator.GetService<IOrderValidator>();
// ... 200 lines of ServiceLocator calls
}
}// MegaCorp.Core/Orders/OrderService.cs — BEFORE
public class OrderService
{
public async Task<OrderResult> ProcessOrder(CreateOrderCommand command)
{
var validator = ServiceLocator.GetService<IOrderValidator>();
// ... 200 lines of ServiceLocator calls
}
}// MegaCorp.OrderService/OrderProcessingService.cs — AFTER (new file, new project)
[ForRequirement(typeof(OrderProcessingFeature))]
public class OrderProcessingService : IOrderProcessingSpec
{
private readonly IInventoryIntegrationSpec _inventory;
private readonly IPaymentIntegrationSpec _payment;
// Constructor injection instead of ServiceLocator
public async Task<Result<OrderConfirmation>> ProcessOrder(CreateOrderCommand command)
{
// Same business logic, different wiring
}
}// MegaCorp.OrderService/OrderProcessingService.cs — AFTER (new file, new project)
[ForRequirement(typeof(OrderProcessingFeature))]
public class OrderProcessingService : IOrderProcessingSpec
{
private readonly IInventoryIntegrationSpec _inventory;
private readonly IPaymentIntegrationSpec _payment;
// Constructor injection instead of ServiceLocator
public async Task<Result<OrderConfirmation>> ProcessOrder(CreateOrderCommand command)
{
// Same business logic, different wiring
}
}Option B: Wrap the existing class (preferred for large legacy classes)
// Keep the old OrderService temporarily.
// Create a new class that wraps it and implements the spec:
[ForRequirement(typeof(OrderProcessingFeature))]
public class OrderProcessingServiceAdapter : IOrderProcessingSpec
{
private readonly OrderService _legacy;
public OrderProcessingServiceAdapter(OrderService legacy) => _legacy = legacy;
[ForRequirement(typeof(OrderProcessingFeature),
nameof(OrderProcessingFeature.OrderTotalMustBePositive))]
public Result ValidateOrderTotal(Order order)
{
// Delegate to legacy, translate result
var legacyResult = _legacy.ValidateOrder(
new CreateOrderCommand { /* map from Order to legacy DTO */ });
return legacyResult.IsValid ? Result.Success() : Result.Failure(legacyResult.Errors);
}
// ... adapt each spec method to call the legacy class
}// Keep the old OrderService temporarily.
// Create a new class that wraps it and implements the spec:
[ForRequirement(typeof(OrderProcessingFeature))]
public class OrderProcessingServiceAdapter : IOrderProcessingSpec
{
private readonly OrderService _legacy;
public OrderProcessingServiceAdapter(OrderService legacy) => _legacy = legacy;
[ForRequirement(typeof(OrderProcessingFeature),
nameof(OrderProcessingFeature.OrderTotalMustBePositive))]
public Result ValidateOrderTotal(Order order)
{
// Delegate to legacy, translate result
var legacyResult = _legacy.ValidateOrder(
new CreateOrderCommand { /* map from Order to legacy DTO */ });
return legacyResult.IsValid ? Result.Success() : Result.Failure(legacyResult.Errors);
}
// ... adapt each spec method to call the legacy class
}The adapter pattern lets you migrate one AC at a time. Start with AC-1 (validation), then AC-7 (discount), then AC-8 (persistence). Each AC is a separate method on the adapter. When all ACs are adapted, you can refactor the legacy class out.
Step 4: The Hybrid Program.cs
During migration, Program.cs has both old and new wiring:
// MegaCorp.Web/Program.cs — Sprint 2 (hybrid)
var builder = WebApplication.CreateBuilder(args);
// ═══════════════════════════════════════════════════════════════════
// NEW: Typed specification interfaces (Order Processing)
// ═══════════════════════════════════════════════════════════════════
builder.Services.AddScoped<IOrderProcessingSpec, OrderProcessingService>();
builder.Services.AddScoped<IInventoryIntegrationSpec, InventoryReservationService>();
builder.Services.AddScoped<IPaymentIntegrationSpec, StripePaymentService>();
builder.Services.AddScoped<INotificationIntegrationSpec, OrderNotificationService>();
builder.Services.AddScoped<IAuditIntegrationSpec, AuditService>();
// ═══════════════════════════════════════════════════════════════════
// OLD: ServiceLocator-based services (everything else — NOT YET MIGRATED)
// These will be migrated in subsequent sprints.
// ═══════════════════════════════════════════════════════════════════
builder.Services.AddScoped<IUserService, UserService>();
builder.Services.AddScoped<IRoleManager, RoleManager>();
builder.Services.AddScoped<IAuthorizationService, AuthorizationService>();
builder.Services.AddScoped<IReportingService, ReportingService>();
builder.Services.AddScoped<ISearchService, ElasticsearchService>();
builder.Services.AddScoped<IBillingService, BillingService>();
// ... (remaining legacy registrations)
// ═══════════════════════════════════════════════════════════════════
// LEGACY: ServiceLocator initialization (still needed for unmigrated code)
// This line will be REMOVED when all features are migrated.
// ═══════════════════════════════════════════════════════════════════
var app = builder.Build();
ServiceLocator.Initialize(app.Services);
// TODO: Remove when last ServiceLocator.GetService<> call is eliminated
app.MapControllers();
app.Run();// MegaCorp.Web/Program.cs — Sprint 2 (hybrid)
var builder = WebApplication.CreateBuilder(args);
// ═══════════════════════════════════════════════════════════════════
// NEW: Typed specification interfaces (Order Processing)
// ═══════════════════════════════════════════════════════════════════
builder.Services.AddScoped<IOrderProcessingSpec, OrderProcessingService>();
builder.Services.AddScoped<IInventoryIntegrationSpec, InventoryReservationService>();
builder.Services.AddScoped<IPaymentIntegrationSpec, StripePaymentService>();
builder.Services.AddScoped<INotificationIntegrationSpec, OrderNotificationService>();
builder.Services.AddScoped<IAuditIntegrationSpec, AuditService>();
// ═══════════════════════════════════════════════════════════════════
// OLD: ServiceLocator-based services (everything else — NOT YET MIGRATED)
// These will be migrated in subsequent sprints.
// ═══════════════════════════════════════════════════════════════════
builder.Services.AddScoped<IUserService, UserService>();
builder.Services.AddScoped<IRoleManager, RoleManager>();
builder.Services.AddScoped<IAuthorizationService, AuthorizationService>();
builder.Services.AddScoped<IReportingService, ReportingService>();
builder.Services.AddScoped<ISearchService, ElasticsearchService>();
builder.Services.AddScoped<IBillingService, BillingService>();
// ... (remaining legacy registrations)
// ═══════════════════════════════════════════════════════════════════
// LEGACY: ServiceLocator initialization (still needed for unmigrated code)
// This line will be REMOVED when all features are migrated.
// ═══════════════════════════════════════════════════════════════════
var app = builder.Build();
ServiceLocator.Initialize(app.Services);
// TODO: Remove when last ServiceLocator.GetService<> call is eliminated
app.MapControllers();
app.Run();The hybrid Program.cs is ugly. That's fine. It's honest — it shows exactly which features are migrated (typed interfaces) and which are not (ServiceLocator). The TODO comment is a migration tracker.
Step 5: Add [Verifies] to Existing Tests
You don't rewrite tests. You annotate them:
// MegaCorp.OrderService.Tests/OrderProcessingTests.cs
// BEFORE: (no requirement link)
[Test]
public void Negative_total_is_rejected()
{
var order = CreateOrder(total: -50m);
var result = _service.ValidateOrderTotal(order);
Assert.That(result.IsSuccess, Is.False);
}
// AFTER: (add [Verifies] — one line change)
[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);
}// MegaCorp.OrderService.Tests/OrderProcessingTests.cs
// BEFORE: (no requirement link)
[Test]
public void Negative_total_is_rejected()
{
var order = CreateOrder(total: -50m);
var result = _service.ValidateOrderTotal(order);
Assert.That(result.IsSuccess, Is.False);
}
// AFTER: (add [Verifies] — one line change)
[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);
}Adding [Verifies] is a one-line change per test method. The test logic doesn't change. The assertion doesn't change. You're just adding a typed link from the test to the AC it verifies.
For a feature with 15 existing tests, this takes ~30 minutes.
The Migration State at Each Sprint
| Sprint | Features Migrated | ServiceLocator Calls | Typed Dependencies | ACs in Code |
|---|---|---|---|---|
| 1 | 0 | ~520 | 0 | 0 |
| 2 | 1 (Order Processing) | ~440 | ~40 | 8 |
| 3-4 | 3 (+Payment, Inventory) | ~310 | ~90 | 17 |
| 5-6 | 5 (+User, Billing) | ~180 | ~160 | 29 |
| 7-8 | 8 (all features) | ~30 | ~240 | 37 |
| 9 | 8 (cleanup) | 0 | ~250 | 37 |
The ServiceLocator calls decrease monotonically. The typed dependencies increase monotonically. At every sprint, the solution compiles and ships. There is no "migration freeze" where nothing ships.
The Adapter Pattern for Legacy Code
For large legacy services that can't be rewritten in one sprint, the adapter pattern allows incremental migration:
// Sprint 2: Adapter wraps legacy OrderService
[ForRequirement(typeof(OrderProcessingFeature))]
public class OrderProcessingAdapter : IOrderProcessingSpec
{
private readonly LegacyOrderService _legacy;
public OrderProcessingAdapter(LegacyOrderService legacy) => _legacy = legacy;
// AC-1: Delegated to legacy — just wraps the result type
[ForRequirement(typeof(OrderProcessingFeature),
nameof(OrderProcessingFeature.OrderTotalMustBePositive))]
public Result ValidateOrderTotal(Order order)
{
var legacyResult = _legacy.Validate(MapToLegacyDto(order));
return legacyResult.IsValid ? Result.Success() : Result.Failure(legacyResult.Error);
}
// AC-7: Rewritten (new logic, not in legacy)
[ForRequirement(typeof(OrderProcessingFeature),
nameof(OrderProcessingFeature.BulkOrderDiscountApplied))]
public Result<Order> ApplyBulkDiscount(Order order)
{
// New implementation — not delegating to legacy
if (order.Lines.Count < 10) return Result<Order>.Success(order);
var discounted = order with { Total = new Money(order.Total.Amount * 0.95m, order.Total.Currency) };
return Result<Order>.Success(discounted);
}
// AC-8: Delegated to legacy with adaptation
[ForRequirement(typeof(OrderProcessingFeature),
nameof(OrderProcessingFeature.OrderPersistedWithFullDetail))]
public async Task<Result> PersistOrder(Order order, Payment payment,
IReadOnlyList<Reservation> reservations)
{
// Legacy Save doesn't take payment/reservation refs — add them separately
await _legacy.SaveOrder(MapToLegacyDto(order));
await _legacy.LinkPayment(order.Id, payment.Id);
await _legacy.LinkReservations(order.Id, reservations.Select(r => r.Id).ToList());
return Result.Success();
}
// ProcessOrder: orchestrator — NEW CODE, replaces the legacy 200-line method
public async Task<Result<OrderConfirmation>> ProcessOrder(CreateOrderCommand command)
{
// New clean implementation using typed spec interfaces
// (see Part 3 for full code)
}
}// Sprint 2: Adapter wraps legacy OrderService
[ForRequirement(typeof(OrderProcessingFeature))]
public class OrderProcessingAdapter : IOrderProcessingSpec
{
private readonly LegacyOrderService _legacy;
public OrderProcessingAdapter(LegacyOrderService legacy) => _legacy = legacy;
// AC-1: Delegated to legacy — just wraps the result type
[ForRequirement(typeof(OrderProcessingFeature),
nameof(OrderProcessingFeature.OrderTotalMustBePositive))]
public Result ValidateOrderTotal(Order order)
{
var legacyResult = _legacy.Validate(MapToLegacyDto(order));
return legacyResult.IsValid ? Result.Success() : Result.Failure(legacyResult.Error);
}
// AC-7: Rewritten (new logic, not in legacy)
[ForRequirement(typeof(OrderProcessingFeature),
nameof(OrderProcessingFeature.BulkOrderDiscountApplied))]
public Result<Order> ApplyBulkDiscount(Order order)
{
// New implementation — not delegating to legacy
if (order.Lines.Count < 10) return Result<Order>.Success(order);
var discounted = order with { Total = new Money(order.Total.Amount * 0.95m, order.Total.Currency) };
return Result<Order>.Success(discounted);
}
// AC-8: Delegated to legacy with adaptation
[ForRequirement(typeof(OrderProcessingFeature),
nameof(OrderProcessingFeature.OrderPersistedWithFullDetail))]
public async Task<Result> PersistOrder(Order order, Payment payment,
IReadOnlyList<Reservation> reservations)
{
// Legacy Save doesn't take payment/reservation refs — add them separately
await _legacy.SaveOrder(MapToLegacyDto(order));
await _legacy.LinkPayment(order.Id, payment.Id);
await _legacy.LinkReservations(order.Id, reservations.Select(r => r.Id).ToList());
return Result.Success();
}
// ProcessOrder: orchestrator — NEW CODE, replaces the legacy 200-line method
public async Task<Result<OrderConfirmation>> ProcessOrder(CreateOrderCommand command)
{
// New clean implementation using typed spec interfaces
// (see Part 3 for full code)
}
}The adapter pattern gives you:
- AC-by-AC migration (some ACs delegate to legacy, some are new)
- A compile-time guarantee that all ACs are covered
- A clear diff showing which ACs are migrated and which are wrapped
- A path to remove the legacy class: when all ACs are new code, delete the adapter wrapper
What to Migrate First
High-Value, High-Cross-Cutting Features First
The first feature to migrate should be the one that:
- Touches the most teams — so the pattern propagates quickly
- Has the most ServiceLocator calls — so the migration removes the most hidden dependencies
- Is actively being worked on — so the migration piggybacks on existing feature work
In MegaCorp, that's Order Processing: it spans 5 services, 9 ServiceLocator calls per order, and there's always a Jira ticket to improve it.
Don't Migrate Infrastructure First
It's tempting to start with the "easy" infrastructure projects (EventBus, CacheService, Common.Utils). Don't. These are physical concerns, not features. They don't have acceptance criteria. They don't benefit from [ForRequirement]. Migrate them as part of the features that use them.
The Migration Order
| Sprint | Feature | Why This Order |
|---|---|---|
| 2 | Order Processing | Most cross-cutting, highest value, 5 teams involved |
| 3 | Payment Processing | Already partially done (PaymentGateway migrated in Sprint 2) |
| 4 | Inventory Management | Already partially done (InventoryService migrated in Sprint 2) |
| 5 | User Management | High ACs (6), auth is critical path |
| 6 | Billing | Medium priority, builds on Payment |
| 7 | Customer Notifications | Lower priority, few ACs |
| 8 | Reporting + Search | Lowest priority, mostly read-only |
| 9 | Cleanup | Remove ServiceLocator, delete MegaCorp.Core |
Sprint 9: The Cleanup
After all 8 features are migrated, the ServiceLocator is still initialized in Program.cs — but no one calls it. Time to clean up.
Step 1: Find Remaining ServiceLocator Calls
# Search for any remaining ServiceLocator usage
grep -rn "ServiceLocator" src/ --include="*.cs"
# Should return: only the ServiceLocator class itself and the Initialize call in Program.cs
grep -rn "GetRequiredService\|GetService" src/ --include="*.cs" | grep -v "test\|Test"
# Should return: only Program.cs DI registration (which is fine)# Search for any remaining ServiceLocator usage
grep -rn "ServiceLocator" src/ --include="*.cs"
# Should return: only the ServiceLocator class itself and the Initialize call in Program.cs
grep -rn "GetRequiredService\|GetService" src/ --include="*.cs" | grep -v "test\|Test"
# Should return: only Program.cs DI registration (which is fine)Step 2: Remove ServiceLocator.Initialize
// MegaCorp.Web/Program.cs — BEFORE (hybrid)
var app = builder.Build();
ServiceLocator.Initialize(app.Services); // ← DELETE THIS LINE
// MegaCorp.Web/Program.cs — AFTER (clean)
var app = builder.Build();
// ServiceLocator is gone. Constructor injection everywhere.// MegaCorp.Web/Program.cs — BEFORE (hybrid)
var app = builder.Build();
ServiceLocator.Initialize(app.Services); // ← DELETE THIS LINE
// MegaCorp.Web/Program.cs — AFTER (clean)
var app = builder.Build();
// ServiceLocator is gone. Constructor injection everywhere.Step 3: Delete the ServiceLocator Class
// DELETE: MegaCorp.Infrastructure/ServiceLocator.cs
// This file has been here since 2018. It was referenced by 47 files.
// Now it's referenced by 0. Delete it.// DELETE: MegaCorp.Infrastructure/ServiceLocator.cs
// This file has been here since 2018. It was referenced by 47 files.
// Now it's referenced by 0. Delete it.Step 4: Distribute MegaCorp.Core
The 400-file grab bag can now be split:
- Order-related classes →
MegaCorp.OrderService - Payment-related classes →
MegaCorp.PaymentGateway - User-related classes →
MegaCorp.UserService - Truly shared utilities →
MegaCorp.SharedKernel(only the ones actually shared) - Dead code → Delete
MegaCorp.Core/ (BEFORE: 400 files)
├── Orders/ → move to MegaCorp.OrderService
├── Payments/ → move to MegaCorp.PaymentGateway
├── Inventory/ → move to MegaCorp.InventoryService
├── Users/ → move to MegaCorp.UserService
├── Notifications/ → move to MegaCorp.NotificationService
├── Common/
│ ├── BaseService.cs → DELETE (dead code)
│ ├── ServiceHelper.cs → DELETE (ServiceLocator helper)
│ └── Extensions.cs → move to SharedKernel (if actually shared)
└── (20+ classes) → Analyze each: move to feature project or DELETE
MegaCorp.Core/ (AFTER: 0 files — project deleted)MegaCorp.Core/ (BEFORE: 400 files)
├── Orders/ → move to MegaCorp.OrderService
├── Payments/ → move to MegaCorp.PaymentGateway
├── Inventory/ → move to MegaCorp.InventoryService
├── Users/ → move to MegaCorp.UserService
├── Notifications/ → move to MegaCorp.NotificationService
├── Common/
│ ├── BaseService.cs → DELETE (dead code)
│ ├── ServiceHelper.cs → DELETE (ServiceLocator helper)
│ └── Extensions.cs → move to SharedKernel (if actually shared)
└── (20+ classes) → Analyze each: move to feature project or DELETE
MegaCorp.Core/ (AFTER: 0 files — project deleted)After distributing MegaCorp.Core, the build graph has no gravity well. Each feature project has only its own code. Changes to one feature don't trigger rebuilds of unrelated features.
Coexistence Rules During Migration
During the 6-9 month migration, the monorepo has both architectures. Some rules make coexistence work:
Rule 1: New Features Use the New Architecture
Any new feature defined after Sprint 1 goes into MegaCorp.Requirements as a type. No new Jira-only features.
Rule 2: New Code in Migrated Features Uses Spec Interfaces
If a developer is adding code to Order Processing (already migrated), they use IOrderProcessingSpec and constructor injection. They do not add new ServiceLocator.GetService<> calls.
Rule 3: Old Code Still Works
ServiceLocator is still initialized. Unmigrated features still resolve via ServiceLocator.GetService<>. They work exactly as before. The migration doesn't break them.
Rule 4: The Analyzer Runs on Migrated Features Only
The Roslyn analyzer only emits diagnostics for features that have been defined in MegaCorp.Requirements. Unmigrated features generate no warnings — the analyzer doesn't know about them.
Rule 5: Tests Can Be Annotated Incrementally
You don't need to annotate all tests for a feature at once. Annotate them as you touch them. A test without [Verifies] still runs — it just isn't counted in the traceability matrix.
Build Performance During Migration
During migration, the build graph changes:
Before migration:
MegaCorp.Core (400 files) → triggers rebuild of 40+ projects on any changeMegaCorp.Core (400 files) → triggers rebuild of 40+ projects on any changeDuring migration (Sprint 4):
MegaCorp.Requirements (20 files) → triggers rebuild of ~15 migrated projects
MegaCorp.Core (300 files, shrinking) → triggers rebuild of ~30 unmigrated projectsMegaCorp.Requirements (20 files) → triggers rebuild of ~15 migrated projects
MegaCorp.Core (300 files, shrinking) → triggers rebuild of ~30 unmigrated projectsAfter migration:
MegaCorp.Requirements (30 files) → triggers rebuild of ~50 projects (but itself is stable)
MegaCorp.Core → DELETEDMegaCorp.Requirements (30 files) → triggers rebuild of ~50 projects (but itself is stable)
MegaCorp.Core → DELETEDThe key insight: MegaCorp.Requirements changes rarely (only when ACs change). MegaCorp.Core changed constantly (because it contained everything). Replacing the gravity well with a stable root reduces unnecessary rebuilds.
Common Migration Challenges
Challenge 1: Circular Dependencies
Problem: OrderService depends on UserService (for customer data) and UserService depends on OrderService (for order history on user profile).
Solution: Both depend on specification interfaces, not on each other:
// OrderService depends on IUserQuerySpec (not UserService directly)
public class OrderProcessingService : IOrderProcessingSpec
{
private readonly IUserQuerySpec _users;
// ...
}
// UserService depends on IOrderQuerySpec (not OrderService directly)
public class UserProfileService : IUserProfileSpec
{
private readonly IOrderQuerySpec _orders;
// ...
}// OrderService depends on IUserQuerySpec (not UserService directly)
public class OrderProcessingService : IOrderProcessingSpec
{
private readonly IUserQuerySpec _users;
// ...
}
// UserService depends on IOrderQuerySpec (not OrderService directly)
public class UserProfileService : IUserProfileSpec
{
private readonly IOrderQuerySpec _orders;
// ...
}The specification interfaces break the cycle. Both services depend on Specifications (which depends on Requirements). Neither depends on the other directly.
Challenge 2: Legacy Code That Can't Be Refactored
Problem: A 2000-line method in MegaCorp.Core/Orders/OrderProcessor.cs that nobody understands, but it's production-critical.
Solution: Wrap it. Don't touch it.
[ForRequirement(typeof(OrderProcessingFeature))]
public class LegacyOrderProcessorAdapter : IOrderProcessingSpec
{
private readonly OrderProcessor _legacy;
// Each spec method delegates to the legacy processor
// The legacy processor still uses ServiceLocator internally
// That's OK — the adapter IS the boundary
// When we eventually rewrite the legacy, only this adapter changes
}[ForRequirement(typeof(OrderProcessingFeature))]
public class LegacyOrderProcessorAdapter : IOrderProcessingSpec
{
private readonly OrderProcessor _legacy;
// Each spec method delegates to the legacy processor
// The legacy processor still uses ServiceLocator internally
// That's OK — the adapter IS the boundary
// When we eventually rewrite the legacy, only this adapter changes
}The adapter creates a typed boundary around the legacy code. The rest of the system interacts with the adapter through IOrderProcessingSpec. The legacy code inside the adapter can use ServiceLocator, static classes, global state — it doesn't matter. The boundary is the adapter.
Challenge 3: Tests That Depend on ServiceLocator Static State
Problem: Existing tests initialize ServiceLocator.Initialize() in [SetUp] and depend on it.
Solution: During migration, both patterns coexist. Tests can be migrated incrementally:
[TestFixture]
[TestsFor(typeof(OrderProcessingFeature))]
public class OrderProcessingTests
{
// MIGRATED tests use constructor-injected spec
private OrderProcessingService _service;
[SetUp]
public void Setup()
{
// New pattern: wire real specs with in-memory infrastructure
_service = new OrderProcessingService(
new InventoryReservationService(new InMemoryStockRepo()),
new StripePaymentService(new FakeStripeClient()),
// ...
);
}
// MIGRATED: uses [Verifies], no ServiceLocator
[Test]
[Verifies(typeof(OrderProcessingFeature),
nameof(OrderProcessingFeature.OrderTotalMustBePositive))]
public void Positive_total_is_accepted() { /* ... */ }
// NOT YET MIGRATED: still uses old pattern, no [Verifies]
// Will be migrated when someone touches this test
[Test]
public void Legacy_test_still_works()
{
// Still uses ServiceLocator internally — that's OK for now
}
}[TestFixture]
[TestsFor(typeof(OrderProcessingFeature))]
public class OrderProcessingTests
{
// MIGRATED tests use constructor-injected spec
private OrderProcessingService _service;
[SetUp]
public void Setup()
{
// New pattern: wire real specs with in-memory infrastructure
_service = new OrderProcessingService(
new InventoryReservationService(new InMemoryStockRepo()),
new StripePaymentService(new FakeStripeClient()),
// ...
);
}
// MIGRATED: uses [Verifies], no ServiceLocator
[Test]
[Verifies(typeof(OrderProcessingFeature),
nameof(OrderProcessingFeature.OrderTotalMustBePositive))]
public void Positive_total_is_accepted() { /* ... */ }
// NOT YET MIGRATED: still uses old pattern, no [Verifies]
// Will be migrated when someone touches this test
[Test]
public void Legacy_test_still_works()
{
// Still uses ServiceLocator internally — that's OK for now
}
}Challenge 4: Management Buy-In
Problem: "We can't afford a refactoring sprint."
Solution: There is no refactoring sprint. The migration happens inside feature work:
Sprint 2: "Add loyalty points to Order Processing" — the feature work IS the migration. You're defining the feature type, creating the spec, implementing it, and testing it. The migration is the structure; the feature is the content.
Sprint 5: "Add two-factor authentication to User Management" — same pattern. Define UserManagementFeature, create IUserManagementSpec, implement it. The 2FA feature is the content; the migration is the structure.
Every sprint ships a real business feature AND migrates one architectural feature. Management sees features shipped. The team sees architecture improved. No dedicated refactoring time needed.
The Migration Is Complete When...
ServiceLocator.csis deletedMegaCorp.Core/is deleted (distributed to feature projects)- No
IServiceProviderconstructor parameters exist (except inProgram.cs) - Every feature has a type in
MegaCorp.Requirements - Every AC has a spec method in
MegaCorp.Specifications - Every spec has an implementation with
[ForRequirement] - Every AC has at least one
[Verifies]test - The Roslyn analyzer reports 100% spec coverage
- The
TraceabilityMatrix.g.cscovers all features
At that point, the monorepo has gone from "50 DLLs and zero architecture" to "52 DLLs and compiler-enforced architecture." The physical structure barely changed. The logical structure changed completely.
Previous: Part 4 — What Changes at 50+ Projects