microservices with dotnet core

Microservices in .NET Made Easy |Expert Strategies for Handling Challenges

Is It Vritra - SDE I
9 min readSep 2, 2024

--

For .NET developers, Imagine you’re building a big, complex software system. Now, think about breaking that system into smaller, independent parts that work together. That’s the essence of microservices. For .NET developers, C# is an excellent tool for creating these microservices. But moving from a big, all-in-one system (often called a Monolith) to lots of small services isn’t easy.

In this article, we’ll explore how to build microservices using C#, looking at common problems and smart ways to solve them.

1. Understanding Microservices in the .NET

Think of microservices as a team of specialists, each good at one thing, working together to run a business. In software terms, each microservice is a small, independent program that does one job well. These services talk to each other using clear, well-defined methods, often through APIs (Application Programming Interfaces).

C# offers some great features for building microservices:

Strong typing and compile-time checks

Asynchronous programming with async/await

Dependency Injection

Cross-platform capabilities

But changing from a big, single program to many small services can be tricky. Let’s look at some common challenges and how to handle them

2. Common Challenges in Building Microservices

Making Things Too Complicated

When developers start working with microservices, they often make too many small services. This can cause more problems than it solves.

The “Too Many Services” Problem

Let’s say we’re building a system to manage user information. We might end up with something like this:

// UserService.cs
public class UserService
{
public async Task<User> GetUserAsync(int userId) { /* ... */ }
}

// UserAddressService.cs
public class UserAddressService
{
public async Task<Address> GetUserAddressAsync(int userId) { /* ... */ }
}

// UserPreferencesService.cs
public class UserPreferencesService
{
public async Task<Preferences> GetUserPreferencesAsync(int userId) { /* ... */ }
}

This might seem like a good idea at first because it separates different aspects of user data. However, it can lead to several problems:

  • Performance issues: The program needs to make many calls to different services to get complete user information, which can slow things down.
  • Data consistency challenges: It becomes harder to ensure that all user-related data is consistent across different services.
  • Increased complexity: Understanding how all the user information fits together becomes more difficult, both for developers and for the system itself.

A Better Way: Smart Service Design with Domain-Driven Design (DDD)

Instead of splitting everything into tiny services, it’s often better to keep related things together. This is where Domain-Driven Design (DDD) comes in handy.

Domain-Driven Design is an approach to software development that focuses on understanding and modeling the business domain (the specific subject area or industry that the software is designed to support). In DDD:

  • We identify “bounded contexts,” which are specific areas of the business with clear boundaries.
  • We create “aggregates,” which are clusters of related entities and value objects treated as a single unit.

Using DDD principles, we might redesign our user service like this:

// UserService.cs
public class UserService
{
public async Task<User> GetUserAsync(int userId) { /* ... */ }
public async Task<Address> GetUserAddressAsync(int userId) { /* ... */ }
public async Task<Preferences> GetUserPreferencesAsync(int userId) { /* ... */ }
}

Here’s why this approach is better:

  1. It keeps related functionality together, treating the user and their associated data as a single “aggregate” within the “user management” bounded context.
  2. It reduces the number of service-to-service calls needed to get complete user information.
  3. It’s easier to maintain data consistency within the user domain.

We should only split services when it really makes sense, such as:

  • When different teams need to work on different parts of the system independently.
  • When certain parts of the system need to scale differently from others.
  • When there’s a clear separation in the business domain that justifies a separate service.

2.2 Keeping Data Consistent

In a system with many services, it’s challenging to keep all the data consistent. This is especially true when one action needs to update data in multiple services.

The Problem with Updating Multiple Services at Once

Imagine we’re building an online store. When a customer places an order, we need to update both the order service and the inventory service. We might try something like this:

// OrderService.cs
public class OrderService
{
public async Task<bool> PlaceOrderAsync(Order order)
{
// Update order
await _orderRepository.CreateOrderAsync(order);

// Update inventory in another service
var inventoryUpdated = await _inventoryService.UpdateInventoryAsync(order.Items);

if (!inventoryUpdated)
{
// Cancel the order if inventory update fails
await _orderRepository.DeleteOrderAsync(order.Id);
return false;
}

return true;
}
}

This approach, often called a distributed transaction, has several problems:

  1. Tight coupling: It connects two separate services closely, making them dependent on each other.
  2. Lack of resilience: If the inventory service is temporarily unavailable, the entire order process fails.
  3. Potential for inconsistency: If the inventory update succeeds but the order cancellation fails, the system ends up in an inconsistent state.
  4. Scalability issues: This approach doesn’t work well when the system grows larger and more complex, involving more services.

