Core Concepts - PogovorovDaniil/Requestum GitHub Wiki
Understanding the core concepts of Requestum is essential for building clean, maintainable applications. This guide explains the fundamental building blocks of the library.
Requestum is built around the Command Query Responsibility Segregation (CQRS) pattern, which separates operations into three distinct categories:
- Commands - Operations that modify state
- Queries - Operations that retrieve data
- Events - Notifications about state changes
This separation provides:
- Clarity - Intent is clear from the interface used
- Maintainability - Easier to understand and modify code
- Scalability - Different scaling strategies for reads and writes
- Testability - Simpler unit tests with focused responsibilities
Commands represent actions that change the system state. They do not return values.
public interface ICommand : IBaseRequest;Key Characteristics:
- Represents an action (imperative naming:
CreateUser,DeleteOrder) - Modifies system state
- Does not return a value
- Has exactly one handler
- Use when you need to change something
Example:
public record CreateUserCommand(string Name, string Email) : ICommand;
public record DeleteOrderCommand(int OrderId) : ICommand;
public record UpdateProductPriceCommand(int ProductId, decimal NewPrice) : ICommand;Queries represent requests for data. They do not modify state and return typed responses.
public interface IQuery<TResponse> : IBaseRequest;Key Characteristics:
- Represents a question (descriptive naming:
GetUser,SearchProducts) - Does not modify state
- Returns a typed response
- Has exactly one handler
- Use when you need to read something
Example:
public record GetUserQuery(int UserId) : IQuery<UserDto>;
public record SearchProductsQuery(string SearchTerm) : IQuery<List<ProductDto>>;
public record GetOrderTotalQuery(int OrderId) : IQuery<decimal>;Events represent notifications about something that has happened in the system.
public interface IEventMessage : IBaseRequest;Key Characteristics:
- Represents a fact (past tense naming:
UserCreated,OrderPlaced) - Notification of a state change that already occurred
- Does not return a value
- Can have zero or more receivers
- Use when you need to notify about something
Example:
public record UserCreatedEvent(int UserId, string Name, string Email) : IEventMessage;
public record OrderPlacedEvent(int OrderId, decimal Total) : IEventMessage;
public record ProductPriceChangedEvent(int ProductId, decimal OldPrice, decimal NewPrice) : IEventMessage;Command handlers execute commands and modify system state.
Synchronous:
public interface ICommandHandler<TCommand> : IBaseHandler<TCommand>
where TCommand : ICommand
{
void Execute(TCommand command);
}Asynchronous:
public interface IAsyncCommandHandler<TCommand> : IBaseHandler<TCommand>
where TCommand : ICommand
{
Task ExecuteAsync(TCommand command, CancellationToken cancellationToken = default);
}When to use:
- Sync: Simple in-memory operations, validation
- Async: Database operations, API calls, file I/O
Query handlers process queries and return data.
Synchronous:
public interface IQueryHandler<TQuery, TResponse> : IBaseHandler<TQuery>
where TQuery : IQuery<TResponse>
{
TResponse Handle(TQuery query);
}Asynchronous:
public interface IAsyncQueryHandler<TQuery, TResponse> : IBaseHandler<TQuery>
where TQuery : IQuery<TResponse>
{
Task<TResponse> HandleAsync(TQuery query, CancellationToken cancellationToken = default);
}When to use:
- Sync: In-memory calculations, cached data access
- Async: Database queries, external API calls
Event receivers respond to published events.
Synchronous:
public interface IEventMessageReceiver<TMessage> : IBaseHandler<TMessage>
where TMessage : IEventMessage
{
void Receive(TMessage message);
}Asynchronous:
public interface IAsyncEventMessageReceiver<TMessage> : IBaseHandler<TMessage>
where TMessage : IEventMessage
{
Task ReceiveAsync(TMessage message, CancellationToken cancellationToken = default);
}Key Differences from Handlers:
- Multiple receivers allowed per event type
- Fire-and-forget semantics
- Decoupled from the event publisher
Client Code
↓
IRequestum.ExecuteAsync()
↓
Middleware Pipeline (if configured)
↓
IAsyncCommandHandler.ExecuteAsync()
↓
Database/External Services
Client Code
↓
IRequestum.HandleAsync()
↓
Middleware Pipeline (if configured)
↓
IAsyncQueryHandler.HandleAsync()
↓
Database/External Services
↓
Return TResponse
Client Code
↓
IRequestum.PublishAsync()
↓
All IAsyncEventMessageReceiver.ReceiveAsync()
↓
Each receiver processes independently
Following consistent naming conventions makes your code more readable and maintainable.
Use imperative verbs (actions):
CreateUserUpdateOrderDeleteProductSendEmailCalculateTotal
Use descriptive questions:
GetUserByIdSearchProductsListOrdersFindCustomerCalculateDiscount
Use past tense (facts):
UserCreatedOrderUpdatedProductDeletedEmailSentPaymentProcessed
Commands typically don't return values, but if you need to return something (like an ID), consider using events instead:
// ❌ Avoid this
public record CreateUserCommand : IQuery<int>; // Returns user ID
// ✅ Better approach
public record CreateUserCommand(string Name, string Email) : ICommand;
public record UserCreatedEvent(int UserId, string Name, string Email) : IEventMessage;
// In handler:
public async Task ExecuteAsync(CreateUserCommand command, CancellationToken ct)
{
var user = await _repository.CreateAsync(command, ct);
await _requestum.PublishAsync(new UserCreatedEvent(user.Id, user.Name, user.Email), ct);
}Always return a specific response type:
// ✅ Good - specific response
public record GetUserQuery(int Id) : IQuery<UserDto>;
// ✅ Good - collection response
public record SearchUsersQuery(string Term) : IQuery<List<UserDto>>;
// ❌ Avoid - too generic
public record GetDataQuery(string Type) : IQuery<object>;Each handler should do one thing well:
// ✅ Good - focused responsibility
public class CreateUserHandler : IAsyncCommandHandler<CreateUserCommand>
{
public async Task ExecuteAsync(CreateUserCommand command, CancellationToken ct)
{
// Only creates user
await _repository.AddAsync(new User(command.Name, command.Email), ct);
}
}
// ❌ Bad - multiple responsibilities
public class CreateUserHandler : IAsyncCommandHandler<CreateUserCommand>
{
public async Task ExecuteAsync(CreateUserCommand command, CancellationToken ct)
{
// Creates user
var user = await _repository.AddAsync(new User(command.Name, command.Email), ct);
// Sends email (should be in event receiver)
await _emailService.SendWelcomeEmail(user.Email);
// Updates analytics (should be in event receiver)
await _analytics.TrackSignup(user.Id);
}
}Keep commands, queries, and events separate:
// ✅ Good - clear separation
public record CreateUserCommand(string Name, string Email) : ICommand;
public record GetUserQuery(int Id) : IQuery<UserDto>;
public record UserCreatedEvent(int UserId, string Name) : IEventMessage;
// ❌ Bad - mixed concerns
public record UserCommand(int? Id, string Name, string Email, bool IsQuery) : ICommand;Use records or immutable classes for requests:
// ✅ Good - immutable record
public record CreateUserCommand(string Name, string Email) : ICommand;
// ✅ Good - immutable class
public class CreateUserCommand : ICommand
{
public required string Name { get; init; }
public required string Email { get; init; }
}
// ❌ Bad - mutable properties
public class CreateUserCommand : ICommand
{
public string Name { get; set; }
public string Email { get; set; }
}Now that you understand the core concepts, explore:
- Commands - Detailed guide on working with commands
- Queries - Detailed guide on working with queries
- Events - Detailed guide on working with events
- Middleware Pipeline - Building cross-cutting concerns