Table of Contents

Controller Based Queries

You can represent queries as regular ASP.NET Core Controller actions with HTTP GET methods.

public record DebitAccount(AccountId Id, AccountName Name, CustomerId Owner, decimal Balance);

[Route("api/accounts")]
public class Accounts : Controller
{
    readonly IMongoCollection<DebitAccount> _collection;

    public Accounts(IMongoCollection<DebitAccount> collection) => _collection = collection;

    [HttpGet]
    public IEnumerable<DebitAccount> AllAccounts() => _collection.Find(_ => true).ToList();
}

Note: This particular model represents its values as concepts - a value type encapsulation that makes us not use primitives - thus creating clearer APIs and models.

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.

Bypassing Query Result Wrappers

By default, controller-based queries return results wrapped in a QueryResult structure. If you need to return the raw result from your controller action without this wrapper, you can use the [AspNetResult] attribute. For more details, see Without wrappers.

Async Support

For asynchronous operations, you can return Task<T>:

[HttpGet]
public async Task<IEnumerable<DebitAccount>> AllAccountsAsync()
{
    var result = await _collection.FindAsync(_ => true);
    return result.ToList();
}

Different Return Types

Queries can return various data types:

Single Object

[HttpGet("{id}")]
public DebitAccount GetAccount(AccountId id)
{
    return _collection.Find(a => a.Id == id).FirstOrDefault();
}

Collections

[HttpGet]
public IEnumerable<DebitAccount> GetAccounts()
{
    return _collection.Find(_ => true).ToList();
}

// Or List<T>
[HttpGet]
public List<DebitAccount> GetAccountsList()
{
    return _collection.Find(_ => true).ToList();
}

// Or arrays
[HttpGet]
public DebitAccount[] GetAccountsArray()
{
    return _collection.Find(_ => true).ToArray();
}

Query Results

For more control over the response metadata, you can return QueryResult<T>:

[HttpGet]
public QueryResult<IEnumerable<DebitAccount>> GetAccountsWithMetadata()
{
    var accounts = _collection.Find(_ => true).ToList();
    return new QueryResult<IEnumerable<DebitAccount>>
    {
        Data = accounts,
        // Additional metadata will be populated automatically
    };
}

Dependency Injection

Controllers support dependency injection in their constructors:

public class Accounts : Controller
{
    readonly IAccountService _accountService;
    readonly ILogger<Accounts> _logger;

    public Accounts(IAccountService accountService, ILogger<Accounts> logger)
    {
        _accountService = accountService;
        _logger = logger;
    }

    [HttpGet]
    public IEnumerable<DebitAccount> AllAccounts()
    {
        _logger.LogInformation("Retrieving all accounts");
        return _accountService.GetAllAccounts();
    }
}

Route Templates

You can use standard ASP.NET Core routing:

[Route("api/accounts")]
public class Accounts : Controller
{
    [HttpGet]
    public IEnumerable<DebitAccount> GetAll() { ... }

    [HttpGet("{id}")]
    public DebitAccount GetById(string id) { ... }

    [HttpGet("by-owner/{ownerId}")]
    public IEnumerable<DebitAccount> GetByOwner(string ownerId) { ... }

    [HttpGet("search")]
    public IEnumerable<DebitAccount> Search([FromQuery] string term) { ... }
}

Query Arguments

Queries can accept arguments to filter, customize, or parameterize the data they return. Arguments can come from route parameters, query strings, or request bodies.

💡 Proxy Generation: The proxy generator automatically analyzes your query arguments and creates strongly-typed TypeScript interfaces, ensuring type safety between your backend and frontend.

Route Parameters

Route parameters are embedded in the URL path and are typically used for primary identifiers:

[Route("api/accounts")]
public class Accounts : Controller
{
    readonly IMongoCollection<DebitAccount> _collection;

    public Accounts(IMongoCollection<DebitAccount> collection) => _collection = collection;

    [HttpGet("{id}")]
    public DebitAccount GetAccountById(AccountId id)
    {
        return _collection.Find(a => a.Id == id).FirstOrDefault();
    }

