Table of Contents

Static Methods

Model-bound queries are implemented as static methods on your read model records. Understanding the method requirements and patterns is essential for effective query implementation.

Method Requirements

Static methods on your read model record must follow these requirements:

  • Must be public and static
  • Can have any descriptive name that describes the query operation
  • Can take dependencies as parameters (injected via dependency injection)
  • Can be async by returning Task<T>
  • Should return appropriate types - the record itself, collections, or custom result types
  • Can be observable by returning ISubject<T> (do not combine with Task<T>)

Basic Static Method Pattern

The simplest query method takes a dependency and returns data:

[ReadModel]
public record DebitAccount(AccountId Id, AccountName Name, CustomerId Owner, decimal Balance)
{
    public static IEnumerable<DebitAccount> GetAllAccounts(IMongoCollection<DebitAccount> collection)
    {
        return collection.Find(_ => true).ToList();
    }
    
    public static DebitAccount GetAccountById(AccountId id, IMongoCollection<DebitAccount> collection)
    {
        return collection.Find(a => a.Id == id).FirstOrDefault();
    }
}

Async Methods

For database operations that support async, return Task<T>:

[ReadModel]
public record DebitAccount(AccountId Id, AccountName Name, CustomerId Owner, decimal Balance)
{
    public static async Task<IEnumerable<DebitAccount>> GetAllAccountsAsync(IMongoCollection<DebitAccount> collection)
    {
        var result = await collection.FindAsync(_ => true);
        return result.ToList();
    }
    
    public static async Task<DebitAccount?> GetAccountByIdAsync(AccountId id, IMongoCollection<DebitAccount> collection)
    {
        var result = await collection.FindAsync(a => a.Id == id);
        return result.FirstOrDefault();
    }
}

Multiple Dependencies

Methods can take multiple dependencies from the service collection:

[ReadModel]
public record DebitAccount(AccountId Id, AccountName Name, CustomerId Owner, decimal Balance)
{
    public static async Task<IEnumerable<DebitAccount>> SearchAccounts(
        string searchTerm,
        IMongoCollection<DebitAccount> collection,
        ILogger<DebitAccount> logger,
        IConfiguration configuration)
    {
        var maxResults = configuration.GetValue<int>("MaxSearchResults", 100);
        logger.LogInformation("Searching accounts with term: {SearchTerm}", searchTerm);
        
        var filter = Builders<DebitAccount>.Filter.Regex(
            a => a.Name, 
            new BsonRegularExpression(searchTerm, "i"));
        
        var result = await collection.FindAsync(filter);
        var accounts = result.Limit(maxResults).ToList();
        
        logger.LogInformation("Found {AccountCount} accounts", accounts.Count);
        return accounts;
    }
}

Parameter Order Flexibility

Dependencies can be placed in any order - they are resolved by type:

[ReadModel]
public record DebitAccount(AccountId Id, AccountName Name, CustomerId Owner, decimal Balance)
{
    // Dependencies can come first
    public static IEnumerable<DebitAccount> GetAccountsByOwner(
        IMongoCollection<DebitAccount> collection,
        ILogger<DebitAccount> logger,
        CustomerId ownerId)
    {
        logger.LogInformation("Getting accounts for owner: {OwnerId}", ownerId);
        return collection.Find(a => a.Owner == ownerId).ToList();
    }
    
    // Or query parameters can come first
    public static IEnumerable<DebitAccount> GetAccountsByBalance(
        decimal minBalance,
        bool includeZero,
        IMongoCollection<DebitAccount> collection)
    {
        var filter = includeZero 
            ? Builders<DebitAccount>.Filter.Gte(a => a.Balance, minBalance)
            : Builders<DebitAccount>.Filter.Gt(a => a.Balance, minBalance);
            
        return collection.Find(filter).ToList();
    }
}

Generic Dependencies

Methods can use generic dependencies:

[ReadModel]
public record DebitAccount(AccountId Id, AccountName Name, CustomerId Owner, decimal Balance)
{
    public static async Task<IEnumerable<DebitAccount>> GetCachedAccounts(
        IMemoryCache cache,
        IMongoCollection<DebitAccount> collection,
        ILogger<DebitAccount> logger)
    {
        const string cacheKey = "all-accounts";
        
        if (cache.TryGetValue(cacheKey, out IEnumerable<DebitAccount>? cached))
        {
            logger.LogInformation("Returning cached accounts");
            return cached ?? Enumerable.Empty<DebitAccount>();
        }
        
        logger.LogInformation("Loading accounts from database");
        var accounts = await collection.Find(_ => true).ToListAsync();
        
        cache.Set(cacheKey, accounts, TimeSpan.FromMinutes(5));
        return accounts;
    }
}

Configuration and Options

Use IOptions<T> or IConfiguration for configuration values:

public class AccountQueryOptions
{
    public int DefaultPageSize { get; set; } = 50;
    public int MaxPageSize { get; set; } = 200;
    public TimeSpan CacheExpiry { get; set; } = TimeSpan.FromMinutes(5);
}

