Getting Started
This is the official Lesan tutorial. It follows the original documentation step by step: we first run a minimal server, then we add our first model, then our first act (the Lesan word for an API endpoint), and finally we connect two models with a relation.
Lesan is a collection of a web server and an ODM. Inspired by GraphQL, it delegates data retrieval management to the client โ without adding an extra layer (such as a GraphQL language processor) on either side. It uses the full power of NoSQL databases to embed all the relationships of a schema within itself, without making the server-side programmer manage the duplicated embeddings.
Prerequisitesโ
Before you begin, make sure you have:
- MongoDB 7+ installed and running (we assume
mongodb://127.0.0.1:27017) - Node.js 18+, Bun, or Deno installed
- MongoDB Compass (optional but recommended) โ to inspect what Lesan stores in the database
Lesan is cross-platform โ the same code runs unchanged on Node.js, Bun, and Deno. Only the import differs.
Installationโ
Create a new project and install Lesan plus the MongoDB driver.
Node.jsโ
npm init -y
npm install @hemedani/lesan mongodb
Bunโ
bun init -y
bun add @hemedani/lesan mongodb
Denoโ
Deno fetches packages from JSR at runtime โ just import lesan from jsr:@hemedani/lesan:
import { lesan, MongoClient } from "jsr:@hemedani/lesan";
The Minimal Serverโ
Create a mod.ts file with the following:
import { lesan, MongoClient } from "@hemedani/lesan";
const coreApp = lesan();
const client = await new MongoClient("mongodb://127.0.0.1:27017/").connect();
const db = client.db("dbName"); // change dbName to the appropriate name for your project
coreApp.odm.setDb(db);
coreApp.runServer({ port: 1366, typeGeneration: false, playground: true });
Now run this command in the terminal:
# Node.js
npx tsx mod.ts
# Bun
bun run mod.ts
# Deno
deno run -A mod.ts
You should see this message:
HTTP webserver running.
please send a post request to http://localhost:1366/lesan
you can visit playground on http://localhost:1366/playground
Listening on http://localhost:1366/
Now you can visit the playground at http://localhost:1366/playground. Because no model and no function have been written yet, we still cannot send a request to the server. Let's implement our first model.
Add a New Modelโ
To add a new model, we call the newModel function from coreApp.odm. Let's add a country model. Add the following code before coreApp.runServer:
const countryPure = {
name: string(),
population: number(),
abb: string(),
};
const countryRelations = {};
const countries = coreApp.odm.newModel("country", countryPure, countryRelations);
We also need to import string and number from Lesan. These are validators exported from Superstruct. We use Superstruct to define models and validate function inputs.
The newModel function accepts three inputs:
- First input โ the name of the new model.
- Second input โ the pure fields of that model in the database. It's an object whose keys are the field names and whose values are Superstruct validators.
- Third input โ the relations between models. Because we have only one model here, we pass an empty object. We'll read more about this later.
Finally, newModel returns an object with services such as insertOne, insertMany, updateOne, deleteOne, and so on.
Add an Access Pointโ
Every model needs at least one act as an access point to communicate and send or receive data. To add an act to countries, add the following code before coreApp.runServer:
const addCountryValidator = () => {
return object({
set: object(countryPure),
get: coreApp.schemas.selectStruct("country", 1),
});
};
const addCountry: ActFn = async (body) => {
const { name, population, abb } = body.details.set;
return await countries.insertOne({
doc: {
name,
population,
abb,
},
projection: body.details.get,
});
};
coreApp.acts.setAct({
schema: "country",
actName: "addCountry",
validator: addCountryValidator(),
fn: addCountry,
});
We need to import object and the ActFn type from Lesan.
The setAct functionโ
To add an act to country, we use the setAct function in coreApp.acts. It receives an object with these keys:
schemaโ the name of the model to which we want to set an action.actNameโ a simple string to identify the act.fnโ the function we call when a request arrives for it.validatorโ a Superstruct object called before the act fn, validating the incoming data. It includessetandgetobjects.
There are also three optional keys:
validationRunTypeโ receivesassertorcreate, and determines how the validator runs, so we can create data or change previous data during validation.preActโ an array of functions executed in order before the main endpoint function. With these we can store information in the context or prevent the main function from running. It's mostly used for authentication and authorization โ think of it as middleware in Express.preValidationโ likepreAct, but executes before the validator.
Inside Lesan there is a context, available through the contextFns.getContextModel() function. We can share information between the functions of an act (preAct, preValidation, validator, and fn) through it. By default, the body and headers of each request are available in this context, and if you change the body in the context, the body passed to fn is updated too.
The validator functionโ
In the addCountryValidator function we return the Superstruct object function. It contains two keys:
setโ an object in which we define the required input information for each request on the client side. In the act function we can read it frombody.details.set.getโ an object in which we specify what data can be sent back to the client. The client specifies what it needs with values of0or1for each key:
get: object({
name: enums([0, 1]),
population: enums([0, 1]),
abb: enums([0, 1]),
});
But as you can see, we used the selectStruct function of coreApp.schemas. It has two inputs: the name of the model we want to generate the object for, and the degree of penetration into each relation. The second input can be a number or an object:
get: coreApp.schemas.selectStruct("country", {
provinces: { cities: 1 },
createdBy: 2,
users: { posts: 1 },
});
If given an object, the keys must be relation names of the model, and each value can be a number or another object of its relations. The generated object expands every relation into its own nested get object. We send the data received in the get key directly as a projection to MongoDB.
The fn functionโ
The fn key receives the main act function. It gets an input called body, which is the request body sent from the client side. The request body should be a JSON like this:
{
"service": "main",
"model": "country",
"act": "addCountry",
"details": {
"set": {
"name": "Iran",
"population": 85000000,
"abb": "IR"
},
"get": {
"_id": 1,
"name": 1,
"population": 1,
"abb": 1
}
}
}
serviceโ selects one of the microservices set on the application.modelโ selects one of the models added to the application.actโ selects one of the acts added to the application.detailsโ carries the data being sent to the server along with the data to be delivered back to the user. It has two internal keys:setโ the information we need in the act function. InaddCountrywe extractname,population, andabbfrombody.details.set.getโ the fields the user needs returned. We pass this object directly to Mongo'sprojection.
We use the insertOne function, exported from the countries model, to add a new document. It accepts an object with these keys:
{
doc: OptionalUnlessRequiredId<InferPureFieldsType>;
relations?: TInsertRelations<TR>;
options?: InsertOptions;
projection?: Projection;
}
docโ an object of the pure values of the selected model.relationsโ an object of the relations of this model. There are no relations here yet โ we'll read about this in the next section.optionsโ the official MongoDB driver options forinsertOne.projectionโ a native MongoDB projection used to shape the written data. IninsertOneyou can only penetrate one step into relations (here only pure fields, since there are no relations).
Run and Testโ
Now you can run deno run -A mod.ts (or npx tsx mod.ts / bun run mod.ts).
Using the Playgroundโ
Open http://localhost:1366/playground in your browser โ an interactive explorer where you pick the model, act, and fields, then send the request without writing a line of code. See The Playground for a full walkthrough of its tabs, history, and E2E testing tool.
Using Postman or curlโ
Send a POST request to http://localhost:1366/lesan with this body:
curl -X POST http://localhost:1366/lesan \
-H "Content-Type: application/json" \
-d '{
"service": "main",
"model": "country",
"act": "addCountry",
"details": {
"set": { "name": "Iran", "population": 85000000, "abb": "IR" },
"get": { "_id": 1, "name": 1, "population": 1, "abb": 1 }
}
}'
You should get this result:
{
"body": {
"_id": "6534d7c6c5dec0be8e7bf751",
"name": "Iran",
"population": 85000000,
"abb": "IR"
},
"success": true
}
The runServer functionโ
The last thing to talk about is coreApp.runServer. It receives an object with these keys:
portโ the port the server runs on.playgroundโ aBooleanthat specifies whether the playground is available athttp://{server-address}:{port}/playground.typeGenerationโ aBooleanthat creates a folder nameddeclarations, inside which the type definitions of the program are generated (including the type-safelesanApiclient). We'll read more about this later.staticPathโ an array of paths (strings) whose contents are served statically.corsโ either"*"or an array of URLs allowed to communicate with the server without CORS errors.
Add a Relationโ
As we said before, Lesan embeds all relationships: when you define a relation, the pure fields of both models are stored inside each other. So far we have defined only one model โ let's add a second one, city, that relates to country. Each city belongs to one country, and each country has many cities.
Add the following code to the previous code, before coreApp.runServer:
const cityPure = {
name: string(),
population: number(),
abb: string(),
};
const cityRelations = {
country: {
optional: false,
schemaName: "country",
type: "single" as RelationDataType,
relatedRelations: {
cities: {
type: "multiple" as RelationDataType,
limit: 50,
sort: {
field: "_id",
order: "desc" as RelationSortOrderType,
},
},
},
},
};
const cities = coreApp.odm.newModel("city", cityPure, cityRelations);
We've already talked about newModel. The third input โ the relation definition โ receives an object whose keys are the field names (the relation data is stored inside each document under this name) and whose values are TRelation metadata:
export type RelationDataType = "single" | "multiple";
export type RelationSortOrderType = "asc" | "desc";
export type TRelatedRelation = {
type: RelationDataType;
limit?: null | number;
sort?: {
field: string;
order: RelationSortOrderType;
};
};
interface TRelation {
schemaName: string;
type: RelationDataType;
optional: boolean;
sort?: {
field: string;
order: RelationSortOrderType;
};
relatedRelations: {
[key: string]: TRelatedRelation;
};
}
schemaNameโ the exact name of the other schema to establish a relation with.typeโ whether the relation type issingleormultiple.optionalโ whether it is mandatory to enter this relation when inserting a new document.sort(optional, only formultiple) โ which field of the related schema the embedded data should be sorted by. It receives an object with afield(a schema field name) and anorder(ascordesc).relatedRelationsโ the effect of this schema on the other side of the relation. For each related relation it receives atype, an optionallimit(how many related documents to keep embedded), and an optionalsort.
Add a new act with a relationโ
Let's define an act for the city model:
const addCityValidator = () => {
return object({
set: object({
...cityPure,
country: objectIdValidation,
}),
get: coreApp.schemas.selectStruct("city", 1),
});
};
const addCity: ActFn = async (body) => {
const { country, name, population, abb } = body.details.set;
return await cities.insertOne({
doc: { name, population, abb },
projection: body.details.get,
relations: {
country: {
_ids: new ObjectId(country),
relatedRelations: {
cities: true,
},
},
},
});
};
coreApp.acts.setAct({
schema: "city",
actName: "addCity",
validator: addCityValidator(),
fn: addCity,
});
We need to import objectIdValidation and ObjectId from Lesan.
We've already seen validator, act, setAct, and insertOne. Here we only talk about the relations input of the insert function. Its type is:
export type TInsertRelations<T extends IRelationsFileds> = {
[mainKey in keyof T]?: {
_ids: ObjectId | ObjectId[];
relatedRelations?: {
[key in keyof T[mainKey]["relatedRelations"]]: boolean;
};
};
};
This object's keys are the relation names we defined in the model. Each value has:
_idsโ anObjectIdor an array ofObjectIds to link to.relatedRelationsโ an object whose keys are the related-relation names, with abooleanvalue. Iftrue, in addition to the relation being saved in this new document, the created document is also saved in the related relation (on the other side). Iffalse, the relation is saved only in this new document.
Here, by adding a city and giving the country's _id, Lesan stores both the pure fields of that country inside the newly created city, and โ inside that country, in an array of objects โ the pure fields of this city. Both sides, automatically.
All the codeโ
Here is the complete code so far:
import {
ActFn,
lesan,
MongoClient,
number,
object,
ObjectId,
objectIdValidation,
RelationDataType,
RelationSortOrderType,
string,
} from "@hemedani/lesan";
const coreApp = lesan();
const client = await new MongoClient("mongodb://127.0.0.1:27017/").connect();
const db = client.db("dbName");
coreApp.odm.setDb(db);
const countryCityPure = {
name: string(),
population: number(),
abb: string(),
};
const countries = coreApp.odm.newModel("country", countryCityPure, {});
const cityRelations = {
country: {
optional: false,
schemaName: "country",
type: "single" as RelationDataType,
relatedRelations: {
cities: {
type: "multiple" as RelationDataType,
limit: 50,
sort: {
field: "_id",
order: "desc" as RelationSortOrderType,
},
},
},
},
};
const cities = coreApp.odm.newModel("city", countryCityPure, cityRelations);
const addCountryValidator = () => {
return object({
set: object(countryCityPure),
get: coreApp.schemas.selectStruct("country", 1),
});
};
const addCountry: ActFn = async (body) => {
const { name, population, abb } = body.details.set;
return await countries.insertOne({
doc: { name, population, abb },
projection: body.details.get,
});
};
coreApp.acts.setAct({
schema: "country",
actName: "addCountry",
validator: addCountryValidator(),
fn: addCountry,
});
const addCityValidator = () => {
return object({
set: object({
...countryCityPure,
country: objectIdValidation,
}),
get: coreApp.schemas.selectStruct("city", 1),
});
};
const addCity: ActFn = async (body) => {
const { country, name, population, abb } = body.details.set;
return await cities.insertOne({
doc: { name, population, abb },
projection: body.details.get,
relations: {
country: {
_ids: new ObjectId(country),
relatedRelations: { cities: true },
},
},
});
};
coreApp.acts.setAct({
schema: "city",
actName: "addCity",
validator: addCityValidator(),
fn: addCity,
});
coreApp.runServer({ port: 1366, typeGeneration: true, playground: true });
Run it and open the playground. You can now add a country, then add a city by selecting the country's _id.
What happens in the database?โ
If you open MongoDB Compass, you'll see that when you add a city, the pure values are stored as embedded on both sides of the relation:
// countries collection
{
"_id": "6534d7c6c5dec0be8e7bf751",
"name": "Iran",
"population": 85000000,
"abb": "IR",
"cities": [
{
"_id": "6534d8a1c5dec0be8e7bf888",
"name": "Tehran",
"population": 9000000,
"abb": "THR"
}
]
}
// cities collection
{
"_id": "6534d8a1c5dec0be8e7bf888",
"name": "Tehran",
"population": 9000000,
"abb": "THR",
"country": {
"_id": "6534d7c6c5dec0be8e7bf751",
"name": "Iran",
"population": 85000000,
"abb": "IR"
}
}
Here is what that looks like in MongoDB Compass โ the country collection:
And the city collection:
This makes receiving data much faster โ no joins or extra queries needed.
The only noteworthy point: a limited number of cities are stored inside the country (here limit: 50). Save as many as you think you'll need for the first page. To get the rest of the cities, you query their own schema.
Next Stepsโ
You now have a running Lesan API with models, relations, and embedded data. From here:
- Add More Relations โ more related relations, a
capital, and a many-to-manyusermodel - Managing Relations โ updating relations of existing documents with
addRelation/removeRelation - Find & findOne โ reading documents and their embedded relations back out
- Aggregation โ penetrating more than one step into relations
- findOneAndUpdate โ updating documents (and why it's the hardest problem in Lesan)
- deleteOne โ deleting documents, with
hardCascade - insertMany โ inserting many documents with relations embedded correctly
- Models & ODM โ pure fields, relations in depth, and every ODM operation
- Queries & Projections โ client-driven projections, filtering, and aggregation
- Server API โ middleware hooks (
preAct), context, services, and configuration - Type System โ generated types and the
lesanApiclient in detail - What Is the Relationship Really? โ why Lesan's relationships work the way they do