Skip to main content

Why Lesan

TL;DR โ€” Lesan treats relationships as self-maintaining data: they're defined one-directional but embedded bi-directionally in storage, and Lesan keeps every embedded copy in sync automatically. Reads that cost other stacks ~2.5 million documents cost Lesan ~25 thousand. This page is the full argument, extracted from the companion article at examples/whyLesan/article.md.

The problem: a "simple" request that kills your APIโ€‹

Imagine a screen that asks for: all 250 countries, and for each one โ€” the 50 most recent users, the 50 oldest users, the 50 most populous provinces (plus their 50 recent and 50 oldest users), and the 50 most populous cities (plus theirs).

LevelWhat you fetchDocuments
1250 countries, by population250
250 recent + 50 oldest users per country250 ร— 2 ร— 50
350 populous provinces per country250 ร— 50
450 recent + 50 oldest users per province250 ร— 50 ร— 2 ร— 50
550 populous cities per country250 ร— 50
650 recent + 50 oldest users per city250 ร— 50 ร— 2 ร— 50
Totalโ‰ˆ 2,550,250

In a classic ORM (Prisma, Mongoose) that's 2.5 million round-trips between your server and your database โ€” and that's where your latency actually lives. See the head-to-head benchmark for the measured difference.

The core idea: relationships as self-maintaining dataโ€‹

Most frameworks compute relationships at read time (SQL JOINs, MongoDB $lookup or nested find loops). Lesan stores them instead.

Relationships are one-directional in definition, embedded bi-directionally in storage, and kept in sync by Lesan. This is the entire model definition from the companion example:

const pure = {
name: string(),
population: number(),
abb: string(),
};

const provinceRelations = {
country: {
optional: false,
schemaName: "country",
type: "single",
relatedRelations: {
provinces: {
type: "multiple",
limit: 50,
sort: { field: "_id", order: "desc" },
},
provincesByPopulation: {
type: "multiple",
limit: 50,
sort: { field: "population", order: "desc" },
},
},
},
};

const provinces = coreApp.odm.newModel("province", pure, provinceRelations);

Read relatedRelations as a contract: "when a province points at a country, that country gains provinces (50 newest) and provincesByPopulation (50 most populous)." Two pre-sorted, limited, embedded arrays โ€” maintained automatically.

Writes: insert a province, everything updatesโ€‹

Adding a country needs no relation code. Adding a province declares the target and which relatedRelations to refresh โ€” and Lesan does the rest (insert, embed into both sorted arrays, embed the forward snapshot, evict past limit):

const addProvince: ActFn = async (body) => {
const { name, population, abb, countryId } = body.details.set;
return await provinces.insertOne({
doc: { name, population, abb },
relations: {
country: {
_ids: new ObjectId(countryId),
relatedRelations: { provinces: true, provincesByPopulation: true },
},
},
projection: body.details.get,
});
};

Update and delete sync automatically too โ€” re-sorting provincesByPopulation when population changes, repairing both arrays on delete. No updateMany + arrayFilters, no sync jobs, no stale-data bugs.

Reads: one request, whole treeโ€‹

The client drives the projection (GraphQL-style, no query language):

const getCountries: ActFn = async (body) => {
const { set, get } = body.details;
return await countries
.aggregation({ pipeline: [], projection: get })
.toArray();
};
{
"service": "main",
"model": "country",
"act": "getCountries",
"details": {
"set": { "page": 1, "limit": 250 },
"get": {
"name": 1,
"provincesByPopulation": {
"name": 1,
"users": { "name": 1, "age": 1 },
"usersByAge": { "name": 1, "age": 1 }
}
}
}
}

The same request that cost ~2,550,250 documents elsewhere costs ~25,250 with Lesan โ€” a ~100ร— reduction in work, and a small number of round-trips instead of thousands.

The honest tradeoffsโ€‹

  • Writes cost more โ€” relation touches update embedded copies too. Mitigated by limit, excludes, and pure projections; write-heavy/shallow-read apps see less benefit.
  • Duplication is real โ€” embedded copies are trimmed pure projections, not full documents.
  • 16 MB doc ceiling โ€” bounded by limit on every back-reference.
  • High-cardinality reverse lists โ€” keep the back-reference limited and query the child collection with an index.

Try it yourselfโ€‹

deno run -A examples/whyLesan/performance.ts # requires local MongoDB

Open the Playground, add a country, add a province with a countryId, then inspect the country in Mongo Compass โ€” provinces and provincesByPopulation are already sorted and limited. Then call getCountries and watch the whole tree come back in one shot.

Resourcesโ€‹

note

Read the sequel โ€” The Nature of Data. The obvious objection to embedding is "but what about the writes?" That article answers it with real-world cases: expensive writes are rare, and Lesan actually guides you to better models when a field changes too often.