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¶
1. Aggregate-Level Transactions (Recommended)¶
- 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¶
- Authorization: Check permissions for all command types upfront
- Rate Limiting: Consider impact of batch operations
- Resource Limits: Maximum commands per batch
- Validation: Validate all commands before execution starts
Future Enhancements¶
- Dependency Graph: Support for inter-aggregate dependencies
- Parallel Execution: Execute independent aggregates in parallel
- Streaming Results: Return results as aggregates complete
- Retry Logic: Automatic retry for transient failures
- 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:
- Maintains aggregate consistency automatically
- Simple API contract
- Clear error reporting per aggregate
- Supports partial success
- Easy to implement and understand
- 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