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.
“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:
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
- Start the observability work earlier. Distributed tracing went in around service number three; the two weeks before that were guesswork with extra steps.
- Version the contracts package from day one. We treated it as “internal” for too long and paid for it in lockstep deploys.
- 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.