Projects

.NET Microservices on Azure: Enterprise Architecture Guide for C# Developers (Complete 2026)

Introduction: The Rare Skill That Gets You Hired

Here’s something I’ve noticed in my 14 years in tech:

Most cloud architects don’t understand .NET.

Most .NET developers don’t understand cloud architecture.

This gap costs companies millions.

Why? Because companies need engineers who can do both. Someone who understands:

  • How C# code actually deploys
  • How Docker containers work with .NET runtime
  • How Kubernetes orchestrates microservices
  • How to monitor distributed .NET applications
  • How Azure services integrate with .NET apps

This skillset is RARE. And it’s worth 2-3x a regular DevOps salary.

When I was a .NET freelancer, I could build amazing applications. But deploying them? Manual, fragile, terrifying.

Then I became a DevOps engineer. I learned infrastructure, CI/CD, Kubernetes. But I didn’t understand the applications I was deploying. I was just moving containers around.

The turning point? When I combined both.

Suddenly, I could:

  • ✅ Build a microservice architecture that actually makes sense for .NET
  • ✅ Design deployments that leverage C# async patterns
  • ✅ Monitor applications knowing what happens inside the code
  • ✅ Troubleshoot issues that span application and infrastructure layers
  • ✅ Optimize cloud costs by understanding application behavior

This isn’t just DevOps. This isn’t just .NET development. This is software engineering at the systems level.

And this is exactly what major companies are hunting for.

In this guide, I’m sharing the complete architecture pattern I’ve built in production. If you’re a .NET developer wanting to level up into cloud architecture, or a DevOps engineer wanting to actually understand the applications you’re deploying, this guide is for you.

By the end, you’ll have a production-ready blueprint for deploying .NET microservices on Azure that actually scales, monitors properly, and doesn’t cost a fortune.


Prerequisites: What You Need Before Starting

Before diving into this guide, make sure you have:

.NET Experience

  • Familiarity with C# and ASP.NET Core (not ASP.NET Framework)
  • Basic understanding of microservices patterns
  • Experience with Entity Framework Core or similar ORM
  • Async/await patterns understanding

Cloud Basics

  • Azure account with some credits
  • Understanding of VNets, Resource Groups, Storage basics
  • Familiarity with containers (Docker 101)

DevOps Knowledge

  • Basic Git/GitHub workflow
  • Understanding of CI/CD concepts
  • Knowledge of container registries

Local Setup

  • .NET 8 SDK installed
  • Docker Desktop installed
  • Azure CLI installed (az login works)
  • Git installed
  • Visual Studio Code or Visual Studio

Estimated Time: 4-6 hours for complete setup and understanding


Why .NET + Azure = Perfect Combination

Let me explain why this matters before we build.

The Problem with Other Approaches

Traditional .NET Deployment (What most companies do):

❌ Deploy ASP.NET Core to single Azure App Service
❌ Tight coupling between application and infrastructure
❌ Hard to scale individual services
❌ Difficult to handle failures gracefully
❌ Monitoring is application-level only (no infrastructure visibility)
❌ Can't update one service without impacting others

 

Generic Microservices (Without .NET understanding):

