Client-Driven Projections
Lesan's signature feature: the client decides the shape of the response. Every act returns whatever the client asked for in details.get, and Lesan builds the correct MongoDB query โ including $lookup, $unwind, and $project stages for nested relations โ automatically.
This page goes deeper than the Queries & Projections reference with real, copyable patterns.
The get Objectโ
A projection is a nested object where each key maps to a field or relation:
{
"service": "main",
"model": "country",
"act": "getCountry",
"details": {
"set": { "countryId": "507f1f77bcf86cd799439011" },
"get": {
"name": 1,
"citiesByPopulation": {
"name": 1,
"population": 1
},
"capital": {
"name": 1,
"abb": 1
}
}
}
}
1 includes a field; a nested object penetrates a relation; 0 excludes a field.
Validating the get Shape with selectStructโ
Because get is a free-form object, you validate it with coreApp.schemas.selectStruct(schema, depth). The generated validator accepts any projection of that schema up to the given depth.
Uniform depth (a number)โ
Applies the same depth to every relation:
import { ActFn, number, object, ObjectId, objectIdValidation } from "@hemedani/lesan";
const getCountryValidator = () =>
object({
set: object({ countryId: objectIdValidation }),
get: coreApp.schemas.selectStruct("country", 1),
});
const getCountry: ActFn = async (body) => {
const { set: { countryId }, get } = body.details;
return await countries.findOne({
filters: { _id: new ObjectId(countryId) },
projection: get, // client decides the response shape
});
};
Per-relation depth (an object)โ
Different relations at different depths. Here users is fully expanded but cities stays shallow:
const getCountryValidator = () =>
object({
set: object({ countryId: objectIdValidation }),
get: coreApp.schemas.selectStruct("country", {
citiesByPopulation: 1, // just the embedded array, no further relation
users: {
country: 1, // users, then each user's country again
},
capital: 1,
}),
});
You can even define per-relation nested objects to control depth through a chain of relations (see the microservice example for selectStruct("state", { country: { states: { country: 1 } } })).
Passing get to ODM Methodsโ
Every read method accepts projection. The client's details.get is passed through unchanged:
const getCity: ActFn = async (body) => {
const { set: { cityId }, get } = body.details;
return await cities.findOne({
filters: { _id: new ObjectId(cityId) },
projection: get,
});
};
Writes accept it too, so a freshly inserted/updated document returns exactly the shape the client wants:
const addCity: ActFn = async (body) => {
const { country, name, population, abb, isCapital } = body.details.set;
return await cities.insertOne({
doc: { name, population, abb },
projection: body.details.get,
relations: {
country: {
_ids: new ObjectId(country),
relatedRelations: {
cities: true,
citiesByPopulation: true,
capital: isCapital,
},
},
},
});
};
Building Projections Dynamicallyโ
Projections are plain objects, so you can compose them from client flags:
const getUsers: ActFn = async (body) => {
const { includeCountry } = body.details.set;
const projection: Record<string, any> = { name: 1, age: 1 };
if (includeCountry) projection.country = { name: 1, abb: 1 };
return await users.find({ filters: {}, projection }).toArray();
};
How the Projection Becomes a Queryโ
For relations that are embedded (the default โ see Server-Client Communication), no join is needed for shallow projections: the relation's snapshot is already inside the document. Only when a client penetrates beyond one level does Lesan build $lookup/$unwind/$project stages to fetch the deeper docs โ see Deep Projection in Aggregation.