| name | entity-hooks |
| description | This skill should be used when implementing entity lifecycle hooks (Rails callbacks equivalent). Provides patterns for before_save, after_create, and other lifecycle events. |
| triggers | ["callback","hook","before_save","after_create","lifecycle","entity hook","model callback"] |
Entity Hooks Skill
Entity hooks are the Next.js equivalent of Rails callbacks (before_save, after_create, after_commit). Since Drizzle doesn't have native callbacks, we implement hooks as composable Effect functions that wrap repository operations.
Location
Hooks live alongside the entity they belong to:
shared/entities/<name>/
├── <name>.ts # Drizzle schema
├── types.ts # Types
├── relations.ts # Relations (optional)
├── scopes.ts # Query scopes (optional)
└── hooks.ts # Entity hooks (NEW)
Basic Hook Pattern
import { Effect } from "effect";
import type { NewUser, UpdateUser, User } from "./types";
export const UserHooks = {
beforeCreate: (data: NewUser): Effect.Effect<NewUser, never> =>
Effect.succeed({
...data,
email: data.email.toLowerCase().trim(),
createdAt: new Date(),
updatedAt: new Date(),
}),
afterCreate: (user: User): Effect.Effect<void, never> =>
Effect.sync(() => {
console.log(`User created: ${user.id}`);
}),
beforeUpdate: (data: UpdateUser): Effect.Effect<UpdateUser, never> =>
Effect.succeed({
...data,
updatedAt: new Date(),
}),
afterUpdate: (user: User): Effect.Effect<void, never> =>
Effect.sync(() => {
console.log(`User updated: ${user.id}`);
}),
beforeDelete: (user: User): Effect.Effect<User, never> =>
Effect.succeed(user),
afterDelete: (user: User): Effect.Effect<void, never> =>
Effect.sync(() => {
console.log(`User deleted: ${user.id}`);
}),
};
Repository Integration
Integrate hooks into repository operations:
import { Effect, pipe } from "effect";
import { eq } from "drizzle-orm";
import { db } from "@/shared/db";
import { users } from "@/shared/entities/user";
import { UserHooks } from "@/shared/entities/user/hooks";
import type { DatabaseError, NewUser, UpdateUser, User } from "./types";
export const UserRepository = {
create: (data: NewUser): Effect.Effect<User, DatabaseError> =>
pipe(
UserHooks.beforeCreate(data),
Effect.flatMap((prepared) =>
Effect.tryPromise({
try: async () => {
const [user] = await db.insert(users).values(prepared).returning();
return user;
},
catch: (error) => new DatabaseError(error),
})
),
Effect.tap(UserHooks.afterCreate)
),
update: (id: string, data: UpdateUser): Effect.Effect<User, DatabaseError> =>
pipe(
UserHooks.beforeUpdate(data),
Effect.flatMap((prepared) =>
Effect.tryPromise({
try: async () => {
const [user] = await db
.update(users)
.set(prepared)
.where(eq(users.id, id))
.returning();
return user;
},
catch: (error) => new DatabaseError(error),
})
),
Effect.tap(UserHooks.afterUpdate)
),
delete: (id: string): Effect.Effect<void, DatabaseError> =>
pipe(
UserRepository.findById(id),
Effect.flatMap((user) =>
user
? pipe(
UserHooks.beforeDelete(user),
Effect.flatMap(() =>
Effect.tryPromise({
try: () => db.delete(users).where(eq(users.id, id)),
catch: (error) => new DatabaseError(error),
})
),
Effect.tap(() => UserHooks.afterDelete(user)),
Effect.asVoid
)
: Effect.void
)
),
};
Advanced Hook Patterns
Hooks with Dependencies (Services)
import { Effect } from "effect";
import { EmailService } from "@/shared/services/email";
import { AnalyticsService } from "@/shared/services/analytics";
import type { User } from "./types";
export const UserHooks = {
afterCreate: (user: User): Effect.Effect<void, never> =>
Effect.gen(function* () {
yield* Effect.fork(
EmailService.sendWelcome(user.email, user.name).pipe(
Effect.catchAll(() => Effect.void)
)
);
yield* AnalyticsService.track("user.created", { userId: user.id }).pipe(
Effect.catchAll(() => Effect.void)
);
}),
};
Conditional Hooks
export const UserHooks = {
afterUpdate: (
before: User,
after: User
): Effect.Effect<void, never> =>
Effect.gen(function* () {
if (before.email !== after.email) {
yield* EmailService.sendEmailChanged(after.email, before.email).pipe(
Effect.catchAll(() => Effect.void)
);
}
if (before.role !== "admin" && after.role === "admin") {
yield* NotificationService.notifyNewAdmin(after.id).pipe(
Effect.catchAll(() => Effect.void)
);
}
}),
};
Validation Hooks
export const UserHooks = {
beforeCreate: (data: NewUser): Effect.Effect<NewUser, ValidationError> =>
Effect.gen(function* () {
if (!isValidEmail(data.email)) {
yield* Effect.fail(new ValidationError("Invalid email format"));
}
if (data.password && !isStrongPassword(data.password)) {
yield* Effect.fail(new ValidationError("Password too weak"));
}
return {
...data,
email: data.email.toLowerCase().trim(),
createdAt: new Date(),
updatedAt: new Date(),
};
}),
};
Hooks with Transactions
export const createUserWithProfile = (
userData: NewUser,
profileData: NewProfile
): Effect.Effect<User, DatabaseError> =>
Effect.tryPromise({
try: () =>
db.transaction(async (tx) => {
const preparedUser = await Effect.runPromise(
UserHooks.beforeCreate(userData)
);
const [user] = await tx.insert(users).values(preparedUser).returning();
await tx.insert(profiles).values({
...profileData,
userId: user.id,
});
return user;
}),
catch: (error) => new DatabaseError(error),
}).pipe(
Effect.tap(UserHooks.afterCreate)
);
Hook Types Reference
| Hook | When Called | Common Uses |
|---|
beforeCreate | Before INSERT | Normalize data, set defaults, validate |
afterCreate | After INSERT | Send notifications, trigger jobs, analytics |
beforeUpdate | Before UPDATE | Update timestamps, validate changes |
afterUpdate | After UPDATE | Notify on changes, sync external systems |
beforeDelete | Before DELETE | Soft delete, cleanup related data |
afterDelete | After DELETE | Cleanup external resources, notify |
Index Export
export { users } from "./user";
export { usersRelations } from "./relations";
export { UserHooks } from "./hooks";
export { UserScopes } from "./scopes";
export type { User, NewUser, UpdateUser } from "./types";
Testing Hooks
import { describe, it, expect } from "vitest";
import { Effect } from "effect";
import { UserHooks } from "./hooks";
describe("UserHooks", () => {
describe("beforeCreate", () => {
it("should normalize email to lowercase", async () => {
const input = { email: "TEST@Example.COM", name: "Test" };
const result = await Effect.runPromise(UserHooks.beforeCreate(input));
expect(result.email).toBe("test@example.com");
});
it("should set createdAt and updatedAt", async () => {
const input = { email: "test@example.com", name: "Test" };
const result = await Effect.runPromise(UserHooks.beforeCreate(input));
expect(result.createdAt).toBeInstanceOf(Date);
expect(result.updatedAt).toBeInstanceOf(Date);
});
});
describe("beforeUpdate", () => {
it("should update updatedAt timestamp", async () => {
const input = { name: "Updated Name" };
const result = await Effect.runPromise(UserHooks.beforeUpdate(input));
expect(result.updatedAt).toBeInstanceOf(Date);
});
});
});
Rails Callback Mapping
| Rails Callback | Next.js Hook |
|---|
before_validation | Part of beforeCreate/beforeUpdate |
after_validation | N/A (validation is explicit) |
before_save | beforeCreate + beforeUpdate |
after_save | afterCreate + afterUpdate |
before_create | beforeCreate |
after_create | afterCreate |
before_update | beforeUpdate |
after_update | afterUpdate |
before_destroy | beforeDelete |
after_destroy | afterDelete |
after_commit | Effect.tap after transaction |
after_rollback | Effect.catchAll in transaction |