Project Structure
A Heximon app is organized by feature, not by layer. Each feature lives in one folder — its module,
controller, providers, schemas, and errors side by side — and a single root module composes them. There
is no server.ts or bootstrap to maintain: the compiler generates the boot entry, so src/ is nothing
but your features.
Start small: one feature, one folder
The smallest real app is a single feature folder plus a root module, sitting beside the three files that
configure it: vite.config.ts (registers the heximon() build step), heximon.config.ts (lists which
Heximon plugins are active — HTTP, events, …), and tsconfig.json.
src/
├── app.module.ts # Root module — imports every feature
└── users/
├── users.module.ts # Declares the feature: providers + http namespace
├── users.controller.ts # HTTP surface; routes declared by handler types
├── users.repository.ts # Data access (a plain provider)
├── user.schema.ts # Request/response DTOs (any Standard Schema)
└── user-errors.ts # Domain errors thrown by the repository
vite.config.ts # heximon() plugin — see Core Concepts
heximon.config.ts # the active compiler plugins
tsconfig.json
There is no entry file because there is nothing for you to write. pnpm dev runs the compiler over
src/, generates the fetch boot, and serves it — by default the generated wiring stays in memory and never
touches disk.
It's written to .heximon/ at your project root (a sibling of src/, gitignored) only when
heximon.config.ts sets debug: true or the platform build needs disk output. The one always-on artifact is
src/.heximon/, the generated graph/declaration directory the editor reads for compile-time type errors —
gitignored and regenerated on every change, so you never edit or commit it.
Group a feature in its module
A feature's module is the one file that names everything the feature owns. It lists plain providers,
names concept classes under their plugin's namespace (http, cqrs, …), and exports any class
another feature may inject. Nothing outside this config gets wired.
import { Module } from "@heximon/runtime";
import { RequestLoggerMiddleware } from "../common/request-logger.middleware";
import { UserNotFoundErrorFilter } from "./user-error.filter";
import { UsersController } from "./users.controller";
import { UsersRepository } from "./users.repository";
export class UsersModule extends Module({
providers: [UsersRepository, RequestLoggerMiddleware],
http: {
controllers: [UsersController],
errorFilters: [UserNotFoundErrorFilter],
},
exports: [UsersRepository], // visible to features that import UsersModule
}) {}
The slot is the registration, so place each class deliberately. The controller and the error filter are
both concept classes the HTTP plugin discovers by the Controller<...> / ErrorFilter<...> clause they
declare (implements or extends), so they go under http.
The
repository is a plain DI class the graph must construct, so it goes in providers — and so does a
middleware, which is then named on the controller (which routes it applies to) but constructed via
providers.
Compose features under a root module
The root module is the one no other module imports. It pulls features together with imports, and the
compiler picks it up as your app automatically — no apps config, no manifest to register.
import { Module } from "@heximon/runtime";
import { CatalogModule } from "./catalog/catalog.module";
import { UsersModule } from "./users/users.module";
export class AppModule extends Module({
imports: [UsersModule, CatalogModule],
}) {}
It is also where app-wide middleware belongs: declared on the root, it becomes the broadest layer of the request onion, wrapping every feature beneath it.
Name files for what they hold
A consistent <domain>.<role>.ts convention makes a file's job obvious from its name, and sorts the
related files for one feature together in your editor. These are the patterns the example apps use.
| Role | Pattern | Example |
|---|---|---|
| Module | <domain>.module.ts | users.module.ts |
| Controller | <domain>.controller.ts | users.controller.ts |
| Contract | <domain>.api.ts | task.api.ts |
| Repository / service | <domain>.repository.ts | users.repository.ts |
| Schema / DTO | <domain>.schema.ts · <domain>.dto.ts | user.schema.ts |
| Domain errors | <domain>-errors.ts | user-errors.ts |
| Error filter | <domain>-error.filter.ts | user-error.filter.ts |
| Middleware | <name>.middleware.ts | request-logger.middleware.ts |
| Test | <subject>.test.ts · <subject>.e2e.test.ts | app.e2e.test.ts |
Share cross-feature code in common/
Code that belongs to no single feature — a request logger, a shared base error — lives in a common/
folder beside your features, never reached for from one feature into another's. Keeping the dependency
arrows pointing at common/, and never sideways between features, is what keeps modules genuinely
independent.
src/
├── app.module.ts
├── common/
│ └── request-logger.middleware.ts # used by more than one feature
├── users/
│ └── …
└── catalog/
└── …
Match the host's layout when you deploy
The src/ convention is for the standalone Vite dev server. A host adapter scans its own directory, so
the same modules just move to where the host expects them. The Nitro and Nuxt adapters look under
server/:
server/
├── app.module.ts
└── users/
└── … # the same feature folders, unchanged
Under Nuxt your Vue frontend lives in app/ and your Heximon modules directly under server/ — one project,
both halves type-checked together.
imports and providers literal — the compiler reads them, it never runs them. Discovery
walks the source AST, so a module's lists must be static arrays of class references. Building a
providers array conditionally, or spreading one in from a function call, is a hard build error —
the compiler rejects the computed list and names the offending entry (config/computed-list), so a
class can never be silently skipped. List every provider and import inline.Growing past one app
A feature with real domain logic earns sub-folders by responsibility (CQRS commands//queries/,
DDD's entity at the feature root), shared infrastructure gets its own module that exports a token every
feature injects, and a whole feature can ship as its own precompiled package. The module-per-feature rule
doesn't change at any of these sizes — only how many folders and packages hold it.
- Publish a Feature Package — ship a module as an installable package with a
requires/exportsboundary. - Library Mode — how the precompiled
create<Module>factory and manifest work under the hood. - Flagship — the full sub-folder layout at real scale: CQRS, DDD, a shared database module, auth, and an error filter, composed under one root per deploy target.
Next steps
- Core Concepts — how modules, providers, and the DI graph fit together.
- Controllers — the two controller modes, routes, middleware, and responses.
- L02 — HTTP validation — two feature folders, a shared
common/middleware, and domain errors that map toproblem+jsonthrough the built-in error formatter, in the flat layout.
Context & Lifecycle
Request-scoped data with Context and the augmentable ContextData, deferred work via waitUntil and background, plus the OnInit, OnBootstrap, and OnShutdown lifecycle hooks, createApp, get, preload, and graceful shutdown with drain.
Overview
The capability map — every production concern (events, background jobs, cron, caching, storage, health, notifications, idempotency, security, tracing, OpenAPI, MCP, schema, branded ids) and the one config line each one adds.