A Smarter Approach: Using Events and Eventual Consistency

Instead of trying to update everything at once, we can use a system where services tell each other about changes through events. This approach embraces the concept of “eventual consistency,” where we accept that the system might be briefly inconsistent but will become consistent over time.

Here’s how it might look:

// OrderService.cs
public class OrderService
{
private readonly IEventBus _eventBus;

public async Task<bool> PlaceOrderAsync(Order order)
{
await _orderRepository.CreateOrderAsync(order);

// Tell other services about the new order
await _eventBus.PublishAsync(new OrderPlacedEvent(order));

return true;
}
}

// InventoryService.cs
public class InventoryService
{
[EventHandler]
public async Task HandleOrderPlacedEvent(OrderPlacedEvent @event)
{
await UpdateInventoryAsync(@event.Order.Items);
// If this fails, we can try again later without affecting the order
}
}

This event-based approach offers several advantages:

  1. Loose coupling: Services are more independent, making them easier to change and maintain.
  2. Improved resilience: Services can keep working even if other parts are temporarily down. The inventory update can happen later if the service is unavailable at the time of order placement.
  3. Better scalability: This pattern works well as the system grows, allowing for more complex workflows across multiple services.
  4. Clear audit trail: Events provide a record of what happened in the system, which can be useful for debugging and auditing.

To implement this effectively:

  • Use a robust message broker like RabbitMQ, Apache Kafka, or Azure Service Bus to manage these events reliably.
  • Design your event handlers to be idempotent, meaning they can safely handle the same event multiple times without causing issues. This is important for reliability in distributed systems.
  • Consider using the Outbox Pattern, where events are first saved to the service’s database before being published, ensuring that events are not lost even if the message broker is temporarily unavailable.

2.3 Handling Service-to-Service Communication

When building microservices, how these services talk to each other is crucial. There are two main types of communication: synchronous and asynchronous.

Synchronous Communication

Synchronous communication is when one service calls another and waits for a response. This is often done using HTTP requests and REST APIs. Here’s an example:

public class OrderService
{
private readonly HttpClient _httpClient;

public async Task<InventoryStatus> CheckInventoryAsync(int productId)
{
var response = await _httpClient.GetAsync($"http://inventory-service/products/{productId}");
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<InventoryStatus>();
}
}

Pros of synchronous communication:

  • It’s simple to understand and implement.
  • It provides immediate consistency (you get the latest data right away).

Cons:

  • If the called service is slow or down, it can slow down or break the calling service.
  • It can lead to tight coupling between services.

Asynchronous Communication

Asynchronous communication is when services send messages to each other without waiting for an immediate response. This is often done using message queues or event streams. Here’s an example using Azure Service Bus:

public class OrderService
{
private readonly ServiceBusClient _client;
private readonly ServiceBusSender _sender;

public async Task NotifyOrderShippedAsync(Order order)
{
var message = new ServiceBusMessage(JsonSerializer.Serialize(new OrderShippedEvent(order.Id)));
await _sender.SendMessageAsync(message);
}
}

Pros of asynchronous communication:

  • It’s more resilient to service failures.
  • It allows for better scalability and performance under high load.
  • It supports loose coupling between services.

Cons:

  • It can be more complex to implement and debug.
  • It may lead to eventual consistency, which can be tricky to manage.

In practice, most microservices architectures use a combination of both synchronous and asynchronous communication, choosing the appropriate method based on the specific needs of each interaction.

3. Monitoring and Observability

As your microservices architecture grows, it becomes crucial to have good monitoring and observability practices in place. This helps you understand how your system is performing and quickly identify and fix issues.

Distributed Tracing

Distributed tracing helps you follow a request as it moves through different services in your system. We will learn more about it in upcoming articles

Here’s an example using OpenTelemetry, a popular open-source observability framework:

public class OrderService
{
private readonly TracerProvider _tracerProvider;

public async Task<Order> PlaceOrderAsync(OrderRequest request)
{
using var activity = _tracerProvider.GetTracer("OrderService").StartActivity("PlaceOrder");
activity?.SetTag("orderId", request.OrderId);

// Process the order...

return order;
}
}

Centralized Logging

Centralized logging collects logs from all your services in one place. This makes it easier to debug issues that span multiple services. Here’s an example using Serilog to log to Elasticsearch:

public class Startup
{
public void ConfigureServices(IServiceCollection services)
{
Log.Logger = new LoggerConfiguration()
.WriteTo.Elasticsearch(new ElasticsearchSinkOptions(new Uri("http://localhost:9200")))
.CreateLogger();

services.AddLogging(loggingBuilder => loggingBuilder.AddSerilog(dispose: true));
}
}

