0%
Microservices with .NET: Architecture, Patterns, and Best Practices

Microservices with .NET: Architecture, Patterns, and Best Practices

A complete guide to designing, building, and deploying robust microservices architectures with .NET and ASP.NET Core.

I

InSkillCoach

· min

Microservices with .NET: Architecture, Patterns, and Best Practices

Introduction to Microservices

Microservices architecture is a software development approach that structures an application as a set of loosely coupled, independently deployable services. Unlike traditional monolithic architectures, microservices provide greater agility, scalability, and resilience.

Why Adopt Microservices with .NET?

  • Independent deployment: Each service can evolve separately
  • Targeted scalability: Scale horizontally only the services that need it
  • Improved resilience: The failure of one service does not bring down the entire system
  • Autonomous teams: Teams can develop, test, and deploy their services independently
  • Technology flexibility: You can use different technologies for different services
  • Seamless integration with the .NET and ASP.NET Core ecosystem

Microservices Fundamentals with .NET

Basic Architecture

A typical microservices architecture in .NET generally includes:

  • API Gateway: A single entry point for clients (often implemented with Ocelot)
  • Business services: ASP.NET Core services that implement the business logic
  • Discovery service: To locate service instances (Consul, Eureka)
  • Database per service: Each service owns its own database
  • Message broker: For asynchronous communication (RabbitMQ, Azure Service Bus)
  • Identity services: For authentication and authorization (IdentityServer)
  • Containers and orchestration: Docker and Kubernetes for deployment

Creating an ASP.NET Core Microservice

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

// Add the required services
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.AddDbContext<ProductContext>(options =>
    options.UseSqlServer(builder.Configuration.GetConnectionString("ProductDb")));
builder.Services.AddScoped<IProductRepository, ProductRepository>();

var app = builder.Build();

// Configure the HTTP request pipeline
if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}

app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();

app.Run();

Design Patterns for Microservices

CQRS Pattern (Command Query Responsibility Segregation)

// Command
public class CreateProductCommand : IRequest<int>
{
    public string Name { get; set; }
    public string Description { get; set; }
    public decimal Price { get; set; }
}

// Command handler
public class CreateProductCommandHandler : IRequestHandler<CreateProductCommand, int>
{
    private readonly ProductContext _context;
    
    public CreateProductCommandHandler(ProductContext context)
    {
        _context = context;
    }
    
    public async Task<int> Handle(CreateProductCommand request, CancellationToken cancellationToken)
    {
        var product = new Product
        {
            Name = request.Name,
            Description = request.Description,
            Price = request.Price
        };
        
        _context.Products.Add(product);
        await _context.SaveChangesAsync(cancellationToken);
        
        return product.Id;
    }
}

// Query
public class GetProductQuery : IRequest<ProductDto>
{
    public int Id { get; set; }
}

// Query handler
public class GetProductQueryHandler : IRequestHandler<GetProductQuery, ProductDto>
{
    private readonly ProductContext _context;
    
    public GetProductQueryHandler(ProductContext context)
    {
        _context = context;
    }
    
    public async Task<ProductDto> Handle(GetProductQuery request, CancellationToken cancellationToken)
    {
        var product = await _context.Products.FindAsync(request.Id);
        
        return new ProductDto
        {
            Id = product.Id,
            Name = product.Name,
            Description = product.Description,
            Price = product.Price
        };
    }
}

Saga Pattern for Distributed Transactions

