Models Overview
Models are the heart of ActiveDrizzle. A model class represents a database table, encapsulates the business logic for that resource, and is the interface through which all reads and writes flow. If you've used Rails' ActiveRecord, the concepts will feel immediately familiar — but everything here is TypeScript-native, type-safe, and builds on top of Drizzle ORM.
After reading this guide, you will understand:
- How your Drizzle schema and model class relate to each other
- How to define a model and what the
@modeldecorator does - How to perform basic CRUD operations
- What a
Relationis and how querying works - How associations, scopes, validations, and lifecycle hooks fit together
- How codegen extends the model for the frontend
1. What is a Model?
A model is a TypeScript class that extends ApplicationRecord. It maps to exactly one database table (defined in your Drizzle schema), and it knows how to:
- Read: find records, filter them, sort them, eagerly load associations
- Write: create, update, and destroy records
- Validate: enforce business rules before writing
- Transform: convert raw DB values to TypeScript values (and back)
- React: run code before and after writes via lifecycle hooks
The Drizzle schema is the source of truth for structure (column names, types, constraints, nullability). The model is the source of truth for behavior (scopes, validations, hooks, virtual attributes, associations).
You never write SQL. You write TypeScript that reads like English.
2. Setup
Your Drizzle Schema
ActiveDrizzle reads your existing Drizzle schema. There's nothing special to add — just define your tables normally:
// db/schema.ts
import { pgTable, serial, integer, varchar, text, timestamp, boolean } from 'drizzle-orm/pg-core'
export const users = pgTable('users', {
id: serial('id').primaryKey(),
email: varchar('email', { length: 255 }).notNull().unique(),
name: varchar('name', { length: 255 }).notNull(),
role: integer('role').notNull().default(0), // 0 = member, 1 = admin
active: boolean('active').notNull().default(true),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
})
export const posts = pgTable('posts', {
id: serial('id').primaryKey(),
title: varchar('title', { length: 255 }).notNull(),
body: text('body'),
userId: integer('user_id').notNull().references(() => users.id),
published: boolean('published').notNull().default(false),
publishedAt: timestamp('published_at'),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
})Boot
Call boot() once at startup, passing your Drizzle database client and the schema object:
// server.ts
import { boot } from 'active-drizzle'
import { drizzle } from 'drizzle-orm/node-postgres'
import { Pool } from 'pg'
import * as schema from './db/schema.js'
import './models/index.js' // ← side-effect import — registers all models
const pool = new Pool({ connectionString: process.env.DATABASE_URL })
const db = drizzle({ client: pool, schema })
boot(db, schema)boot() binds the database executor to the global context. After this, any model can execute queries without needing to pass a db reference around.
Registering Models
Models register themselves via the @model decorator at import time. You need to make sure every model file is imported at startup:
// models/index.ts
export { User } from './User.model.js'
export { Post } from './Post.model.js'
export { Comment } from './Comment.model.js'
// ... every model your app uses3. Defining a Model
The minimal model is just a class with the @model decorator:
// models/User.model.ts
import { ApplicationRecord, model } from 'active-drizzle'
@model('users')
export class User extends ApplicationRecord {}That's it. You now have full CRUD, querying, associations, and lifecycle support for the users table:
const user = await User.create({ email: 'alice@example.com', name: 'Alice', role: 0 })
const found = await User.find(user.id)
await user.update({ name: 'Alice Smith' })
await user.destroy()The @model('users') decorator does two things:
- Binds the class to its Drizzle table (the key must match what's in your schema object exactly)
- Registers the class in the global
MODEL_REGISTRY, used for association resolution and STI
Column Access
Columns map directly to properties on the model instance. The column name convention is camelCase in TypeScript, matching Drizzle's convention:
const user = await User.find(1)
user.id // number
user.email // string
user.name // string
user.active // boolean
user.createdAt // Date
user.updatedAt // DateTypes are inferred from your Drizzle schema via codegen — .gen.d.ts files are generated alongside your model files and extend the class with the correct types.
4. Reading Records
All queries start from a static method on your model class and return a Relation — a lazy, chainable query builder. Nothing hits the database until you call a terminal method like .load(), .first(), or .find().
Finding by ID
// find(id) — throws RecordNotFound if missing
const user = await User.find(1)
// findBy — returns null if missing
const user = await User.findBy({ email: 'alice@example.com' })
// findBy with multiple conditions
const admin = await User.findBy({ role: 1, active: true })find() raises RecordNotFound if no record matches the primary key. This mirrors Rails behavior exactly — use findBy() when absence is an expected state, and find() when you expect the record to exist.
Collections
// All records
const users = await User.all()
// Filtered
const admins = await User.where({ role: 1 }).load()
const actives = await User.where({ active: true }).load()
// Multiple conditions (AND)
const result = await User.where({ role: 1, active: true }).load()
// first() / last()
const newest = await User.order('createdAt', 'desc').first()
const oldest = await User.order('createdAt', 'asc').first()Chaining
Relation methods are chainable and combine with AND semantics:
const results = await User
.where({ active: true })
.where({ role: 1 })
.order('name', 'asc')
.limit(20)
.offset(0)
.load()See Querying Basics for the complete query reference.
5. Saving Records
Creating
// Create and return the saved record
const user = await User.create({
email: 'alice@example.com',
name: 'Alice',
role: 0,
})
// Build first, save later
const user = new User()
user.email = 'bob@example.com'
user.name = 'Bob'
await user.save()
user.isNewRecord // false after saveUser.create() raises a ValidationError if the record is invalid. save() returns true or false — check user.errors when it returns false.
Updating
// Update specific fields
await user.update({ name: 'Alice Smith', role: 1 })
// Assign then save (only changed columns are sent)
user.name = 'Alice Smith'
await user.save()
// Bulk update (no hooks)
await User.where({ active: false }).updateAll({ role: 0 })Destroying
await user.destroy()
user.isDestroyed // true
// Bulk destroy (hooks run for each record)
await User.where({ active: false }).destroyAll()
// Raw DELETE (no hooks, faster)
await User.where({ active: false }).deleteAll()See Create, Update, Destroy for the complete reference.
6. Attributes and Enums
Columns often need transformation between the database representation and your TypeScript code. The Attr system handles this transparently through a Proxy — there's no manual get/set boilerplate.
Attr.enum — Integer ↔ Label
Store integers in the DB, work with descriptive strings everywhere in TypeScript:
@model('posts')
export class Post extends ApplicationRecord {
static status = Attr.enum({
draft: 0,
published: 1,
archived: 2,
} as const)
}const post = await Post.create({ status: 'draft', title: 'Hello' })
post.status // → 'draft' (string)
post.isDraft() // → true (auto-generated predicate)
post.isPublished() // → false
post.toDraft() // sets status = 'draft', returns instance
// Filtering works with labels — Attr converts to integer before the WHERE
await Post.where({ status: 'published' }).load()
// SELECT * FROM posts WHERE status = 1Auto-generated helpers for each enum value:
post.isDraft()/post.isPublished()— boolean predicatespost.toDraft()/post.toPublished()— transition helpers (set, don't save)post.statusChanged()— dirty trackingpost.statusWas()— previous value
Other Attr Types
static priceCents = Attr.new({
get: (v: number) => v / 100, // read: cents → dollars
set: (v: number) => Math.round(v * 100), // write: dollars → cents
default: 0,
})
static tags = Attr.json<string[]>({ default: () => [] })
static publishedAt = Attr.date() // ISO string ↔ Date objectSee Attributes & Enums for the full reference.
7. Associations
Associations are declared as static properties using the association marker functions. ActiveDrizzle infers foreign keys from column names (following Drizzle conventions) and resolves the target model from the MODEL_REGISTRY.
// schema.ts (foreign key must be present in schema)
export const posts = pgTable('posts', {
id: serial('id').primaryKey(),
userId: integer('user_id').notNull().references(() => users.id),
// ...
})// models/Post.model.ts
import { ApplicationRecord, model, belongsTo, hasMany } from 'active-drizzle'
@model('posts')
export class Post extends ApplicationRecord {
static author = belongsTo('users') // FK = userId
}
// models/User.model.ts
@model('users')
export class User extends ApplicationRecord {
static posts = hasMany() // inferred: posts.userId
}Loading Associations
// Lazy load (separate query, N+1 risk)
const post = await Post.find(1)
const author = await post.author // SELECT * FROM users WHERE id = post.userId
// Eager load — one query with LEFT JOIN
const posts = await Post.includes('author').load()
posts[0].author // already resolved, no extra query
// Nested includes
const users = await User.includes({ posts: ['comments'] }).load()
users[0].posts[0].comments // deeply loadedSee Associations for hasMany, hasOne, belongsTo, habtm, dependent options, through associations, and custom FK configuration.
8. Scopes
Scopes are reusable, named query fragments declared as static methods. They return a Relation so they chain naturally with each other and with where, order, includes, etc.
@model('posts')
export class Post extends ApplicationRecord {
@scope
static published() {
return this.where({ published: true })
}
@scope
static recent() {
return this.order('publishedAt', 'desc')
}
@scope
static forUser(userId: number) {
return this.where({ userId })
}
}// Scopes compose
const posts = await Post.published().recent().forUser(currentUser.id).limit(10).load()The @scope decorator is a codegen hint — it tells the generator to include this scope in type definitions and expose it as a requestable filter in controllers. See Scopes.
9. Validations
Declare validation logic as instance methods decorated with @validate. Validations run automatically during save() and create().
import { ApplicationRecord, model, validate, serverValidate } from 'active-drizzle'
@model('users')
export class User extends ApplicationRecord {
@validate()
validateEmail() {
if (!this.email?.includes('@')) {
this.errors.add('email', 'must be a valid email address')
}
}
// Server-only: runs on save, excluded from the client bundle
@serverValidate()
async validateEmailUnique() {
const existing = await User.findBy({ email: this.email })
if (existing && existing.id !== this.id) {
this.errors.add('email', 'is already taken')
}
}
}const user = new User({ email: 'not-an-email' })
await user.isValid() // false
user.errors.all() // { email: ['must be a valid email address'] }
// create() raises ValidationError if invalid
try {
await User.create({ email: 'bad' })
} catch (e) {
e.errors // { email: ['...'] }
}See Validations for conditional validation, inline Attr validation, and the full errors API.
10. Lifecycle Callbacks
Callbacks are methods that run automatically at specific points in a record's lifecycle. Decorate them with the appropriate lifecycle decorator:
import { ApplicationRecord, model, beforeSave, afterCreate, afterCommit } from 'active-drizzle'
@model('users')
export class User extends ApplicationRecord {
@beforeSave()
normalizeEmail() {
this.email = this.email.toLowerCase().trim()
}
@afterCreate()
async sendWelcomeEmail() {
await EmailQueue.enqueue('welcome', { userId: this.id })
}
// Use afterCommit for external side effects —
// guarantees the DB row exists before firing
@afterCommit()
async notifySlack() {
await Slack.post(`New user: ${this.email}`)
}
}Available hooks: @beforeSave, @afterSave, @beforeCreate, @afterCreate, @beforeUpdate, @afterUpdate, @beforeDestroy, @afterDestroy, @afterCommit.
See Lifecycle Callbacks for conditional hooks, aborting saves, and the @afterCommit pattern for external side effects.
11. The Save Pipeline
When you call save() or create(), ActiveDrizzle runs this sequence:
@beforeSave/@beforeCreate/@beforeUpdatehooks — normalize, set defaults@validate/@serverValidate— if any errors, abort and returnfalse(or throw oncreate())Attr.setcoercions — transform values before writing to DBINSERTorUPDATE— only changed columns are included in the SQL@afterSave/@afterCreate/@afterUpdatehooks- Queue
@afterCommitcallbacks for when the outermost transaction commits - Autosave associated records (if
autosave: true)
12. Transactions
Wrap multiple operations in a transaction using the class-level transaction() method. Transactions are implicit — if a save() happens inside a transaction() block, it automatically participates:
await User.transaction(async () => {
const user = await User.create({ email: 'alice@example.com', name: 'Alice' })
const post = await Post.create({ title: 'Hello', userId: user.id })
// Both INSERT in the same transaction — if either throws, both are rolled back
})@afterCommit hooks queue during the transaction and flush only when the outermost transaction commits — making them safe for emails, webhooks, and other external side effects.
See Transactions for nested transactions, AsyncLocalStorage implicit propagation, and rollback patterns.
13. Instance Methods and @pure
Add instance methods to encapsulate derived values and business logic:
@model('campaigns')
export class Campaign extends ApplicationRecord {
static status = Attr.enum({ draft: 0, active: 1, paused: 2 } as const)
// @pure marks a method as safe to include in the client bundle
// (no DB calls, no server secrets)
@pure
isLaunchable(): boolean {
return this.status === 'draft' && this.name.length > 0
}
@pure
displayBudget(): string {
return this.budget != null ? `$${this.budget.toLocaleString()}` : 'No budget'
}
}The @pure decorator signals to codegen that this method is safe to bundle into the generated ClientModel subclass on the frontend. Methods without @pure remain server-only.
14. Codegen and the Frontend
When the Vite plugin is running (or you run npx active-drizzle generate), codegen reads each .model.ts file and generates:
.gen.d.ts— TypeScript interface with all column types, associations, and enum labels.gen.ts— RuntimeClientModelsubclass with@puremethods, enum predicates, and cache key factories
This means the full model interface — types, predicates, computed properties — is available on the frontend without duplicating a single line of type definition.
// Frontend — using the generated ClientModel
import type { UserAttrs, UserWrite } from './_generated'
// UserAttrs = full read shape including any eager-loaded associations
// UserWrite = only permit-listed writable fields (defined in the controller)
const user: UserAttrs = data.items[0]
user.role // 'admin' | 'member' — typed from Attr.enum
user.isAdmin() // true/false — from @pure method
user.email // stringSee ClientModel & Type Safety for the full frontend type system.
What's Next
| Topic | Where to go |
|---|---|
| All query methods (find, where, order, includes, pluck...) | Querying Basics |
| Scopes — named, parameterised, composable | Scopes |
| Aggregates (count, sum, avg, tally) | Aggregates & Counting |
| Pluck and Pick — selective column loading | Pluck & Pick |
| create / update / destroy deep dive | Create, Update, Destroy |
| Validation reference | Validations |
| All lifecycle hooks | Lifecycle Callbacks |
| Dirty tracking | Dirty Tracking |
| Transactions | Transactions |
| Attribute transforms (Attr) | Attributes & Enums |
| All association types and options | Associations |
| STI (Single Table Inheritance) | STI |
| Custom primary keys | Custom Primary Keys |