Model-bound queries
A task list, a lookup by ID, and a live board all read the same kind of data. Written as separate routes, each needs its own argument parsing, its own “not found” convention, and its own response shape. In Arc you put these reads on the read model they return, as static methods. Each @query() method becomes a route, its parameters are bound from the request or resolved as services, and every answer uses the same QueryResult envelope.
Declare a read model and its queries
Section titled “Declare a read model and its queries”The Tasks sample exposes a list, a lookup, and a live list:
import { field } from '@cratis/fundamentals';import { query, readModel, service } from '@cratis/arc.core';import type { BehaviorSubject } from 'rxjs';import { TaskId } from '../TaskId.js';import { TaskTitle } from '../TaskTitle.js';import { Tasks } from '../Tasks.js';
@readModel()export class TaskItem { @field(TaskId) id!: TaskId; @field(TaskTitle) title!: TaskTitle;
@query(service(Tasks)) static allTasks(tasks: Tasks): TaskItem[] { return tasks.all(); }
@query() static taskById(id: TaskId, tasks: Tasks): TaskItem | undefined { return tasks.byId(id); }
@query() static observeAllTasks(tasks: Tasks): BehaviorSubject<TaskItem[]> { return tasks.observeAll(); }}With the sample running, GET /api/tasks/listing/all-tasks returns the list, and GET /api/tasks/listing/task-by-id?id=<uuid> returns one task. The @field declarations describe the shape each query returns; Arc encodes it on the way out, and the proxy generator uses the same declarations for the frontend model.
What makes a method a query
Section titled “What makes a method a query”Arc serves a method when all three hold:
- the class is marked
@readModel(), - the method is
static, - the method is marked
@query(...).
Any other static method on the class is an ordinary helper and gets no route. That lets you keep shared filtering or mapping code next to the queries without exposing it. Arc rejects the declarations that would otherwise be silently ignored. @query() on an instance or private method throws as soon as the class is loaded, and @roles, @authorize, @allowAnonymous, or @path on a static method without @query() fails at build().
Bind every parameter
Section titled “Bind every parameter”Each parameter is one of three kinds:
| Descriptor | Binds |
|---|---|
argument(name, Type, options?) | A named argument from the query string or QUERY body; see Query arguments |
service(Token) | A service from the execution scope; see Dependency injection |
queryOptions() | The request’s paging and sorting; see Paging and sorting |
Standard decorators cannot see parameter types, so Arc needs to learn them somewhere:
- With generated artifact metadata, as the sample uses,
@query()is enough. The generator reads the source: a primitive, concept, or array of them becomes a named argument, and a concrete class becomes a service. That is howtaskById(id: TaskId, tasks: Tasks)bindsidfrom the query string andtasksfrom the scope. - Without it, list one descriptor per parameter, in the same order as the method signature:
@query(argument('id', TaskId), service(Tasks)).allTasksshows this form; explicit descriptors always win over generated ones.
A mismatch fails early. TypeScript error TS1241 on a @query(...) usually means the descriptors do not match the parameters, and a parameter nobody describes fails at build() with Unbound parameters. See Troubleshooting.
Services resolve from a fresh scope per request, or per subscription for an observable query. A scoped service is never shared between two callers.
Return what the caller should see
Section titled “Return what the caller should see”A query method returns data, and Arc wraps it:
| The method returns | The caller gets |
|---|---|
| An array of the model | data is the array. Arc pages and sorts it in memory when the request asks |
| One model | data is the object |
undefined or null | A successful result with no data property |
queryPage(items, totalItems) | data is items, and paging reports your totals; see Paging and sorting |
| A value a registered renderer accepts | Whatever the renderer produces, such as a database-side page |
| An observable source | A live query; see Declare observable queries |
A method can be async or return a promise of any of these. Arc awaits it before rendering, so static async byId(...): Promise<TaskItem | undefined> behaves exactly like its synchronous version. The same holds for an observable query: an async method that awaits setup work and then returns a source is fine.
Decorated models and concepts are encoded to their wire shape, so a TaskId goes out as a UUID string.
Absence is an answer, not an error
Section titled “Absence is an answer, not an error”taskById returns undefined when no task has that ID. The caller gets HTTP 200, isSuccess: true, and no data property. An empty array is a successful empty list. Arc does not turn absence into a 404, because a query that found nothing did its job.
A thrown error is different. It becomes a failed result with hasExceptions: true and status 500, and the message is replaced unless you enable exception details. Let storage failures throw; never catch them and return undefined or [], or the caller cannot tell “no task” from “the database is down”.
With generated metadata, Arc also checks the value against the declared return type. A method declared as TaskItem that returns undefined, or one declared as TaskItem[] that returns a single object, fails with an exception instead of sending the caller a shape its generated client does not expect. Declare TaskItem | undefined when absence is possible.
Declare observable queries
Section titled “Declare observable queries”A query that returns a live source serves a snapshot on GET and streams changes to subscribers. With generated metadata, Arc infers this from the declared return type, as it does for observeAllTasks. Without it, add { observable: true }: @query({ observable: true }, service(Tasks)). Arc needs to know before registration so snapshots, server-sent events, WebSocket admission, introspection, and generated clients agree on the contract. A snapshot query that returns a live source anyway fails at run time. Arc releases through the first hook on the returned object: Symbol.asyncDispose, Symbol.dispose, dispose(), close(), or return() when the object itself has next() and return(). Arc never releases a service-container-owned value; its owning scope or registry handles disposal. It never subscribes or calls [Symbol.asyncIterator]() or [Symbol.iterator]() during cleanup. A cold RxJS Observable or shared Subject without a release hook is rejected but left untouched; release failures (including timeouts) are reported alongside the rejection even if the request is canceled. A release that fails after the deadline is logged.
Use an RxJS BehaviorSubject when there is always a current value, and Subject or Observable when there may not be one yet. Observable queries covers sources, paging, and authorization, and Subscribe to an observable query covers the transports.
Routes and identity
Section titled “Routes and identity”By default the route is /api/<discovery-namespace>/<method-name>: TaskItem.allTasks lives at /api/tasks/listing/all-tasks. Its identity is Tasks.Listing.TaskItem.allTasks, including the read-model class. @path('/api/custom-path') on a query method overrides the class path. See Endpoint mapping.
Authorization
Section titled “Authorization”@roles, @authorize, and @allowAnonymous work on the read-model class and on query methods. An explicit method declaration replaces the class declaration; without one, the method inherits it. For example, class @allowAnonymous() does not bypass method @roles('Reader'). A denied caller never reaches the method and gets isAuthorized: false. A role says who may call the query, not which rows they may see; see Authorizing commands and queries.
A read model owns its queries as static @query() methods. Generated metadata, or explicit descriptors, tell Arc which parameters are arguments and which are services. Return the data, sync or async; return nothing for absence and throw for failure; return a source for a live query. Arc supplies the route, the envelope, and paging.
Next step
Section titled “Next step”Query arguments covers optional, array, and concept arguments. Then Paging and sorting shows how to cut a page in your data source instead of in memory.