// Definition of a saga for the order process
public class OrderSaga : 
    IAmStartedByMessages<CreateOrderCommand>,
    IHandleMessages<PaymentProcessedEvent>,
    IHandleMessages<InventoryReservedEvent>,
    IHandleMessages<ShippingPreparedEvent>
{
    private readonly IBus _bus;
    private readonly IOrderRepository _orderRepository;
    
    public OrderSaga(IBus bus, IOrderRepository orderRepository)
    {
        _bus = bus;
        _orderRepository = orderRepository;
    }
    
    public async Task Handle(CreateOrderCommand message, IMessageHandlerContext context)
    {
        // Create the order
        var order = new Order(message.OrderId, message.CustomerId, message.Products);
        await _orderRepository.SaveAsync(order);
        
        // Trigger the payment process
        await _bus.Publish(new ProcessPaymentCommand
        {
            OrderId = message.OrderId,
            Amount = order.TotalAmount,
            PaymentDetails = message.PaymentDetails
        });
    }
    
    public async Task Handle(PaymentProcessedEvent message, IMessageHandlerContext context)
    {
        // Update the order status
        var order = await _orderRepository.GetByIdAsync(message.OrderId);
        order.MarkPaymentComplete();
        await _orderRepository.UpdateAsync(order);
        
        // Reserve the inventory
        await _bus.Publish(new ReserveInventoryCommand
        {
            OrderId = message.OrderId,
            Products = order.Products
        });
    }
    
    // Handle the other events...
    
    // Handle compensations on failure
    private async Task CompensateOrder(string orderId, string reason)
    {
        var order = await _orderRepository.GetByIdAsync(orderId);
        order.Cancel(reason);
        await _orderRepository.UpdateAsync(order);
        
        // Emit compensation events (refund, inventory release, etc.)
    }
}

API Gateway with Ocelot

// ocelot.json
{
  "Routes": [
    {
      "DownstreamPathTemplate": "/api/products/{everything}",
      "DownstreamScheme": "https",
      "DownstreamHostAndPorts": [
        {
          "Host": "product-service",
          "Port": 443
        }
      ],
      "UpstreamPathTemplate": "/products/{everything}",
      "UpstreamHttpMethod": [ "GET", "POST", "PUT", "DELETE" ]
    },
    {
      "DownstreamPathTemplate": "/api/orders/{everything}",
      "DownstreamScheme": "https",
      "DownstreamHostAndPorts": [
        {
          "Host": "order-service",
          "Port": 443
        }
      ],
      "UpstreamPathTemplate": "/orders/{everything}",
      "UpstreamHttpMethod": [ "GET", "POST", "PUT", "DELETE" ]
    }
  ],
  "GlobalConfiguration": {
    "BaseUrl": "https://api.example.com"
  }
}
// Program.cs for the API Gateway
var builder = WebApplication.CreateBuilder(args);

builder.Configuration.AddJsonFile("ocelot.json", optional: false, reloadOnChange: true);
builder.Services.AddOcelot(builder.Configuration);

var app = builder.Build();

app.UseOcelot().Wait();

app.Run();

Communication Between Microservices

Synchronous Communication with HTTP/REST

// HTTP client service
public class ProductService : IProductService
{
    private readonly HttpClient _httpClient;
    
    public ProductService(HttpClient httpClient)
    {
        _httpClient = httpClient;
    }
    
    public async Task<ProductDto> GetProductAsync(int id)
    {
        var response = await _httpClient.GetAsync($"/api/products/{id}");
        response.EnsureSuccessStatusCode();
        
        return await response.Content.ReadFromJsonAsync<ProductDto>();
    }
}

// Configuration
services.AddHttpClient<IProductService, ProductService>(client =>
{
    client.BaseAddress = new Uri("https://product-service");
    client.DefaultRequestHeaders.Add("Accept", "application/json");
})
.AddPolicyHandler(GetRetryPolicy())
.AddPolicyHandler(GetCircuitBreakerPolicy());

// Resilience policies
static IAsyncPolicy<HttpResponseMessage> GetRetryPolicy()
{
    return HttpPolicyExtensions
        .HandleTransientHttpError()
        .OrResult(msg => msg.StatusCode == System.Net.HttpStatusCode.NotFound)
        .WaitAndRetryAsync(3, retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)));
}

static IAsyncPolicy<HttpResponseMessage> GetCircuitBreakerPolicy()
{
    return HttpPolicyExtensions
        .HandleTransientHttpError()
        .CircuitBreakerAsync(5, TimeSpan.FromSeconds(30));
}

Asynchronous Communication with Events

