Models & Schemas
Define data schemas using Zod and interact with MongoDB using the native driver in Thunder.
Thunder uses Zod as its schema definition and validation library. Zod provides a TypeScript-first approach to declaring the shape of your data - from request bodies to database documents - with full static type inference.
For database interaction, Thunder integrates the native MongoDB driver directly, giving you full control over your queries without the overhead of an abstraction layer.
File Structure
Models live inside the schemas/ directory of your Thunder project. The recommended structure is:
Creating a Model
Define a Zod Schema
Use z.object() to define the shape of your data. It is recommended to separate your input schema (what the user provides) from the full document schema (what gets stored in the database). By convention, schemas are prefixed with $:
import z from "zod";
import { mongodb } from "@/database.ts";
import { $objectId } from "@/core/utils/createCRUD.ts";
// Input schema - fields accepted from the client
export const $userInput = z.object({
name: z.string(),
email: z.string().email(),
});
// Full document schema - includes server-managed fields
export const $user = $userInput.extend({
_id: $objectId.optional(),
createdAt: z.date().default(() => new Date()),
});Create the Model
Use the native MongoDB driver to bind your schema to a collection:
export const userModel = mongodb.db().collection<
z.infer<typeof $user>
>("users");Reference Fields (Foreign References)
Fields that store a reference to another document must not end with the Id suffix. Name the field after the entity it points to, not after the column, and mark it with $objectId.meta({ ref: "<collection>" }).
import { $objectId } from "@/core/utils/createCRUD.ts";
// ✅ CORRECT - reference fields named after the entity
const $post = z.object({
title: z.string(),
author: $objectId.meta({ ref: "users" }), // references one user
tenant: $objectId.meta({ ref: "tenants" }), // references one tenant
categories: z.array($objectId.meta({ ref: "categories" })), // references many categories
});
// ❌ WRONG - do NOT suffix reference fields with `Id`
const $postWrong = z.object({
title: z.string(),
authorId: $objectId,
tenantId: $objectId,
categoryIds: z.array($objectId),
});Naming rules:
- Single reference → singular entity name:
author,tenant,category. - Multiple references → plural entity name:
users,categories,tags. - Drop the
Id/Idssuffix entirely.
Thunder treats these as foreign references (forignRef). The generated SDK can unwrap a reference field into the actual related object on demand - a author field becomes the populated User object, and categories becomes an array of Category objects. Suffixing with Id breaks this relationship inference and forces consumers to resolve references manually.
Using the Model
Once a model is defined, import and use it anywhere in your business logic:
import { ObjectId } from "mongodb";
import { mongodb } from "@/database.ts";
import { userModel, $user } from "@/schemas/user.ts";
// Insert a new user (see strictParse below)
await userModel.insertOne(
$user.strictParse({ name: "John", email: "john@mail.com" })
);
// Find a user by id
await userModel.findOne({ _id: new ObjectId(id) });
// Update
await userModel.updateOne({ _id }, { $set: { name: "Jane" } });
// Delete
await userModel.deleteOne({ _id });Validate Before Insert with strictParse
When inserting data, always validate it against your schema first. Thunder adds a custom strictParse method to your schemas that:
- Validates the input shape at runtime.
- Automatically applies any default values (e.g.
createdAt) so you don't hard-code them. - Enforces the exact input shape when typing manually.
await userModel.insertOne(
$user.strictParse({ name: "John", email: "john@mail.com" })
);You can still use Zod's regular .parse() method, but strictParse is Thunder's type-safe variant that forces the input shape and inserts defaults for you - the recommended practice for writes.
Transactions
For operations that must succeed or fail together, use mongodb.withSession to run them inside a transaction. Pass the session to each operation:
import { mongodb } from "@/database.ts";
await mongodb.withSession((session) =>
session.withTransaction(async () => {
await userModel.insertOne(
$user.strictParse({ name: "John", email: "john@mail.com" }),
{ session }
);
await logsModel.insertOne({ action: "user.created" }, { session });
})
);Import Conventions
Always use the @/ import alias instead of relative paths. Relative imports into core/ or other framework internals are forbidden and will trigger a lint error:
Relative import into "core/" is forbidden. Use "@/core/..." instead.Correct:
import { userModel } from "@/schemas/user.ts";Incorrect:
import { userModel } from "../schemas/user.ts"; // ❌ Avoid thisComplete Model Example
import z from "zod";
import { mongodb } from "@/database.ts";
import { $objectId } from "@/core/utils/createCRUD.ts";
export const $postInput = z.object({
title: z.string(),
content: z.string(),
author: $objectId.meta({ ref: "users" }), // reference field - named after the entity, no `Id` suffix
});
export const $post = $postInput.extend({
_id: $objectId.optional(),
createdAt: z.date().default(() => new Date()),
});
export const postModel = mongodb.db().collection<
z.infer<typeof $post>
>("posts");