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

Andrei Bespamiatnov

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:
- Start with the domain - Proper bounded context identification is crucial
- Embrace event-driven patterns - They provide loose coupling and scalability
- Invest in observability - You can’t manage what you can’t see
- Test at multiple levels - Unit, integration, and contract tests are all important
- 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.