Skip to content

Batch Command Execution Design

Overview

This document outlines design options for executing multiple commands in a single API request, with support for transactional execution grouped by aggregate root.

Core Concept: Aggregate-Based Batching

Commands targeting the same aggregate root should be executed sequentially within a single transaction to maintain consistency and proper event ordering.

Commands: [
  CreateUser(id: 123),
  UpdateUserEmail(id: 123),
  CreateUser(id: 456),
  UpdateUserStatus(id: 123),
  UpdateUserRoles(id: 456)
]

Grouped Execution:
  Transaction 1 (Aggregate 123):
    - CreateUser(id: 123)
    - UpdateUserEmail(id: 123)
    - UpdateUserStatus(id: 123)

  Transaction 2 (Aggregate 456):
    - CreateUser(id: 456)
    - UpdateUserRoles(id: 456)

Design Options

Option 1: Simple Batch Endpoint with Aggregate Grouping

API Contract:

{
  "requestId": "batch-001",
  "commands": [
    {
      "commandType": "CreateUser",
      "expectedVersion": 0,
      "command": {
        "aggregateRootId": "550e8400-e29b-41d4-a716-446655440001",
        "email": "user1@example.com",
        "firstName": "User",
        "lastName": "One"
      }
    },
    {
      "commandType": "UpdateUserStatus",
      "expectedVersion": 1,
      "command": {
        "aggregateRootId": "550e8400-e29b-41d4-a716-446655440001",
        "status": "Active"
      }
    }
  ]
}

Implementation Approach:

public async Task<BatchExecutionResult> ExecuteBatchAsync(BatchCommandRequest request)
{
    // Group commands by aggregate root ID
    var commandGroups = request.Commands
        .GroupBy(cmd => ExtractAggregateRootId(cmd))
        .ToList();

    var results = new List<AggregateExecutionResult>();

    // Execute each aggregate's commands in its own transaction
    foreach (var group in commandGroups)
    {
        var aggregateResult = await ExecuteAggregateCommandsAsync(
            group.Key,
            group.ToList()
        );
        results.Add(aggregateResult);
    }

    return new BatchExecutionResult
    {
        Success = results.All(r => r.Success),
        AggregateResults = results
    };
}

private async Task<AggregateExecutionResult> ExecuteAggregateCommandsAsync(
    Guid aggregateId,
    List<CommandWrapper> commands)
{
    using var session = documentStore.OpenSession();
    var events = new List<IDomainEvent>();

    try
    {
        foreach (var commandWrapper in commands)
        {
            var command = DeserializeCommand(commandWrapper);
            var commandEvents = await commandExecutor.ExecuteAsync(
                command,
                commandWrapper.ExpectedVersion,
                session
            );
            events.AddRange(commandEvents);
        }

        await session.SaveChangesAsync();

        return new AggregateExecutionResult
        {
            AggregateRootId = aggregateId,
            Success = true,
            EventCount = events.Count,
            ProcessedCommands = commands.Count
        };
    }
    catch (Exception ex)
    {
        return new AggregateExecutionResult
        {
            AggregateRootId = aggregateId,
            Success = false,
            Error = ex.Message,
            ProcessedCommands = 0
        };
    }
}

Option 2: Polymorphic Command Array

API Contract:

{
  "requestId": "batch-002",
  "commands": [
    {
      "commandType": "CreateUser",
      "aggregateRootId": "550e8400-e29b-41d4-a716-446655440001",
      "email": "user@example.com",
      "expectedVersion": 0
    },
    {
      "commandType": "UpdateUserStatus",
      "aggregateRootId": "550e8400-e29b-41d4-a716-446655440001",
      "status": "Active",
      "expectedVersion": 1
    }
  ]
}

Advantages: - Commands are self-contained with all data - Easier to generate from client code - Consistent with query pattern

Disadvantages: - Requires polymorphic deserialization - Mixes metadata with command data - More complex JSON converter needed

Option 3: Explicit Aggregate Batches

API Contract:

{
  "requestId": "batch-003",
  "aggregateBatches": [
    {
      "aggregateRootId": "550e8400-e29b-41d4-a716-446655440001",
      "commands": [
        {
          "commandType": "CreateUser",
          "expectedVersion": 0,
          "data": {
            "email": "user@example.com",
            "firstName": "Test"
          }
        },
        {
          "commandType": "UpdateUserStatus",
          "expectedVersion": 1,
          "data": {
            "status": "Active"
          }
        }
      ]
    }
  ]
}