❌ Deploy each service to Kubernetes
✅ Great scalability
❌ BUT: Doesn't leverage C# async patterns
❌ Overkill for some .NET workloads
❌ Added complexity with .NET's lightweight footprint
❌ Cost balloons (you're paying for Kubernetes overhead)

 

 

The Right Way (.NET + Azure Microservices Architecture):

✅ Each microservice is independently deployable
✅ Leverages C# async/await for efficient resource usage
✅ Auto-scaling based on actual metrics (not guessing)
✅ Failures isolated (one service down ≠ entire system down)
✅ Infrastructure + Application monitoring combined
✅ Update services independently, zero downtime
✅ Cost-optimized (pay for what you use)
✅ Development to production: Same deployment pattern

The Architecture: What You’re Building

Here’s the complete system you’ll create:

Developer pushes code to GitHub
    ↓
GitHub Actions triggers CI/CD
    ├─ Build: Compile C# projects
    ├─ Test: Run unit tests
    ├─ Build Images: Create Docker images (optimized for .NET)
    └─ Push: Send to Azure Container Registry
    ↓
Kubernetes in Azure (AKS) receives signal
    ├─ Pull latest images
    ├─ Start new pods (with health checks)
    ├─ Stop old pods gracefully
    └─ Route traffic to new pods (zero downtime)
    ↓
Running Microservices
    ├─ UserService (authentication)
    ├─ ProductService (business logic)
    ├─ OrderService (order processing)
    └─ NotificationService (email/SMS)
    ↓
All services monitored
    ├─ Application Insights (C# code-level monitoring)
    ├─ Prometheus (infrastructure metrics)
    ├─ Loki (centralized logging)
    └─ Grafana (unified dashboards)
    ↓
When something breaks:
    ├─ Alert fires
    ├─ You know EXACTLY what failed (code + infra)
    ├─ Logs tell story from code → container → infrastructure
    └─ Rollback: One command, instant recovery

Step 1: Design Your .NET Microservices Architecture

Before writing code, let’s design the right structure.

Service Decomposition Strategy

Bad Approach (What not to do):

❌ One "MonolithService" 
❌ One database shared by everything
❌ Everything in same codebase
Result: Can't scale, can't deploy independently, one bug = everything fails

 

Good Approach (What we’re building):

UserService: Authentication, user management
├─ Database: PostgreSQL (users only)
├─ Responsibility: Single - manage user identity
├─ Scale: Independent

ProductService: Product catalog, inventory
├─ Database: PostgreSQL (products only)
├─ Responsibility: Single - manage products
├─ Scale: Independent

OrderService: Order processing, business logic
├─ Database: PostgreSQL (orders only)
├─ Dependency: Calls UserService + ProductService
├─ Responsibility: Single - manage orders
├─ Scale: Independent

NotificationService: Email, SMS, notifications
├─ Database: None (or cache only)
├─ Dependency: Listens to events from other services
├─ Responsibility: Single - send notifications
├─ Scale: Independent (handles spikes independently)

Why This Matters

 

This architecture lets you:

✅ Update ProductService without touching UserService
✅ Scale OrderService to 10 replicas when orders spike
✅ Deploy NotificationService every hour if needed
✅ If UserService fails: Others keep running
✅ If one database is slow: Doesn't affect other services

Communication Patterns

Service-to-Service (Synchronous):

csharp
// OrderService needs product info
public class OrderService {
    private readonly HttpClient _httpClient;
    
    public async Task<Order> CreateOrder(CreateOrderDto dto) {
        // Call ProductService
        var product = await _httpClient.GetAsync(
            $"http://product-service:5000/api/products/{dto.ProductId}"
        );
        
        // Create order only if product exists
        var order = new Order {
            ProductId = dto.ProductId,
            Quantity = dto.Quantity,
            CreatedAt = DateTime.UtcNow
        };
        
        await _dbContext.Orders.AddAsync(order);
        await _dbContext.SaveChangesAsync();
        return order;
    }
}

 

Service-to-Service (Asynchronous – Better):

csharp
// OrderService publishes event
public class OrderService {
    private readonly IMessageBus _messageBus;
    
    public async Task<Order> CreateOrder(CreateOrderDto dto) {
        var order = new Order {
            ProductId = dto.ProductId,
            Quantity = dto.Quantity,
            CreatedAt = DateTime.UtcNow
        };
        
        await _dbContext.Orders.AddAsync(order);
        await _dbContext.SaveChangesAsync();
        
        // Publish event - NotificationService listens
        await _messageBus.PublishAsync(new OrderCreatedEvent {
            OrderId = order.Id,
            UserId = dto.UserId,
            ProductId = dto.ProductId
        });
        
        return order;
    }
}

// NotificationService listens
public class OrderCreatedEventHandler {
    private readonly IEmailService _emailService;
    
    public async Task Handle(OrderCreatedEvent @event) {
        // Get user email from UserService
        var user = await _userService.GetUserAsync(@event.UserId);
        
        // Send confirmation email
        await _emailService.SendOrderConfirmationAsync(user.Email, @event.OrderId);
    }
}

Why async is better:

  • ✅ Services don’t wait for each other
  • ✅ If NotificationService is slow, orders still process
  • ✅ Natural scaling (one service can’t bottleneck another)
  • ✅ Resilient (if one service is down, others keep working)

Step 2: Create .NET Microservices

Let’s build the actual services.

Project Structure

dotnet-microservices/
├── src/
│   ├── Services/
│   │   ├── UserService/
│   │   │   ├── UserService.Api/
│   │   │   │   ├── Controllers/
│   │   │   │   ├── Services/
│   │   │   │   ├── Data/
│   │   │   │   └── Program.cs
│   │   │   ├── UserService.Domain/
│   │   │   └── UserService.Tests/
│   │   ├── ProductService/
│   │   ├── OrderService/
│   │   └── NotificationService/
│   ├── Shared/
│   │   ├── Events/ (shared event definitions)
│   │   └── Dtos/ (shared DTOs)
├── .github/workflows/
│   └── deploy.yml
├── kubernetes/
│   ├── base/
│   ├── overlays/
│   └── helm-charts/
└── docker-compose.yml

Create UserService (Example)

Create project:

bash
dotnet new globaljson --sdk-version 8.0.0
dotnet new sln -n DotnetMicroservices
dotnet new webapi -n UserService.Api
cd UserService.Api
dotnet add package Microsoft.EntityFrameworkCore
dotnet add package Microsoft.EntityFrameworkCore.Npgsql
dotnet add package MassTransit.RabbitMQ

User entity:

csharp
// UserService.Domain/Models/User.cs
public class User
{
    public Guid Id { get; set; }
    public string Email { get; set; }
    public string FirstName { get; set; }
    public string LastName { get; set; }
    public string PasswordHash { get; set; }
    public bool IsActive { get; set; }
    public DateTime CreatedAt { get; set; }
    public DateTime? LastLoginAt { get; set; }
}

DbContext:

csharp
// UserService.Api/Data/UserDbContext.cs
public class UserDbContext : DbContext
{
    public DbSet<User> Users { get; set; }
    
    public UserDbContext(DbContextOptions<UserDbContext> options) : base(options) { }
    
    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<User>(entity =>
        {
            entity.HasKey(e => e.Id);
            entity.Property(e => e.Email).IsRequired().HasMaxLength(255);
            entity.Property(e => e.FirstName).IsRequired().HasMaxLength(100);
            entity.Property(e => e.PasswordHash).IsRequired();
            
            // Index for lookups
            entity.HasIndex(e => e.Email).IsUnique();
        });
    }
}

Service layer:

csharp
// UserService.Api/Services/UserService.cs
public interface IUserService
{
    Task<UserDto> CreateUserAsync(CreateUserDto dto);
    Task<UserDto> GetUserByIdAsync(Guid id);
    Task<UserDto> GetUserByEmailAsync(string email);
    Task UpdateLastLoginAsync(Guid userId);
}

public class UserService : IUserService
{
    private readonly UserDbContext _context;
    private readonly ILogger<UserService> _logger;
    
    public UserService(UserDbContext context, ILogger<UserService> logger)
    {
        _context = context;
        _logger = logger;
    }
    
    public async Task<UserDto> CreateUserAsync(CreateUserDto dto)
    {
        _logger.LogInformation("Creating user: {Email}", dto.Email);
        
        // Check if user exists
        var existingUser = await _context.Users
            .FirstOrDefaultAsync(u => u.Email == dto.Email);
        
        if (existingUser != null)
        {
            _logger.LogWarning("User already exists: {Email}", dto.Email);
            throw new InvalidOperationException("User already exists");
        }
        
        var user = new User
        {
            Id = Guid.NewGuid(),
            Email = dto.Email,
            FirstName = dto.FirstName,
            LastName = dto.LastName,
            PasswordHash = BCrypt.Net.BCrypt.HashPassword(dto.Password),
            IsActive = true,
            CreatedAt = DateTime.UtcNow
        };
        
        _context.Users.Add(user);
        await _context.SaveChangesAsync();
        
        _logger.LogInformation("User created successfully: {UserId}", user.Id);
        
        return MapToDto(user);
    }
    
    public async Task<UserDto> GetUserByIdAsync(Guid id)
    {
        var user = await _context.Users.FindAsync(id);
        
        if (user == null)
        {
            _logger.LogWarning("User not found: {UserId}", id);
            throw new KeyNotFoundException($"User {id} not found");
        }
        
        return MapToDto(user);
    }
    
    public async Task<UserDto> GetUserByEmailAsync(string email)
    {
        var user = await _context.Users
            .FirstOrDefaultAsync(u => u.Email == email);
        
        if (user == null)
        {
            _logger.LogWarning("User not found: {Email}", email);
            throw new KeyNotFoundException($"User {email} not found");
        }
        
        return MapToDto(user);
    }
    
    public async Task UpdateLastLoginAsync(Guid userId)
    {
        var user = await _context.Users.FindAsync(userId);
        if (user != null)
        {
            user.LastLoginAt = DateTime.UtcNow;
            await _context.SaveChangesAsync();
            _logger.LogInformation("Last login updated for user: {UserId}", userId);
        }
    }
    
    private UserDto MapToDto(User user) => new()
    {
        Id = user.Id,
        Email = user.Email,
        FirstName = user.FirstName,
        LastName = user.LastName,
        IsActive = user.IsActive,
        CreatedAt = user.CreatedAt,
        LastLoginAt = user.LastLoginAt
    };
}

Controller:

csharp
// UserService.Api/Controllers/UsersController.cs
[ApiController]
[Route("api/[controller]")]
public class UsersController : ControllerBase
{
    private readonly IUserService _userService;
    private readonly ILogger<UsersController> _logger;
    
    public UsersController(IUserService userService, ILogger<UsersController> logger)
    {
        _userService = userService;
        _logger = logger;
    }
    
    [HttpPost]
    public async Task<ActionResult<UserDto>> CreateUser([FromBody] CreateUserDto dto)
    {
        try
        {
            var user = await _userService.CreateUserAsync(dto);
            return CreatedAtAction(nameof(GetUser), new { id = user.Id }, user);
        }
        catch (InvalidOperationException ex)
        {
            _logger.LogError(ex, "Error creating user");
            return BadRequest(ex.Message);
        }
    }
    
    [HttpGet("{id}")]
    public async Task<ActionResult<UserDto>> GetUser(Guid id)
    {
        try
        {
            var user = await _userService.GetUserByIdAsync(id);
            return Ok(user);
        }
        catch (KeyNotFoundException ex)
        {
            _logger.LogError(ex, "User not found: {UserId}", id);
            return NotFound(ex.Message);
        }
    }
    
    [HttpPost("{id}/login")]
    public async Task<IActionResult> Login(Guid id)
    {
        await _userService.UpdateLastLoginAsync(id);
        return Ok();
    }
}

Program.cs (Dependency Injection):

csharp
// UserService.Api/Program.cs
var builder = WebApplication.CreateBuilder(args);

// Add services
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

// Database
var connectionString = builder.Configuration.GetConnectionString("DefaultConnection");
builder.Services.AddDbContext<UserDbContext>(options =>
    options.UseNpgsql(connectionString)
);

// Business logic
builder.Services.AddScoped<IUserService, UserService>();

// Logging
builder.Services.AddLogging(config =>
{
    config.AddConsole();
    config.AddApplicationInsights();
});

// Health checks
builder.Services.AddHealthChecks()
    .AddDbContextCheck<UserDbContext>();

var app = builder.Build();

// Middleware
if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}

