Skip to content
Kumar Chandrachooda
Architecture

From Monolith to Microservices on AKS: A Field Guide

Lessons from migrating a multi-tier .NET Framework monolith to .NET microservices on Azure Kubernetes Service: what to split first, what to leave alone, and the manifests that hold it together.

By Kumar Chandrachooda 12 Jul 2025 3 min read
One big box becomes four small ones, plus a helm to steer them

“Strangle the monolith” is great conference advice and a terrifying Monday-morning instruction. Having now done this migration in anger (a .NET Framework multi-tier application moved to .NET microservices running on Azure Kubernetes Service), this is the field guide I wish someone had handed me at the start.

The first rule of microservices: don't start with microservices. Start with a monolith whose seams you understand, then cut along the seams.

Every architect, eventually

Where to cut first

The instinct is to split by entity: an Orders service, a Customers service, a Products service. Resist it. Entities are how the database sees the world; services should be cut along business capabilities and, more pragmatically, along the lines where teams and release cadences differ.

Our first three candidates, and why:

Candidate Coupling Release pressure Verdict
Identity / auth Low, already behind interfaces Quarterly Split first, lowest risk
Catalog & search Medium, read-heavy and cache-friendly Weekly Split second, big win
Order processing High, transactional core Daily Split last, needs sagas
Reporting Tangled but read-only Monthly Leave in place, point at replicas

The target shape looked like this:

Target architecture: clients reach an ingress gateway which routes to three services inside AKS, each owning its data store

Target state: every service owns its data; nothing reaches across a boundary

Notice reporting never became a service. Not everything needs to. A read replica and a WITH (NOLOCK)-free reporting schema solved what a “Reporting microservice” would have only relocated.

The contract comes first

Before any code moves, freeze the seam with an explicit contract. In .NET that meant extracting interfaces into a contracts package and making the monolith consume its own future API:

public interface ICatalogClient
{
    Task<CatalogItem?> GetItemAsync(string sku, CancellationToken ct = default);
    Task<IReadOnlyList<CatalogItem>> SearchAsync(CatalogQuery query, CancellationToken ct = default);
}

// In the monolith, the first implementation is just the old in-process call.
// Swapping it for an HTTP client later is a one-line DI change.
builder.Services.AddHttpClient<ICatalogClient, HttpCatalogClient>(client =>
{
    client.BaseAddress = new Uri(builder.Configuration["Services:Catalog"]!);
    client.Timeout = TimeSpan.FromSeconds(5);
});

The day we flipped from in-process to HTTP, nothing else in the codebase changed. That is the whole trick: make the seam boring before you make it remote.

Kubernetes manifests that earn their keep

Each service ships with a deployment, a service, and an autoscaler. The deployment is where most of the operational lessons ended up encoded:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: catalog-api
  labels:
    app: catalog-api
spec:
  replicas: 2
  selector:
    matchLabels:
      app: catalog-api
  template:
    metadata:
      labels:
        app: catalog-api
    spec:
      containers:
        - name: catalog-api
          image: acrkumar.azurecr.io/catalog-api:1.4.2
          ports:
            - containerPort: 8080
          resources:
            requests:
              cpu: 250m
              memory: 256Mi
            limits:
              memory: 512Mi
          readinessProbe:
            httpGet:
              path: /healthz/ready
              port: 8080
            initialDelaySeconds: 5
            periodSeconds: 10
          livenessProbe:
            httpGet:
              path: /healthz/live
              port: 8080
            periodSeconds: 30

Set memory limits but think twice before CPU limits on .NET workloads: CPU throttling interacts badly with the thread pool and GC. Requests for scheduling, limits only where a noisy neighbour genuinely must be contained.

Rolling it out is deliberately unexciting:

az aks get-credentials --resource-group rg-platform --name aks-prod
kubectl apply -f k8s/catalog-api/
kubectl rollout status deployment/catalog-api --timeout=120s

What I would do differently

  1. Start the observability work earlier. Distributed tracing went in around service number three; the two weeks before that were guesswork with extra steps.
  2. Version the contracts package from day one. We treated it as “internal” for too long and paid for it in lockstep deploys.
  3. Keep a strangler scoreboard. A visible list of routes still served by the monolith kept momentum honest. The monolith does not disappear, it just stops being load-bearing.

The monolith, for what it is worth, is still running. It serves four admin screens nobody wants to rewrite, and that is a perfectly dignified retirement.

Filed under Architecture