// Definition of an event
public class OrderCreatedEvent : IntegrationEvent
{
    public int OrderId { get; set; }
    public int CustomerId { get; set; }
    public List<OrderItem> Items { get; set; }
    public DateTime OrderDate { get; set; }
}

// Publishing an event with MassTransit and RabbitMQ
public class OrderController : ControllerBase
{
    private readonly IOrderRepository _orderRepository;
    private readonly IPublishEndpoint _publishEndpoint;
    
    public OrderController(IOrderRepository orderRepository, IPublishEndpoint publishEndpoint)
    {
        _orderRepository = orderRepository;
        _publishEndpoint = publishEndpoint;
    }
    
    [HttpPost]
    public async Task<IActionResult> CreateOrder(CreateOrderCommand command)
    {
        // Order creation logic
        var order = new Order(command);
        await _orderRepository.AddAsync(order);
        
        // Publish the event
        await _publishEndpoint.Publish(new OrderCreatedEvent
        {
            OrderId = order.Id,
            CustomerId = order.CustomerId,
            Items = order.Items,
            OrderDate = order.OrderDate
        });
        
        return CreatedAtAction(nameof(GetOrder), new { id = order.Id }, order);
    }
}

// MassTransit configuration
services.AddMassTransit(x =>
{
    x.UsingRabbitMq((context, cfg) =>
    {
        cfg.Host("rabbitmq://localhost");
        cfg.ConfigureEndpoints(context);
    });
});

Data Persistence

Database per Service

// DbContext for the Product service
public class ProductContext : DbContext
{
    public ProductContext(DbContextOptions<ProductContext> options) : base(options)
    {
    }
    
    public DbSet<Product> Products { get; set; }
    
    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Product>().ToTable("Products");
        modelBuilder.Entity<Product>().HasKey(p => p.Id);
        modelBuilder.Entity<Product>().Property(p => p.Name).IsRequired().HasMaxLength(100);
        modelBuilder.Entity<Product>().Property(p => p.Price).HasColumnType("decimal(18,2)");
    }
}

// Configuration in Program.cs
services.AddDbContext<ProductContext>(options =>
    options.UseSqlServer(Configuration.GetConnectionString("ProductDb")));

Repository Pattern

// Repository interface
public interface IProductRepository
{
    Task<Product> GetByIdAsync(int id);
    Task<IEnumerable<Product>> GetAllAsync();
    Task<IEnumerable<Product>> GetByNameAsync(string name);
    Task AddAsync(Product product);
    Task UpdateAsync(Product product);
    Task DeleteAsync(int id);
}

// Repository implementation
public class ProductRepository : IProductRepository
{
    private readonly ProductContext _context;
    
    public ProductRepository(ProductContext context)
    {
        _context = context;
    }
    
    public async Task<Product> GetByIdAsync(int id)
    {
        return await _context.Products
            .FindAsync(id);
    }
    
    public async Task<IEnumerable<Product>> GetAllAsync()
    {
        return await _context.Products
            .ToListAsync();
    }
    
    public async Task<IEnumerable<Product>> GetByNameAsync(string name)
    {
        return await _context.Products
            .Where(p => p.Name.Contains(name))
            .ToListAsync();
    }
    
    public async Task AddAsync(Product product)
    {
        _context.Products.Add(product);
        await _context.SaveChangesAsync();
    }
    
    public async Task UpdateAsync(Product product)
    {
        _context.Entry(product).State = EntityState.Modified;
        await _context.SaveChangesAsync();
    }
    
    public async Task DeleteAsync(int id)
    {
        var product = await _context.Products.FindAsync(id);
        if (product != null)
        {
            _context.Products.Remove(product);
            await _context.SaveChangesAsync();
        }
    }
}

Deployment and Orchestration

Containerization with Docker

# Dockerfile for a .NET microservice
FROM mcr.microsoft.com/dotnet/aspnet:7.0 AS base
WORKDIR /app
EXPOSE 80
EXPOSE 443

FROM mcr.microsoft.com/dotnet/sdk:7.0 AS build
WORKDIR /src
COPY ["ProductService.csproj", "./"]
RUN dotnet restore "ProductService.csproj"
COPY . .
RUN dotnet build "ProductService.csproj" -c Release -o /app/build