app.UseAuthorization();
app.MapControllers();
app.MapHealthChecks("/health");

app.Run();

Step 3: Dockerize .NET Microservices

This is where C# + Docker gets interesting.

Multi-Stage Dockerfile (Optimized for .NET)

dockerfile
# UserService/Dockerfile

# Stage 1: Build
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS builder

WORKDIR /src

# Copy project files
COPY ["UserService.Api/UserService.Api.csproj", "UserService.Api/"]
COPY ["UserService.Domain/UserService.Domain.csproj", "UserService.Domain/"]

# Restore dependencies (cached layer)
RUN dotnet restore "UserService.Api/UserService.Api.csproj"

# Copy source code
COPY . .

# Build and publish
RUN dotnet publish "UserService.Api/UserService.Api.csproj" \
    -c Release \
    -o /app/publish \
    --no-restore

# Stage 2: Runtime (Small image)
FROM mcr.microsoft.com/dotnet/aspnet:8.0

WORKDIR /app

# Copy only what we need from builder
COPY --from=builder /app/publish .

# Create non-root user for security
RUN useradd -m -u 1001 dotnetapp && chown -R dotnetapp:dotnetapp /app
USER dotnetapp

# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
    CMD curl -f http://localhost:8080/health || exit 1

EXPOSE 8080

