Lifecycle Hooks

Execute custom logic before and after database operations

Available Hooks

Stabilize provides the following lifecycle hooks:

  • beforeCreate - Before inserting a new record
  • afterCreate - After inserting a new record
  • beforeUpdate - Before updating a record
  • afterUpdate - After updating a record
  • beforeSave - Before create or update
  • afterSave - After create or update
  • beforeDelete - Before deleting a record
  • afterDelete - After deleting a record

Register Hooks

Hooks are registered using registerHooks() after defining your model. You can pass a single callback or an array of callbacks for each hook type:

models/user.ts
import { defineModel, DataTypes } from "stabilize-orm";
import { registerHooks } from "stabilize-orm";
const User = defineModel({
tableName: "users",
columns: {
id: { type: DataTypes.STRING, required: true, unique: true },
email: { type: DataTypes.STRING, length: 255, required: true },
password: { type: DataTypes.STRING, length: 255, required: true },
},
});
// Register hooks after model definition
registerHooks(User, {
beforeCreate: async (entity) => {
console.log("Creating user:", entity.email);
entity.password = await hashPassword(entity.password);
},
afterCreate: async (entity) => {
console.log("User created with ID:", entity.id);
},
beforeUpdate: async (entity) => {
console.log("Updating user:", entity.id);
},
beforeDelete: async (entity) => {
console.log("Deleting user:", entity.id);
},
});
export { User };

Multiple Callbacks

You can register multiple callbacks for the same hook type. They execute in order:

hooks/multiple.ts
import { registerHooks } from "stabilize-orm";
registerHooks(User, {
beforeCreate: [
async (entity) => {
// First: validate
if (!entity.email.includes("@")) {
throw new Error("Invalid email");
}
},
async (entity) => {
// Second: hash password
entity.password = await hashPassword(entity.password);
},
async (entity) => {
// Third: set defaults
entity.isActive = true;
},
],
});

Hooks in defineModel

registerHooks() is not the only entry point. Hooks can also be declared inline on the model, which keeps the definition in one place:

models/user.ts
const User = defineModel({
tableName: "users",
columns: { /* ... */ },
hooks: {
beforeCreate: async (entity) => {
entity.password = await hashPassword(entity.password);
},
},
});

Hooks as Class Methods

A third form: if the entity has a method whose name matches the hook, it is called. This is resolved on the instance, so it works only where the ORM holds a real entity — a hook declared this way on a row that arrived as a plain object will not be found.

models/user.ts
class UserEntity {
id!: string;
email!: string;
async beforeCreate() {
this.email = this.email.toLowerCase();
}
}

When more than one form is used, defineModel / registerHooks callbacks run first and class methods run second. Within a form, callbacks run in the order given.

Which Operations Fire Which Hook

MethodHooksNotes
create()beforeCreate, beforeSave, afterCreate, afterSaveafter* sees the row with its generated id
update()beforeUpdate, beforeSave, afterUpdate, afterSaveper call
delete()beforeDelete, afterDeleteBoth fire on a soft delete too — the row is marked, not removed
bulkCreate()beforeCreate, beforeSaveAll rows, then one insert — entirely inside a single transaction
bulkUpdate()afterUpdate, afterSaveRuns per matched row; no before* hook

Note the asymmetry in bulkCreate(): the before hooks run for every row up front, but the insert is a single batched statement — so a hook that mutates the entity in place is reflected in the insert, while one that observes the database sees nothing yet.

Events Are a Separate Mechanism

Hooks are per model and per row. The orm.events emitter is ORM-wide and concerns the connection, not your data — the two are unrelated, and an event handler cannot observe a row change. See Events for the emitter and exactly which events fire today.