Advantages: - Explicit about transaction boundaries - Clear aggregate grouping - Easier to validate

Disadvantages: - Client must group commands - More complex request structure

Transaction Strategies

  • Each aggregate's commands execute in a separate transaction
  • Maintains aggregate consistency
  • Allows partial success (some aggregates succeed, others fail)

2. Global Transaction

  • All commands across all aggregates in one transaction
  • All-or-nothing execution
  • Risk of long-running transactions and conflicts

3. Command-Level Transactions

  • Each command in its own transaction
  • Maximum isolation but no consistency within aggregate
  • Not recommended for related commands

Error Handling

Response Structure

{
  "requestId": "batch-001",
  "success": false,
  "aggregateResults": [
    {
      "aggregateRootId": "550e8400-e29b-41d4-a716-446655440001",
      "success": true,
      "eventsGenerated": 3,
      "commandsProcessed": 2
    },
    {
      "aggregateRootId": "550e8400-e29b-41d4-a716-446655440002",
      "success": false,
      "error": "Concurrency conflict",
      "errorCode": "CONCURRENCY_CONFLICT",
      "failedAtCommand": 1
    }
  ]
}

Implementation Considerations

1. Command Ordering

  • Commands for same aggregate must maintain order
  • Cross-aggregate commands can execute in parallel
  • Consider dependencies between aggregates

2. Version Conflicts

  • Each command specifies expected version
  • Conflict in one command fails the aggregate's transaction
  • Other aggregates can still succeed

3. Performance

  • Parallel execution of different aggregate batches
  • Connection pool considerations
  • Maximum batch size limits

4. Audit Trail

public class BatchExecutionAudit
{
    public Guid BatchId { get; set; }
    public DateTime ExecutedAt { get; set; }
    public string ExecutedBy { get; set; }
    public int TotalCommands { get; set; }
    public int SuccessfulAggregates { get; set; }
    public int FailedAggregates { get; set; }
    public TimeSpan ExecutionDuration { get; set; }
}

Example Usage Scenarios

1. Bulk User Import

// Create multiple users with initial settings
var commands = users.SelectMany(user => new[]
{
    new CreateUserCommand { ... },
    new AssignUserRolesCommand { ... },
    new SetUserPreferencesCommand { ... }
});

2. Complex Business Operation

// Transfer player between teams
var commands = new[]
{
    new RemovePlayerFromTeamCommand { TeamId = oldTeam, PlayerId = player },
    new AddPlayerToTeamCommand { TeamId = newTeam, PlayerId = player },
    new UpdatePlayerContractCommand { PlayerId = player, TeamId = newTeam }
};

3. Saga Compensation

// Rollback a complex operation
var compensatingCommands = failedOperations
    .Select(op => CreateCompensatingCommand(op))
    .ToList();

Security Considerations

  1. Authorization: Check permissions for all command types upfront
  2. Rate Limiting: Consider impact of batch operations
  3. Resource Limits: Maximum commands per batch
  4. Validation: Validate all commands before execution starts

Future Enhancements

  1. Dependency Graph: Support for inter-aggregate dependencies
  2. Parallel Execution: Execute independent aggregates in parallel
  3. Streaming Results: Return results as aggregates complete
  4. Retry Logic: Automatic retry for transient failures
  5. Circuit Breaker: Fail fast if system is overloaded

Configuration Options

public class BatchCommandOptions
{
    public int MaxCommandsPerBatch { get; set; } = 100;
    public int MaxAggregatesPerBatch { get; set; } = 10;
    public TimeSpan BatchTimeout { get; set; } = TimeSpan.FromSeconds(30);
    public bool AllowPartialSuccess { get; set; } = true;
    public bool EnableParallelExecution { get; set; } = false;
}

Decision Matrix

Approach Complexity Flexibility Performance Transaction Control
Simple Batch Low Medium Good Automatic
Polymorphic High High Good Automatic
Explicit Batches Medium Low Best Explicit
Single Command Lowest Lowest Varies Simple

Recommendation

For the LBS Foundry system, recommend Option 1 (Simple Batch with Aggregate Grouping) because:

  1. Maintains aggregate consistency automatically
  2. Simple API contract
  3. Clear error reporting per aggregate
  4. Supports partial success
  5. Easy to implement and understand
  6. Natural transaction boundaries

The implementation would: - Group commands by AggregateRootId - Execute each group in a transaction - Return detailed results per aggregate - Support both full and partial success modes