# Run with optimizations for containers
ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false \
    DOTNET_EnableDiagnostics=0

ENTRYPOINT ["dotnet", "UserService.Api.dll"]

Why This Dockerfile is Optimized for .NET

What makes it good:

Multi-stage build (removes SDK from final image)

  • SDK image: 1.5 GB
  • Runtime image: 150 MB
  • Final image: ~250 MB (SDK + compiled app)

Caching layers

  • Dependencies cached
  • Only code changes rebuild dependencies

Non-root user

  • Container doesn’t run as root (security)
  • Dotnetapp user (1001) is unprivileged

Health check

  • Kubernetes knows when container is healthy
  • Automatic restart if unhealthy

Environment variables

  • Optimized for container runtime
  • Better performance in Kubernetes

Build and Test Locally

bash
# Build Docker image
docker build -t user-service:latest -f UserService/Dockerfile .

# Run container
docker run -d \
  --name user-service \
  -p 8080:8080 \
  -e "ConnectionStrings__DefaultConnection=Server=postgres;Database=users;User=postgres;Password=password;" \
  user-service:latest

# Test
curl http://localhost:8080/health
curl http://localhost:8080/swagger

# Check logs
docker logs user-service

# Stop
docker stop user-service
docker rm user-service

Step 4: Push to Azure Container Registry (ACR)

Create ACR

bash
# Create resource group
az group create --name microservices-rg --location eastus

# Create ACR
az acr create \
  --resource-group microservices-rg \
  --name myacr \
  --sku Standard

# Login to ACR
az acr login --name myacr

Push Images

bash
# Tag image
docker tag user-service:latest myacr.azurecr.io/user-service:latest
docker tag user-service:latest myacr.azurecr.io/user-service:v1.0.0

# Push to ACR
docker push myacr.azurecr.io/user-service:latest
docker push myacr.azurecr.io/user-service:v1.0.0

# Verify
az acr repository list --name myacr --output table

Step 5: Deploy to Azure Kubernetes Service (AKS)

Create AKS Cluster

bash
# Create AKS cluster (takes 5-10 minutes)
az aks create \
  --resource-group microservices-rg \
  --name microservices-cluster \
  --node-count 3 \
  --vm-set-type VirtualMachineScaleSets \
  --load-balancer-sku standard \
  --attach-acr myacr \
  --network-plugin azure \
  --zones 1 2 3

# Get credentials
az aks get-credentials \
  --resource-group microservices-rg \
  --name microservices-cluster

# Verify connection
kubectl cluster-info
kubectl get nodes