    [HttpGet("owner/{ownerId}")]
    public IEnumerable<DebitAccount> GetAccountsByOwner(CustomerId ownerId)
    {
        return _collection.Find(a => a.Owner == ownerId).ToList();
    }

    [HttpGet("{id}/balance")]
    public decimal GetAccountBalance(AccountId id)
    {
        var account = _collection.Find(a => a.Id == id).FirstOrDefault();
        return account?.Balance ?? 0;
    }
}

Query String Parameters

Query string parameters are appended to the URL after a ? and are typically used for optional filters or configuration:

[HttpGet]
public IEnumerable<DebitAccount> GetAccounts([FromQuery] string? nameFilter = null)
{
    var filter = Builders<DebitAccount>.Filter.Empty;
    
    if (!string.IsNullOrEmpty(nameFilter))
    {
        filter = Builders<DebitAccount>.Filter.Regex(
            account => account.Name, 
            new BsonRegularExpression(nameFilter, "i"));
    }
    
    return _collection.Find(filter).ToList();
}

[HttpGet("search")]
public async Task<IEnumerable<DebitAccount>> SearchAccounts(
    [FromQuery] string? name = null,
    [FromQuery] decimal? minBalance = null,
    [FromQuery] decimal? maxBalance = null,
    [FromQuery] bool includeInactive = false)
{
    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 (minBalance.HasValue)
    {
        filters.Add(filterBuilder.Gte(a => a.Balance, minBalance.Value));
    }

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

    if (!includeInactive)
    {
        filters.Add(filterBuilder.Gt(a => a.Balance, 0));
    }

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

    var result = await _collection.FindAsync(combinedFilter);
    return result.ToList();
}

Complex Query Objects

For complex queries with multiple parameters, you can create dedicated query objects:

public record AccountSearchQuery(
    string? Name,
    decimal? MinBalance,
    decimal? MaxBalance,
    bool IncludeInactive,
    string? OwnerName);

