dotnet

Building Scalable Microservices with .NET Core and Event-Driven Architecture

Andrei Bespamiatnov

Andrei Bespamiatnov

Building Scalable Microservices with .NET Core and Event-Driven Architecture

Introduction

Having spent years designing and implementing microservices architectures in enterprise environments, I’ve learned that successful microservices adoption requires more than just breaking up a monolith. In this comprehensive guide, I’ll share practical insights on building scalable microservices using .NET Core, with a focus on event-driven architecture patterns that have proven successful in production environments.

Why Microservices with .NET Core?

The .NET Advantage

.NET Core provides several key advantages for microservices development:

  • Performance: High-throughput, low-latency applications
  • Cross-platform: Deploy anywhere - Windows, Linux, containers
  • Mature ecosystem: Rich libraries and tooling
  • Developer productivity: Strong typing, excellent IDE support
  • Enterprise ready: Security, monitoring, and governance features

When to Choose Microservices

Before diving into implementation, evaluate team size and structure, domain clarity, scalability needs, tech stack variation, and operational maturity (DevOps/monitoring). Favor microservices when multiple teams work independently across clear bounded contexts with distinct scaling characteristics and sufficient ops maturity.

Green flags for microservices:

  • Multiple teams working on the same product
  • Clear domain boundaries
  • Different scalability requirements per service
  • Mature DevOps practices

Red flags:

  • Small team (< 8 people)
  • Unclear domain boundaries
  • Limited operational capabilities
  • Tight coupling between business functions

Designing Microservices Architecture

Domain-Driven Design Approach

Start with proper domain modeling: identify bounded contexts (e.g., Order Management, Inventory, Payments), their responsibilities, and data ownership. Align service boundaries to these contexts to minimize coupling and cross‑team friction.

Service Boundaries

Define service boundaries by business capability and expose clear APIs. Use commands, events, and DTOs consistently across services.

Event-Driven Architecture Implementation

Event Sourcing with .NET

Consider event sourcing where auditability and temporal queries are critical; otherwise use pragmatic state persistence with outbox/inbox for reliability.

Message Bus Implementation

Adopt a mature message bus with retries, DLQs, and idempotency. Standardize consumers, serialization, and endpoint conventions.

Service Implementation Patterns

Use CQRS where it simplifies scaling and clarity; keep validation, persistence, and event publication consistent and observable.

Repository Pattern with Entity Framework

Keep repositories simple and optimized for use cases; use outbox to bridge write model and integration events.

Cross-Cutting Concerns

Distributed Tracing

Adopt distributed tracing (OpenTelemetry), structured logging, and metrics from the start; make correlation IDs first‑class across services.

Health Checks

Provide health, readiness, and liveness endpoints for core dependencies (DB, message bus, external services) and integrate with your orchestrator.

Configuration Management

Implement proper configuration patterns:

// Configuration models
public class ServiceConfiguration
{
    public DatabaseConfiguration Database { get; set; }
    public MessageBusConfiguration MessageBus { get; set; }
    public ExternalServicesConfiguration ExternalServices { get; set; }
    public SecurityConfiguration Security { get; set; }
}

public class DatabaseConfiguration
{
    public string ConnectionString { get; set; }
    public int CommandTimeout { get; set; } = 30;
    public bool EnableSensitiveDataLogging { get; set; } = false;
    public int MaxRetryCount { get; set; } = 3;
}

// Configuration validation
public class ServiceConfigurationValidator : AbstractValidator<ServiceConfiguration>
{
    public ServiceConfigurationValidator()
    {
        RuleFor(x => x.Database.ConnectionString)
            .NotEmpty()
            .WithMessage("Database connection string is required");
        
        RuleFor(x => x.Database.CommandTimeout)
            .GreaterThan(0)
            .WithMessage("Command timeout must be positive");
        
        RuleFor(x => x.MessageBus.Host)
            .NotEmpty()
            .WithMessage("Message bus host is required");
    }
}

// Startup configuration
public void ConfigureServices(IServiceCollection services)
{
    var configuration = Configuration.Get<ServiceConfiguration>();
    
    // Validate configuration
    var validator = new ServiceConfigurationValidator();
    var validationResult = validator.Validate(configuration);
    
    if (!validationResult.IsValid)
    {
        throw new InvalidOperationException(
            $"Configuration validation failed: {string.Join(", ", validationResult.Errors)}");
    }
    
    services.AddSingleton(configuration);
}

Testing Strategies

Integration Testing

Test microservices interactions:

// Integration test base
public abstract class IntegrationTestBase : IDisposable
{
    protected readonly WebApplicationFactory<Program> Factory;
    protected readonly HttpClient Client;
    protected readonly IServiceScope Scope;
    
    protected IntegrationTestBase()
    {
        Factory = new WebApplicationFactory<Program>()
            .WithWebHostBuilder(builder =>
            {
                builder.ConfigureTestServices(services =>
                {
                    // Replace real dependencies with test doubles
                    services.RemoveAll<IMessageBus>();
                    services.AddSingleton<IMessageBus, TestMessageBus>();
                    
                    // Use in-memory database
                    services.RemoveAll<DbContextOptions<OrderDbContext>>();
                    services.AddDbContext<OrderDbContext>(options =>
                        options.UseInMemoryDatabase("TestDb"));
                });
            });
        
        Client = Factory.CreateClient();
        Scope = Factory.Services.CreateScope();
    }
    