Create Kubernetes Manifests

Namespace (organize resources):

yaml
# kubernetes/namespace.yaml
apiVersion: v1
kind: Namespace
metadata:
  name: microservices

ConfigMap (configuration):

yaml
# kubernetes/configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: app-config
  namespace: microservices
data:
  aspnetcore_environment: "Production"
  logging_level: "Information"
  feature_flags: "new_ui=true,beta_api=false"

Secret (sensitive data):

bash
# Create secret
kubectl create secret generic app-secrets \
  --from-literal=db_password=<your-secure-password> \
  --from-literal=jwt_secret=<your-jwt-secret> \
  -n microservices

UserService Deployment:

yaml
# kubernetes/user-service-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: user-service
  namespace: microservices
spec:
  replicas: 3  # Always run 3 replicas
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 0
  selector:
    matchLabels:
      app: user-service
  template:
    metadata:
      labels:
        app: user-service
      annotations:
        prometheus.io/scrape: "true"
        prometheus.io/port: "8080"
        prometheus.io/path: "/metrics"
    spec:
      containers:
      - name: user-service
        image: myacr.azurecr.io/user-service:latest
        imagePullPolicy: Always
        ports:
        - name: http
          containerPort: 8080
        env:
        - name: ASPNETCORE_ENVIRONMENT
          valueFrom:
            configMapKeyRef:
              name: app-config
              key: aspnetcore_environment
        - name: ConnectionStrings__DefaultConnection
          valueFrom:
            secretKeyRef:
              name: app-secrets
              key: db_connection_string
        - name: JWT_SECRET
          valueFrom:
            secretKeyRef:
              name: app-secrets
              key: jwt_secret
        
        # Resource requests (important for cost!)
        resources:
          requests:
            cpu: 100m
            memory: 256Mi
          limits:
            cpu: 500m
            memory: 512Mi
        
        # Health checks
        livenessProbe:
          httpGet:
            path: /health
            port: http
          initialDelaySeconds: 10
          periodSeconds: 30
          timeoutSeconds: 3
        
        readinessProbe:
          httpGet:
            path: /health/ready
            port: http
          initialDelaySeconds: 5
          periodSeconds: 10
          timeoutSeconds: 2

Service (network exposure):

yaml
# kubernetes/user-service-service.yaml
apiVersion: v1
kind: Service
metadata:
  name: user-service
  namespace: microservices
spec:
  type: ClusterIP  # Internal only
  selector:
    app: user-service
  ports:
  - name: http
    port: 5000
    targetPort: http
    protocol: TCP

Ingress (external access):

yaml
# kubernetes/ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: api-ingress
  namespace: microservices
spec:
  ingressClassName: nginx
  rules:
  - host: api.example.com
    http:
      paths:
      - path: /users
        pathType: Prefix
        backend:
          service:
            name: user-service
            port:
              number: 5000
      - path: /products
        pathType: Prefix
        backend:
          service:
            name: product-service
            port:
              number: 5000

Deploy to Kubernetes

bash
# Apply manifests
kubectl apply -f kubernetes/namespace.yaml
kubectl apply -f kubernetes/configmap.yaml
kubectl apply -f kubernetes/user-service-deployment.yaml
kubectl apply -f kubernetes/user-service-service.yaml
kubectl apply -f kubernetes/ingress.yaml

# Check status
kubectl get pods -n microservices
kubectl get svc -n microservices
kubectl get ingress -n microservices

# View logs
kubectl logs -f deployment/user-service -n microservices

# Port forward (test locally)
kubectl port-forward svc/user-service 5000:5000 -n microservices

# Then curl: http://localhost:5000/api/users

Step 6: CI/CD Pipeline with GitHub Actions

GitHub Actions Workflow

yaml
# .github/workflows/deploy-user-service.yml
name: Build and Deploy UserService

on:
  push:
    branches: [ main ]
    paths:
      - 'src/Services/UserService/**'
      - '.github/workflows/deploy-user-service.yml'
  pull_request:
    branches: [ main ]

env:
  REGISTRY: myacr.azurecr.io
  IMAGE_NAME: user-service

