If I walked into a company with a tangled C# or .NET service architecture, I wouldn’t assume the first problem is the code itself.
I’d look at the boundaries.
In most legacy systems, risk doesn’t come from the age of the language. It comes from years of small decisions that gradually mixed communication, serialization, business logic, security, retries, and infrastructure concerns into the same execution paths.
Over time, service calls start carrying hidden assumptions:
- payload formats
- timeout behavior
- auth expectations
- serialization defaults
- side effects
- retry semantics
That’s when changes become expensive. Not because engineers can’t modify the code, but because nobody can confidently predict the blast radius.
That’s the real modernization problem.
Small engineering shortcuts compound at scale
The patterns are surprisingly consistent across large legacy .NET systems.
A service directly calls another service:
var payload = JsonConvert.SerializeObject(order);
await httpClient.PostAsync(
"http://service-b/process",
new StringContent(payload, Encoding.UTF8, "application/json")
);And the receiving service:
var body = await new StreamReader(Request.Body).ReadToEndAsync();
var order = JsonConvert.DeserializeObject<OrderDto>(body);
if (order.CustomerType == "VIP")
{
ApplyDiscount(order);
}
Save(order);Individually, none of this looks terrible.
That’s exactly why these patterns spread.
But at scale, you eventually get:
- tightly coupled services
- weakly enforced or inconsistent contracts
- multiple serialization behaviors
- duplicated infrastructure logic
- inconsistent security enforcement
- transport concerns mixed with business rules
- little visibility into dependencies between services
The result is a system where even small changes feel risky.
Ad-hoc migrations create bloated PRs, duplicated work, and stalled rollouts
Based on my experience working with many teams, what usually happens is that a platform team publishes migration guidance, and then individual teams handle the migration themselves using their preferred coding agents, workflows, and local conventions.
For one isolated migration path, this approach can work.
An engineer or coding agent manually works through the migration step by step. But the scope naturally expands beyond the required changes. While touching the code, teams often rename variables, clean up formatting, extract helpers, remove dead code, or apply optional refactors.
Now safe mechanical changes become mixed with risky manual edits and unrelated cleanup in the same PR.
The result is a bloated migration PR that becomes harder to review, validate, and roll out safely, while relying heavily on individual developer judgment.

The bigger problem is that almost none of this workflow becomes reusable. Team A manually goes through discovery, validation, cleanup, and rollout, while Team B repeats many of the same steps again in a slightly different way.
At enterprise scale, this breaks down quickly.
Large systems contain thousands of similar migration paths spread across many services and repositories, often built by different teams over many years. Documentation becomes outdated, services evolve independently, and the same pattern exists in many slightly different forms.

Without shared tooling, standardized workflows, and clear migration boundaries, every team starts reinventing the migration differently. Some rewrite aggressively. Others patch around the problem. Others delay the migration entirely.
The result is predictable:
- bloated and risky PRs
- duplicated migration work
- inconsistent implementations
- review bottlenecks
- stalled rollouts
- increasing architectural drift
At that point, modernization stops being just a coding problem. It becomes a human and organizational problem.
Large migrations are mostly an organizational challenge.
At enterprise scale, migrations stop being purely technical projects. They become organizational challenges around confidence, trust, and coordination.

