Project Layout
Before we look at any model or act, understand how the app is organized. Lesan doesn't dictate a folder structure โ this is the one the advanced-tutorial uses, and it scales well: one file per model, one folder per act, and a shared utils/ layer.
advanced-tutorial/
โโโ mod.ts # entry: lesan(), setDb, register all models, runServer
โโโ deno.json # tasks + import aliases (@model, @lib, lesan)
โโโ models/
โ โโโ mod.ts # barrel re-exporting every model file
โ โโโ user.ts # one file per model: pure fields + relations + factory
โ โโโ excludes.ts # per-schema field-exclusion lists used by relations
โ โโโ featureConstants.ts # the feature-enum array used by feature flags
โ โโโ ... # 15 model files total
โโโ src/
โ โโโ mod.ts # functionsSetup(): calls every domain's *Setup()
โ โโโ <domain>/
โ โ โโโ mod.ts # e.g. userSetup(): registers all user acts
โ โ โโโ <act>/
โ โ โโโ mod.ts # coreApp.acts.setAct({ schema, actName, fn, validator, preAct })
โ โ โโโ <act>.fn.ts # the ActFn implementation
โ โ โโโ <act>.val.ts# the superstruct validator (set / get)
โ โโโ ... # one folder per model domain
โโโ utils/ # shared helpers, aliased as @lib
โโโ http/
โ โโโ e2e.hurl # the 38-request end-to-end test
โ โโโ _fixtures/seed.ts # ghost-admin seed script
โโโ declarations/ # generated selectInp.ts (do not hand-edit)
The entry point (mod.ts)โ
Everything starts here. lesan() creates the app, setDb attaches MongoDB, each newModel(...) factory registers a model, functionsSetup() registers every act, and runServer boots the HTTP server.
import { lesan, MongoClient } from "lesan";
import {
budgetLines, createInventoryIndex, createUserTextIndex, files,
inventories, organizations, processes, processSteps, products,
purchaseOrders, stepApprovals, stockMovements, stores, tags,
tenders, units, users,
} from "@model";
import { functionsSetup } from "./src/mod.ts";
const MONGO_URI = Deno.env.get("MONGO_URI") || "mongodb://127.0.0.1:27017/";
const APP_PORT = Deno.env.get("APP_PORT") || 1380;
const ENV = Deno.env.get("ENV") || "development";
export const coreApp = lesan();
const client = await new MongoClient(MONGO_URI).connect();
const db = client.db("advancedTutorial");
coreApp.odm.setDb(db);
// Registering a model returns the ODM handle. One exported handle per model:
export const user = users();
export const file = files();
export const tag = tags();
export const organization = organizations();
export const unit = units();
export const process = processes();
export const processStep = processSteps();
export const product = products();
export const store = stores();
export const inventory = inventories();
export const stockMovement = stockMovements();
export const purchaseOrder = purchaseOrders();
export const stepApproval = stepApprovals();
export const budgetLine = budgetLines();
export const tender = tenders();
export const { setAct, setService, getAtcsWithServices } = coreApp.acts;
export const { selectStruct, getSchemas } = coreApp.schemas;
functionsSetup();
createInventoryIndex();
createUserTextIndex();
coreApp.runServer({
port: Number(APP_PORT),
typeGeneration: true,
playground: ENV === "development" ? true : false,
staticPath: ["/uploads"],
cors: ["http://localhost:3000"],
});
Model registration order matters
Each newModel call registers one schema and then rebuilds the inverse (relatedRelations) map for every schema. All registrations stay together in mod.ts in dependency order โ a model can reference another model's name in its relations before that model is registered, but keep every newModel call together so the reverse map is complete before any act runs.
Import aliasesโ
The app uses path aliases so imports stay clean. In deno.json:
{
"imports": {
"lesan": "../../src/mod.ts",
"mongodb": "npm:mongodb@^6.3.0",
"@model": "./models/mod.ts",
"@lib": "./utils/mod.ts"
}
}
lesanโ the framework itself (this repo's source). In your own project this would be@hemedani/lesan(npm/Bun) orjsr:@hemedani/lesan(Deno).@modelโ the model barrel (pure fields, relations, factories,excludes, feature enums).@libโ the shared utils barrel.
So an act can import both its model handle and the utils in one line:
import { coreApp, purchaseOrder } from "../../../mod.ts";
import { resolveProcessForPO, throwError } from "@lib";
One file per modelโ
A model file declares its pure fields, its relations, and a factory that registers it. For example models/tag.ts:
import { string, color } from "lesan";
import { coreApp } from "../mod.ts";
export const tag_pure = {
name: string(),
color: string(),
};
export const tag_relations = {};
export const tags = () => coreApp.odm.newModel("tag", tag_pure, tag_relations);
Relations reference other models by schemaName. Here's models/unit.ts showing a single relation with excludes and a back-reference:
export const unit_relations = {
organization: {
schemaName: "organization",
type: "single" as RelationDataType,
optional: false,
excludes: organization_excludes,
relatedRelations: { units: { type: "multiple" as RelationDataType, limit: 50 } },
},
// head (User), parentUnit (Unit), ...
};
One folder per actโ
Every act is a folder with three files โ the registration (mod.ts), the implementation (<act>.fn.ts), and the validator (<act>.val.ts). For example src/tag/addTag/:
// src/tag/addTag/mod.ts
export const addTagSetup = () =>
coreApp.acts.setAct({
schema: "tag",
actName: "addTag",
validationRunType: "create",
validator: addTagValidator(),
fn: addTagFn,
});
// src/tag/addTag/addTag.fn.ts
export const addTagFn: ActFn = async (body) => {
const { set, get } = body.details;
return await tag.insertOne({ doc: set, projection: get });
};
// src/tag/addTag/addTag.val.ts
export const addTagValidator = () => {
return object({
set: object({ name: string(), color: optional(string()) }),
get: selectStruct("tag", 1),
});
};
Wiring the acts (src/mod.ts)โ
Each domain exports a *Setup() that registers its acts; functionsSetup() calls them all:
export const functionsSetup = () => {
userSetup();
fileSetup();
tagSetup();
organizationSetup();
unitSetup();
productSetup();
storeSetup();
inventorySetup();
stockMovementSetup();
processSetup();
processStepSetup();
purchaseOrderSetup();
stepApprovalSetup();
budgetLineSetup();
tenderSetup();
};
Generated types (declarations/)โ
With typeGeneration: true, every boot writes declarations/selectInp.ts โ a superstruct schema for each model's selectable get projection. It's generated output: do not hand-edit it.
Runtime differencesโ
This app reads config from Deno.env and runs with deno task. On Node/Bun you'd read process.env instead, and the import aliases would be handled by your bundler/tsconfig paths rather than deno.json. Everything else is identical.
Next: the user model and authentication.