Add a CRUD module
Every backend has a moment where you need a new resource. A products table, articles, tickets, whatever — they all want the same five endpoints (list, show, create, update, delete) and the same six files (model, migration, repository, schemas, controllers, routes). Warlock’s generators give you the skeleton in seconds; this recipe walks the full path from “I have nothing” to “I just curl’d a row I created.”
We’ll build products. Substitute your own name everywhere you see it.
Step 1 — Scaffold the module
Section titled “Step 1 — Scaffold the module”yarn warlock generate.module productsThat command creates the module folder with all the standard subdirectories at once:
src/app/products/ controllers/ services/ models/ repositories/ resources/ schema/ ← seal schemas (value + inferred type from one file) events/ ← auto-loaded types/ utils/ utils/locales.ts main.ts routes.tsmain.ts is your one-time setup file (event listeners, registrations). routes.ts is the module’s public URL surface. Both are auto-loaded — you never import them.
Open routes.ts and you’ll see a stub:
import { router } from "@warlock.js/core";import { guarded } from "app/shared/utils/router";
// Define your routes here// Example:// router.get("/products", listController);We’ll come back to it once the controllers exist.
Step 2 — Generate the model with a resource
Section titled “Step 2 — Generate the model with a resource”yarn warlock generate.model products/product --with-resourceThe argument is <module>/<entity>. The module is plural (products); the entity is singular (product). The --with-resource flag also drops a resource file under resources/.
What lands:
src/app/products/ models/product/ product.model.ts index.ts migrations/ resources/ product.resource.tsThe generated model is a stub — you’ll fill in the schema fields:
import { Model, RegisterModel } from "@warlock.js/cascade";import { type Infer, v } from "@warlock.js/seal";import { ProductResource } from "app/products/resources/product.resource";
export const productSchema = v.object({ // TODO: Add more fields});
export type ProductSchema = Infer<typeof productSchema>;
@RegisterModel()export class Product extends Model<ProductSchema> { public static table = "products";
public static schema = productSchema;
public static relations = {};
public static resource = ProductResource;}Replace the schema with real fields:
import { Model, RegisterModel } from "@warlock.js/cascade";import { type Infer, v } from "@warlock.js/seal";import { ProductResource } from "app/products/resources/product.resource";
export const productSchema = v.object({ name: v.string().min(2).max(120), slug: v.string().min(2).max(160), price: v.number().min(0), description: v.string().optional(), in_stock: v.boolean(),});
export type ProductSchema = Infer<typeof productSchema>;
@RegisterModel()export class Product extends Model<ProductSchema> { public static table = "products";
public static schema = productSchema;
public static resource = ProductResource;}Two notes on what changed:
- Dropped
public static relations = {}because we don’t have any. Cascade infers relations from@BelongsTo/@HasManydecorators; the empty literal is just generator noise. - Schema fields use
snake_casefor columns that map to DB columns (in_stock). Cascade reads the schema keys verbatim as column names. You can use camelCase if you prefer — pick one convention per project and stick with it.
The resource is even thinner:
import { defineResource } from "@warlock.js/core";
export const ProductResource = defineResource({ schema: { id: "number", // TODO: Add more resource fields },});Make it match the model:
import { defineResource } from "@warlock.js/core";
export const ProductResource = defineResource({ schema: { id: "string", name: "string", slug: "string", price: "number", description: "string", in_stock: "boolean", createdAt: "date", updatedAt: "date", },});Resources are output-only — they map model fields to wire fields and nothing else. No hydration, no reconciliation, no computed side effects. Those belong in services or model accessors.
Step 3 — Write the migration
Section titled “Step 3 — Write the migration”The generator created the migrations/ folder but not a migration file. Generate one explicitly with the column DSL:
yarn warlock generate.migration products/product \ --add "name:text,slug:text:unique,price:double,description:text:nullable,in_stock:boolean"The --add syntax is name:type:modifier per column, comma-separated. Common types: text, string, int, bigInt, double, boolean, timestamp, uuid, json. Modifiers include nullable, unique, and references (for foreign keys).
What you get:
import { boolean, double, Migration, text } from "@warlock.js/cascade";import { Product } from "../product.model";
export default Migration.create(Product, { name: text().notNullable(), slug: text().notNullable().unique(), price: double().notNullable(), description: text().nullable(), in_stock: boolean().notNullable(),});Run it:
yarn warlock migrateYou should see products created. The migration also adds id, createdAt, updatedAt, and deletedAt (for soft-delete support) by default — you don’t declare those.
Step 4 — Generate the repository
Section titled “Step 4 — Generate the repository”yarn warlock generate.repository products/productOutput:
import type { FilterRules, RepositoryOptions } from "@warlock.js/core";import { RepositoryManager } from "@warlock.js/core";import { Product } from "../models/product";
type ProductListFilter = { // Repository list filters};
export type ProductListOptions = RepositoryOptions & ProductListFilter;
class ProductsRepository extends RepositoryManager<Product, ProductListOptions> { public source = Product;
public simpleSelectColumns: string[] = ["id"];
public filterBy: FilterRules = { id: "=", };
public defaultOptions: RepositoryOptions = { orderBy: { id: "desc", }, };}
export const productsRepository = new ProductsRepository();Three knobs to set before this is useful:
import type { FilterRules, RepositoryOptions } from "@warlock.js/core";import { RepositoryManager } from "@warlock.js/core";import { Product } from "../models/product";
type ProductListFilter = { ids?: string[]; id?: string; slug?: string; in_stock?: boolean; search?: string;};
export type ProductListOptions = RepositoryOptions & ProductListFilter;
class ProductsRepository extends RepositoryManager<Product, ProductListOptions> { public source = Product;
public simpleSelectColumns: string[] = ["id", "name", "slug", "price", "in_stock"];
public filterBy: FilterRules = { id: "=", ids: ["in", "id"], slug: "=", in_stock: "=", search: ["like", "name"], };
public defaultOptions: RepositoryOptions = { orderBy: { createdAt: "desc", }, };}
export const productsRepository = new ProductsRepository();simpleSelectColumns— the columns returned when the repository is embedded as a relation. Smaller payloads, faster joins.filterBy— a map of “query-string key” to “(operator, column?)”.["in", "id"]means?ids[]=...filters byWHERE id IN (...).["like", "name"]means?search=foobecomesWHERE name LIKE '%foo%'.defaultOptions— the default sort. Override per-request viarequest.input("orderBy")if you expose it.
Step 5 — Generate the controllers
Section titled “Step 5 — Generate the controllers”Five separate calls, each scoped to one endpoint:
yarn warlock generate.controller products/list-productsyarn warlock generate.controller products/get-productyarn warlock generate.controller products/create-product --with-validationyarn warlock generate.controller products/update-product --with-validationyarn warlock generate.controller products/remove-productThe naming convention: list-<plural>, get-<singular>, create-<singular>, update-<singular>, remove-<singular> (or delete-<singular> — pick one). The --with-validation flag also creates a schema in schema/ — value + inferred type from one file, no separate *.request.ts.
What lands per controller:
controllers/list-products.controller.tscontrollers/get-product.controller.tscontrollers/create-product.controller.tsschema/create-product.schema.tscontrollers/update-product.controller.tsschema/update-product.schema.tscontrollers/remove-product.controller.tsWire the list controller
Section titled “Wire the list controller”The generator gives you a generic stub. Wire it to the repository:
import { type RequestHandler } from "@warlock.js/core";import { productsRepository } from "../repositories/products.repository";
export const listProductsController: RequestHandler = async (request, response) => { const { data, pagination } = await productsRepository.listCached(request.all());
return response.success({ products: data, pagination, });};request.all() returns every query/body/param input. The repository’s listCached reads filterBy to build a SQL filter from those inputs, hits the cache first, and falls back to the database on miss.
Wire the get controller
Section titled “Wire the get controller”import { ResourceNotFoundError, type RequestHandler } from "@warlock.js/core";import { productsRepository } from "../repositories/products.repository";
export const getProductController: RequestHandler = async (request, response) => { const product = await productsRepository.getCached(request.input("id"));
if (!product) { throw new ResourceNotFoundError("Product not found"); }
return response.success({ product });};request.input("id") reads the path param the router gave us. ResourceNotFoundError extends the framework’s HttpError — throwing it returns a 404 with a structured body. No need to write return response.notFound(...) by hand.
Wire the create controller
Section titled “Wire the create controller”The generator created a schema stub. Replace it with real rules:
import { type Infer, v } from "@warlock.js/seal";
export const createProductSchema = v.object({ name: v.string().min(2).max(120), slug: v.string().min(2).max(160).unique("Product"), price: v.number().min(0), description: v.string().optional(), in_stock: v.boolean(),});
export type CreateProductSchema = Infer<typeof createProductSchema>;v.string().unique("Product") is the DB-aware validator from Cascade’s seal plugin — it checks the products table for an existing row with the same slug and fails the schema before the controller runs. Want to scope to non-deleted rows? Pass a query callback.
Now the controller — the inferred CreateProductSchema type comes from the schema file directly, no separate *.request.ts alias:
import { type Request, type RequestHandler } from "@warlock.js/core";import { Product } from "../models/product";import { type CreateProductSchema, createProductSchema,} from "../schema/create-product.schema";
export const createProductController: RequestHandler<Request<CreateProductSchema>> = async ( request, response,) => { const product = await Product.create(request.validated());
return response.successCreate({ product });};
createProductController.validation = { schema: createProductSchema,};request.validated() returns the schema-typed object. The schema is attached to the controller via the .validation property — that’s the wiring, no decorators required. response.successCreate(...) returns 201.
For anything richer than a one-liner, extract a service:
import { Product } from "../models/product";import { type CreateProductSchema } from "../schema/create-product.schema";
export async function createProductService(data: CreateProductSchema) { return Product.create(data);}Then the controller becomes:
const product = await createProductService(request.validated());Thin controllers, fat services. Always.
Wire the update controller
Section titled “Wire the update controller”Update schemas usually mirror create schemas. Reuse with .without(...) or .partial() depending on what you want:
import { type Infer } from "@warlock.js/seal";import { productSchema } from "../models/product";
export const updateProductSchema = productSchema.partial();
export type UpdateProductSchema = Infer<typeof updateProductSchema>;.partial() makes every field optional. .without("slug") drops a field entirely (you might forbid slug updates). Pick what fits.
The controller:
import { ResourceNotFoundError, type Request, type RequestHandler,} from "@warlock.js/core";import { Product } from "../models/product";import { type UpdateProductSchema, updateProductSchema,} from "../schema/update-product.schema";
export const updateProductController: RequestHandler<Request<UpdateProductSchema>> = async ( request, response,) => { const product = await Product.find(request.input("id"));
if (!product) { throw new ResourceNotFoundError("Product not found"); }
await product.merge(request.validated()).save();
return response.success({ product });};
updateProductController.validation = { schema: updateProductSchema,};product.merge(data) overlays the partial data on the model; .save() persists it. Cascade tracks which columns changed and only writes those.
Wire the remove controller
Section titled “Wire the remove controller”import { ResourceNotFoundError, type RequestHandler } from "@warlock.js/core";import { Product } from "../models/product";
export const removeProductController: RequestHandler = async (request, response) => { const product = await Product.find(request.input("id"));
if (!product) { throw new ResourceNotFoundError("Product not found"); }
await product.destroy();
return response.success({ message: "Product deleted" });};destroy() uses the model’s default delete strategy (soft-delete if the model has a deletedAt column, hard-delete otherwise). Pass { strategy: "permanent" } to force a hard delete.
Step 6 — Register the routes
Section titled “Step 6 — Register the routes”Open routes.ts and wire the RESTful chain:
import { router } from "@warlock.js/core";import { guarded } from "app/shared/utils/router";import { createProductController } from "./controllers/create-product.controller";import { getProductController } from "./controllers/get-product.controller";import { listProductsController } from "./controllers/list-products.controller";import { removeProductController } from "./controllers/remove-product.controller";import { updateProductController } from "./controllers/update-product.controller";
guarded(() => { router .route("/products") .list(listProductsController) .show(getProductController) .create(createProductController) .update(updateProductController) .destroy(removeProductController);});router.route(path) returns a chainable builder with five named slots — list, show, create, update, destroy. Each registers the conventional REST verb:
| Slot | Verb | Path |
|---|---|---|
list | GET | /products |
show | GET | /products/:id |
create | POST | /products |
update | PATCH | /products/:id |
destroy | DELETE | /products/:id |
The guarded(...) wrapper is a project-local helper from src/app/shared/utils/router.ts — it applies authMiddleware("user") to every route inside. Drop it if your routes are public, or use router.route(...) directly outside the wrapper.
The dev server picks up the new module on the next save. No restart, no compile step.
Step 7 — Seed some data
Section titled “Step 7 — Seed some data”Generators only emit a seed file as part of the full generate.module scaffold — building the module piece by piece like this, you add one by hand:
import { seeder } from "@warlock.js/core";import { Product } from "../models/product";
export default seeder({ name: "Seed Products", once: true, enabled: true, order: 50, run: async () => { const products = [ { name: "Cotton T-Shirt", slug: "cotton-tee", price: 19.99, in_stock: true }, { name: "Hoodie", slug: "hoodie", price: 49.99, in_stock: true }, { name: "Cap", slug: "cap", price: 14.99, in_stock: false }, ];
for (const data of products) { await Product.create(data); }
return { recordsCreated: products.length }; },});once: true means the seeder skips if it has run before. Run it:
yarn warlock seedStep 8 — Hit the endpoints
Section titled “Step 8 — Hit the endpoints”Routes are guarded, so you need an access token. Grab one with /auth/login and use it:
TOKEN="<your-access-token>"
# Listcurl -H "Authorization: Bearer $TOKEN" http://localhost:3000/products
# Showcurl -H "Authorization: Bearer $TOKEN" http://localhost:3000/products/<id>
# Createcurl -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -X POST \ -d '{"name":"Mug","slug":"mug","price":9.99,"in_stock":true}' \ http://localhost:3000/products
# Updatecurl -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -X PATCH \ -d '{"price":12.99}' \ http://localhost:3000/products/<id>
# Deletecurl -H "Authorization: Bearer $TOKEN" -X DELETE http://localhost:3000/products/<id>Each response is wrapped by the resource — only the fields you declared in ProductResource.schema come back over the wire.
What the generator gives you vs. the worked example
Section titled “What the generator gives you vs. the worked example”The full generate.module products scaffold generates everything in one shot. That’s faster for greenfield work but produces stubs you’d refine anyway. The step-by-step path here is the same code at the end — you choose where to spend time.
A few drift notes worth flagging if you compare the generator output to the canonical src/app/faqs/ module:
organization_idandcreated_by/updated_bycolumns. The faqs module stamps these fromrequest.userinside the service. The generator doesn’t know about your auth model — add the columns and the stamping yourself.schema/create-*.schema.tsvsschema/update-*.schema.ts. Both can derive from the model’s schema via.without(...)or.partial(). The generator emits standalonev.object({...})stubs because it can’t see your model yet.- Module folder is
schema/, notvalidation/. Older modules in this codebase usevalidation/— that’s historical. New modules useschema/, which is what the generator and thewarlock-conventionsskill assume.
Gotchas
Section titled “Gotchas”- Always plural for module, singular for entity.
generate.module productsthengenerate.model products/product. The generator pluralizes/singularizes for you, but mixing them up creates files in the wrong place. - Run migrations after every schema change. Cascade has runtime schema validation; if you add a column to the model without a migration, inserts will fail at the DB layer.
request.validated()only works on controllers with.validation = { schema }. Without it,request.validated()returnsundefined. Easy to forget when copy-pasting from another controller.- Don’t put business logic in
routes.ts. Conditional routes break the dev server’s HMR diff. If you need feature flags, branch inside the controller.
See also
Section titled “See also”- First route — the minimal walkthrough this recipe expands on
- Project layout — the module convention in full
- Routing —
router.route(...), prefix groups, middleware groups - Controllers — controller signature, validation, response shape
- Repositories —
filterBy,listCached, custom queries - Resources — output shaping, computed fields
- Validation guide — seal schemas, DB-aware rules, custom validators
- “create-module
skill - “register-route
skill - “create-controller
skill - “use-repository
skill - “define-resource
skill - “build-restful
skill