Defining Models
This page explains how models are declared, configured, typed, and organized in Ambiten applications.
If you are looking for the architectural role of models during execution, see AmbitenModel.
In Ambiten, a model defines a stable execution surface around a collection.
It combines:
collection configuration
schema behavior
provider relationship
typing
middleware
model defaultsinto a reusable definition that can participate in context-aware execution.
The important distinction is:
A model is defined statically. Its execution context is resolved dynamically.
A model does not need to be recreated every time the tenant, request, transaction session, database, or execution environment changes.
Models Are an Optional Higher-Level Surface
Ambiten does not require every application to begin with the full model architecture.
For direct MongoDB-oriented execution, teaching examples, scripts, small applications, or gradual adoption, AmbitenClient provides context-aware static and instance APIs that can be used directly.
Conceptually:
Simple / Direct Usage
AmbitenContext
↓
AmbitenClient
↓
MongoDBAs an application needs more structure, AmbitenModel adds:
schema binding
middleware
validation
collection definition
effective context resolution
reusable operation behaviorConceptually:
Structured Model Usage
AmbitenContext
↓
AmbitenModel
↓
Infrastructure Resolution
↓
AmbitenClient / MongoDBNeither approach is intended to be treated as an invalid use of Ambiten.
The model layer exists when a reusable schema-bound execution surface is useful.
Minimal Model Definition
A model is commonly defined with three structural elements:
collection name
schema
providerFor example:
import {
AmbitenModel
} from "@ambiten/core";
import {
userSchema
} from "./user.schema";
import {
db
} from "../infrastructure/database";
export const UserModel =
new AmbitenModel({
collectionName: "users",
schema: userSchema,
provider: db
});This defines the model's structural relationship with the collection, schema, and persistence provider.
It does not permanently bind the model to one request, tenant, transaction session, or execution context.
Those concerns are resolved when operations execute.
Structural Definition vs Runtime Execution
A useful way to understand model definition is to separate two lifetimes.
Definition Time
At definition or initialization time, the model establishes structural behavior such as:
schema association
collection configuration
provider relationship
middleware
model defaults
typingExecution Time
When an operation runs, the runtime may resolve:
tenant identity
requestId
database
collection override
transaction session
runtime metadata
tenant infrastructureConceptually:
MODEL DEFINITION
───────────────
collection
schema
provider
middleware
defaults
typing
MODEL EXECUTION
───────────────
effective context
tenant
database
collection
session
runtime infrastructureThis separation allows one model definition to serve many independent executions.
Collection Boundaries
collectionName defines the model's default persistence collection.
collectionName: "users"The model remains structurally associated with that collection boundary while the database or other supported execution-specific values may vary according to runtime context.
For example:
Request A
tenantId = tenantA
↓
UserModel
↓
db_tenantA
↓
users
Request B
tenantId = tenantB
↓
UserModel
↓
db_tenantB
↓
usersThe model definition stays the same.
The infrastructure surrounding the operation changes.
Where supported collection overrides are present in the effective operation context, those can participate according to Ambiten's context-resolution rules.
Schema Definition
The schema defines the document contract associated with the model.
AmbitenSchema provides a schema-aware surface for behavior such as:
document structure
validation
middleware
normalization
lifecycle behavior
type relationshipsFor example:
import {
AmbitenSchema
} from "@ambiten/core";
export const userSchema =
new AmbitenSchema({
name: {
type: "string",
required: true
},
email: {
type: "string",
required: true
}
});Schemas should remain focused on persistence-oriented behavior.
Good schema concerns include:
validation
normalization
data integrity
middleware
persistence lifecycleApplication orchestration such as HTTP responses, framework routing, or controller workflows belongs elsewhere.
Schema and Model Stability
A schema should not be recreated merely because:
the request tenant changed
a transaction started
the application moved from Express to Fastify
a worker executes the same operation
a Lambda invocation beginsThose are execution concerns.
The model and schema remain structural definitions.
Stable Model + Schema
│
├── Execution A
├── Execution B
├── Execution C
└── Execution DThis is a central Ambiten design principle.
Static structure. Dynamic execution.
Provider Configuration
The provider participates in resolving the persistence resources required by model execution.
provider: dbA provider can participate in resolving resources such as:
database
collection
client
transaction session
runtime overridesThe provider consumes execution information.
It should not be confused with request tenant identification.
The architectural distinction is:
TenantResolver
→ identifies the tenant
AmbitenContext
→ carries execution state
AmbitenModel
→ resolves effective operation context
Provider
→ resolves persistence bindingsFor tenant-aware execution, provider resolution may also cooperate with MultiTenantManager.
Static Provider Pattern
A simple application may use a stable provider:
export const UserModel =
new AmbitenModel({
collectionName: "users",
schema: userSchema,
provider: db
});This is useful when infrastructure is straightforward and a single provider can serve the model consistently.
There is nothing inherently temporary or incorrect about this pattern.
Ambiten does not require an application to become multi-tenant or infrastructure-heavy before the model API becomes useful.
Runtime-Aware Provider Resolution
More advanced applications may use providers that participate in execution-time resolution.
The important architectural idea is not a particular provider syntax.
It is that the model does not need to be recreated merely because the runtime state changes.
Conceptually:
UserModel
↓
active effective context
↓
provider
↓
database / client / sessionThis makes it possible for the same model definition to participate in:
request-scoped execution
tenant-aware execution
transactions
workers
serverless functions
custom runtime environmentswithout coupling the model itself to those environments.
Tenant-Aware Models
A model does not discover tenant identity from the incoming framework request.
Instead:
Request
↓
TenantResolver
↓
tenantId
↓
AmbitenContext
↓
AmbitenModelWhen the model operation needs persistence, the effective tenant identity can participate in infrastructure resolution.
AmbitenModel
↓
Effective Context
tenantId = tenant5
↓
MultiTenantManager
↓
TenantConfig
↓
Tenant Client
↓
Tenant DatabaseThe model definition itself remains unchanged.
Static and Dynamic Tenants
Model code does not need separate APIs for static and dynamically discovered tenants.
The same call:
await UserModel.find({});may execute against a tenant registered during startup:
tenant5
↓
already registered
↓
getClient()or one discovered dynamically:
tenant5
↓
not registered
↓
TenantConfigResolver
↓
register tenant
↓
getClient()The model API remains stable because tenant discovery belongs to runtime infrastructure.
Lazy Tenant Infrastructure
A tenant may also be registered but not connected yet.
For example:
registered = true
connected = false
lazy = trueThe first model operation that requires persistence can cause the runtime infrastructure to obtain a usable client.
UserModel.find(...)
↓
tenant-aware infrastructure resolution
↓
MultiTenantManager.getClient()
↓
connection established
↓
MongoDBThe application does not need a special "lazy tenant" model definition.
Type-Safe Execution
Ambiten models support TypeScript generics for strongly typed operations.
import type {
Document
} from "@ambiten/core";
interface User
extends Document {
name: string;
email: string;
}The same document type should be used consistently with the schema and model:
const userSchema =
new AmbitenSchema<User>({
name: {
type: "string",
required: true
},
email: {
type: "string",
required: true
}
});
export const UserModel =
new AmbitenModel<User>({
collectionName: "users",
schema: userSchema,
provider: db
});Model operations can then benefit from stronger editor and compiler feedback:
await UserModel.create({
name: "John Doe",
email: "john@example.com"
});Benefits include:
editor inference
compile-time validation
safer refactoring
consistent schema/model typing
clearer application contractsNOTE
Use the Document type exported by @ambiten/core when defining document interfaces intended for AmbitenSchema<T> and AmbitenModel<T>.
import type {
Document
} from "@ambiten/core";The document type used by the schema should also be used by the corresponding model so their generic contracts remain compatible.
Do not substitute an unrelated MongoDB Document type when the Ambiten APIs expect Ambiten's exported document contract.
Runtime-Aware Execution
Once defined, model operations participate in the active runtime boundary.
await UserModel.find({});The operation may consume runtime information such as:
tenantId
requestId
dbName
collectionName
transaction session
runtime metadatawithout requiring those values to appear in the ordinary application call.
The model does not necessarily take those values directly from one source.
It resolves an effective operation context.
Effective Context Resolution
The general model is:
explicit operation context
↓
active AmbitenContext
↓
model defaultsWhere a supported operation-level override is supplied, it can take precedence over lower-level runtime or model defaults.
For normal request-aware execution, however, application code can usually remain:
await UserModel.find({});rather than manually recreating the runtime context for every operation.
Transaction-Aware Models
A model definition does not change when execution enters a transaction.
await AmbitenContext.withTransaction(
async () => {
await UserModel.create(
user
);
await AuditModel.create(
audit
);
}
);Participating model operations can resolve the active transaction session from execution context.
Transaction Boundary
↓
session S1
↓
UserModel
↓
AuditModelThe transaction boundary owns commit and rollback behavior.
The model participates in the active transaction; it does not own the surrounding transaction lifecycle.
Middleware
Model definitions can participate in Ambiten's middleware architecture.
Conceptually:
before middleware
↓
model operation
↓
after middlewareMiddleware may support concerns such as:
validation
normalization
policy enforcement
auditing
logging
soft-delete behavior
instrumentation
result shapingBecause middleware runs around the model operation, it can participate in the same effective execution context.
This allows model policies to remain independent from the ingress framework.
Models and AmbitenClient
AmbitenModel and AmbitenClient serve different levels of abstraction.
Direct Client Usage
AmbitenClient provides context-aware static and instance methods that can be useful for:
learning Ambiten
live coding
YouTube tutorials
educational examples
scripts
small services
direct database workflows
gradual adoptionConceptually:
AmbitenContext
↓
AmbitenClient
↓
MongoDBModel Usage
AmbitenModel adds a reusable schema-bound operation surface:
AmbitenContext
↓
AmbitenModel
↓
schema
middleware
effective context
infrastructure resolution
↓
MongoDBAn application can choose the level of structure appropriate for its needs.
Ambiten's runtime architecture is designed to support growth rather than require maximum complexity from the beginning.
Progressive Adoption
A small application or tutorial may begin with:
AmbitenClientthen introduce:
AmbitenContextto demonstrate execution-scoped state.
Later it may add:
AmbitenSchema
↓
AmbitenModelfor reusable model behavior.
And when application requirements grow further:
Adapters
MultiTenantManager
TenantConfigResolver
Transactions
Middleware
Runtime instrumentationcan be introduced without changing the fundamental execution model.
Conceptually:
Direct Client
↓
Context-Aware Client
↓
Schema + Model
↓
Framework Adapters
↓
Multi-Tenant Runtime
↓
Advanced InfrastructureThe architecture scales upward without requiring every user to start at the final stage.
Recommended Project Structure
For applications with several models, separating schema and model definitions helps keep persistence behavior organized.
src/
models/
user.model.ts
user.schema.tsFor larger applications, feature-oriented layouts are equally possible:
src/
users/
user.schema.ts
user.model.ts
user.service.tsThe important distinction is conceptual rather than directory-specific:
schema
→ document structure and persistence behavior
model
→ collection-bound execution surface
service
→ application workflowAmbiten does not require one particular project layout.
Model Initialization Is Structural
Model creation or bootstrap registration should prepare structural behavior.
Conceptually:
Application Startup
↓
register schema
↓
register model definitionIt should not require the application to resolve a request-specific tenant database merely to define the model.
That work belongs to operation execution.
Request / Job
↓
AmbitenContext
↓
Model Operation
↓
Effective Context
↓
Infrastructure ResolutionThis keeps model startup independent from request-specific runtime state.
Recommended Design Approach
Model definitions should remain stable as execution conditions change.
A model should generally not be recreated because:
the request changed
the tenant changed
a transaction started
the framework changed
a worker called the same service
a Lambda invocation beganInstead:
model definition
→ stable
execution context
→ dynamicProviders should remain responsible for infrastructure relationships.
Schemas should remain focused on persistence-oriented structure and behavior.
Services should remain focused on application workflows.
One Model, One Clear Collection Boundary
A model should normally represent one clear collection boundary.
For example:
const UserModel =
new AmbitenModel({
collectionName: "users",
schema: userSchema,
provider: db
});This makes behavior easier to reason about across:
middleware
validation
instrumentation
runtime context
database routing
collection resolutionA predictable model boundary helps preserve architectural clarity as the application grows.
Common Anti-Patterns
Repeating Runtime Context on Every Operation
Avoid routinely writing:
await UserModel.find(
{},
{
tenantId,
session,
dbName
}
);when those values already belong to the active runtime execution.
Prefer:
await UserModel.find({});inside a valid context boundary.
Explicit operation context should be used when the operation intentionally needs a supported override.
Resolving Tenant Identity Inside the Model
Avoid making the model inspect framework requests:
req.headers
GraphQL context
NestJS ExecutionContext
Lambda eventto determine the tenant.
Tenant identity should already have been established by the execution boundary.
Creating Infrastructure Inside the Model Definition
Avoid patterns such as:
const client =
new MongoClient(...);solely inside a model declaration.
Client and infrastructure lifecycle should remain separate from model structure.
Storing Request State on the Model
Avoid:
UserModel.currentTenant =
tenantId;A model may be shared by concurrent executions.
Request-specific state belongs in the execution context.
Coupling Schemas to Frameworks
Schemas should not depend on:
Express Request
Fastify Request
NestJS controller state
GraphQL resolver context
Lambda event objectsPersistence definitions should remain portable.
Framework Independence
A single model can be used behind multiple execution environments.
Express ─────┐
Fastify ─────┤
NestJS ──────┤
GraphQL ─────┤
Lambda ──────┤
Worker ──────┘
↓
UserModelThe host environment determines how execution enters Ambiten.
The model remains concerned with the operation itself.
Mental Model
Schema defines structure.
Model defines the reusable operation surface.
Context defines the current execution.
Provider resolves persistence bindings.
MultiTenantManager owns tenant infrastructure.
AmbitenClient offers direct context-aware database access
and participates in MongoDB execution.
MongoDB performs persistence.The compact version is:
Static model definition.
Dynamic runtime execution.Design Principles
The model layer follows a small set of runtime-oriented architectural principles.
Model operations consume the active execution context and resolve effective tenant identity, request metadata, transaction state, and supported runtime overrides without requiring those values to be manually propagated through application code.
Models coordinate operation behavior while providers and MultiTenantManager resolve the client, database, collection, session, and tenant infrastructure required for the active execution.
Given the same effective context, model configuration, middleware registration, and runtime rules, an operation follows the same resolution path and execution lifecycle.
Model definitions remain stable while execution context and resolved infrastructure can vary per request, tenant, transaction scope, worker, or other runtime boundary.
These principles keep model definitions stable while allowing execution behavior to adapt across requests, tenants, transactions, frameworks, workers, and infrastructure environments.
Summary
Defining an AmbitenModel creates a stable, typed, schema-bound execution surface around a MongoDB collection.
A model can combine:
- collection configuration,
AmbitenSchema,- provider relationships,
- TypeScript typing,
- middleware,
- model defaults,
while leaving runtime-specific state to execution time.
The core relationship is:
Model Definition
↓
Schema + Collection + Provider
↓
Operation Begins
↓
Effective Context
↓
Infrastructure Resolution
↓
MongoDBFor simple or educational use cases, applications can also work directly with the context-aware AmbitenClient APIs and introduce models as additional structure becomes valuable.
Ambiten therefore supports both:
approachable direct usageand:
structured runtime architecturewithout changing the underlying principle:
Define structure once. Resolve execution dynamically.
