Tutorial 2 — Schema and Model
Estimated time: 10–15 minutes
In Tutorial 1, you accessed MongoDB directly through AmbitenClient:
Application
↓
AmbitenClient
↓
MongoDBThat remains a valid Ambiten usage pattern. As an application grows, though, application code should not repeatedly own collection identity, document shape, persistence behavior, and provider selection.
This tutorial makes one architectural jump: direct collection access becomes schema-bound model execution.
Application
↓
AmbitenModel
↓
Schema / Model Behavior
↓
Collection Resolution
↓
AmbitenClient
↓
MongoDBWe are still not introducing framework adapters, runtime context, multi-tenancy, transactions, or full CRUD.
What you will build
We continue the Workspace application, which still has only users:
Workspace
↓
UsersInstead of requesting client.collection<User>("users") in application code, define a reusable persistence boundary:
const UserModel = new AmbitenModel<User>({
collectionName: "users",
schema: userSchema,
provider: client
});The application then uses UserModel.create(...) and UserModel.findOne(...).
What you need
Complete Tutorial 1 — Your First Ambiten Application first. You need Node.js 20+, pnpm, MongoDB, @ambiten/core, and mongodb.
Starting point
Copy the Tutorial 1 checkpoint and move into it:
cp -R examples/ambiten-tutorial/01-first-app examples/ambiten-tutorial/02-schema-model
cd examples/ambiten-tutorial/02-schema-modelPowerShell:
Copy-Item -Recurse examples/ambiten-tutorial/01-first-app examples/ambiten-tutorial/02-schema-modelThe finished source layout is:
src/
├── core/
│ └── db.ts
├── models/
│ └── user.model.ts
└── index.tsThis separates infrastructure, domain persistence, and application execution without adding unnecessary architecture.
Step 1 — Move database infrastructure into core
Create src/core/db.ts and move the Tutorial 1 AmbitenClient configuration into it. The client is reusable process infrastructure and will become the model's database provider.
export const client = new AmbitenClient({
uri,
options: { dbName }
});Step 2 — Define the User type and schema
Create src/models/user.model.ts:
export interface User {
name: string;
email: string;
createdAt: Date;
}
export const userSchema = new AmbitenSchema<User>({
name: String,
email: String,
createdAt: Date
});TypeScript describes the code shape; AmbitenSchema participates in runtime persistence behavior. For example, model creation validates its input through the attached schema before insertion.
Step 3 — Create an AmbitenModel
Add the client import using its .js runtime extension, then create the model:
import { client } from "../core/db.js";
export const UserModel = new AmbitenModel<User>({
collectionName: "users",
schema: userSchema,
provider: client
});The model owns the users collection boundary; the schema supplies persistence behavior; the provider supplies database capability.
UserModel
↓
DbProvider
↓
AmbitenClient
↓
MongoDBStep 4 — Use the model
src/index.ts connects the reusable client and calls the model:
const createdUser = await UserModel.create({
name: "Amina",
email,
createdAt: new Date()
});
const foundUser = await UserModel.findOne({ email });The timestamp in email makes repeated runs independent of cleanup from a previous run.
The operation path is now:
Application
↓
UserModel.create()
↓
Schema participation
↓
Collection Resolution
↓
AmbitenClient
↓
MongoDBNotice the returned _id
MongoDB driver collections expose identifiers as ObjectId values. AmbitenModel returns normalized model results, so model _id values are strings. This is the first observable difference between direct collection and model execution.
Run the application
Make sure MongoDB is running, then run:
pnpm install
pnpm dev
pnpm typecheck
pnpm build
pnpm startExpected output is similar to:
Connected to MongoDB.
Created user: { name: 'Amina', email: 'amina....@team.io', createdAt: ..., _id: '...' }
Found user: { name: 'Amina', email: 'amina....@team.io', createdAt: ..., _id: '...' }What just happened
Tutorial 1 used a direct MongoDB collection. Tutorial 2 adds structured persistence:
Application
↓
AmbitenModel
↓
AmbitenSchema
↓
Collection Resolution
↓
DbProvider
↓
AmbitenClient
↓
MongoDBDirect access remains available. The model layer is introduced when reusable persistence behavior becomes useful.
Schema vs Model
AmbitenSchema defines document structure, validation, schema hooks, and lifecycle behavior. AmbitenModel coordinates collection operations, schema participation, middleware participation, and provider resolution.
Schema → what persistence behavior belongs to the document
Model → how an operation is coordinated against the collectionRuntime flow
Application
↓
UserModel
↓
AmbitenSchema
↓
Collection Resolution
↓
AmbitenClient
↓
MongoDBThere is still no AmbitenContext, framework adapter, tenant resolution, transaction boundary, or background execution.
Common mistakes
Missing .js in local imports
With NodeNext/ESM, use ../core/db.js, not ../core/db. The source uses the emitted JavaScript extension.
Forgetting to connect the provider
Call await client.connect() before model operations. The model uses that client as its provider.
Creating a client for every operation
AmbitenClient is reusable process-level infrastructure. Create it once and supply it where database capability is needed.
Bypassing the model unintentionally
Direct client.collection("users") access remains valid, but a workflow designed around UserModel should normally route persistence operations through the model so schema and lifecycle behavior can participate.
Checkpoint
You now have an Ambiten application with AmbitenClient, AmbitenSchema, and AmbitenModel.
Schema defines persistence behavior.
Model coordinates the operation.
Provider supplies infrastructure.
AmbitenClient provides MongoDB capability.
MongoDB performs persistence.Next tutorial
Continue to Tutorial 3 — CRUD With Ambiten. It keeps this UserModel and focuses on its complete CRUD surface.
