Heximon Logo
Essentials

Project Structure

The recommended directory layout for a Heximon app — one folder per feature module, the three config files, naming conventions, and the generated boot entry.

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.

project
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.

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

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

RolePatternExample
Module<domain>.module.tsusers.module.ts
Controller<domain>.controller.tsusers.controller.ts
Contract<domain>.api.tstask.api.ts
Repository / service<domain>.repository.tsusers.repository.ts
Schema / DTO<domain>.schema.ts · <domain>.dto.tsuser.schema.ts
Domain errors<domain>-errors.tsuser-errors.ts
Error filter<domain>-error.filter.tsuser-error.filter.ts
Middleware<name>.middleware.tsrequest-logger.middleware.ts
Test<subject>.test.ts · <subject>.e2e.test.tsapp.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.

project
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/:

nitro / nuxt
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.

Keep 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/exports boundary.
  • 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 to problem+json through the built-in error formatter, in the flat layout.
Copyright © 2026