Quick Start
Build the smallest real Heximon app: a provider, a controller that injects it, and a module that ties them together. The compiler reads these three files, builds the wiring, and serves it — you never write a bootstrap or a server file.
pnpm create heximon my-app scaffolds
the same app for you — see Create a new app. This tutorial builds it by hand on
purpose, so you see every file and what it does.What you'll build
GET /users returns a list of users, and GET /users/:id returns one by id — served from an injected
repository.
Create a provider
A provider is any plain class. There's no decorator to add — a class becomes injectable simply by being
listed in a module's providers, and its constructor parameters are its dependencies.
export interface User {
id: string;
name: string;
}
export class UsersRepository {
private readonly users: User[] = [
{ id: "1", name: "Alice" },
{ id: "2", name: "Bob" },
];
public async list(): Promise<User[]> {
return this.users;
}
public async findById(id: string): Promise<User | undefined> {
return this.users.find((user) => user.id === id);
}
public async create(input: { name: string }): Promise<User> {
const user = { id: String(this.users.length + 1), name: input.name };
this.users.push(user);
return user;
}
}
Create a controller
A controller declares implements Controller<"/prefix"> — Controller is a marker whose type argument
carries the prefix, so a type-only import is all it takes. Each route is declared by its handler's action
parameter type (Get<"/:id">) — never in a separate route-config list. The UsersRepository arrives
through the constructor, and the compiler resolves it by class identity.
import type { Controller, Get } from "@heximon/http";
import { type User, UsersRepository } from "./users.repository";
export class UsersController implements Controller<"/users"> {
constructor(private readonly users: UsersRepository) {}
// GET /users
public async list(_action: Get<"/">): Promise<User[]> {
return this.users.list();
}
// GET /users/:id — `pathParams.id` is typed as a string from the "/:id" literal
public async get(action: Get<"/:id">): Promise<User | { error: string }> {
const user = await this.users.findById(action.request.pathParams.id);
return user ?? { error: "User not found" };
}
}
Delete UsersRepository from the module's providers list below and save — your editor puts an error
right on that entry, naming the unmet dependency, before you even run the compiler.
Create a module
A module extends Module({...}). It lists plain providers and names the controller under the HTTP
plugin's namespace, http: { controllers }. This config is the only place the compiler looks to find
your concepts.
import { Module } from "@heximon/runtime";
import { UsersController } from "./users.controller";
import { UsersRepository } from "./users.repository";
export class UsersModule extends Module({
providers: [UsersRepository],
http: { controllers: [UsersController] },
exports: [UsersRepository],
}) {}
A root module composes feature modules with imports — the single root module (the one no other module
imports) is auto-discovered as your app:
import { Module } from "@heximon/runtime";
import { UsersModule } from "./users/users.module";
export class AppModule extends Module({
imports: [UsersModule],
}) {}
Run it
pnpm dev
npm run dev
yarn run dev
bun run dev
curl http://localhost:3000/users
# → [{ "id": "1", "name": "Alice" }, { "id": "2", "name": "Bob" }]
curl http://localhost:3000/users/2
# → { "id": "2", "name": "Bob" }
The dev server compiles src/ to wiring in memory, serves the generated app, and recompiles on every
source change — no entry file, no bootstrap, nothing to restart.
Validate a request
Author a DTO as a SchemaObject class and reference it directly in the route's schema slot. The compiler
turns that one declaration into both the static parameter type and the runtime validator — an invalid
body is rejected with a 400 application/problem+json before your handler runs.
import { SchemaObject } from "@heximon/schema";
import { z } from "zod";
export class CreateUser extends SchemaObject({
name: z.string().min(1),
email: z.email(),
}) {}
import type { Controller, Get, Post } from "@heximon/http";
import { CreateUser } from "./user.schema";
import { type User, UsersRepository } from "./users.repository";
export class UsersController implements Controller<"/users"> {
constructor(private readonly users: UsersRepository) {}
public async list(_action: Get<"/">): Promise<User[]> {
return this.users.list();
}
// POST /users — body validated against `CreateUser` before the handler runs
public async create(action: Post<"/", { body: CreateUser }>): Promise<User> {
const body = await action.request.readValidatedBody(); // invalid → built-in 400
return this.users.create(body);
}
}
curl -X POST http://localhost:3000/users -H "content-type: application/json" -d '{"name": ""}'
# → 400 application/problem+json — "name" is empty, "email" is missing
Write your first test
A provider is a plain class, so it's a plain constructor call in a test — no compiler, no running server.
import { expect, test } from "vitest";
import { UsersRepository } from "../src/users/users.repository";
test("UsersRepository lists seeded users", async () => {
const repo = new UsersRepository();
const users = await repo.list();
expect(users).toHaveLength(2);
expect(users[0]?.name).toBe("Alice");
});
test("UsersRepository finds a user by id", async () => {
const repo = new UsersRepository();
expect(await repo.findById("2")).toEqual({ id: "2", name: "Bob" });
expect(await repo.findById("999")).toBeUndefined();
});
vitest --run
# ✓ test/users.test.ts (2 tests)
A controller takes the same shape — construct it with a fake repository and call its methods directly.
The full story, including in-process HTTP tests via createTestApp, is on Testing.
Make it type-safe with a contract
The controller above works, but a client has no way to know the shape of a response without duplicating
it. A Contract lets you define the API once and bind both the server and a client to the same value.
Define the contract
import { Contract, Route } from "@heximon/contract";
import { z } from "zod";
import { CreateUser } from "./user.schema";
const user = z.object({ id: z.string(), name: z.string() });
export class UsersApi extends Contract({
prefix: "/users",
routes: {
list: Route.get("/").summary("List users").responses({ 200: z.array(user) }),
create: Route.post("/").body(CreateUser).responses({ 201: user }),
},
}) {}
Bind the contract to the controller
A contract-mode controller names the contract as the type argument — implements Controller<UsersApi> —
and the contract carries its own prefix. Handler method names match the route keys, and each action is
typed off its route with Action<UsersApi, "key">.
import type { Action, Controller } from "@heximon/http";
import { UsersApi } from "./users.api";
import { UsersRepository } from "./users.repository";
export class UsersController implements Controller<UsersApi> {
constructor(private readonly users: UsersRepository) {}
public async list(action: Action<UsersApi, "list">) {
return this.users.list();
}
public async create(action: Action<UsersApi, "create">) {
const body = await action.request.readValidatedBody();
return action.respond(201, await this.users.create(body));
}
}
The implements Controller<UsersApi> clause checks that every contract route has a matching handler, and
that each handler's return type satisfies the route's responses schemas — delete the create method and
the class itself fails to compile.
Call it with type safety
The same UsersApi class drives a fully typed client — from tests, another service, or a browser.
The contract is the client: construct it with a transport, then call routes through .client:
import { ClientTransport } from "@heximon/client";
import { UsersApi } from "./users/users.api";
const api = new UsersApi(ClientTransport.fetch({ baseUrl: "http://localhost:3000" }));
const created = await api.client.create({ body: { name: "Carol", email: "carol@example.com" } });
// ^? body: { id: string; name: string } — the 2xx body union; throws ContractError on a non-2xx
Rename a field on UsersApi and every server handler and client call that touches it turns red — you fix
them at compile time, not in production.
Where to go from here
- Build out your API — Contracts covers path params, query schemas, multipart bodies, and per-route middleware.
- Make it a real product — Where next? maps the goal you have next (a database, auth, background jobs, real-time) to the page and package that gets you there.
- Ship it — Deploy covers Node, Cloudflare, Vercel, Netlify, AWS Lambda, and Deno Deploy from the same compiled output.
Installation
Install @heximon/runtime, @heximon/http, and @heximon/build; register the heximon() Vite plugin and the HttpPlugin in heximon.config.ts; set strict ESM tsconfig options; and add the vp dev, check, and pack scripts.
Core Concepts
The six terms a Heximon app is built from — module, provider, constructor injection, controller, the build step, and the config line — each with a link to its dedicated page.