Confidence
Fleet-wide migrations are large organizational bets. Before platform teams can rally leadership and impacted teams, they first need confidence themselves.
They need to know:
- which maintenance projects are actually worth funding
- the expected effort, risk, ROI, and engineering impact
- the real blast radius across services and teams
- whether the migration can improve velocity without destabilizing production
Without that visibility, migrations often stall before they even begin.
Trust
To drive adoption, platform teams need to earn engineers’ trust. Trust in the tools, the migration process, and the people driving the initiative.
Impacted teams naturally hesitate when another team mandates a migration:
- Will this break production?
- Will this turn into weeks of manual cleanup?
- Will this disrupt my roadmap?
That hesitation is usually rational. Trust is earned through predictable and safe execution, not top-down mandates alone.
Large migration PRs often mix simple mechanical changes with risky manual edits, making them difficult to review, validate, and deploy safely. The more migration work feels unpredictable, the more teams resist adoption.
Coordination
Large migrations fail less because of technology and more because of coordination overhead across dozens of teams.
Engineers dislike the non-technical work that pulls them out of flow:
- rollout coordination
- dependency tracking
- ownership discovery
- status reporting
- migration sequencing
- cross-team communication
The heavier and riskier the process feels, the more teams avoid it.
At scale, coordination overhead often becomes harder than the code transformation itself.
Legacy systems slow down the business
Eventually these systems start affecting:
- incident frequency
- operational stability
- developer velocity
- maintenance costs
- product delivery speed
In the AI era, this matters even more.
Companies that cannot evolve their systems safely and continuously eventually lose execution speed.

A methodical approach to safe and repeatable modernization at production scale
If I had to modernize a large legacy .NET system, I’d start small.
I’d begin with a few representative files and manually work through the migration with a coding agent to understand the exact workflow required to safely roll it out in production.
At this stage, the goal is discipline:
- make dependencies visible
- introduce enforceable boundaries
- reduce coupling gradually
- standardize infrastructure behavior
No optional refactors. No unrelated cleanup. No architectural redesign mixed into the same PR.
Once the workflow becomes clear, I’d break the migration into small atomic steps:
- repetitive and mechanically verifiable changes
- changes requiring human judgment or domain expertise
From there, I’d automate as many repeatable transformations as possible using compiler-aware codemods and deterministic tooling.
For the remaining edge cases, I’d isolate them into smaller reviewable tasks with the right context and guidance for AI systems or engineers to handle separately.
The goal is to turn a risky and inconsistent migration into a repeatable workflow that scales safely across teams and repositories.

