Skip to main content

Decomposing a .NET Monolith: Step-by-Step

Breaking a monolithic .NET application into microservices is a multi-month or multi-year journey, not a big-bang rewrite. The strangler pattern, named after a fig tree that slowly strangles its host, enables gradual decomposition: new services are built alongside the monolith, gradually taking over responsibilities until the monolith is obsolete. This approach minimizes risk, allows continuous delivery, and lets you learn before fully committing to a distributed architecture.

A monolith is a single .NET application: one Visual Studio solution, one database, one deployment artifact. As it grows (thousands of developers, hundreds of thousands of lines), deployments become risky (a small bug in inventory breaks payments), scaling becomes inefficient (if only payments are CPU-bound, you scale the entire monolith), and onboarding is slow (new developers must understand all domains). Microservices solve this by decoupling domains.

However, a full rewrite of a monolith into microservices is extremely risky. You stop feature development for months, introduce new bugs in the rewrite, and may never ship. The strangler pattern avoids this: keep the monolith running and profitable; incrementally extract services.

The Strangler Pattern: Overview

Step 1: Identify a small, self-contained domain (e.g., notifications). Step 2: Build a new microservice for that domain. Step 3: Route traffic to the new service; the monolith's code for that domain is now a fallback. Step 4: Once the service is stable, remove the old code from the monolith. Step 5: Repeat with the next domain.

After 5–10 iterations, the monolith has shrunk significantly, and you have a production-proven microservices architecture.

Phase 1: Identify the First Service to Extract

Choose a domain that is:

  • Loosely coupled to the monolith. Notifications, reporting, or email are good candidates. Payments or Orders are tightly coupled and should be extracted later.
  • Low-risk to users. If the Email Service goes down, users are inconvenienced but not blocked. If the Payment Service goes down, the business stops.
  • Well-understood. Pick a domain with clear business rules and stable APIs.

For an e-commerce monolith, the Notification Service is a great first choice: send confirmation emails, SMS alerts, push notifications. It has few dependencies (only needs to read orders, customers) and low business criticality (if emails are delayed, the order still processes).

Phase 2: Build the New Service

Build the Notification Service as a standalone .NET application:

// NotificationService/Controllers/NotificationsController.cs
[ApiController]
[Route("api/notifications")]
public class NotificationsController : ControllerBase
{
private readonly NotificationService _service;

public NotificationsController(NotificationService service)
{
_service = service;
}

[HttpPost("send-order-confirmation")]
public async Task<IActionResult> SendOrderConfirmation([FromBody] SendOrderConfirmationRequest req)
{
try
{
await _service.SendOrderConfirmationEmailAsync(req.OrderId, req.CustomerId);
return Ok(new { message = "Notification sent" });
}
catch (Exception ex)
{
return StatusCode(500, new { error = ex.Message });
}
}
}

public class NotificationService
{
private readonly IEmailProvider _emailProvider;
private readonly ILogger<NotificationService> _logger;

public async Task SendOrderConfirmationEmailAsync(int orderId, int customerId)
{
_logger.LogInformation("Sending order confirmation for order {OrderId}", orderId);

var customer = await _dbContext.Customers.FindAsync(customerId);
var order = await _dbContext.Orders.FindAsync(orderId);

if (customer == null || order == null)
throw new ArgumentException("Customer or order not found");

var emailBody = $@"
Hello {customer.FirstName},

Your order #{order.Id} has been confirmed.
Total: ${order.Total:F2}

Thank you for shopping with us.
";

await _emailProvider.SendEmailAsync(customer.Email, "Order Confirmation", emailBody);
}
}

Deploy the Notification Service to a separate environment (separate Docker container, Azure App Service, or Kubernetes pod). It has its own database (if needed; for notifications, often just a log table) and API endpoints.

Phase 3: Implement the Strangler: API Gateway

The API Gateway intercepts requests and routes them to either the monolith or the new service:

// APIGateway/Middleware/StranglerMiddleware.cs
public class StranglerMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<StranglerMiddleware> _logger;
private readonly HttpClient _httpClient;

public StranglerMiddleware(RequestDelegate next, ILogger<StranglerMiddleware> logger, HttpClient httpClient)
{
_next = next;
_logger = logger;
_httpClient = httpClient;
}

public async Task InvokeAsync(HttpContext context)
{
var path = context.Request.Path.ToString();

// Route notifications to the new Notification Service
if (path.StartsWith("/api/notifications"))
{
await RouteToService(context, "https://notification-service");
return;
}

// Route everything else to the monolith (fallback)
await _next(context);
}

private async Task RouteToService(HttpContext context, string serviceUrl)
{
var path = context.Request.Path.ToString();
var queryString = context.Request.QueryString.ToString();

var upstreamUrl = $"{serviceUrl}{path}{queryString}";

_logger.LogInformation("Routing {Path} to {ServiceUrl}", path, serviceUrl);

try
{
var response = await _httpClient.SendAsync(
new HttpRequestMessage(
new HttpMethod(context.Request.Method),
upstreamUrl
)
{
Content = context.Request.Body.CanRead
? new StreamContent(context.Request.Body)
: null
}
);

context.Response.StatusCode = (int)response.StatusCode;
await response.Content.CopyToAsync(context.Response.Body);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to route to {ServiceUrl}", serviceUrl);

// Fallback to monolith if new service is down
await _next(context);
}
}
}