jobs:
  build-and-test:
    runs-on: ubuntu-latest
    
    steps:
    - name: Checkout code
      uses: actions/checkout@v3
    
    - name: Setup .NET
      uses: actions/setup-dotnet@v3
      with:
        dotnet-version: '8.0.x'
    
    - name: Restore dependencies
      run: |
        cd src/Services/UserService
        dotnet restore
    
    - name: Build
      run: |
        cd src/Services/UserService
        dotnet build --configuration Release --no-restore
    
    - name: Run tests
      run: |
        cd src/Services/UserService
        dotnet test --configuration Release --no-restore --no-build
    
    - name: Run code analysis (optional)
      run: |
        cd src/Services/UserService
        dotnet build --configuration Release --no-restore /p:EnforceCodeStyleInBuild=true

  docker-build-push:
    needs: build-and-test
    runs-on: ubuntu-latest
    if: github.event_name == 'push'
    
    steps:
    - name: Checkout code
      uses: actions/checkout@v3
    
    - name: Set up Docker Buildx
      uses: docker/setup-buildx-action@v2
    
    - name: Log in to Azure Container Registry
      uses: azure/docker-login@v1
      with:
        login-server: ${{ env.REGISTRY }}
        username: ${{ secrets.ACR_USERNAME }}
        password: ${{ secrets.ACR_PASSWORD }}
    
    - name: Extract metadata
      id: meta
      run: |
        echo "tags=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest" >> $GITHUB_OUTPUT
        echo "tags=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
    
    - name: Build and push Docker image
      uses: docker/build-push-action@v4
      with:
        context: .
        file: ./src/Services/UserService/Dockerfile
        push: true
        tags: ${{ steps.meta.outputs.tags }}
        cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache
        cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache,mode=max

  deploy-to-aks:
    needs: docker-build-push
    runs-on: ubuntu-latest
    if: github.event_name == 'push'
    
    steps:
    - name: Checkout code
      uses: actions/checkout@v3
    
    - name: Set up kubectl
      uses: azure/setup-kubectl@v3
    
    - name: Get AKS credentials
      uses: azure/aks-set-context@v3
      with:
        admin: 'false'
        cluster-name: microservices-cluster
        resource-group: microservices-rg
        use-kubelogin: true
    
    - name: Deploy to Kubernetes
      run: |
        kubectl set image deployment/user-service \
          user-service=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:$(git rev-parse --short HEAD) \
          -n microservices
        
        kubectl rollout status deployment/user-service -n microservices
    
    - name: Verify deployment
      run: |
        kubectl get pods -n microservices
        kubectl get svc -n microservices

Add Secrets to GitHub

bash
# Get ACR credentials
az acr credential show --name myacr --query passwords[0].value -o tsv

# In GitHub:
# Settings → Secrets and variables → Actions → New repository secret
# ACR_USERNAME: (from credentials)
# ACR_PASSWORD: (from credentials)

Step 7: Monitor .NET Applications on Azure

Application Insights Integration

Add to Program.cs:

csharp
// UserService.Api/Program.cs
builder.Services.AddApplicationInsightsTelemetry();

// In appsettings.json:
{
  "ApplicationInsights": {
    "InstrumentationKey": "your-instrumentation-key"
  }
}

Custom tracking:

csharp
public class OrderCreatedEventHandler
{
    private readonly TelemetryClient _telemetryClient;
    
    public async Task Handle(OrderCreatedEvent @event)
    {
        var properties = new Dictionary<string, string>
        {
            { "UserId", @event.UserId.ToString() },
            { "OrderId", @event.OrderId.ToString() },
            { "ProductId", @event.ProductId.ToString() }
        };
        
        var metrics = new Dictionary<string, double>
        {
            { "OrderQuantity", @event.Quantity }
        };
        
        _telemetryClient.TrackEvent("OrderCreated", properties, metrics);
    }
}

Prometheus Integration

Add NuGet:

bash
dotnet add package prometheus-net

Expose metrics:

csharp
// Program.cs
app.UseHttpMetrics();
app.MapMetrics();

// Endpoint: http://localhost:8080/metrics

Prometheus scrape config:

yaml
scrape_configs:
  - job_name: 'user-service'
    static_configs:
      - targets: ['user-service:8080']
    metrics_path: '/metrics'

Common Mistakes & How to Avoid Them

❌ Mistake #1: Forgetting to Handle Service Startup Failures

Bad:

csharp
// Assumes ProductService is always available
var product = await _httpClient.GetAsync("http://product-service:5000/api/products/1");

Good:

csharp
try
{
    var product = await _httpClient.GetAsync(
        "http://product-service:5000/api/products/1",
        timeout: TimeSpan.FromSeconds(5)
    );
    
    if (!product.IsSuccessStatusCode)
    {
        _logger.LogWarning("ProductService returned {StatusCode}", product.StatusCode);
        return StatusCode(503, "Service unavailable");
    }
}
catch (HttpRequestException ex)
{
    _logger.LogError(ex, "ProductService unreachable");
    return StatusCode(503, "Product service unavailable");
}

❌ Mistake #2: Not Setting Resource Limits

Bad:

yaml
resources: {}  # Unlimited!

Good:

yaml
resources:
  requests:
    cpu: 100m      # Guaranteed
    memory: 256Mi
  limits:
    cpu: 500m      # Maximum
    memory: 512Mi

Why: Without limits, one pod can consume all cluster resources, crashing everything.

❌ Mistake #3: Tight Coupling Between Services

Bad:

csharp
// UserService directly calls ProductService
var product = await _httpClient.GetAsync("http://product-service/products/1");
var user = await _httpClient.GetAsync("http://user-service/users/1");
// Both must succeed or entire operation fails

