Skip to content

Model-bound query arguments

Arc’s model-bound HTTP readers are not ASP.NET MVC model binding. GET reads named values from the query string, not route values. QUERY reads an arguments envelope, converts each value to a string, and uses the same scalar conversion path.

This alternative read-model declaration uses the shared AccountId and AccountName concepts and the configured Arc MongoDB provider. The query searches by an exact account name and a minimum balance:

using System.Linq;
using Cratis.Arc.Queries.ModelBound;
using MongoDB.Driver;
using MongoDB.Driver.Linq;
namespace Banking.Accounts;
[ReadModel]
public record DebitAccount(AccountId Id, AccountName Name, decimal Balance)
{
[Path("/api/accounts/search")]
public static IQueryable<DebitAccount> Search(
IMongoCollection<DebitAccount> collection,
AccountName name,
decimal minimumBalance = 0) =>
collection.AsQueryable().Where(account =>
account.Name == name && account.Balance >= minimumBalance);
}
GET /api/accounts/search?name=Savings&minimumBalance=100

Arc converts the incoming name to AccountName; the predicate uses that domain value and the requested minimum balance. This is query filtering, not authorization: never trust an owner ID supplied by the caller without checking that caller’s access.

The built-in conversion path handles scalar values such as strings, numbers, booleans, GUIDs, enums, dates, and supported ConceptAs<T> wrappers. A custom TypeConverter can extend conversion; test it through each transport you expose.

It does not provide general nested-JSON DTO binding or array/list deserialization. A JSON object in arguments does not make an arbitrary SearchCriteria parameter bindable. Repeated GET keys likewise are not a promise of collection binding. Unsupported conversion can yield a missing/null/default value or a conversion error, rather than a useful DTO.

These limitations concern the supplied HTTP readers. Already-typed arguments passed directly to IQueryPipeline, custom readers/converters, and MVC DTO binding are different paths. FluentValidation’s ability to traverse an object does not prove HTTP can construct that object.

[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()
};
}
// A nullable enum works the same way — omit it from the query string to search across every status.
public static IEnumerable<DebitAccount> GetAccountsByOptionalStatus(
AccountStatus? status,
IMongoCollection<DebitAccount> collection)
{
return status.HasValue
? collection.Find(a => a.Balance > 0).ToList()
: collection.Find(_ => true).ToList();
}
}

Arc classifies a method parameter as a caller-supplied query argument — rather than a value resolved from the dependency injection container — when it is a primitive, a concept, an enum (plain or nullable), or a collection of primitives, concepts, or enums. Everything else, including a plain class or an interface like IMongoCollection<T> or ILogger<T>, is treated as an injected dependency. This is why AccountStatus/AccountStatus? above are read from the query string while IMongoCollection<DebitAccount> is resolved from the container in the same method signature.

A collection parameter — IEnumerable<T>, an array, or List<T> — is classified the same way as a scalar one: it is a caller-supplied argument whenever its element type is a primitive, a concept, or an enum. Everything else about it works the same as a single value; the caller just sends the argument name repeated once per value (?ids=1&ids=2&ids=3), and Arc binds it back into the collection type your method declares.

[ReadModel]
public record DebitAccount(AccountId Id, AccountName Name, CustomerId Owner, decimal Balance)
{
public static IEnumerable<DebitAccount> GetAccountsByIds(
IEnumerable<AccountId> ids,
IMongoCollection<DebitAccount> collection)
{
return collection.Find(a => ids.Contains(a.Id)).ToList();
}
public static IEnumerable<DebitAccount> GetAccountsByOwners(
List<CustomerId> ownerIds,
IMongoCollection<DebitAccount> collection)
{
return collection.Find(a => ownerIds.Contains(a.Owner)).ToList();
}
public static IEnumerable<DebitAccount> GetAccountsByStatuses(
IEnumerable<AccountStatus> statuses,
IMongoCollection<DebitAccount> collection)
{
// Same derived-status logic as GetAccountsByStatus above, matched against any of the requested statuses.
return collection.Find(_ => true).ToList().Where(a => statuses.Any(status => status switch
{
AccountStatus.Active => a.Balance > 0,
AccountStatus.Inactive => a.Balance == 0,
AccountStatus.Suspended => a.Balance < 0,
_ => false
}));
}
}

The classification rule, stated once: a parameter is caller-supplied when it is a primitive, a concept, an enum, or a collection of those — plain, nullable, or wrapped in IEnumerable<T>/an array/List<T> makes no difference. Everything else — a plain class, an interface, or a collection of any other element type such as IEnumerable<IMongoCollection<T>> — is resolved from the dependency injection container instead.

Argument names match case-insensitively. GET and QUERY readers skip empty string values. At invocation, absent optional arguments use the method’s default value. Nullable value types can be omitted; nonnullable value types without defaults are required. Concept nullability/defaults affect requiredness.

Plain reference types, including string, are implicitly optional in the current performer even without a nullable annotation. A string parameter alone is not a required-input rule. Use a whole-argument query validator to reject missing or empty input. Put required dependencies before optional arguments, as in the example above.

The GET reader removes these names from ordinary arguments, case-insensitively:

KeysPurpose
page, pageSizeArc paging context
sortby, sortDirectionArc sorting context
waitForFirstResult, waitForFirstResultTimeoutObservable HTTP snapshot control

Do not declare method parameters named page or pageSize expecting GET to populate them. Use automatic paging, or distinct business argument names if you implement a separate result cap. QUERY places paging/sorting in separate envelope properties; hub subscriptions also have dedicated paging/sorting fields. Keep the distinction explicit across transports.

Without [Path], Arc derives a kebab-case path from the namespace and generated-API options, including the configured route prefix, skipped namespace segments, and query-name inclusion. It does not infer /{id} from a parameter named id or a method named ById.

Use Cratis.Arc.Queries.ModelBound.PathAttribute for an explicit model-bound path. Method-level paths take precedence over a type-level path. Give different queries distinct paths; do not put multiple methods under one identical type-level path and expect separate endpoints.

For example, a query configured with [Path("/api/accounts/by-id")] takes its GUID-backed AccountId as /api/accounts/by-id?id=11111111-1111-1111-1111-111111111111, not as an appended route segment. Use controller route templates when you need route-value binding.

The current model-bound DataAnnotationValidationFilter reads validation attributes from the parameter type, not from the method parameter’s attributes, and does not recursively inspect DTO properties. [Required], [MinLength], or [Range] on a static query parameter therefore do not enforce the advertised rule. This is a current limitation, not a recommendation to omit validation.

Use the complete FluentValidation argument-set example. MVC DataAnnotations follow MVC’s separate rules.