FROM build AS publish
RUN dotnet publish "ProductService.csproj" -c Release -o /app/publish

FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "ProductService.dll"]

Orchestration with Kubernetes

# deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: product-service
  labels:
    app: product-service
spec:
  replicas: 3
  selector:
    matchLabels:
      app: product-service
  template:
    metadata:
      labels:
        app: product-service
    spec:
      containers:
      - name: product-service
        image: acr.io/myorg/product-service:latest
        ports:
        - containerPort: 80
        env:
        - name: ConnectionStrings__ProductDb
          valueFrom:
            secretKeyRef:
              name: product-service-secrets
              key: db-connection-string
        resources:
          limits:
            cpu: "500m"
            memory: "512Mi"
          requests:
            cpu: "100m"
            memory: "256Mi"
        livenessProbe:
          httpGet:
            path: /health/live
            port: 80
          initialDelaySeconds: 30
          periodSeconds: 10
        readinessProbe:
          httpGet:
            path: /health/ready
            port: 80
          initialDelaySeconds: 15
          periodSeconds: 5

---
# service.yaml
apiVersion: v1
kind: Service
metadata:
  name: product-service
spec:
  selector:
    app: product-service
  ports:
  - port: 80
    targetPort: 80
  type: ClusterIP

Observability and Monitoring

Health Checks

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

// Add health checks
builder.Services.AddHealthChecks()
    .AddDbContextCheck<ProductContext>("database")
    .AddCheck("self", () => HealthCheckResult.Healthy())
    .AddRabbitMQ($"amqp://guest:guest@rabbitmq:5672/", name: "rabbitmq");

var app = builder.Build();

// Configure health check endpoints
app.MapHealthChecks("/health/live", new HealthCheckOptions
{
    Predicate = _ => true
});

app.MapHealthChecks("/health/ready", new HealthCheckOptions
{
    Predicate = check => check.Tags.Contains("ready"),
    ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse
});

app.Run();

Distributed Tracing with OpenTelemetry

// Program.cs
services.AddOpenTelemetryTracing(builder =>
{
    builder
        .SetResourceBuilder(ResourceBuilder.CreateDefault().AddService("ProductService"))
        .AddAspNetCoreInstrumentation()
        .AddHttpClientInstrumentation()
        .AddEntityFrameworkCoreInstrumentation()
        .AddJaegerExporter(options =>
        {
            options.AgentHost = "jaeger";
            options.AgentPort = 6831;
        });
});

Microservices Security

Authentication with IdentityServer

// Program.cs
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.Authority = "https://identity-service";
        options.Audience = "product-api";
        options.RequireHttpsMetadata = false;
    });

builder.Services.AddAuthorization(options =>
{
    options.AddPolicy("ReadProducts", policy => 
        policy.RequireClaim("scope", "product.read"));
    options.AddPolicy("WriteProducts", policy => 
        policy.RequireClaim("scope", "product.write"));
});

// Controller
[Authorize(Policy = "ReadProducts")]
[HttpGet("{id}")]
public async Task<ActionResult<ProductDto>> GetProduct(int id)
{
    // ...
}

[Authorize(Policy = "WriteProducts")]
[HttpPost]
public async Task<ActionResult<ProductDto>> CreateProduct(CreateProductCommand command)
{
    // ...
}

API Security with Rate Limiting

// Program.cs
builder.Services.AddRateLimiter(options =>
{
    options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(context =>
    {
        return RateLimitPartition.GetFixedWindowLimiter(
            partitionKey: context.User.Identity?.Name ?? context.Request.Headers.Host.ToString(),
            factory: partition => new FixedWindowRateLimiterOptions
            {
                AutoReplenishment = true,
                PermitLimit = 100,
                QueueLimit = 0,
                Window = TimeSpan.FromMinutes(1)
            });
    });
    
    options.OnRejected = async (context, token) =>
    {
        context.HttpContext.Response.StatusCode = 429;
        await context.HttpContext.Response.WriteAsync("Too many requests. Please try again later.", token);
    };
});