    public void Dispose()
    {
        Scope?.Dispose();
        Client?.Dispose();
        Factory?.Dispose();
    }
}

// Specific integration test
public class OrderServiceIntegrationTests : IntegrationTestBase
{
    [Fact]
    public async Task CreateOrder_ValidRequest_ReturnsSuccess()
    {
        // Arrange
        var command = new CreateOrderCommand(
            CustomerId: Guid.NewGuid(),
            Items: new[]
            {
                new OrderItem(Guid.NewGuid(), 2, 10.99m),
                new OrderItem(Guid.NewGuid(), 1, 25.50m)
            },
            ShippingAddress: new Address("123 Test St", "Test City", "12345"),
            PaymentMethod: new CreditCardPayment("4111111111111111", "12/25", "123")
        );
        
        // Act
        var response = await Client.PostAsJsonAsync("/api/orders", command);
        
        // Assert
        response.StatusCode.Should().Be(HttpStatusCode.Created);
        
        var result = await response.Content.ReadFromJsonAsync<OrderResult>();
        result.Should().NotBeNull();
        result.IsSuccess.Should().BeTrue();
        result.OrderId.Should().NotBeEmpty();
    }
    
    [Fact]
    public async Task CreateOrder_PublishesOrderCreatedEvent()
    {
        // Arrange
        var testMessageBus = Scope.ServiceProvider.GetRequiredService<IMessageBus>() as TestMessageBus;
        var command = CreateValidOrderCommand();
        
        // Act
        await Client.PostAsJsonAsync("/api/orders", command);
        
        // Assert
        testMessageBus.PublishedEvents
            .Should().ContainSingle(e => e is OrderCreatedEvent);
    }
}

Contract Testing

Use Pact for consumer-driven contract testing:

// Consumer test (Order Service testing Inventory Service)
public class InventoryServiceContractTests : IClassFixture<PactFixture>
{
    private readonly PactFixture _pactFixture;
    
    public InventoryServiceContractTests(PactFixture pactFixture)
    {
        _pactFixture = pactFixture;
    }
    
    [Fact]
    public async Task CheckInventory_ValidProductIds_ReturnsAvailability()
    {
        // Arrange
        var productIds = new[] { Guid.NewGuid(), Guid.NewGuid() };
        
        _pactFixture.MockProviderService
            .Given("Products exist in inventory")
            .UponReceiving("A request for inventory availability")
            .With(new ProviderServiceRequest
            {
                Method = HttpVerb.Post,
                Path = "/api/inventory/check",
                Headers = new Dictionary<string, object>
                {
                    { "Content-Type", "application/json" }
                },
                Body = new { ProductIds = productIds }
            })
            .WillRespondWith(new ProviderServiceResponse
            {
                Status = 200,
                Headers = new Dictionary<string, object>
                {
                    { "Content-Type", "application/json" }
                },
                Body = new
                {
                    Items = productIds.Select(id => new
                    {
                        ProductId = id,
                        Available = true,
                        Quantity = 100
                    })
                }
            });
        
        // Act
        var inventoryService = new InventoryServiceClient(_pactFixture.MockProviderServiceBaseUri);
        var result = await inventoryService.CheckInventoryAsync(productIds);
        
        // Assert
        result.Should().NotBeNull();
        result.Items.Should().HaveCount(2);
        result.Items.Should().OnlyContain(item => item.Available);
    }
}

Deployment and Operations

Docker Configuration

Use multi‑stage Docker builds with non‑root users, proper health checks, and small runtime images.

For Kubernetes, configure replicas, probes, resource limits, secrets for configuration, and proper service exposure. Add HPA and PodDisruptionBudgets for resilience.

Performance Optimization

Caching Strategies

Adopt multi‑level caching (in‑memory + distributed) with clear TTLs and cache keys; cache read‑heavy queries.

Database Optimization

Optimize queries for hot paths, prefer read models with AsNoTracking, and paginate consistently.

Monitoring and Observability

Structured Logging

Adopt structured logging with correlation and sensitive‑data policies; centralize logs and build useful dashboards.

Conclusion

Building scalable microservices with .NET Core requires careful consideration of architecture, patterns, and operational concerns. Key takeaways from my experience:

  1. Start with the domain - Proper bounded context identification is crucial
  2. Embrace event-driven patterns - They provide loose coupling and scalability
  3. Invest in observability - You can’t manage what you can’t see
  4. Test at multiple levels - Unit, integration, and contract tests are all important
  5. Automate operations - DevOps practices are essential for microservices success

Remember that microservices are not a silver bullet. They introduce complexity in exchange for scalability and team autonomy. Make sure the trade-offs align with your organizational needs and capabilities.

The patterns and practices outlined in this guide have been proven in production environments and can help you build robust, scalable microservices architectures with .NET Core.