Table of Contents

Model Bound Queries

For a more lightweight approach, queries can be their own performers. This is achieved by adorning your read model record with the [ReadModel] attribute and implementing static methods for query operations directly on the record type.

[ReadModel]  // The ReadModel attribute is needed
public record DebitAccount(AccountId Id, AccountName Name, CustomerId Owner, decimal Balance)
{
    public static IEnumerable<DebitAccount> GetAllAccounts(IMongoCollection<DebitAccount> collection)
    {
        return collection.Find(_ => true).ToList();
    }
}

Note: If you're using the Cratis ApplicationModel proxy generator, the method name will become the query name for the generated TypeScript file and class.

Static Method Requirements

The static methods on your read model record:

  • Must be public and static
  • Can have any name that describes the query operation
  • Can take dependencies as parameters (injected via dependency injection)
  • Can be async
  • Should return the record itself, a generic enumerable/list/collection of the record type
  • Can be observable by returning an ISubject<> of the record type (do not make it async by using Task<>)

Dependencies

Your query method can take dependencies from the service collection as parameters. The application model will automatically resolve and inject these dependencies:

[ReadModel]
public record DebitAccount(AccountId Id, AccountName Name, CustomerId Owner, decimal Balance)
{
    public static IEnumerable<DebitAccount> GetActiveAccounts(IMongoCollection<DebitAccount> collection) =>
        collection.Find(account => account.IsActive);
}

Async Support

Your static methods can also be asynchronous:

[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();
    }
}

Multiple Query Methods

A single read model record can have multiple query methods:

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

    public static DebitAccount GetAccountById(AccountId id, IMongoCollection<DebitAccount> collection) =>
        collection.Find(a => a.Id == id).FirstOrDefault();

    public static IEnumerable<DebitAccount> GetAccountsByOwner(CustomerId ownerId, IMongoCollection<DebitAccount> collection) =>
        collection.Find(a => a.Owner == ownerId).ToList();
}

Return Types

Model bound queries support various return types:

Collections

[ReadModel]
public record DebitAccount(AccountId Id, AccountName Name, CustomerId Owner, decimal Balance)
{
    public static IEnumerable<DebitAccount> GetAccounts(IMongoCollection<DebitAccount> collection) 
        => collection.Find(_ => true);
}

Single Objects

[ReadModel]
public record DebitAccount(AccountId Id, AccountName Name, CustomerId Owner, decimal Balance)
{
    public static DebitAccount GetFirstAccount(IMongoCollection<DebitAccount> collection)
        => collection.Find(_ => true).FirstOrDefault();
}

Complex Return Types

public record AccountSummary(int TotalAccounts, decimal TotalBalance);

[ReadModel]
public record DebitAccount(AccountId Id, AccountName Name, CustomerId Owner, decimal Balance)
{
    public static AccountSummary GetSummary(IMongoCollection<DebitAccount> collection)
    {
        var accounts = collection.Find(_ => true).ToList();
        return new AccountSummary(accounts.Count, accounts.Sum(a => a.Balance));
    }
}

Observable Queries (ISubject<>)

For real-time queries that can push updates to subscribers:

[ReadModel]
public record DebitAccount(AccountId Id, AccountName Name, CustomerId Owner, decimal Balance)
{
    public static ISubject<IEnumerable<DebitAccount>> GetAccountsObservable(
        IMongoCollection<DebitAccount> collection) => 
            collection.Observe(); // Leveraging MongoDB Extension methods
}

Query Arguments

Model-bound queries can accept arguments as method parameters. Arguments can be route parameters, query string parameters, or complex objects.

Method Parameters

Arguments are passed as method parameters and are automatically bound from the HTTP request:

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

    public static IEnumerable<DebitAccount> SearchAccounts(
        string nameFilter,
        decimal? minBalance,
        IMongoCollection<DebitAccount> collection,
        ILogger<Accounts> logger)
    {
        logger.LogInformation("Searching accounts with filter: {Filter}", nameFilter);
        
        var filterBuilder = Builders<DebitAccount>.Filter;
        var filters = new List<FilterDefinition<DebitAccount>>();

        if (!string.IsNullOrEmpty(nameFilter))
        {
            filters.Add(filterBuilder.Regex(a => a.Name, new BsonRegularExpression(nameFilter, "i")));
        }

        if (minBalance.HasValue)
        {
            filters.Add(filterBuilder.Gte(a => a.Balance, minBalance.Value));
        }

        var combinedFilter = filters.Any() 
            ? filterBuilder.And(filters) 
            : filterBuilder.Empty;

        return collection.Find(combinedFilter).ToList();
    }
}

Argument Types

Model-bound queries support various argument types:

Primitive Types

[ReadModel]
public record DebitAccount(AccountId Id, AccountName Name, CustomerId Owner, decimal Balance)
{
    public static IEnumerable<DebitAccount> GetAccountsByBalance(
        decimal balance,
        bool exactMatch,
        IMongoCollection<DebitAccount> collection)
    {
        return exactMatch 
            ? collection.Find(a => a.Balance == balance).ToList()
            : collection.Find(a => a.Balance >= balance).ToList();
    }
}

Concept Types

Using concept types (value objects) for stronger typing:

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

Enums

public enum AccountStatus { Active, Inactive, Suspended }

