Skip to content

Model-bound query arguments

To find an account by name, pass name to Search in the query string. Arc converts it to AccountName before the method applies the minimum-balance predicate. These model-bound HTTP readers are not ASP.NET MVC model binding: GET reads named values from the query string, not route values, while QUERY reads an arguments envelope. Scalar values use the scalar conversion path; JSON arrays keep their element boundaries and Arc converts each element separately.

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. If a supplied scalar or concept value cannot be converted to the declared parameter type, GET and QUERY reject it with HTTP 400 and a validation error naming the argument and its type; the query does not run. This also applies to nullable and defaulted parameters when a value is supplied: invalid input does not fall back to null or the default. Use the named validation error to correct the argument before trying the read again. Omitting a defaulted parameter is different from supplying an invalid value.

Arc binds supported scalar values and collections of those values, including arrays and lists. It does not provide general nested-JSON DTO binding. A JSON object in arguments does not make an arbitrary SearchCriteria parameter bindable. Repeated GET keys bind only to collections of those scalar types, not to collections of objects. Unsupported DTO shapes cannot be constructed by these readers; use a supported scalar or collection argument instead.

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.

The following method-shape illustration assumes application domain types CustomerId and AccountStatus. Supplying a status uses the matching predicate in GetAccountsByStatus; omitting the nullable status searches across all statuses. It is not a standalone application declaration.

[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
? GetAccountsByStatus(status.Value, collection)
: 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), a collection of primitives, concepts, or enums, or a string-keyed dictionary of those scalar values. Anything else is injected when the container can supply it, as with IMongoCollection<T> or ILogger<T>. A type the container does not know, such as an unregistered plain class, is not injected: Arc treats it as a query argument, which the HTTP readers cannot construct. 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, List<T>, or HashSet<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. This includes nullable elements and scalar types such as DateOnly, TimeOnly, and Uri. For GET, send the argument name repeated once per value (?ids=1&ids=2&ids=3); for QUERY, send a JSON array under arguments in the request body. Arc binds either form into the collection type your method declares. GET collapses repeated values into a comma-delimited string and splits on every comma, so any element whose text contains a comma (including a Uri) cannot round-trip; use QUERY for those values. Collections of JsonObject or JsonArray elements require QUERY; GET rejects them with HTTP 400. Invalid collection elements and null in non-nullable element types are rejected with HTTP 400 and the same named, malformed-request validation error as invalid scalars. Nested CLR collections such as int[][] are not supported; JsonArray elements in QUERY are supported.

[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, a collection of those — plain, nullable, or wrapped in IEnumerable<T>/an array/List<T>/HashSet<T> makes no difference — or a dictionary with string keys and values of those scalar types. Everything else — a class, an interface, or a collection of any other element type such as IEnumerable<IMongoCollection<T>> — is resolved from the dependency injection container when it is registered there; an unregistered type falls back to being a query argument.

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:

Keys Purpose
page, pageSize Arc paging context
sortBy, sortDirection Arc sorting context
waitForFirstResult, waitForFirstResultTimeout Observable HTTP snapshot control

GET also reads the reserved keys case-insensitively (sortby, SORTBY, PAGE, and PAGESIZE are equivalent spellings). 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.

Use Dictionary<string, V>, IDictionary<string, V>, or IReadOnlyDictionary<string, V> for named query inputs. The key must be a string; values must be primitives, strings, concepts, or enums, including nullable values. Dictionaries of complex objects and non-string keys are not caller-supplied query arguments. Arc creates a Dictionary<string, V> assignable to the declared type; custom dictionary classes are not supported.

The generated TypeScript proxy exposes Record<string, V> (using the primitive type for a concept value), not an array. Pass a plain object to the proxy. For example, this query echoes its named parts:

using System.Collections.Generic;
using Cratis.Arc.Queries.ModelBound;
namespace Banking.Accounts;
[ReadModel]
public record NamedParts(Dictionary<string, string> Parts)
{
[Path("/api/accounts/find")]
public static NamedParts Find(IDictionary<string, string> parts) =>
new(new Dictionary<string, string>(parts));
}

GET carries one argument containing JSON object text, rather than repeated keys or parts[key] entries:

GET /api/accounts/find?parts=%7B%22region%22%3A%22west%22%2C%22number%22%3A%2242%22%7D

QUERY carries the object directly under arguments in the JSON body:

{
"arguments": {
"parts": { "region": "west", "number": "42" }
}
}

Observable query subscriptions send the same JSON object text as one named argument. String values containing JSON literals (such as "42" or "\"abc\"") and commas stay strings, unchanged. Invalid JSON, a non-object argument, an invalid scalar value, or null for a non-nullable value type returns HTTP 400 with a named malformed-request validation error. An empty object is valid.

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.