Health Checks

Health checks help you monitor the status of your services and their dependencies. ASP.NET Core provides built-in support for health checks:

public class Startup
{
public void ConfigureServices(IServiceCollection services)
{
services.AddHealthChecks()
.AddCheck("database_check", () =>
{
// Check database connection
return HealthCheckResult.Healthy();
});
}

public void Configure(IApplicationBuilder app)
{
app.UseHealthChecks("/health");
}
}

Conclusion

Building microservices with C# and .NET offers many benefits, but it also comes with its own set of challenges. By understanding these challenges and applying best practices like:

  • Using Domain-Driven Design for smart service boundaries
  • Implementing event-driven architecture for better resilience and scalability
  • Choosing the right communication patterns for different scenarios
  • Implementing robust monitoring and observability solutions

You can create a microservices architecture that is scalable, maintainable, and resilient. Remember, the goal is not to create the smallest possible services, but to create services that are just the right size to solve your specific business problems effectively.

As you continue your microservices journey, keep learning and adapting. The field is constantly evolving, and what works best for your system may change over time.

Add-Ons Here — [ Outbox Pattern ]

So you reached here Fellas! here is the explaination for Outbox Pattern
So the Outbox Pattern addresses a common problem in distributed systems:

ensuring that a database transaction and the publishing of an event happen together, as if they were a single, atomic operation

Here’s a more detailed explanation:

Problem

Imagine you’re processing an order. You need to:

a) Save the order to the database

b) Publish an “OrderCreated” event to a message broker [ like RabbitMQ ]

If you do these as separate operations, you might run into issues:

  • What if the database save succeeds, but the event publish fails?
  • What if the event is published, but then the database transaction is rolled back?

These scenarios can lead to inconsistencies between your database and the events in your system.

How the Outbox Pattern Works ?

The Outbox Pattern solves this by introducing these steps:

a) Start a database transaction

b) Save the main entity (e.g. the Order) to its table

c) Save the event details to an “outbox” table in the same database

d) Commit the transaction

e) In a separate process, read events from the outbox table and publish them to the message broker

f) Once an event is successfully published, mark it as processed in the outbox table

let say -

public class OrderService
{
private readonly DbContext _dbContext;
private readonly IEventPublisher _eventPublisher;

public async Task CreateOrderAsync(Order order)
{
using (var transaction = await _dbContext.Database.BeginTransactionAsync())
{
try
{
// Save the order
_dbContext.Orders.Add(order);

// Create the event
var orderCreatedEvent = new OrderCreatedEvent(order.Id, order.CustomerId, order.TotalAmount);

// Save the event to the outbox
_dbContext.OutboxEvents.Add(new OutboxEvent
{
EventType = "OrderCreated",
EventData = JsonSerializer.Serialize(orderCreatedEvent),
CreatedAt = DateTime.UtcNow
});

// Commit the transaction
await _dbContext.SaveChangesAsync();
await transaction.CommitAsync();
}
catch
{
await transaction.RollbackAsync();
throw;
}
}
}
}

// In a separate background process:
public class OutboxProcessor
{
private readonly DbContext _dbContext;
private readonly IEventPublisher _eventPublisher;

public async Task ProcessOutboxEventsAsync()
{
var unprocessedEvents = await _dbContext.OutboxEvents
.Where(e => !e.ProcessedAt.HasValue)
.ToListAsync();

foreach (var outboxEvent in unprocessedEvents)
{
await _eventPublisher.PublishAsync(outboxEvent.EventType, outboxEvent.EventData);

outboxEvent.ProcessedAt = DateTime.UtcNow;
await _dbContext.SaveChangesAsync();
}
}
}

so it ,

  • Ensures data consistency: The database transaction and event creation are atomic.
  • Resilience: If the message broker is down, events are safely stored and can be published later.
  • Ordered event publishing: Events are published in the order they were created.

But keep in mind

  • You need to implement a reliable process to read from the outbox and publish events.
  • Consider how to handle failed event publications (e.g., retry logic).
  • Think about how long to keep processed events in the outbox table

My Seniors told me to use it when,

You need strong consistency between your database state and published events.

Your system must be resilient to message broker outages.

You want to ensure that events are published in the exact order they were created

In the next sections, we’ll delve into other crucial aspects of microservices development in C#, including service communication patterns, resilience strategies, and advanced monitoring techniques.

Stay tuned for more in-depth insights and practical examples that will elevate your microservices architecture to the next level!!

--

--