Introduction
Modern cloud-native applications demand scalability, resilience, and performance. Distributed architectures enable systems to meet those demands. But with great distribution comes great complexity. In this post, we’ll explore synchronous and asynchronous patterns in distributed systems and finish with caching patterns that help improve efficiency.
🧭 1. Synchronous Communication Patterns
Synchronous communication happens when a service waits for a response from another before continuing execution. This is often done over HTTP.
📦 Pattern: API Composition
Use when multiple services must be queried to fulfill a request.
Example: Aggregating Responses from Microservices
public class OrderAggregatorService(HttpClient httpClient)
{
public async Task<OrderDetailsDto> GetOrderDetailsAsync(Guid orderId)
{
var orderTask = httpClient.GetFromJsonAsync<OrderDto>($"http://orders/api/orders/{orderId}");
var customerTask = httpClient.GetFromJsonAsync<CustomerDto>($"http://customers/api/customers/{orderId}");
await Task.WhenAll(orderTask, customerTask);
return new OrderDetailsDto
{
Order = await orderTask,
Customer = await customerTask
};
}
}
Pros:
- Simpler to implement.
- Immediate feedback to the client.
Cons:
- Tight coupling.
- Latency grows with each additional service call.
🕊️ 2. Asynchronous Communication Patterns
These patterns rely on message queues, event brokers, and event-driven design. They enable decoupled, resilient services.
🧩 Pattern: Event-Driven Architecture
Services emit and subscribe to domain events via a message broker like RabbitMQ or Kafka.
Example: Order Service Publishing an Event
public class OrderService(IMessageBus messageBus)
{
public async Task PlaceOrderAsync(Order order)
{
// Save order to DB (omitted)
var orderPlaced = new OrderPlacedEvent(order.Id, order.CustomerId);
await messageBus.PublishAsync(orderPlaced);
}
}
Consumer: Inventory Service Listens
public class OrderPlacedHandler : IMessageHandler<OrderPlacedEvent>
{
public async Task HandleAsync(OrderPlacedEvent evt)
{
Console.WriteLine($"Reserving stock for Order {evt.OrderId}");
// Call internal logic to reserve inventory
}
}
Pros:
- Loose coupling.
- High scalability and resilience.
Cons:
- Harder to debug.
- Eventual consistency requires careful handling.
🧠 3. Caching Patterns
To reduce load on services and databases, caching plays a crucial role. Caching is typically combined with both sync and async patterns.
🎯 Pattern: Cache Aside (Lazy Loading)
Data is fetched from cache if available, otherwise loaded from source and cached.
Example with Redis and StackExchange.Redis
public class ProductService(IConnectionMultiplexer redis, HttpClient httpClient)
{
private readonly IDatabase _cache = redis.GetDatabase();
public async Task<ProductDto> GetProductAsync(Guid productId)
{
var key = $"product:{productId}";
var cached = await _cache.StringGetAsync(key);
if (cached.HasValue)
return JsonSerializer.Deserialize<ProductDto>(cached!);
var product = await httpClient.GetFromJsonAsync<ProductDto>($"http://products/api/products/{productId}");
if (product is not null)
{
await _cache.StringSetAsync(key, JsonSerializer.Serialize(product), TimeSpan.FromMinutes(30));
}
return product!;
}
}
🧯 Pattern: Write-Through Cache
Writes go through the cache, and the cache updates the database.
Example with MemoryCache
public class CustomerService(IMemoryCache memoryCache, ICustomerRepository db)
{
public async Task SaveCustomerAsync(Customer customer)
{
// Save to DB first
await db.SaveAsync(customer);
// Update cache immediately
memoryCache.Set($"customer:{customer.Id}", customer, TimeSpan.FromHours(1));
}
}
⚡ Pattern: Refresh Ahead
Preemptively refresh cache before expiry to avoid stale data or cache misses.
Example with BackgroundService
public class ProductCacheRefresher : BackgroundService
{
private readonly IProductRepository _repo;
private readonly IMemoryCache _cache;
public ProductCacheRefresher(IProductRepository repo, IMemoryCache cache)
{
_repo = repo;
_cache = cache;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
var allProducts = await _repo.GetAllAsync();
foreach (var product in allProducts)
{
_cache.Set($"product:{product.Id}", product, TimeSpan.FromMinutes(30));
}
await Task.Delay(TimeSpan.FromMinutes(25), stoppingToken);
}
}
}
🎁 Summary
| Topic | Pattern | Benefit | Trade-Off |
|---|---|---|---|
| Synchronous Communication | API Composition | Simpler orchestration | Tight coupling, latency |
| Asynchronous Communication | Event-Driven | Loose coupling, scalability | Eventual consistency, debugging |
| Caching | Cache-Aside | Low latency reads | Slight cache miss penalty |
| Write-Through | Always-fresh reads | Write overhead | |
| Refresh-Ahead | Prevents cache stampedes | Background complexity |
✅ Final Thoughts
Choosing the right architecture pattern depends on your latency, resilience, and consistency needs. In many systems, a hybrid approach—mixing synchronous and asynchronous patterns with smart caching—is the best path forward.
.NET 9 brings performance improvements and minimal APIs that make implementing these patterns more productive than ever. When combined with distributed tracing and metrics, you’re well on your way to building robust cloud-native applications.
Views: 9
