This document serves as the "how we do things here" guide for developers contributing to the Wallet Framework project. It explains the coding standards, design patterns, and conventions used throughout the codebase.
- Result Pattern
- Value Objects
- CQRS (Command Query Responsibility Segregation)
- Validation with FluentValidation
- Project Directory Structure
In the Wallet Framework, we use the Result Pattern instead of throwing exceptions for business logic failures. This approach provides several benefits:
- Exceptions are for exceptional cases: Exceptions should be reserved for truly exceptional situations (system failures, null references, etc.), not for expected business rule violations
- Type-safe error handling: The Result pattern makes errors explicit in the method signature, forcing callers to handle them
- Better flow control: Early returns with Result values make code more readable and maintainable
- No performance overhead: Unlike exceptions, Result pattern doesn't have stack unwinding overhead
- Composable: Results can be easily chained and combined
The Result pattern is implemented in Result.cs and Error.cs:
// Result for void operations
public class Result
{
public bool IsSuccess { get; }
public bool IsFailure => !IsSuccess;
public Error Error { get; }
protected Result(bool isSuccess, Error error)
{
// Validation: success must have Error.None, failure must have an error
if (isSuccess && error != Error.None)
throw new InvalidOperationException();
if (!isSuccess && error == Error.None)
throw new InvalidOperationException();
IsSuccess = isSuccess;
Error = error;
}
public static Result Success() => new(true, Error.None);
public static Result Failure(Error error) => new(false, error);
}
// Result<T> for operations returning a value
public class Result<T> : Result
{
private readonly T? _value;
public T Value => IsSuccess
? _value!
: throw new InvalidOperationException("The value of a failure result can not be accessed.");
protected internal Result(T? value, bool isSuccess, Error error)
: base(isSuccess, error)
{
_value = value;
}
public static Result<T> Success(T value) => new(value, true, Error.None);
public static new Result<T> Failure(Error error) => new(default, false, error);
public static Result<T> Create(T? value)
{
return value is not null
? Success(value)
: Failure(Error.NullValue);
}
}Errors are represented as immutable records with a code and message:
public sealed record Error(string Code, string Message)
{
public static readonly Error None = new(string.Empty, string.Empty);
public static readonly Error NullValue = new("Error.NullValue", "The specified result value is null.");
// Factory methods for common error types
public static Error NotFound(string name, object id) =>
new("NotFound", $"{name} with id '{id}' was not found.");
public static Error Validation(string code, string message) =>
new(code, message);
public static Error Conflict(string code, string message) =>
new(code, message);
public static Error Failure(string code, string message) =>
new(code, message);
}Here's how the Result pattern is used in practice, from CreateCustomerCommandHandler.cs:
public async Task<Result<Guid>> Handle(CreateCustomerCommand request, CancellationToken cancellationToken)
{
// Create value objects with validation
var nameResult = PersonName.Create(request.FirstName, request.LastName);
if (nameResult.IsFailure)
{
_logger.LogWarning("Failed to create person name: {Error}", nameResult.Error.Message);
return Result<Guid>.Failure(nameResult.Error); // Early return on failure
}
var emailResult = Email.Create(request.Email);
if (emailResult.IsFailure)
{
_logger.LogWarning("Failed to create email: {Error}", emailResult.Error.Message);
return Result<Guid>.Failure(emailResult.Error);
}
// Use the value from successful result
var customerResult = Customer.Create(
identityId,
nameResult.Value, // Extract value from Result
emailResult.Value,
customerNumber,
phoneNumberResult.Value);
if (customerResult.IsFailure)
{
return Result<Guid>.Failure(customerResult.Error);
}
// Success path
var customer = customerResult.Value;
await _customerRepository.AddCustomerAsync(customer);
await _unitOfWork.SaveChangesAsync(cancellationToken);
return Result<Guid>.Success(customer.Id);
}Key Points:
- Always check
IsFailurebefore accessingValue - Use early returns to avoid deep nesting
- Propagate errors by returning
Result.Failure(error) - Log failures appropriately before returning
Value Objects are used throughout the domain to represent domain concepts with validation and behavior. Benefits include:
- Type safety: Prevents primitive obsession (e.g.,
string emailvsEmail email) - Self-validating: Validation logic is encapsulated in the value object
- Immutability:
readonly record structensures values cannot be modified after creation - Domain semantics: Makes the code more expressive and self-documenting
- Encapsulated behavior: Methods and operators can be defined on value objects
| Service | Value Objects | Purpose |
|---|---|---|
| CustomerService | Email, PersonName, PhoneNumber |
Customer identity and contact information |
| WalletService | Money, Iban |
Financial amounts and bank account identifiers |
| FraudService | IpAddress, Money, TimeRange |
Fraud detection data |
All value objects follow a consistent pattern using readonly record struct:
public readonly record struct Money
{
public decimal Amount { get; }
public string Currency { get; }
private Money(decimal amount, string currency)
{
Amount = amount;
Currency = currency;
}
// Factory method with validation
public static Result<Money> Create(decimal amount, string currency)
{
if (amount < 0)
return Result<Money>.Failure(
Error.Validation("Money.NegativeAmount", "Money amount cannot be negative."));
if (string.IsNullOrWhiteSpace(currency))
return Result<Money>.Failure(
Error.Validation("Money.InvalidCurrency", "Currency cannot be null or empty."));
return Result<Money>.Success(new Money(amount, currency.Trim().ToUpperInvariant()));
}
// Operator overloads for domain behavior
public static Money operator +(Money left, Money right)
{
ValidateSameCurrency(left, right);
return new Money(left.Amount + right.Amount, left.Currency);
}
public static Money operator -(Money left, Money right)
{
ValidateSameCurrency(left, right);
var result = left.Amount - right.Amount;
if (result < 0)
throw new InvalidOperationException("Result of subtraction cannot be negative.");
return new Money(result, left.Currency);
}
// Comparison operators
public static bool operator <(Money left, Money right) { ... }
public static bool operator >(Money left, Money right) { ... }
private static void ValidateSameCurrency(Money left, Money right)
{
if (left.Currency != right.Currency)
throw new InvalidOperationException(
$"Cannot operate money with different currencies: {left.Currency} and {right.Currency}.");
}
public override string ToString() => $"{Amount:F2} {Currency}";
}Key Characteristics:
- Private constructor: Forces creation through the
Createfactory method - Static
Createmethod: ReturnsResult<T>for validation - Static
FromDatabaseValuemethod: For EF Core mapping (bypasses validation, assumes data is valid) - Immutability:
readonly record structensures values cannot be modified - Operator overloads: Domain-specific behavior (e.g., money arithmetic)
From Email.cs:
public readonly record struct Email
{
private const int MaxEmailLength = 320;
private static readonly Regex EmailRegex = new(
@"^[^@\s]+@[^@\s]+\.[^@\s]+$",
RegexOptions.Compiled | RegexOptions.IgnoreCase,
TimeSpan.FromMilliseconds(250));
public string Value { get; }
private Email(string value)
{
Value = value;
}
public static Result<Email> Create(string value)
{
if (string.IsNullOrWhiteSpace(value))
return Result<Email>.Failure(
Error.Validation("Email.Required", "Email cannot be null or empty."));
var trimmedValue = value.Trim();
if (trimmedValue.Length > MaxEmailLength)
return Result<Email>.Failure(
Error.Validation("Email.MaxLength", $"Email must not exceed {MaxEmailLength} characters."));
if (!EmailRegex.IsMatch(trimmedValue))
return Result<Email>.Failure(
Error.Validation("Email.InvalidFormat", "Email must be a valid email address."));
// Additional validation using MailAddress
try
{
var mailAddress = new MailAddress(trimmedValue);
if (mailAddress.Address != trimmedValue)
return Result<Email>.Failure(
Error.Validation("Email.InvalidCharacters", "Email contains invalid characters."));
}
catch (Exception ex) when (ex is ArgumentException || ex is FormatException)
{
return Result<Email>.Failure(
Error.Validation("Email.InvalidFormat", "Email must be a valid email address."));
}
return Result<Email>.Success(new Email(trimmedValue));
}
// For EF Core mapping (assumes database value is valid)
public static Email FromDatabaseValue(string? value)
{
if (string.IsNullOrWhiteSpace(value))
throw new InvalidOperationException("Email cannot be null or empty when reading from database.");
return new Email(value.Trim());
}
public static implicit operator string(Email email) => email.Value;
public override string ToString() => Value;
}From Iban.cs:
The Iban value object demonstrates more complex validation including:
- Format validation (regex)
- Length validation
- Check digit validation (Mod-97 algorithm)
- Normalization (removes spaces, converts to uppercase)
The project implements CQRS to separate read and write operations, optimizing each for its specific purpose:
| Concern | Technology | Pattern | Purpose |
|---|---|---|---|
| Write (Commands) | EF Core 8 | Repository + Unit of Work | Domain behavior, transactions, event publishing |
| Read (Queries) | Dapper | Query Services + DTOs | Optimized reads, direct DTO projection |
Command handlers use Entity Framework Core with the Repository pattern:
Key Components:
IRepository<T>: Abstraction for aggregate accessIUnitOfWork: Transaction management- Domain entities: Rich domain models with behavior
- Outbox pattern: Reliable event publishing
Example Flow:
public class CreateAccountAgeRuleCommandHandler(
IAccountAgeRuleRepository _repository,
IUnitOfWork _unitOfWork)
: IRequestHandler<CreateAccountAgeRuleCommand, Result<Guid>>
{
public async Task<Result<Guid>> Handle(CreateAccountAgeRuleCommand request, CancellationToken ct)
{
// 1. Create domain entity (with validation)
var ruleResult = AccountAgeRule.Create(
request.MinAccountAgeDays,
request.MaxAllowedAmount,
request.Description);
if (ruleResult.IsFailure)
return Result<Guid>.Failure(ruleResult.Error);
// 2. Add to repository
var rule = ruleResult.Value;
await _repository.AddAsync(rule, ct);
// 3. Commit transaction (includes Outbox pattern for events)
await _unitOfWork.SaveChangesAsync(ct);
// 4. Return success with created ID
return Result<Guid>.Success(rule.Id);
}
}Benefits:
- Domain logic is encapsulated in entities
- Transaction boundaries are explicit
- Events are published atomically with data changes (Outbox pattern)
- Easy to test with repository abstractions
Query services use Dapper for optimized read operations:
Key Characteristics:
NpgsqlDataSource: Connection management- Raw SQL: Optimized queries with direct DTO projection
- No entity tracking: Reduced memory overhead
- Direct mapping: SQL results → DTOs
Example from CustomerQueryService.cs:
public class CustomerQueryService(NpgsqlDataSource dataSource) : ICustomerQueryService
{
public async Task<CustomerLookupDto?> GetCustomerByIdentityAsync(
string identityId,
CancellationToken cancellationToken)
{
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
const string sql = """
SELECT "Id" AS "CustomerId", "CustomerNumber"
FROM "Customers"
WHERE "IdentityId" = @identityId
AND "IsActive" = true
AND "IsDeleted" = false;
""";
return await connection.QueryFirstOrDefaultAsync<CustomerLookupDto>(
new CommandDefinition(sql, new { identityId }, cancellationToken: cancellationToken));
}
}Benefits:
- Performance: No entity tracking overhead, optimized SQL
- Flexibility: Can write complex queries with joins, aggregations
- Direct projection: SQL → DTOs without intermediate entities
- Read models: Can query denormalized read models for complex views
Example with Complex Query from AdminCustomerQueryService.cs:
public async Task<PagedResult<AdminCustomerListDto>> GetAllCustomersWithWalletsAsync(
int pageNumber,
int pageSize,
CancellationToken cancellationToken)
{
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
var offset = (pageNumber - 1) * pageSize;
// Count query
const string countSql = """
SELECT COUNT(*) FROM "Customers" WHERE "IsDeleted" = false;
""";
var totalCount = await connection.ExecuteScalarAsync<int>(
new CommandDefinition(countSql, cancellationToken: cancellationToken));
// Data query with join
const string sql = """
SELECT
c."Id",
c."CustomerNumber",
CONCAT(c."FirstName", ' ', c."LastName") AS "FullName",
c."Email",
c."IsActive",
c."CreatedAtUtc" AS "CreatedAt",
w."Id" AS "WalletId",
w."WalletNumber",
w."Balance",
w."Currency",
w."State"
FROM "Customers" c
LEFT JOIN "WalletReadModels" w ON c."Id" = w."CustomerId"
WHERE c."IsDeleted" = false
ORDER BY c."CreatedAtUtc" DESC
OFFSET @offset LIMIT @pageSize;
""";
// Multi-mapping for one-to-many relationship
var customerDictionary = new Dictionary<Guid, AdminCustomerListDto>();
await connection.QueryAsync<AdminCustomerListDto, AdminWalletDto?, AdminCustomerListDto>(
new CommandDefinition(sql, new { offset, pageSize }, cancellationToken: cancellationToken),
(customer, wallet) =>
{
if (!customerDictionary.TryGetValue(customer.Id, out var customerEntry))
{
customerEntry = customer;
customerEntry.Wallets = new List<AdminWalletDto>();
customerDictionary.Add(customer.Id, customerEntry);
}
if (wallet != null)
customerEntry.Wallets.Add(wallet);
return customerEntry;
},
splitOn: "WalletId");
return new PagedResult<AdminCustomerListDto>(
customerDictionary.Values.ToList(),
totalCount,
pageNumber,
pageSize);
}Validation is handled automatically via MediatR pipeline behaviors. All validators are discovered and executed before command/query handlers.
Implementation from ValidationBehavior.cs:
public class ValidationBehavior<TRequest, TResponse>
(IEnumerable<IValidator<TRequest>> _validators)
: IPipelineBehavior<TRequest, TResponse>
where TRequest : IRequest<TResponse>
{
public async Task<TResponse> Handle(
TRequest request,
RequestHandlerDelegate<TResponse> next,
CancellationToken cancellationToken)
{
if (!_validators.Any())
{
return await next();
}
var context = new ValidationContext<TRequest>(request);
// Run all validators in parallel
var validationResults = await Task.WhenAll(
_validators.Select(v => v.ValidateAsync(context, cancellationToken)));
var failures = validationResults
.Where(r => r.Errors.Any())
.SelectMany(r => r.Errors)
.ToList();
if (failures.Any())
{
throw new ValidationException(failures);
}
return await next();
}
}Validators are registered automatically from the assembly:
// In DependencyInjectionExtensions.cs
public static IServiceCollection AddApplication(this IServiceCollection services)
{
services.AddMediatR(cfg =>
cfg.RegisterServicesFromAssembly(Assembly.GetExecutingAssembly()));
// Auto-discover validators
services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly());
// Register validation behavior
services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
return services;
}The project uses a two-tier validation approach:
-
Domain Validation (Value Objects and Entities):
- Encapsulated in
Createfactory methods - Returns
Result<T>with domain errors - Examples: Email format, Money amount, PersonName rules
- Encapsulated in
-
Application Validation (Commands/Queries):
- FluentValidation for request DTOs
- Validates input format, required fields, business rules
- Throws
ValidationException(caught by exception handler middleware)
When to use which:
- Domain validation: Always use for value objects and entity creation
- FluentValidation: Use for command/query DTOs to validate request structure and business rules before domain logic
Each microservice follows Clean Architecture with clear layer separation:
src/Services/{ServiceName}/
├── WF.{ServiceName}.Api/ # Presentation Layer
│ ├── Controllers/
│ │ ├── Base/ # BaseController with common functionality
│ │ ├── Admin/ # Admin-only endpoints
│ │ └── Internal/ # Internal service-to-service endpoints
│ ├── Extensions/ # Service registration extensions
│ │ ├── AuthenticationExtensions.cs
│ │ ├── ConfigurationExtensions.cs
│ │ └── OpenTelemetryExtensions.cs
│ ├── Middleware/ # Custom middleware
│ │ └── ExceptionHandler.cs
│ └── Program.cs # Application entry point
│
├── WF.{ServiceName}.Application/ # Application Layer (CQRS)
│ ├── Common/
│ │ └── Behaviors/ # MediatR pipeline behaviors
│ │ └── ValidationBehavior.cs
│ ├── Contracts/ # Application contracts
│ │ └── DTOs/ # Data Transfer Objects
│ ├── Features/ # Feature-based organization
│ │ └── {Feature}/ # e.g., Customers, Wallets
│ │ ├── Commands/
│ │ │ └── {CommandName}/ # e.g., CreateCustomer
│ │ │ ├── {CommandName}Command.cs
│ │ │ └── {CommandName}CommandHandler.cs
│ │ └── Queries/
│ │ └── {QueryName}/ # e.g., GetCustomerById
│ │ ├── {QueryName}Query.cs
│ │ └── {QueryName}QueryHandler.cs
│ └── DependencyInjectionExtensions.cs
│
├── WF.{ServiceName}.Domain/ # Domain Layer
│ ├── Abstractions/ # Repository interfaces
│ │ └── I{Entity}Repository.cs
│ ├── Entities/ # Aggregate roots
│ │ └── {Entity}.cs
│ └── ValueObjects/ # Value Objects
│ └── {ValueObject}.cs
│
└── WF.{ServiceName}.Infrastructure/ # Infrastructure Layer
├── Data/
│ ├── {ServiceName}DbContext.cs # EF Core DbContext
│ └── UnitOfWork.cs # Transaction management
├── Migrations/ # EF Core migrations
├── QueryServices/ # Dapper read services
│ └── {Entity}QueryService.cs
├── Repositories/ # EF Core repository implementations
│ └── {Entity}Repository.cs
├── EventBus/ # MassTransit integration
│ └── MassTransitEventPublisher.cs
└── DependencyInjectionExtensions.cs
shared/
├── WF.Shared.Contracts/ # Shared contracts across services
│ ├── Abstractions/ # Shared interfaces
│ │ ├── IUnitOfWork.cs
│ │ ├── IIntegrationEventPublisher.cs
│ │ └── ICurrentUserService.cs
│ ├── Commands/ # Inter-service command contracts
│ │ ├── Fraud/
│ │ └── Wallet/
│ ├── Dtos/ # Shared DTOs
│ ├── Enums/ # Shared enumerations
│ │ ├── Currency.cs
│ │ └── KycStatus.cs
│ ├── IntegrationEvents/ # Event contracts
│ │ ├── Customer/
│ │ ├── Transaction/
│ │ └── Wallet/
│ └── Result/ # Result pattern implementation
│ ├── Result.cs
│ ├── Error.cs
│ └── ResultExtensions.cs
│
└── WF.Shared.Observability/ # OpenTelemetry configuration
└── OpenTelemetryConfig.cs
| Type | Convention | Example |
|---|---|---|
| Commands | {Action}{Entity}Command |
CreateCustomerCommand |
| Command Handlers | {Command}Handler |
CreateCustomerCommandHandler |
| Queries | Get{Entity}Query or {Action}{Entity}Query |
GetCustomerByIdQuery, GetAllCustomersQuery |
| Query Handlers | {Query}Handler |
GetCustomerByIdQueryHandler |
| DTOs | {Entity}Dto |
CustomerDto, CustomerLookupDto |
| Events | {Entity}{Action}Event |
CustomerCreatedEvent, WalletDebitedEvent |
| Repositories (Interface) | I{Entity}Repository |
ICustomerRepository |
| Repositories (Implementation) | {Entity}Repository |
CustomerRepository |
| Query Services | {Entity}QueryService |
CustomerQueryService |
| Value Objects | PascalCase noun | Email, Money, PersonName |
| Entities | PascalCase noun | Customer, Wallet, Transaction |
Commands and queries are organized by feature, not by type:
Features/
└── Customers/
├── Commands/
│ ├── CreateCustomer/
│ │ ├── CreateCustomerCommand.cs
│ │ └── CreateCustomerCommandHandler.cs
│ └── UpdateCustomer/
│ ├── UpdateCustomerCommand.cs
│ └── UpdateCustomerCommandHandler.cs
└── Queries/
├── GetCustomerById/
│ ├── GetCustomerByIdQuery.cs
│ └── GetCustomerByIdQueryHandler.cs
└── GetAllCustomers/
├── GetAllCustomersQuery.cs
└── GetAllCustomersQueryHandler.cs
Benefits:
- Related code is co-located
- Easy to find all code for a feature
- Clear boundaries between features
- Supports feature flags and modular development
- Architecture Documentation - System design and patterns
- Services Documentation - Service responsibilities and APIs
- Getting Started Guide - Setup and first steps