Good:

csharp
// OrderService publishes event
await _messageBus.PublishAsync(new OrderCreatedEvent { OrderId, UserId, ProductId });

// ProductService listens independently
// UserService listens independently
// If one fails, others still work

❌ Mistake #4: No Health Checks

Bad:

yaml
# No health checks - Kubernetes doesn't know when pod is unhealthy

Good:

yaml
livenessProbe:
  httpGet:
    path: /health
    port: 8080
  periodSeconds: 30

readinessProbe:
  httpGet:
    path: /health/ready
    port: 8080
  periodSeconds: 10

❌ Mistake #5: Hardcoding Secrets

Bad:

csharp
var connectionString = "Server=localhost;Password=SuperSecretPassword123";

Good:

csharp
// In appsettings.json
var connectionString = configuration.GetConnectionString("DefaultConnection");

// Connection string from Kubernetes Secret, not code

Real-World Example: OrderService + Async Events

Here’s a complete, production-like scenario:

csharp
// OrderService/Events/OrderCreatedEvent.cs
public class OrderCreatedEvent
{
    public Guid OrderId { get; set; }
    public Guid UserId { get; set; }
    public Guid ProductId { get; set; }
    public int Quantity { get; set; }
    public DateTime CreatedAt { get; set; }
}

// OrderService/Services/OrderService.cs
public class OrderService
{
    private readonly OrderDbContext _dbContext;
    private readonly IPublishEndpoint _publishEndpoint;
    private readonly ILogger<OrderService> _logger;
    
    public async Task<OrderDto> CreateOrderAsync(CreateOrderDto dto)
    {
        _logger.LogInformation("Creating order for user {UserId}", dto.UserId);
        
        var order = new Order
        {
            Id = Guid.NewGuid(),
            UserId = dto.UserId,
            ProductId = dto.ProductId,
            Quantity = dto.Quantity,
            Status = OrderStatus.Pending,
            CreatedAt = DateTime.UtcNow
        };
        
        // Save to database
        _dbContext.Orders.Add(order);
        await _dbContext.SaveChangesAsync();
        
        // Publish event (asynchronously)
        try
        {
            await _publishEndpoint.Publish(new OrderCreatedEvent
            {
                OrderId = order.Id,
                UserId = order.UserId,
                ProductId = order.ProductId,
                Quantity = order.Quantity,
                CreatedAt = order.CreatedAt
            });
            
            _logger.LogInformation("Order event published: {OrderId}", order.Id);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Failed to publish order event for {OrderId}", order.Id);
            // Still return success - order was saved
            // Event will be retried
        }
        
        return MapToDto(order);
    }
}

// NotificationService/Consumers/OrderCreatedConsumer.cs
public class OrderCreatedConsumer : IConsumer<OrderCreatedEvent>
{
    private readonly IEmailService _emailService;
    private readonly IUserServiceClient _userServiceClient;
    private readonly ILogger<OrderCreatedConsumer> _logger;
    
    public async Task Consume(ConsumeContext<OrderCreatedEvent> context)
    {
        var @event = context.Message;
        
        try
        {
            _logger.LogInformation("Processing order created event: {OrderId}", @event.OrderId);
            
            // Get user details
            var user = await _userServiceClient.GetUserAsync(@event.UserId);
            
            // Send confirmation email
            await _emailService.SendOrderConfirmationAsync(
                user.Email,
                @event.OrderId,
                @event.Quantity
            );
            
            _logger.LogInformation("Order confirmation sent to {Email}", user.Email);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Failed to process order created event: {OrderId}", @event.OrderId);
            throw;  // Let message bus retry
        }
    }
}

 

What happens:

User creates order
    ↓
OrderService saves to database
    ↓
OrderService publishes OrderCreatedEvent
    ↓
NotificationService receives event (in its own container, independently)
    ↓
NotificationService calls UserService for email
    ↓
NotificationService sends email
    ↓
If email fails: OrderService still succeeded
    ↓
If UserService is down: Order still created, notification retried later

This is resilience. 💪


Cost Breakdown: What Will This Cost?

Monthly costs for production setup:

Component Size Cost/Month
AKS Cluster 3 nodes (B2s) €120
Azure SQL Database Standard tier €40
Azure Container Registry Standard €10
Application Insights Standard €5
Load Balancer Standard €18
Data Transfer ~50GB/month €10
Total ~€203/month

Cost optimization:

  • Use smaller node types (B1s = €30/node vs B2s = €40)
  • Use Spot Instances (80% discount, ~€25/node)
  • Reserved Instances (30% discount for 1-year commitment)
  • Autoscaling (scale down at night)

With optimization: €80-120/month for entire production system.


FAQ: Questions You’ll Have

Q: When should I use microservices vs monolith?

Use microservices when:

  • ✅ Teams working independently (different deployment cycles)
  • ✅ Services scale differently (one service needs 10x capacity)
  • ✅ Different persistence needs (some PostgreSQL, some MongoDB)
  • ✅ Services use different tech stacks