[ReadModel]
public record DebitAccount(AccountId Id, AccountName Name, CustomerId Owner, decimal Balance)
{
    public static IEnumerable<DebitAccount> GetPagedAccounts(
        int page,
        int? pageSize,
        IMongoCollection<DebitAccount> collection,
        IOptions<AccountQueryOptions> options)
    {
        var opts = options.Value;
        var actualPageSize = Math.Min(pageSize ?? opts.DefaultPageSize, opts.MaxPageSize);
        
        return collection.Find(_ => true)
            .Skip(page * actualPageSize)
            .Limit(actualPageSize)
            .ToList();
    }
}

Complex Business Logic

Static methods can implement complex business logic:

[ReadModel]
public record DebitAccount(AccountId Id, AccountName Name, CustomerId Owner, decimal Balance)
{
    public static async Task<AccountRiskAssessment> AssessAccountRisk(
        AccountId accountId,
        IMongoCollection<DebitAccount> accountCollection,
        IMongoCollection<Transaction> transactionCollection,
        IRiskCalculator riskCalculator,
        ILogger<DebitAccount> logger)
    {
        logger.LogInformation("Assessing risk for account: {AccountId}", accountId);
        
        var account = await accountCollection.Find(a => a.Id == accountId).FirstOrDefaultAsync();
        if (account is null)
        {
            return new AccountRiskAssessment(accountId, RiskLevel.Unknown, "Account not found");
        }
        
        var recentTransactions = await transactionCollection
            .Find(t => t.AccountId == accountId && t.Date > DateTime.UtcNow.AddDays(-30))
            .ToListAsync();
        
        var riskScore = riskCalculator.CalculateRisk(account, recentTransactions);
        
        logger.LogInformation("Account {AccountId} risk score: {RiskScore}", accountId, riskScore);
        
        return new AccountRiskAssessment(accountId, riskScore.Level, riskScore.Reason);
    }
}

Observable Methods

For real-time queries, return ISubject<T>:

[ReadModel]
public record DebitAccount(AccountId Id, AccountName Name, CustomerId Owner, decimal Balance)
{
    public static ISubject<IEnumerable<DebitAccount>> GetAccountsObservable(
        IMongoCollection<DebitAccount> collection)
    {
        return collection.Observe(); // MongoDB extension method
    }
    
    public static ISubject<DebitAccount> GetAccountObservable(
        AccountId id,
        IMongoCollection<DebitAccount> collection)
    {
        return collection.Observe(a => a.Id == id);
    }
}

Error Handling

Implement proper error handling in your static methods:

[ReadModel]
public record DebitAccount(AccountId Id, AccountName Name, CustomerId Owner, decimal Balance)
{
    public static async Task<DebitAccount?> GetAccountSafely(
        AccountId id,
        IMongoCollection<DebitAccount> collection,
        ILogger<DebitAccount> logger)
    {
        try
        {
            var result = await collection.FindAsync(a => a.Id == id);
            return result.FirstOrDefault();
        }
        catch (Exception ex)
        {
            logger.LogError(ex, "Error retrieving account {AccountId}", id);
            return null;
        }
    }
}

Method Naming Conventions

Use descriptive names that clearly indicate what the method does:

[ReadModel]
public record DebitAccount(AccountId Id, AccountName Name, CustomerId Owner, decimal Balance)
{
    // ✅ Good - descriptive and clear
    public static IEnumerable<DebitAccount> GetActiveAccounts(IMongoCollection<DebitAccount> collection)
        => collection.Find(a => a.Balance > 0).ToList();
    
    public static IEnumerable<DebitAccount> SearchAccountsByName(string name, IMongoCollection<DebitAccount> collection)
        => collection.Find(a => a.Name.Contains(name)).ToList();
    
    public static AccountSummary GetAccountSummaryByOwner(CustomerId ownerId, IMongoCollection<DebitAccount> collection)
    {
        var accounts = collection.Find(a => a.Owner == ownerId).ToList();
        return new AccountSummary(accounts.Count, accounts.Sum(a => a.Balance));
    }
    
    // ❌ Avoid - too generic or unclear
    public static IEnumerable<DebitAccount> Get(IMongoCollection<DebitAccount> collection) => /* ... */;
    public static IEnumerable<DebitAccount> DoSomething(string param, IMongoCollection<DebitAccount> collection) => /* ... */;
}

Best Practices

  1. Use descriptive method names - Make it clear what the query does
  2. Keep methods focused - Each method should have a single responsibility
  3. Handle dependencies properly - Order parameters logically (query params first or dependencies first, be consistent)
  4. Use async when appropriate - For I/O operations that support it
  5. Implement error handling - Handle exceptions gracefully and log appropriately
  6. Don't mix async and observable - Use either Task<T> or ISubject<T>, not both
  7. Validate inputs - Check parameters and throw appropriate exceptions for invalid input
  8. Use nullable return types - When queries might not find results
  9. Leverage dependency injection - Let the framework resolve your dependencies
  10. Keep business logic in services - Don't put complex business logic directly in query methods