Model Guide
This guide explains how models work in Vona within the Cabloy monorepo.
Why models matter in the backend contract loop
Models are the persistence-facing layer of the backend contract loop.
A useful split is:
- controllers define HTTP-facing contracts
- services orchestrate business flow
- models own ORM-facing persistence behavior
- entities and DTOs shape the data contract around that behavior
That means model design affects not only SQL behavior, but also CRUD defaults, DTO assumptions, OpenAPI-facing contracts, and test setup.
Create a model
Example: create a model named student in module training-student.
npm run vona :create:bean model student -- --module=training-studentModel definition
Representative pattern:
import { BeanModelBase, Model } from 'vona-module-a-orm';
import { EntityStudent } from '../entity/student.ts';
@Model({ entity: EntityStudent })
export class ModelStudent extends BeanModelBase<EntityStudent> {}The key relationship is that a model is bound to an entity and exposes ORM-oriented operations for that entity.
Using a model
Using models in Vona supports both dependency injection and dependency lookup, but dependency lookup is usually the clearer default.
Lookup in the current module
class ServiceStudent {
async findAll(): Promise<EntityStudent[]> {
return await this.scope.model.student.select();
}
}Lookup across modules
class ServiceStudent {
async findAll(): Promise<EntityStudent[]> {
return await this.$scope.trainingStudent.model.student.select();
}
}Direct container access
class ServiceStudent {
async findAll(): Promise<EntityStudent[]> {
return await this.bean._getBean('training-student.model.student').select();
}
}This is one of the important backend layer boundaries:
- services should usually orchestrate through models
- models should remain the main persistence-facing abstraction instead of bypassing directly to ad hoc SQL everywhere
Basic CRUD patterns
Create
return await this.scope.model.student.insert(student);Read
return await this.scope.model.student.select();
return await this.scope.model.student.getById(id);Update
return await this.scope.model.student.updateById(id, student);Delete
return await this.scope.model.student.deleteById(id);These methods are also part of the generated CRUD thread; see CRUD Workflow.
When using magic methods, keep one semantic detail in mind:
- default
eqmethods can treat an omitted argument asnull - non-
eqmethods require an explicit argument
For omission inside structured where objects, prefer Op.omit; for SQL null checks, use null.
Query-builder support
Vona models are built on Knex, so the model layer also supports lower-level query builder access when needed.
Representative patterns:
this.scope.model.student.builder().where('name', 'tom').orderBy('name');
this.scope.model.student.builderSelect().where('name', 'tom').orderBy('name');
this.scope.model.student.query('select * from trainingStudent');A practical rule is:
- prefer model methods and model-aware query paths first
- use builder or raw SQL only when the higher-level model surface is not enough
Important model options
Several important model options include:
entitytabledisableDeleteddisableInstancedisableCreateTimedisableUpdateTimesoftDeletionPruneclientcacherelations
These options matter because Vona models are not only raw database adapters. They also carry framework-level behavior such as soft deletion, multi-instance support, datasource selection, caching, and relations.
App-config overrides
Model options can also be configured in app config.
Representative pattern:
config.onions = {
model: {
'training-student:student': {
disableDeleted: true,
disableInstance: true,
client: 'mysql',
cache: false,
},
},
};This is one of the key contract-loop ideas: model behavior is not defined only inside the model file. It can also be refined through project configuration.
Dynamic table partitioning
Model supports dynamic table partitioning.
Representative pattern:
@Model({
table: (ctx: VonaContext, where: EntityOrder | undefined, defaultTable: keyof ITableRecord) => {
return `${defaultTable}_${moment().format('YYYYMMDD')}`;
},
})
class ModelOrder {}This matters because some persistence decisions belong at the model abstraction level even when they materially change how the table is resolved.
Soft deletion and multi-instance defaults
By default, Vona models support:
- soft deletion
- multi-instance or multi-tenancy filtering
That means model behavior can be richer than a naïve SQL wrapper. For example, a normal select() already participates in instance filtering and deleted-record filtering unless configured otherwise.
A practical implication is:
- contract and test behavior should not assume raw-table semantics when the model layer is active
Instance-aware model semantics
One important current-runtime distinction is that ordinary model usage is instance-aware by default. In Vona's current tenancy model, the active instance is the tenant boundary.
For normal resource models, the model layer handles the active instance iid as part of ordinary persistence behavior:
- inserts receive the active instance identity
- selects,
getById, counts, updates, and deletes receive the active instance predicate - request-scoped code should preserve this behavior instead of accepting caller-controlled tenant or
iidscope
This is why disableInstance is a meaningful semantic choice rather than a minor ORM toggle. Use it only for a genuinely global/system model or another explicitly authorized contract that must ignore the active instance boundary.
A scoped miss is absent in the current tenant/instance. Do not use disableInstance, a plain builder, or raw SQL merely to probe another instance and choose between 403 and not-found behavior. A real authorization failure can still be 403; the distinction is that an ordinary resource lookup should not disclose cross-instance existence.
Lower-level builders and raw SQL need extra care when replacing ordinary model behavior. Preserve the active instance predicate when the operation is meant to remain instance-scoped, and document the authorization and audit contract when it deliberately is not.
For the tenancy terminology and runtime boundary, see Multi-Instance and Instance Resolution. For generated resource flows, see CRUD Workflow. For the broader instance/config story, also see Config Guide.
Datasource selection
Vona supports multi-database and multi-datasource behavior at the model layer.
Representative modes include:
- default datasource
- static datasource in model options
- app-config datasource override
- adaptive datasource through a function
- dynamic datasource chosen in code with
newInstance(...)
Representative dynamic pattern:
const modelMysql = this.scope.model.student.newInstance('mysql');
return await modelMysql.select();This matters because backend contract behavior can depend on datasource choice, not just on query shape.
In the current runtime, datasource choice can also change indirectly through instance isolation. If the active instance is configured as isolated, the effective default datasource may switch through that instance’s isolateClient setting even when the calling code did not name a datasource explicitly.
That is why model behavior should be read together with:
Cache behavior
Vona models enable cache behavior by default.
A practical distinction is:
- entity cache stores entity records
- query cache stores query-derived entity id sets
That means model behavior affects performance and consistency semantics, not only data retrieval.
For the broader cache story, also see Cache Guide.
Relationship to the backend contract loop
Read this guide together with:
Implementation checks for model-layer changes
When creating backend persistence logic:
- start from the model and entity pairing
- prefer model methods and model-aware query paths
- preserve Vona soft-delete and instance-aware behavior unless there is a real reason not to
- choose deliberately among local scope, cross-module scope, and direct bean access
- remember that datasource, cache, and model options can change the behavior of the backend contract loop
- drop to raw SQL or lower-level query builder logic only when the higher-level model surface is insufficient
Where to read next
If your next question is how model persistence behavior fits into one complete backend contract thread, continue with: