Thursday, April 17, 2025

Microservices -Saga Pattern & CQRS in .NET Core with Azure

 

Deep Dive: Saga Pattern & CQRS in .NET Core with Azure

Let’s break down Saga Pattern and CQRS in detail, focusing on .NET Core implementations and Azure services.


1. Saga Pattern: Distributed Transactions

Problem

In microservices, a single business transaction (e.g., "Place Order") spans multiple services (Order, Payment, Inventory). Traditional ACID transactions don’t work across services.

Solution: Saga

A saga is a sequence of local transactions, where each step has a compensating action (undo operation) if something fails.


Two Saga Approaches

A. Choreography (Event-Driven)

  • How it works: Services emit events (e.g., OrderPlacedPaymentProcessed) and react to them.

  • Pros: Decentralized, no single point of failure.

  • Cons: Complex to debug (event spaghetti).

B. Orchestration (Central Coordinator)

  • How it works: A central orchestrator (e.g., Azure Durable Functions) controls the flow.

  • Pros: Easier to manage, explicit workflow.

  • Cons: Orchestrator can become a bottleneck.


Implementing Saga in .NET Core + Azure

Option 1: Choreography with Azure Service Bus

csharp
Copy
// OrderService (Publishes OrderPlaced event)
await _serviceBusSender.SendMessageAsync(new OrderPlacedEvent { OrderId = orderId });

// PaymentService (Listens to OrderPlaced, processes payment, then emits PaymentProcessed)
_serviceBusProcessor.ProcessMessageAsync += async (args) => 
{
    var orderPlaced = args.Message.Body.ToObjectFromJson<OrderPlacedEvent>();
    await _paymentService.ProcessPayment(orderPlaced.OrderId);
    await _serviceBusSender.SendMessageAsync(new PaymentProcessedEvent { OrderId = orderPlaced.OrderId });
};

Option 2: Orchestration with Azure Durable Functions

csharp
Copy
[FunctionName("OrderSaga")]
public static async Task RunOrchestrator(
    [OrchestrationTrigger] IDurableOrchestrationContext context)
{
    var orderId = context.GetInput<string>();
    
    try {
        // Step 1: Reserve Inventory
        await context.CallActivityAsync("ReserveInventory", orderId);
        
        // Step 2: Process Payment
        await context.CallActivityAsync("ProcessPayment", orderId);
        
        // Step 3: Finalize Order
        await context.CallActivityAsync("FinalizeOrder", orderId);
    }
    catch (Exception) {
        // Compensating transactions
        await context.CallActivityAsync("CancelPayment", orderId);
        await context.CallActivityAsync("RestoreInventory", orderId);
    }
}

Azure Services Used

  • Choreography: Azure Service Bus / Event Grid.

  • Orchestration: Azure Durable Functions.

  • Compensation Logic: Stored in each service.


2. CQRS (Command Query Responsibility Segregation)

Problem

  • Reads and writes have different scalability needs.

  • Complex queries slow down write operations.

Solution: CQRS

  • Commands (Writes): Modify state (e.g., CreateOrder).

  • Queries (Reads): Fetch data (e.g., GetOrderDetails).

  • Separate Databases: Optimized for each operation.


Implementing CQRS in .NET Core + Azure

A. Simple CQRS with MediatR

csharp
Copy
// Command (Write)
public class CreateOrderCommand : IRequest<Guid>
{
    public string ProductId { get; set; }
}

public class CreateOrderHandler : IRequestHandler<CreateOrderCommand, Guid>
{
    public async Task<Guid> Handle(CreateOrderCommand request, CancellationToken ct)
    {
        var order = new Order { ProductId = request.ProductId };
        _dbContext.Orders.Add(order);
        await _dbContext.SaveChangesAsync(ct);
        return order.Id;
    }
}

// Query (Read)
public class GetOrderQuery : IRequest<OrderDto>
{
    public Guid OrderId { get; set; }
}

public class GetOrderHandler : IRequestHandler<GetOrderQuery, OrderDto>
{
    public async Task<OrderDto> Handle(GetOrderQuery request, CancellationToken ct)
    {
        return await _dbContext.Orders
            .Where(o => o.Id == request.OrderId)
            .ProjectTo<OrderDto>()
            .FirstOrDefaultAsync(ct);
    }
}

B. Advanced CQRS with Separate Read/Write Databases

  • Write DB (OLTP): Azure SQL (normalized schema).

  • Read DB (OLAP): Azure Cosmos DB (denormalized for fast queries).

  • Syncing Data: Use Azure Event Grid or Change Feed in Cosmos DB.

csharp
Copy
// When an order is created in SQL, publish an event to update Cosmos DB
_serviceBusSender.Publish(new OrderCreatedEvent { OrderId = order.Id });

Azure Services Used

  • Command DB: Azure SQL.

  • Query DB: Cosmos DB.

  • Event Syncing: Azure Service Bus / Event Grid.


Comparison: Saga vs. CQRS

AspectSagaCQRS
PurposeManage distributed transactionsSeparate read/write operations
Data ConsistencyEventual consistencyImmediate (write) + eventual (read)
Azure ToolsDurable Functions, Service BusCosmos DB, Event Grid, MediatR
ComplexityHigh (compensation logic)Medium (dual models)

When to Use Which?

  • Use Saga when you need transactions across microservices (e.g., e-commerce checkout).

  • Use CQRS when reads and writes have different scaling needs (e.g., dashboard analytics).


Final Architecture Example (Azure + .NET Core)

  1. Frontend → Azure API Management (Gateway).

  2. Commands → MediatR → Azure SQL.

  3. Events → Azure Service Bus → Saga (Durable Functions).

  4. Queries → Cosmos DB (denormalized).

  5. Monitoring → Application Insights.


Deep Dive into CQRS with .NET Core and Azure Implementations

CQRS (Command Query Responsibility Segregation) is a powerful pattern for separating read and write operations in microservices. Below is a detailed breakdown of CQRS, covering core concepts, implementation strategies, and real-world Azure + .NET Core examples.


1. Core Concepts of CQRS

What is CQRS?

  • Commands (Writes): Modify state (e.g., CreateOrderUpdateUser).

  • Queries (Reads): Retrieve data (e.g., GetOrderByIdListUsers).

  • Separate Models: Optimized for their purpose (e.g., normalized for writes, denormalized for reads).

Why Use CQRS?

✅ Scalability: Reads (often 80% of traffic) can scale independently.
✅ Performance: Optimized read models (e.g., materialized views).
✅ Flexibility: Different databases for reads vs. writes.
⚠ Complexity: Requires event syncing between models.


2. CQRS Implementation Strategies

A. Basic CQRS (Single Database)

  • Same database, but separate models (commands vs. queries).

  • Best for: Simple apps where eventual consistency is acceptable.

Example: MediatR in .NET Core

csharp
Copy
// Command (Write)
public record CreateProductCommand(string Name, decimal Price) : IRequest<Guid>;

public class CreateProductHandler : IRequestHandler<CreateProductCommand, Guid>
{
    private readonly AppDbContext _db;
    public CreateProductHandler(AppDbContext db) => _db = db;

    public async Task<Guid> Handle(CreateProductCommand cmd, CancellationToken ct)
    {
        var product = new Product { Name = cmd.Name, Price = cmd.Price };
        _db.Products.Add(product);
        await _db.SaveChangesAsync(ct);
        return product.Id;
    }
}

// Query (Read)
public record GetProductByIdQuery(Guid Id) : IRequest<ProductDto>;

public class GetProductByIdHandler : IRequestHandler<GetProductByIdQuery, ProductDto>
{
    private readonly AppDbContext _db;
    public GetProductByIdHandler(AppDbContext db) => _db = db;

    public async Task<ProductDto> Handle(GetProductByIdQuery query, CancellationToken ct)
        => await _db.Products
            .Where(p => p.Id == query.Id)
            .Select(p => new ProductDto(p.Id, p.Name, p.Price))
            .FirstOrDefaultAsync(ct);
}

B. Advanced CQRS (Separate Databases)

  • Write DB (OLTP): Optimized for transactions (e.g., Azure SQL).

  • Read DB (OLAP): Optimized for queries (e.g., Cosmos DB, Redis Cache).

  • Syncing Data: Use events (e.g., ProductCreated) to keep models in sync.

Example: Event-Driven CQRS with Azure Service Bus

