Coming from Express and NestJS
If you build Node.js backends today, you probably write one of two shapes: an Express route handler that parses, validates, and answers by hand, or a NestJS controller with DTO classes and class-validator decorators. Arc keeps the parts you like (classes, decorators, dependency injection) and removes the parts you repeat: the route, the parser, the status-code mapping, and the client that has to match all of it.
An Express 5 route handler
Section titled “An Express 5 route handler”import express from 'express';import { z } from 'zod';
const tasks = new Map<string, string>();const RegisterTask = z.object({ id: z.uuid(), title: z.string().min(1, 'A title is required') });
const app = express();app.use(express.json());app.post('/api/tasks/register', (request, response) => { const parsed = RegisterTask.safeParse(request.body); if (!parsed.success) { response.status(400).json({ errors: parsed.error.issues.map(issue => ({ path: issue.path, message: issue.message })) }); return; } tasks.set(parsed.data.id, parsed.data.title); response.json({ id: parsed.data.id });});app.get('/api/tasks', (_request, response) => { response.json([...tasks].map(([id, title]) => ({ id, title })));});app.listen(3000, '127.0.0.1');A NestJS 11 controller
Section titled “A NestJS 11 controller”This is illustrative NestJS 11 code with class-validator and the global ValidationPipe:
import { Body, Controller, Get, Injectable, Post } from '@nestjs/common';import { IsNotEmpty, IsUUID, MaxLength } from 'class-validator';
export class RegisterTaskDto { @IsUUID() id!: string; @IsNotEmpty({ message: 'A title is required' }) @MaxLength(100) title!: string;}
@Injectable()export class TasksService { readonly #items = new Map<string, string>(); register(id: string, title: string): void { this.#items.set(id, title); } all() { return [...this.#items].map(([id, title]) => ({ id, title })); }}
@Controller('api/tasks')export class TasksController { constructor(private readonly tasks: TasksService) {}
@Post('register') register(@Body() dto: RegisterTaskDto) { this.tasks.register(dto.id, dto.title); return { id: dto.id }; }
@Get() all() { return this.tasks.all(); }}The same thing in Arc
Section titled “The same thing in Arc”The Tasks sample splits it by what each piece does:
@command()export class RegisterTask { @field(TaskId) id!: TaskId; @field(TaskTitle) title!: TaskTitle;
handle(tasks: Tasks): TaskId { tasks.register(this.id, this.title); return this.id; }}
@validator(RegisterTask)export class RegisterTaskValidator extends CommandValidator<RegisterTask> { constructor() { super(); this.ruleFor(command => command.title).notEmpty().withMessage('A title is required'); this.ruleFor(command => command.title).maxLength(100).withMessage('A title can have at most 100 characters'); }}
@readModel()export class TaskItem { @field(TaskId) id!: TaskId; @field(TaskTitle) title!: TaskTitle;
@query(service(Tasks)) static allTasks(tasks: Tasks): TaskItem[] { return tasks.all(); }}The imports and the TaskId, TaskTitle, and Tasks types are in the linked sample. The sample’s generated metadata binds handle(tasks: Tasks) to the Tasks service; without it, add @inject(Tasks). Your first command shows every file.
What changed and why
Section titled “What changed and why”| You wrote | Arc gives you |
|---|---|
| A route string per endpoint | Routes derived from folders and names, overridable with @path |
express.json() and safeParse, or @Body() and ValidationPipe | Binding from @field declarations; the wrong shape is a 400 malformedRequest before your code runs |
| Your own error JSON | The Arc result envelope with validationResults, the same for every endpoint, which the @cratis/arc client understands |
class-validator decorators on the DTO | A CommandValidator beside the command, plus ConceptValidator rules that follow a value everywhere |
| A separate “validate only” endpoint, if any | POST <route>/validate for every command |
| NestJS guards | @roles, @authorize, policies, checked before validation so denied callers never see rule messages |
| NestJS providers and constructor injection | Arc services bound by generated metadata or explicit tokens, and a build that rejects missing registrations and captive lifetimes |
Hand-written frontend fetch calls and types | Generated proxies with the same routes and the client-safe validation rules |
| Polling or a custom WebSocket for live lists | Observable queries on the same route |
The biggest shift is the split between commands that change state and read models that serve it. Arc is a CQRS framework: the command does not return the list, and the query does not change anything. You do not have to adopt event sourcing to get that split.
Arc does not replace your web framework. You can mount Arc in Express, Fastify, or Hono and keep your existing routes, health checks, and middleware beside it.
When to stay where you are
Section titled “When to stay where you are”- You need what NestJS’s module system gives you: GraphQL resolvers, microservice transports, a large plugin ecosystem, or a team already fluent in it. Arc covers commands, queries, and live queries over HTTP, not those.
- You need production stability now. Arc for TypeScript is a source preview: nothing is published to npm, and it does not have full parity with Arc on .NET. Check the capability reference.
- Your endpoints are not command- or query-shaped, such as file uploads, webhooks with fixed external contracts, or streaming downloads. Keep those as ordinary routes next to Arc.