Use monolith when:

  • ✅ Small team (<5 people)
  • ✅ Shared database (tightly coupled data)
  • ✅ Low scale needs
  • ✅ Developing MVP

For most .NET shops starting out: Monolithic with clear separation of concerns, then break into microservices later.


Q: How do I handle transactions across services?

Problem: Order service saves order, notification service fails. Now you have order without notification.

Solutions:

1. Saga Pattern (Choreography):

csharp
// Order created
await _publishEndpoint.Publish(new OrderCreatedEvent(...));

// Notification service listens
await _publishEndpoint.Publish(new NotificationSentEvent(...));

// Notification failed - publish rollback
await _publishEndpoint.Publish(new OrderCancelledEvent(...));

2. Saga Pattern (Orchestration):

csharp
public class OrderSaga : StateMachine<OrderState>
{
    public State Pending { get; private set; }
    public State NotificationSent { get; private set; }
    public State Completed { get; private set; }
    
    // Define transitions and compensation logic
}

3. Accept Eventually Consistent:

Order created immediately
Notification sent later
If notification fails, manual retry or accept loss

Q: How do I call one service from another?

Three approaches:

1. HTTP/REST (Synchronous):

csharp
var response = await _httpClient.GetAsync("http://user-service:5000/users/1");

Pros: Simple, understood Cons: Tight coupling, cascading failures

2. gRPC (Fast, Synchronous):

csharp
var user = await _grpcClient.GetUserAsync(new GetUserRequest { Id = userId });

Pros: Fast (protobuf), strongly typed Cons: More complex setup, .proto definitions

3. Message Bus (Asynchronous):

csharp
await _messageBus.PublishAsync(new UserRequiredEvent(...));

Pros: Decoupled, resilient, scalable Cons: Eventually consistent, more complex debugging

Recommendation: Start with HTTP, migrate to message bus for critical paths.


Q: How do I debug a distributed system?

Tools:

1. Centralized Logging (Loki/ELK)
   - All logs in one place
   - Query by OrderId across all services

2. Distributed Tracing (Jaeger/Zipkin)
   - Follow request through all services
   - See which service is slow

3. Application Insights
   - C# code level monitoring
   - See exceptions, slow methods

4. kubectl logs
   - See container output
   - Debug Kubernetes issues

5. Port forwarding
   - Debug locally against prod service
   - kubectl port-forward pod-name 5000:5000

Q: When should I scale up?

Watch these metrics:

CPU > 70% consistently → Add more pods
Memory > 80% → Increase memory requests
Request latency > 1s → Optimize code or scale horizontally
Error rate > 0.1% → Investigate, then scale if needed

Kubernetes does this automatically:

yaml
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: user-service-hpa
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: user-service
  minReplicas: 3
  maxReplicas: 10
  metrics:
  - type: Resource
    resource:
      name: cpu
      target:
        type: Utilization
        averageUtilization: 70
  - type: Resource
    resource:
      name: memory
      target:
        type: Utilization
        averageUtilization: 80

Next Steps: Becoming a .NET Cloud Expert

This guide covers the foundation. To go deeper:

Advanced Topics

  • 🚀 gRPC for inter-service communication
  • 🔒 Service mesh (Istio) for advanced networking
  • 📊 Custom metrics and monitoring
  • 🔐 OAuth2/OpenID Connect for distributed auth
  • 📈 Load testing and performance optimization
  • 🌍 Multi-region deployment
  • 💾 Distributed caching (Redis)
  • 🔄 Event sourcing and CQRS patterns

Books & Resources

  • “Building Microservices” by Sam Newman
  • “Designing Data-Intensive Applications” by Martin Kleppmann
  • Microsoft docs: learn.microsoft.com/dotnet/architecture/microservices
  • Kubernetes docs: kubernetes.io/docs

Conclusion: You’re Now a Cloud-Native .NET Expert

Congratulations! You’ve built:

✅ Multiple .NET microservices
✅ Containerized with Docker
✅ Orchestrated with Kubernetes
✅ Deployed to Azure (production-grade)
✅ Automated CI/CD pipeline
✅ Monitoring and observability
✅ Resilient, scalable architecture

This is a rare skillset. Most engineers can do cloud. Most can do .NET. Very few can do both well.

Mo Assem

My name is Mohamed Assem, and I am a Cloud & Infrastructure Engineer with over 14 years of experience in IT, working across both Microsoft Azure and AWS. My expertise lies in cloud operations, automation, and building modern, scalable infrastructure. I design and implement CI/CD pipelines and infrastructure as code solutions using tools like Terraform and Docker to streamline operations and improve efficiency. Through my blog, TechWithAssem, I share practical tutorials, real-world implementations, and step-by-step guides to help engineers grow in Cloud and DevOps.

Related Articles

Leave a Reply

Your email address will not be published. Required fields are marked *

Back to top button