csharp
Copy
// 1. Command Side (Write DB - Azure SQL)
public class ProductCommandHandler
{
    private readonly AppDbContext _db;
    private readonly IEventPublisher _publisher;

    public ProductCommandHandler(AppDbContext db, IEventPublisher publisher)
        => (_db, _publisher) = (db, publisher);

    public async Task<Guid> Handle(CreateProductCommand cmd)
    {
        var product = new Product { Name = cmd.Name, Price = cmd.Price };
        _db.Products.Add(product);
        await _db.SaveChangesAsync();

        // Publish event to update read model
        await _publisher.Publish(new ProductCreatedEvent(product.Id, product.Name, product.Price));
        return product.Id;
    }
}

// 2. Event Handler (Updates Read DB - Cosmos DB)
public class ProductCreatedEventHandler
{
    private readonly CosmosClient _cosmos;
    public ProductCreatedEventHandler(CosmosClient cosmos) => _cosmos = cosmos;

    public async Task Handle(ProductCreatedEvent @event)
    {
        var container = _cosmos.GetContainer("ProductsDB", "Products");
        await container.UpsertItemAsync(new ProductReadModel
        {
            Id = @event.Id,
            Name = @event.Name,
            Price = @event.Price
        });
    }
}

Azure Services Used

ComponentTechnology
Command DatabaseAzure SQL (EF Core)
Query DatabaseCosmos DB (optimized for reads)
Event BusAzure Service Bus / Event Grid
Event ProcessorAzure Functions (Serverless)

3. Optimizing Read Models

A. Materialized Views

  • Pre-computed query results stored for fast access.

  • Example:

    sql
    Copy
    -- In Azure SQL (for reporting)
    CREATE VIEW ActiveUsers AS
    SELECT Id, Name FROM Users WHERE IsActive = 1;

B. Caching (Redis)

  • Cache frequent queries (e.g., GetTopProducts).

  • Example: .NET Core + Azure Redis Cache

    csharp
    Copy
    public class CachedProductQueryHandler
    {
        private readonly IDatabase _cache;
        private readonly AppDbContext _db;
    
        public CachedProductQueryHandler(IConnectionMultiplexer redis, AppDbContext db)
            => (_cache, _db) = (redis.GetDatabase(), db);
    
        public async Task<ProductDto> Handle(GetProductByIdQuery query)
        {
            var cacheKey = $"product:{query.Id}";
            var cachedProduct = await _cache.StringGetAsync(cacheKey);
            
            if (cachedProduct.HasValue)
                return JsonSerializer.Deserialize<ProductDto>(cachedProduct!);
    
            var product = await _db.Products.FindAsync(query.Id);
            await _cache.StringSetAsync(cacheKey, JsonSerializer.Serialize(product), TimeSpan.FromMinutes(5));
            return product;
        }
    }

4. When to Use CQRS?

ScenarioRecommended Approach
Simple CRUD appBasic CQRS (MediatR)
High-read systems (e.g., dashboards)Separate read DB (Cosmos DB)
Real-time analyticsEvent Sourcing + CQRS
Microservices with eventual consistencyEvent-Driven CQRS

5. Full CQRS Architecture (Azure + .NET Core)

mermaid
Copy
flowchart LR
    Client -->|Commands| API[API Gateway]
    API -->|"CreateProduct"| Command[Command Handler]
    Command -->|"Save to SQL DB"| SQL[(Azure SQL)]
    Command -->|"Publish ProductCreated"| SB[Service Bus]
    SB -->|"Update Read Model"| Query[Query Handler]
    Query -->|"Save to Cosmos DB"| Cosmos[(Cosmos DB)]
    Client -->|"GetProduct"| API
    API -->|Query| Cosmos

6. Key Takeaways

  1. Start simple (MediatR) before scaling to separate databases.

  2. Use events (Service Bus/Event Grid) to sync read/write models.

  3. Optimize reads with caching (Redis) or materialized views.

  4. Azure Services:

    • Commands: Azure SQL + EF Core.

    • Queries: Cosmos DB + Redis Cache.

    • Events: Service Bus + Azure Functions.


Need a Working Example?

Check out this .NET CQRS Template:
🔗 GitHub: .NET CQRS with Azure


No comments:

Post a Comment