[HttpGet("advanced-search")]
public IEnumerable<DebitAccount> SearchAccountsAdvanced([FromQuery] AccountSearchQuery query)
{
    var filterBuilder = Builders<DebitAccount>.Filter;
    var filters = new List<FilterDefinition<DebitAccount>>();

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

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

    if (query.MaxBalance.HasValue)
    {
        filters.Add(filterBuilder.Lte(a => a.Balance, query.MaxBalance.Value));
    }

    if (!query.IncludeInactive)
    {
        filters.Add(filterBuilder.Gt(a => a.Balance, 0));
    }

    // Additional complex filtering logic...
    
    var combinedFilter = filters.Any() 
        ? filterBuilder.And(filters) 
        : filterBuilder.Empty;

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

Observable Query Arguments

Observable queries can also accept arguments:

[HttpGet("owner/{ownerId}/observable")]
public ISubject<IEnumerable<DebitAccount>> GetAccountsByOwnerObservable(CustomerId ownerId)
{
    return _collection.Observe(account => account.Owner == ownerId);
}

[HttpGet("filtered-observable")]
public ISubject<IEnumerable<DebitAccount>> GetFilteredAccountsObservable(
    [FromQuery] decimal? minBalance = null)
{
    if (minBalance.HasValue)
    {
        return _collection.Observe(account => account.Balance >= minBalance.Value);
    }
    
    return _collection.Observe();
}

Argument Types

The application model supports various argument types:

Primitive Types

[HttpGet("by-balance")]
public IEnumerable<DebitAccount> GetAccountsByBalance(
    [FromQuery] decimal balance,
    [FromQuery] bool exactMatch = false)
{
    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:

[HttpGet("by-owner-concept/{ownerId}")]
public IEnumerable<DebitAccount> GetAccountsByOwnerConcept(CustomerId ownerId)
{
    return _collection.Find(a => a.Owner == ownerId).ToList();
}

Collection Arguments

[HttpGet("by-ids")]
public IEnumerable<DebitAccount> GetAccountsByIds([FromQuery] AccountId[] ids)
{
    return _collection.Find(a => ids.Contains(a.Id)).ToList();
}

[HttpGet("by-owners")]
public IEnumerable<DebitAccount> GetAccountsByOwners([FromQuery] List<CustomerId> ownerIds)
{
    return _collection.Find(a => ownerIds.Contains(a.Owner)).ToList();
}

Enums

public enum AccountStatus { Active, Inactive, Suspended }

[HttpGet("by-status")]
public IEnumerable<DebitAccount> GetAccountsByStatus([FromQuery] AccountStatus status)
{
    // 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:

[HttpGet("flexible-search")]
public IEnumerable<DebitAccount> FlexibleSearch(
    [FromQuery] string? name = null,
    [FromQuery] CustomerId? ownerId = null,
    [FromQuery] decimal? minBalance = null,
    [FromQuery] decimal? maxBalance = null)
{
    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:

[HttpGet("paged")]
public IEnumerable<DebitAccount> GetPagedAccounts(
    [FromQuery] int page = 0,
    [FromQuery] int pageSize = 50,
    [FromQuery] string sortBy = "name",
    [FromQuery] bool ascending = true)
{
    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();
}

Observable Queries

Observable queries provide real-time data streaming using WebSockets, enabling reactive user experiences where data changes are pushed to clients as they occur. You achieve this by returning ISubject<T> from your controller actions.

Basic Observable Query

The key to an observable query is to leverage the ClientObservable<T> generic type:

[HttpGet("observable")]
public ISubject<IEnumerable<DebitAccount>> AllAccountsObservable()
{
    return _collection.Observe(); // Simple MongoDB extension method
}

Observable with Arguments

Observable queries can accept arguments just like regular queries:

[HttpGet("owner/{ownerId}/observable")]
public ISubject<IEnumerable<DebitAccount>> GetAccountsByOwnerObservable(CustomerId ownerId)
{
    return _collection.Observe(account => account.Owner == ownerId);
}

[HttpGet("filtered-observable")]
public ISubject<IEnumerable<DebitAccount>> GetFilteredAccountsObservable(
    [FromQuery] decimal? minBalance = null)
{
    if (minBalance.HasValue)
    {
        return _collection.Observe(account => account.Balance >= minBalance.Value);
    }
    
    return _collection.Observe();
}

Single Object Observable

For observing changes to a single object:

[HttpGet("{id}/observable")]
public ISubject<DebitAccount> GetAccountObservable(AccountId id)
{
    return _collection.Observe(account => account.Id == id);
}

Custom Observable Logic

For more complex scenarios, you can implement custom observable logic:

[HttpGet("summary")]
public ISubject<AccountSummary> GetAccountSummaryObservable()
{
    var observable = new ClientObservable<AccountSummary>();
    
    var calculateSummary = () =>
    {
        var accounts = _collection.Find(_ => true).ToList();
        return new AccountSummary(accounts.Count, accounts.Sum(a => a.Balance));
    };

    // Send initial summary
    observable.OnNext(calculateSummary());

    // Watch for any account changes
    var cursor = _collection.Watch();
    Task.Run(() =>
    {
        while (cursor.MoveNext())
        {
            if (cursor.Current.Any())
            {
                observable.OnNext(calculateSummary());
            }
        }
    });

    observable.ClientDisconnected = () => cursor.Dispose();
    return observable;
}

Best Practices for Observable Queries

  1. Use the .Observe() extension method for simple cases - it handles initial data load and change monitoring automatically
  2. Always handle client disconnection with the ClientDisconnected callback when using ClientObservable<T> directly
  3. Send initial data immediately before setting up change monitoring
  4. Use appropriate filters to minimize unnecessary data transmission
  5. Consider the frequency of changes and implement throttling if necessary

Important: When using ClientObservable<T> directly, the ClientDisconnected callback is essential for cleaning up resources like MongoDB cursors to prevent memory leaks.

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