Step 1: map what actually exists
Before changing anything, I’d want a code-level dependency map.
Not architecture diagrams.
Reality.
What to detect
Patterns like:
httpClient.PostAsync(...)
httpClient.GetAsync(...)
JsonConvert.SerializeObject(...)
JsonConvert.DeserializeObject(...)
bus.Publish(...)
new SqlConnection(...)
new TcpClient(...)What you want out of this
Something like:
BillingService → CustomerService (12 call sites)
OrderService → InventoryService (8 payload variations)
NotificationService → EmailService (no auth detected)This changes the conversation from guessing to knowing.
Where Codemod fits
This is where compiler-aware automation becomes useful.
Not as a magical architecture solution.
But as a way to automate the repetitive and mechanically verifiable parts of modernization.
For example, a mining codemod can:
- detect service-to-service communication
- identify serialization patterns
- surface risky infrastructure usage
- measure migration scope
- generate org-wide visibility
Example:
npx codemod csharp-service-call-miningThis kind of tooling helps teams:
- prioritize migrations
- measure blast radius
- coordinate rollout sequencing
- track modernization progress across repositories
Instead of manually auditing thousands of files.
Step 2: introduce explicit boundaries
I wouldn’t rewrite service internals first.
I’d wrap what already exists.
Before
await httpClient.PostAsync(
"http://orders/create",
content
);The caller implicitly depends on:
- payload structure
- transport behavior
- serialization settings
- auth expectations
- retry semantics
After
public record CreateOrderRequest(
string CustomerId,
List<OrderItemDto> Items
);
public record CreateOrderResponse(
string OrderId,
string Status
);And expose a stable boundary:
[HttpPost("/orders")]
public async Task<CreateOrderResponse> CreateOrder(
CreateOrderRequest request)
{
return await existingOrderProcessor.Process(request);
}The key idea is not “perfect architecture.”
It’s explicit contracts.
Step 3: standardize infrastructure behavior
One of the biggest hidden problems in legacy systems is inconsistent infrastructure behavior.
Serialization is a classic example.
Before
JsonConvert.SerializeObject(order)
JsonConvert.SerializeObject(
order,
Formatting.Indented
)
JsonConvert.SerializeObject(
order,
customSettings
)Different defaults eventually create:
- compatibility bugs
- casing mismatches
- enum inconsistencies
- DateTime problems
- null handling drift
After
public static class Json
{
public static string Serialize<T>(T obj) =>
JsonSerializer.Serialize(obj, JsonOptions.Default);
public static T Deserialize<T>(string json) =>
JsonSerializer.Deserialize<T>(
json,
JsonOptions.Default
)!;
}Then usage becomes:
var payload = Json.Serialize(order);This kind of standardization sounds boring.
But it dramatically improves predictability.
In large production systems, these changes should usually be rolled out incrementally because serialization differences can create subtle compatibility issues between services.
Step 4: replace direct calls with typed clients
This is where coupling actually starts to drop.
Before
await httpClient.PostAsync(
"http://service-b/process",
content
);After
await orderApiClient.CreateOrderAsync(request);And the infrastructure logic becomes centralized:
public class OrderApiClient
{
private readonly HttpClient _http;
public async Task<CreateOrderResponse>
CreateOrderAsync(CreateOrderRequest request)
{
var response = await _http.PostAsJsonAsync(
"/orders",
request
);
response.EnsureSuccessStatusCode();
return await response.Content
.ReadFromJsonAsync<CreateOrderResponse>();
}
}In real production systems, these clients usually also centralize:
- retries
- timeouts
- observability
- correlation IDs
- resiliency policies
- auth propagation
This is where platform consistency starts compounding.
Step 5: improve security through consistent boundaries
Structural boundaries make security controls much easier to apply consistently across services.
What to look for
"http://service-b/process"
client.DefaultRequestHeaders.Clear()
var connectionString = "Server=..."What modernized systems usually move toward
"https://service-b/process"
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue(
"Bearer",
token
);
var connectionString =
config.GetConnectionString("MainDb");Once boundaries become explicit, teams can enforce:
- auth policies
- encryption standards
- observability
- resiliency rules
- compliance requirements
More systematically.
Step 6: separate transport concerns from business logic
You’ll often find business rules tightly coupled to controllers or transport handlers.
Before
[HttpPost("/process")]
public IActionResult Process(OrderDto order)
{
if (order.Total > 1000)
{
ApplyApprovalFlow(order);
}
Save(order);
return Ok();
}After
[HttpPost("/process")]
public async Task<IActionResult> Process(OrderDto order)
{
var result = await _service.ProcessOrder(order);
return Ok(result);
}
public class OrderApplicationService
{
public async Task<OrderResult>
ProcessOrder(OrderDto order)
{
if (order.Total > 1000)
{
ApplyApprovalFlow(order);
}
return await Save(order);
}
}The goal is not “thin controllers.”
The goal is clearer separation between:
- transport concerns
- orchestration
- domain behavior
- infrastructure policies
Successful modernization combines deterministic tooling, AI, and incremental rollouts
Large-scale migrations are rarely “flip the switch” deployments. In production systems, teams usually roll changes out incrementally behind feature flags, canary deployments, compatibility layers, or staged rollouts to reduce operational risk while keeping feature delivery moving.
In these migrations, a large portion of the work is often repetitive and mechanically verifiable across many repositories and teams. Things like standardizing infrastructure usage, replacing deprecated APIs, or introducing safer boundaries can usually be automated reliably with deterministic compiler-aware tooling.
But not every change should be automated the same way.
Architectural decisions, rollout strategy, and domain-specific edge cases still require human judgment, local context, and nuanced decision-making. The goal is to use the right tool for the right task: deterministic tooling for repeatable changes, and AI systems or engineers for judgment-heavy ones.
Successful modernization is usually not about rewriting everything at once. It’s about making dependencies visible, introducing enforceable boundaries, automating repetitive transformations, and rolling changes out incrementally without breaking production. That’s what turns modernization from a risky one-time project into a repeatable engineering capability.