Skip to main content

Common .NET Microservice Mistakes: Avoid Anti-Patterns

A poorly designed microservice architecture becomes costlier to maintain than the monolith it replaced. Common mistakes—shared databases, synchronous calls without timeouts, distributed transactions, missing monitoring—create fragile systems prone to cascading failures. This article catalogs the most costly mistakes and how to avoid them.

Mistake 1: Sharing a Database Between Services

Two services reading and writing the same database table violates data ownership. If Order Service adds a column to the Orders table and Inventory Service's code assumes the column does not exist, Inventory breaks. Schema migrations become bottlenecks: both services must coordinate.

// WRONG: Both services use the same Orders table
public class OrderContext : DbContext
{
public DbSet<Order> Orders { get; set; }
// ...
}

public class InventoryContext : DbContext
{
public DbSet<Order> Orders { get; set; } // Same table, tight coupling
// ...
}

Fix: Each service owns its database:

// Order Service
public class OrderContext : DbContext
{
public DbSet<Order> Orders { get; set; }
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder.UseSqlServer("Server=order-db;Database=OrderDb;");
}
}

// Inventory Service
public class InventoryContext : DbContext
{
public DbSet<InventoryItem> Items { get; set; }
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder.UseSqlServer("Server=inventory-db;Database=InventoryDb;");
}
}

Mistake 2: Synchronous Calls Without Timeouts

If Order Service calls Inventory Service synchronously without a timeout, a slow Inventory Service blocks Order Service threads indefinitely. Threads exhaust, and Order Service becomes unresponsive.

// WRONG: No timeout
var response = await _httpClient.PostAsJsonAsync(
"https://inventory-service/api/check-availability",
request
);

Fix: Always set a timeout:

// CORRECT: 5-second timeout
using (var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)))
{
var response = await _httpClient.PostAsJsonAsync(
"https://inventory-service/api/check-availability",
request,
cancellationToken: cts.Token
);
}

// Or configure at HttpClientFactory level
builder.Services.AddHttpClient<InventoryClient>()
.ConfigureHttpClient(c => c.Timeout = TimeSpan.FromSeconds(5));

Mistake 3: Missing Circuit Breakers

Without circuit breakers, a failing service is continuously hammered with requests. If Inventory Service crashes, Order Service retries every request, overwhelming Inventory with pointless traffic. The service never recovers.

// WRONG: Infinite retries without circuit breaker
for (int i = 0; i < 100; i++)
{
try
{
return await _httpClient.GetAsync("https://inventory-service/api/items");
}
catch
{
// Retry immediately; no circuit breaker
}
}

Fix: Use Polly circuit breaker:

var policy = Policy
.Handle<HttpRequestException>()
.CircuitBreakerAsync(3, TimeSpan.FromSeconds(30)); // Break after 3 failures

var response = await policy.ExecuteAsync(() =>
_httpClient.GetAsync("https://inventory-service/api/items")
);

Mistake 4: Ignoring Eventual Consistency

Developers new to microservices expect ACID transactions across services (immediate consistency). When a saga compensates asynchronously (order is created, then payment fails 5 seconds later and the order is cancelled), they are surprised.

Fix: Design for eventual consistency:

// Accept that the order is created before payment is verified
var order = await _orderService.CreateOrderAsync(req); // Confirmed immediately

// Payment is processed asynchronously; order may be cancelled if payment fails
await _publisher.PublishAsync(new OrderCreatedEvent { OrderId = order.Id });

// Client polls for order status or subscribes to status-changed events

Document expectations: "Orders are confirmed immediately but may be cancelled within 5 seconds if payment fails." Educate users and stakeholders.

Mistake 5: Overengineering the First Service

Building your first service with event sourcing, CQRS, service mesh, and 100% test coverage is overengineering. Start simple: synchronous HTTP calls, a relational database, and basic unit tests. Add patterns when problems demand them.

Fix: Use simple patterns first:

// FIRST SERVICE: Keep it simple
public class OrderService
{
public async Task<Order> CreateAsync(CreateOrderRequest req)
{
var order = new Order { CustomerId = req.CustomerId, Items = req.Items };
await _dbContext.Orders.AddAsync(order);
await _dbContext.SaveChangesAsync();
return order;
}
}

Add event sourcing, CQRS, or event-driven architecture only when you outgrow the simple pattern (performance needs, consistency requirements, audit trails).

Mistake 6: No Correlation IDs

Without correlation IDs, tracing a request across services is impossible. A user reports an issue; you cannot find related logs in Inventory, Payment, and Notification services.

// WRONG: No correlation ID
public async Task<Order> CreateOrderAsync(CreateOrderRequest req)
{
var order = new Order { CustomerId = req.CustomerId };
// No correlation ID; logs are scattered across services
_logger.LogInformation("Order created");
return order;
}

Fix: Generate and propagate correlation IDs:

public async Task<Order> CreateOrderAsync(CreateOrderRequest req)
{
var correlationId = req.CorrelationId ?? Guid.NewGuid().ToString();

using (LogContext.PushProperty("CorrelationId", correlationId))
{
var order = new Order { CustomerId = req.CustomerId, CorrelationId = correlationId };
await _dbContext.Orders.AddAsync(order);
await _dbContext.SaveChangesAsync();

// Pass correlation ID to downstream services
await _publisher.PublishAsync(new OrderCreatedEvent { OrderId = order.Id }, context =>
{
context.CorrelationId = correlationId;
});

_logger.LogInformation("Order {OrderId} created", order.Id);
return order;
}
}

All logs for this order flow will include the correlation ID, enabling end-to-end tracing.

Mistake 7: Deploying Without Tests