// Program.cs
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

app.UseMiddleware<StranglerMiddleware>();
app.Run();

Now requests to /api/notifications go to the new Notification Service. If the service is down, the middleware falls back to the monolith (which still has notification code as a backup).

Phase 4: Remove Code from the Monolith

Once the Notification Service is stable in production (weeks or months), remove the notification code from the monolith:

// In the monolith, replace the email sending logic with a call to the new service
public class OrderService
{
private readonly OrderContext _dbContext;
private readonly HttpClient _httpClient;

public async Task CreateOrderAsync(CreateOrderRequest req)
{
var order = new Order { CustomerId = req.CustomerId, Items = req.Items };
await _dbContext.Orders.AddAsync(order);
await _dbContext.SaveChangesAsync();

// Monolith no longer sends emails; delegates to Notification Service
await _httpClient.PostAsJsonAsync(
"https://notification-service/api/notifications/send-order-confirmation",
new { OrderId = order.Id, CustomerId = req.CustomerId }
);
}
}

Delete the monolith's EmailService, SMTP configuration, email templates—all notification concerns are now in the Notification Service. The monolith shrinks; the new service grows.

Phase 5: Measure and Iterate

After extracting one service, measure:

  • Error rates: Did the Notification Service introduce new bugs?
  • Latency: Is the new service slower than the monolith's code?
  • Cost: Is running two services more expensive than the monolith?

Use application insights (Azure App Insights, New Relic, DataDog) to track these metrics. If the service is stable and performant, extract the next domain (e.g., Reporting Service). If there are issues, fix them before extracting more.

Handling Shared State During Decomposition

If a domain to extract shares state with the monolith, use an anti-corruption layer:

// OrderService being extracted from monolith
public class OrderServiceClient
{
private readonly HttpClient _httpClient;

public async Task<Customer?> GetCustomerAsync(int customerId)
{
// Call monolith's API (temporary; will be replaced by direct database access later)
var response = await _httpClient.GetAsync(
$"https://monolith/api/customers/{customerId}"
);

if (!response.IsSuccessStatusCode)
return null;

var json = await response.Content.ReadAsStringAsync();
return JsonSerializer.Deserialize<Customer>(json);
}
}

The extracted service initially depends on the monolith for customer data. Over time, the Customer domain is extracted or the Order Service gets a local copy of customer data (event sourcing, caching). This temporary coupling is acceptable during decomposition.

Testing the Strangler

Write tests to ensure the strangler routes correctly:

[TestClass]
public class StranglerTests
{
[TestMethod]
public async Task NotificationRoutes_ToNewService()
{
// Mock the new Notification Service
var mockHandler = new MockHttpMessageHandler();
mockHandler.Setup(m => m.SendAsync(It.IsAny<HttpRequestMessage>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new HttpResponseMessage(System.Net.HttpStatusCode.OK));

var httpClient = new HttpClient(mockHandler);
var middleware = new StranglerMiddleware(null, null, httpClient);

var context = new DefaultHttpContext();
context.Request.Path = "/api/notifications/send-email";
context.Request.Method = "POST";

// This should route to the new service, not the monolith
// Assert the request went to the correct URL
}
}

Tests validate that the strangler routes correctly and that the new service API works as expected.

Common Pitfalls

  • Too many services at once: Extract one service, stabilize it, then extract another. Avoid extracting Orders, Payments, and Inventory simultaneously.
  • Shared database: Do not extract a service that shares a database with the monolith. Extract and ownership go hand-in-hand.
  • No monitoring: Do not extract without application insights. You will not know if the new service is causing problems.
  • Skipping tests: Write integration tests for the new service and strangler logic before deploying.

Key Takeaways

  • Use the strangler pattern to decompose monoliths gradually, minimizing risk and maintaining continuous delivery.
  • Extract loosely coupled, low-criticality domains first (notifications, reporting).
  • Use an API Gateway with strangler middleware to route traffic to the new service; fall back to the monolith if the service fails.
  • Remove monolith code only after the new service is proven stable in production.
  • Use application insights to measure error rates, latency, and cost before and after extraction.

Frequently Asked Questions

How long does decomposition take?

A typical mid-sized monolith takes 1–3 years to fully decompose into microservices, extracting one service every 2–4 months. Start with the easiest services (notifications, reporting); save tightly coupled domains (orders, payments) for later when you have more experience.

What if the new service introduces bugs?

The strangler has a fallback: if the new service fails (500 error, timeout), the gateway routes to the monolith. This means the monolith temporarily handles the request, reducing downtime. However, keep both implementations in sync; do not let them diverge.

Can I skip the API Gateway and route at the code level?

Yes, but less safely. Instead of a central strangler, each client (web app, mobile) must know whether to call the monolith or the new service. Multiplying this logic across clients is error-prone. A central API Gateway is simpler and more maintainable.

What if the new service is much slower than the monolith?

This is common during initial extraction. Profile the new service, optimize bottlenecks (database queries, external API calls, serialization). If performance is critical, keep the monolith code and serve from the monolith while the new service optimizes.

Do I need to rewrite the extracted code?

Not necessarily. Initially, the new service can be a copy of the monolith's code (same logic, same database queries). Over time, refactor to be more efficient, cleaner, and simpler. Extraction is a long-term project; do not expect the new service to be perfect on day one.

Further Reading