Heximon Logo
Getting Started

Quick Start

Build your first Heximon app in three files — a provider, a controller, and a module, wired by the compiler and served by Vite.

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.

Want the finished shape in one command instead of typing it? 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.

src/users/users.repository.ts
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.

src/users/users.controller.ts
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.

src/users/users.module.ts
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:

src/app.module.ts
import { Module } from "@heximon/runtime";
import { UsersModule } from "./users/users.module";

export class AppModule extends Module({
  imports: [UsersModule],
}) {}

Run it

pnpm dev
terminal
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.

src/users/user.schema.ts
import { SchemaObject } from "@heximon/schema";
import { z } from "zod";

export class CreateUser extends SchemaObject({
  name: z.string().min(1),
  email: z.email(),
}) {}
src/users/users.controller.ts
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);
  }
}
terminal
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.

test/users.test.ts
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();
});
terminal
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

src/users/users.api.ts
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">.

src/users/users.controller.ts
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:

client.ts
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 APIContracts covers path params, query schemas, multipart bodies, and per-route middleware.
  • Make it a real productWhere next? maps the goal you have next (a database, auth, background jobs, real-time) to the page and package that gets you there.
  • Ship itDeploy covers Node, Cloudflare, Vercel, Netlify, AWS Lambda, and Deno Deploy from the same compiled output.
Copyright © 2026