Skip to content

Build an Arc application

An application builder collects your commands, read models, validators, and services, checks that they fit together, and creates the ArcServer that every host uses. You describe the application once; the host is a separate choice.

The Tasks entry point shows discovery, service registration, and generated metadata in one place. For an artifact with no injected parameters, you can also add it explicitly:

Status.ts
import { field } from '@cratis/fundamentals';
import { query, readModel } from '@cratis/arc.core';
@readModel()
export class Status {
@field(String) value = 'ready';
@query()
static current(): Status { return new Status(); }
}
main.ts
import { ArcApplication } from '@cratis/arc.core';
import { Status } from './Status.js';
const builder = ArcApplication.createBuilder();
builder.add(Status);
export const app = await builder.build();
await app.run({ port: Number(process.env.PORT ?? 3000) });

The Tasks command and queries do take parameters. In the Tasks bootstrap, useGeneratedMetadata(metadata) installs the bindings that arc-proxygenerator --metadata extracted from the source, so handle(tasks: Tasks) and a bare @query() need no token lists. Call it before discover() or add(). Without it, the sample’s artifacts would need explicit @inject(...) and @query(...) tokens, and build() rejects them as written. See Generate artifact metadata.

The Tasks sample sets Development in Samples/Tasks/appsettings.json; run the workspace command from its package directory (Yarn does this). createBuilder accepts the configuration options (ArcOptions). build() returns an ArcApplication, and app.server is its ArcServer. This TypeScript setup corresponds to C#‘s standalone ArcApplication.CreateBuilder(args), builder.AddCratisArc(), builder.Build(), app.UseCratisArc(), app.RunAsync(). On Node, app.run() performs the standalone host step. For an Arc + Chronicle comparison, see Add event sourcing.

Before a listener opens, build() checks the declared graph: missing service registrations, dependency cycles, singletons that capture shorter-lived services, and decorators placed where they have no effect. It never runs a service factory to do this.

discover(folderUrl, { rootNamespace? }) imports every exported decorated class below the folder, in deterministic path order.

  • It accepts emitted .js files or loader-backed .ts files, not a mix of both.
  • It skips dist, node_modules, given, for_*, index.*, declaration files, symbolic links, co-located *.proxy.ts / *.proxy.js files, and React .tsx files.
  • It refuses a folder that contains the entry point, or an imported bootstrap that is itself calling discovery. Keep artifacts in a dedicated folder.
  • It derives each artifact’s namespace from its path below the folder, and reports a class discovered under two different namespaces.

A folder rename changes the derived routes. Endpoint mapping shows how to pin a route.

Bundled production builds often cannot import a folder at runtime. Pass the classes instead:

builder.add(RegisterTask, RegisterTaskValidator, TaskItem);

add() rejects a class without an Arc decorator. Give explicitly added artifacts stable namespaces with @command({ namespace: 'Tasks.Registration' }) and @readModel({ namespace: 'Tasks.Listing' }). If you rely on class names in routes, configure the bundler to keep them (keepNames in esbuild), or set explicit namespaces and paths.

Besides commands, read models, and validators, add() and discover() recognize classes marked @queryRenderer(), @readModelInterceptor(), @commandResponseValueHandler(), @identityDetailsProvider(), and the lifetime decorators @singleton(), @scoped(), and @transient().

The builder also registers services that change pipeline behavior:

Builder methodRegisters
services.addSingleton / addScoped / addTransientAn application service; see Dependency injection
addAuthorizationPolicy(name, policy)A named policy; see Authorization policies
addCommandResponseValueHandler(token)A handler for server-side return values; see Response value handlers
addCommandKeyResolver(token)A rule that computes a command key; see Command context
addCommandContextValuesProvider(token)Values attached to every command context
addReadModelForCommandResolver(token)A source for commandReadModel(...) parameters
addQueryRenderer(token)A renderer for provider-owned query results; see Query renderers
addReadModelInterceptor(token)A read-model transform; see Read-model interception

After importing their packages, call builder.withMongoDB(...), builder.withDrizzle(...), or builder.withChronicle(...). Importing the package adds its method to the builder. Each package also exports a function form, withX(builder, options). Configuration from appsettings.json is available to Chronicle and MongoDB, but model classes, clients, and authentication must be provided explicitly.

  • await app.run(...) or await app.start(...) use the standalone Node host.
  • expressApp.use(cratisArc(app)), await fastifyApp.register(cratisArc, { arc: app }), and honoApp.use(cratisArc(app)) hand the application to a web framework. Import cratisArc from the matching adapter package. The host keeps listener ownership; call await app.dispose() at shutdown. See Host adapters.
  • app.fetch(request) returns an Arc response or 404 for foreign paths; app.handle(request) returns null for fall-through. These Fetch methods alone do not establish edge-runtime compatibility: the core still imports Node runtime modules.

A caller-owned ServiceRegistry passed in the options cannot be combined with builder service registrations.