Skip to main content

Runtime CRUD

This page defines the recommended CRUD contract for application code.

Core rule

Use one of these two creation paths:
const user = new User();
user.fill({
  name: "Alice",
  email: "alice@example.com",
});
await user.save();
const created = await User.create({
  name: "Alice",
  email: "alice@example.com",
});
Interpretation:
  • new User() means a transient in-memory model instance
  • save() persists the current instance state
  • User.create(...) persists immediately and returns the created model
Do not treat User.create(...) and save() as the same step.

Create

Use new User() when

  • you want to build the model gradually
  • you want to call fill(...) before persistence
  • the code reads better as an explicit instance lifecycle
const user = new User();
user.fill({
  name: "Alice",
  email: "alice@example.com",
});
await user.save();

Use User.create(...) when

  • the payload is already validated
  • you want a one-shot insert
  • you want a concise service method
const created = await User.create({
  name: "Alice",
  email: "alice@example.com",
});
Do not immediately call save() after User.create(...) unless you changed the model again.

Use User.createMany(...) when

  • you already have multiple validated payloads
  • the service owns an explicit bulk insert operation
  • you want hydrated created models back in input order
const createdMany = await User.createMany([
  { name: "Alice", email: "alice@example.com" },
  { name: "Bob", email: "bob@example.com" },
]);

Read

Use primary-key and safe-finder reads before mutation:
const found = await User.find(1);
const one = await User.findOneBy("email", "alice@example.com");
const rows = await User.where("is_active", true)
  .orderBy("created_at", "desc")
  .limit(20)
  .get();
Use new User().all() when you explicitly want the instance collection-read path:
const allUsers = await new User().all();

Update

This is the preferred public runtime path:
const found = await User.find(1);
if (found) {
  found.update({ name: "Alice Updated" });
  await found.save();
}
Why this is the preferred path:
  • the record is loaded first
  • the code reads like a model lifecycle
  • it works well with dirty tracking, hooks, and service-level decisions

Partial update with patch()

Use patch() when the instance already exists and you are intentionally updating a subset of fields.
const found = await User.find(1);
if (found) {
  await found.patch({ name: "Alice Patch" });
}
Use patch() when:
  • the row is already loaded
  • the service is applying a narrow change
  • you do not want the full update(...); save() flow

Bulk update and patch

Use bulk helpers only when the service already owns an explicit list of ids or patch rows.
await User.updateMany([1, 2], { status: "inactive" });

await User.patchMany([
  { id: 1, email: "alice+1@example.com" },
  { id: 2, email: "bob+1@example.com" },
]);

Delete

const found = await User.find(1);
if (found) {
  await found.delete();
}
This is the cleanest public path because:
  • the code reads as “load then delete”
  • soft-delete behavior stays attached to the model runtime
  • service logic can branch on “record exists” before deletion

Restore

Restore applies only when the model supports soft deletes.
const found = await User.find(1);
if (found) {
  await found.restore();
}
Use restore when:
  • the model includes soft-delete behavior
  • the service is intentionally exposing record recovery
  • the controller is generated with or supports a restore route

Bulk delete and restore

await User.deleteMany([1, 2]);
await User.restoreMany([1, 2]);

Explicit by-id helpers

Use these helpers only when you intentionally want a direct by-id service path:
await User.updateById(1, { name: "Alice Direct" });
await User.deleteById(1);
await User.restoreById(1);
Use them when:
  • the service already owns the target primary key
  • you do not want the full loaded-instance lifecycle
  • the code is intentionally thin and explicit
Do not use these helpers as the first-choice teaching path for most application logic. Bulk helpers and by-id helpers are both explicit service paths. Prefer loaded-instance flows when the service already reads the record first.
export class UserService {
  async create(data: Record<string, unknown>) {
    return User.create(data);
  }

  async createMany(rows: Record<string, unknown>[]) {
    return User.createMany(rows);
  }

  async find(id: number | string) {
    return User.find(id);
  }

  async update(id: number | string, data: Record<string, unknown>) {
    const user = await User.find(id);
    if (!user) return null;

    user.update(data);
    await user.save();
    return user;
  }

  async patch(id: number | string, data: Record<string, unknown>) {
    const user = await User.find(id);
    if (!user) return null;

    await user.patch(data);
    return user;
  }

  async delete(id: number | string) {
    return User.deleteById(id);
  }

  async restore(id: number | string) {
    return User.restoreById(id);
  }

  async deactivateMany(ids: Array<number | string>) {
    await User.updateMany(ids, { status: "inactive" });
  }
}
Do not treat these as the primary public teaching path:
  • new User().create(...)
  • new User().update(id, data)
  • new User().delete(id)
They may exist for compatibility or lower-level flows, but they are not the preferred runtime contract for consumer-facing code.