var app = builder.Build();
app.UseRateLimiter();

Testing Microservices

Unit Tests

// ProductServiceTests.cs
public class ProductServiceTests
{
    [Fact]
    public async Task GetProductAsync_ExistingProduct_ReturnsProduct()
    {
        // Arrange
        var mockRepository = new Mock<IProductRepository>();
        mockRepository.Setup(repo => repo.GetByIdAsync(1))
            .ReturnsAsync(new Product { Id = 1, Name = "Test Product", Price = 10.99m });
        
        var service = new ProductService(mockRepository.Object);
        
        // Act
        var result = await service.GetProductAsync(1);
        
        // Assert
        Assert.NotNull(result);
        Assert.Equal(1, result.Id);
        Assert.Equal("Test Product", result.Name);
    }
}

Integration Tests

// ProductControllerIntegrationTests.cs
public class ProductControllerIntegrationTests
    : IClassFixture<WebApplicationFactory<Program>>
{
    private readonly WebApplicationFactory<Program> _factory;
    
    public ProductControllerIntegrationTests(WebApplicationFactory<Program> factory)
    {
        _factory = factory.WithWebHostBuilder(builder =>
        {
            builder.ConfigureServices(services =>
            {
                // Replace the DbContext with an in-memory database
                var descriptor = services.SingleOrDefault(
                    d => d.ServiceType == typeof(DbContextOptions<ProductContext>));
                
                if (descriptor != null)
                {
                    services.Remove(descriptor);
                }
                
                services.AddDbContext<ProductContext>(options =>
                {
                    options.UseInMemoryDatabase("TestProductDb");
                });
                
                // Add test data
                var sp = services.BuildServiceProvider();
                using var scope = sp.CreateScope();
                var scopedServices = scope.ServiceProvider;
                var db = scopedServices.GetRequiredService<ProductContext>();
                
                db.Products.Add(new Product { Id = 1, Name = "Test Product", Price = 10.99m });
                db.SaveChanges();
            });
        });
    }
    
    [Fact]
    public async Task GetProducts_ReturnsSuccessAndCorrectContentType()
    {
        // Arrange
        var client = _factory.CreateClient();
        
        // Act
        var response = await client.GetAsync("/api/products");
        
        // Assert
        response.EnsureSuccessStatusCode();
        Assert.Equal("application/json; charset=utf-8", 
            response.Content.Headers.ContentType.ToString());
    }
}

Best Practices for .NET Microservices

  1. Separation of Concerns

    • Each microservice should have a single, well-defined responsibility
  2. Configuration Management

    • Use external configuration providers (Azure App Configuration, Kubernetes ConfigMaps/Secrets)
  3. Resilience

    • Implement patterns such as Circuit Breaker, Retry, and Timeout with Polly
  4. API Versioning

    • Use explicit versions in routes or HTTP headers
  5. Traceability

    • Use correlation IDs to follow requests across services
  6. Documentation

    • Use Swagger/OpenAPI to document your APIs
  7. Domain-Based Architecture

    • Organize your microservices around business domains (DDD)
  8. Infrastructure as Code

    • Automate deployment and configuration with tools like Terraform or Pulumi

Further Resources

Conclusion

Microservices architecture offers many benefits for modern applications, but it also introduces additional complexity. With .NET and its rich ecosystem, you have all the tools you need to design, develop, and deploy robust, scalable, and maintainable microservices. By following the patterns and best practices presented in this guide, you’ll be able to build effective microservices architectures that meet your organization’s needs.

InSkillCoach

About InSkillCoach

Training and technology expert

Coach specializing in advanced technologies and AI, backed by GNeurone Inc.

Certifications:

  • AWS Certified Solutions Architect – Professional
  • Google Cloud certifications
  • Microsoft Certified: DevOps Engineer Expert
  • Certified Kubernetes Administrator (CKA)
  • CompTIA Security+
1.3k
125

Comments

Comments are powered by GitHub Discussions

Sign in with GitHub to join the discussion

Link copied!