[ReadModel]
public record DebitAccount(AccountId Id, AccountName Name, CustomerId Owner, decimal Balance)
{
    public static IEnumerable<DebitAccount> GetAccountsByStatus(
        AccountStatus status,
        IMongoCollection<DebitAccount> collection)
    {
        // Implement status filtering logic
        return status switch
        {
            AccountStatus.Active => collection.Find(a => a.Balance > 0).ToList(),
            AccountStatus.Inactive => collection.Find(a => a.Balance == 0).ToList(),
            AccountStatus.Suspended => collection.Find(a => a.Balance < 0).ToList(),
            _ => collection.Find(_ => false).ToList()
        };
    }
}

Nullable Arguments

Optional arguments should be nullable:

[ReadModel]
public record DebitAccount(AccountId Id, AccountName Name, CustomerId Owner, decimal Balance)
{
    public static IEnumerable<DebitAccount> FlexibleSearch(
        string? name,
        CustomerId? ownerId,
        decimal? minBalance,
        decimal? maxBalance,
        IMongoCollection<DebitAccount> collection)
    {
        var filterBuilder = Builders<DebitAccount>.Filter;
        var filters = new List<FilterDefinition<DebitAccount>>();

        if (!string.IsNullOrEmpty(name))
            filters.Add(filterBuilder.Regex(a => a.Name, new BsonRegularExpression(name, "i")));

        if (ownerId.HasValue)
            filters.Add(filterBuilder.Eq(a => a.Owner, ownerId.Value));

        if (minBalance.HasValue)
            filters.Add(filterBuilder.Gte(a => a.Balance, minBalance.Value));

        if (maxBalance.HasValue)
            filters.Add(filterBuilder.Lte(a => a.Balance, maxBalance.Value));

        var combinedFilter = filters.Any() 
            ? filterBuilder.And(filters) 
            : filterBuilder.Empty;

        return collection.Find(combinedFilter).ToList();
    }
}

Default Values

Provide sensible default values for optional parameters:

[ReadModel]
public record DebitAccount(AccountId Id, AccountName Name, CustomerId Owner, decimal Balance)
{
    public static IEnumerable<DebitAccount> GetPagedAccounts(
        int page = 0,
        int pageSize = 50,
        string sortBy = "name",
        bool ascending = true,
        IMongoCollection<DebitAccount> collection)
    {
        var query = collection.Find(_ => true);
        
        // Apply sorting
        query = ascending 
            ? query.SortBy(sortBy) 
            : query.SortByDescending(sortBy);
        
        // Apply paging
        return query.Skip(page * pageSize).Limit(pageSize).ToList();
    }
}

Authorization

Model-bound queries support authorization through standard ASP.NET Core authorization attributes as well as the convenient [Roles] attribute provided by the Application Model.

Using the Authorize Attribute

You can secure query methods using the standard [Authorize] attribute:

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

    [Authorize(Roles = "Admin,Manager")]
    public static IEnumerable<DebitAccount> GetSensitiveAccounts(IMongoCollection<DebitAccount> collection) =>
        collection.Find(a => a.Balance > 100000).ToList();
}

Using the Roles Attribute

The Application Model provides a more convenient [Roles] attribute for cleaner syntax when specifying multiple roles:

[ReadModel]
public record DebitAccount(AccountId Id, AccountName Name, CustomerId Owner, decimal Balance)
{
    [Roles("Admin", "Auditor")]
    public static IEnumerable<DebitAccount> GetAdminAccounts(IMongoCollection<DebitAccount> collection) =>
        collection.Find(_ => true).ToList();

    [Roles("Manager")]
    public static IEnumerable<DebitAccount> GetManagerAccounts(IMongoCollection<DebitAccount> collection) =>
        collection.Find(a => a.Owner != CustomerId.Empty).ToList();
}

The user needs to have at least one of the specified roles to execute the query.

Read Model-Level Authorization

You can also apply authorization at the read model level to protect all query methods:

[ReadModel]
[Roles("User")] // All methods require at least "User" role
public record DebitAccount(AccountId Id, AccountName Name, CustomerId Owner, decimal Balance)
{
    public static IEnumerable<DebitAccount> GetAllAccounts(IMongoCollection<DebitAccount> collection) =>
        collection.Find(_ => true).ToList();

    [Roles("Admin")] // Override read model-level authorization
    public static IEnumerable<DebitAccount> GetAdminOnlyAccounts(IMongoCollection<DebitAccount> collection) =>
        collection.Find(a => a.Balance < 0).ToList();
}

Authorization Results

When authorization fails, the query pipeline automatically returns an unauthorized result. The query method will not be executed:

var result = await queryManager.Execute(new GetSensitiveAccountsQuery());

if (!result.IsAuthorized)
{
    // Handle unauthorized access - query was not executed
    return Forbid();
}

if (result.IsSuccess)
{
    // Query executed successfully
    return Ok(result.Data);
}

Policy-Based Authorization

For more complex authorization scenarios, you can use policy-based authorization:

[ReadModel]
public record DebitAccount(AccountId Id, AccountName Name, CustomerId Owner, decimal Balance)
{
    [Authorize(Policy = "RequireAccountAccess")]
    public static DebitAccount GetAccountById(
        AccountId id, 
        IMongoCollection<DebitAccount> collection) =>
        collection.Find(a => a.Id == id).FirstOrDefault();
}

Note: Authorization is evaluated before the query method is called. If authorization fails, the query will not be executed and the result will indicate the authorization failure.

Note: The proxy generator automatically creates TypeScript types for your query arguments, making them strongly typed on the frontend as well.