A microservice deployed without integration tests can silently break downstream services. A schema change in Order Service breaks Inventory Service's API contract.

Fix: Write integration and contract tests:

[TestClass]
public class OrderServiceIntegrationTests
{
[TestMethod]
public async Task CreateOrder_PublishesOrderCreatedEvent()
{
// Arrange
var service = new OrderService(_dbContext, _publisher);

// Act
var order = await service.CreateOrderAsync(new CreateOrderRequest { CustomerId = 1 });

// Assert
var publishedEvents = _publisher.GetPublishedEvents();
Assert.IsTrue(publishedEvents.Any(e => e is OrderCreatedEvent));
}

[TestMethod]
public async Task GetOrder_ReturnsOrderWithCorrectSchema()
{
// Verify API contract: ensure response includes all expected fields
var response = await _httpClient.GetAsync($"/api/orders/1");
var order = await response.Content.ReadAsAsync<OrderDto>();

Assert.IsNotNull(order.Id);
Assert.IsNotNull(order.CustomerId);
Assert.IsNotNull(order.Items);
}
}

Mistake 8: Silent Failures

A message is published to a broker but silently dropped. An event handler crashes but the error is not logged. The system appears to work, but data is lost.

Fix: Log all errors and monitor dead-letter queues:

public class OrderCreatedEventHandler : IConsumer<OrderCreatedEvent>
{
public async Task Consume(ConsumeContext<OrderCreatedEvent> context)
{
try
{
await _inventoryService.ReserveItemsAsync(context.Message.OrderId);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to process OrderCreatedEvent for order {OrderId}", context.Message.OrderId);
throw; // Re-throw so MassTransit moves to dead-letter queue
}
}
}

// Monitor dead-letter queue for failures
public class DeadLetterQueueMonitor
{
public async Task CheckDeadLettersAsync()
{
var deadLetters = await _messageStore.GetDeadLettersAsync();
foreach (var deadLetter in deadLetters)
{
_alertService.SendAlert($"Message in DLQ: {deadLetter.Id}", AlertSeverity.Critical);
}
}
}

Mistake 9: Not Handling Idempotency

A payment is processed twice because the consumer restarted and reprocessed the same event. The customer is double-charged.

Fix: Make operations idempotent:

public class PaymentEventHandler : IConsumer<OrderCreatedEvent>
{
public async Task Consume(ConsumeContext<OrderCreatedEvent> context)
{
var idempotencyKey = $"payment:{context.Message.OrderId}";

// Check if already processed
var existing = await _dbContext.ProcessedPayments
.FirstOrDefaultAsync(p => p.IdempotencyKey == idempotencyKey);

if (existing != null)
{
_logger.LogInformation("Payment already processed for order {OrderId}", context.Message.OrderId);
return;
}

// Process payment
var result = await _paymentProvider.AuthorizeAsync(context.Message.OrderId, context.Message.Total);

// Record that we processed this
await _dbContext.ProcessedPayments.AddAsync(new ProcessedPayment
{
IdempotencyKey = idempotencyKey,
OrderId = context.Message.OrderId,
ProcessedAt = DateTime.UtcNow
});

await _dbContext.SaveChangesAsync();
}
}

Mistake 10: Skipping Monitoring and Observability

Without monitoring, you discover issues from user complaints, not alerts. A service is down; no one knows until the phone rings.

Fix: Instrument services with Application Insights or Datadog:

// Log critical operations
_logger.LogInformation("Order {OrderId} created by customer {CustomerId}", order.Id, order.CustomerId);

// Track custom metrics
_telemetry.TrackEvent("OrderCreated", new Dictionary<string, string>
{
{ "CustomerId", req.CustomerId.ToString() },
{ "Total", req.Total.ToString() }
});

// Track exceptions
try
{
await _service.ProcessAsync();
}
catch (Exception ex)
{
_telemetry.TrackException(ex);
throw;
}

Set up alerts for error rates, latency, and resource usage. Monitor database query performance, message broker lag, and circuit breaker state.

Key Takeaways

  • Do not share databases between services; each service owns its data.
  • Always set timeouts on synchronous calls; use circuit breakers to prevent cascading failures.
  • Design for eventual consistency; do not expect ACID transactions across services.
  • Start simple; add complexity only when justified by real requirements.
  • Use correlation IDs to trace requests across services; implement comprehensive monitoring.
  • Write integration tests; do not deploy without verifying service contracts.
  • Handle idempotency and transient failures; log all errors and monitor dead-letter queues.

Frequently Asked Questions

When should I move from a monolith to microservices?

When deployment becomes a bottleneck (large team, frequent releases), scaling is uneven (one feature is CPU-bound; others are I/O-bound), or team scaling is limited (can not add more developers without increasing conflict). For small teams (fewer than 10 engineers), a monolith is usually sufficient.

How many microservices are too many?

If you have more than one service per developer (e.g., 30 engineers, 40 services), you have too many. Services become hard to maintain; each team owns a service; complexity is high. Consolidate services to reduce cognitive load.

Should I use a service mesh (Istio, Linkerd)?

A service mesh is optional but valuable in production Kubernetes deployments. It handles resilience (retries, circuit breakers), security (mTLS), and observability (request tracing). For small deployments (fewer than 10 services), add the complexity later.

How do I decide between choreography and orchestration sagas?

For simple workflows (3–5 steps), choreography (event-driven) is simpler. For complex workflows with many conditional paths, orchestration is clearer. Start with choreography; move to orchestration if complexity grows.

What is the biggest risk of microservices?

Distributed systems complexity. You trade monolith complexity (single deployment, shared database, testing) for distributed systems complexity (eventual consistency, partial failures, operational overhead). Understand the trade-off before committing.

Further Reading