Skip to main content

Runtime Controllers

Controllers are the HTTP edge of the runtime. They should stay thin.

Core controller rule

Controllers should:
  • bind Express routes
  • read request params and body
  • delegate persistence to services
  • return HTTP responses
Controllers should not:
  • implement model persistence directly
  • own cache policy
  • own relation loading policy
  • introduce driver-specific branching
The preferred runtime contract behind controllers is:
  • User.find(...) for one-record reads
  • User.create(...) for one-shot inserts
  • loaded-instance update(...); save() for readable updates
  • User.deleteById(...) and User.restoreById(...) for explicit by-id writes inside services

Express wiring

import express from "express";
import { UserController } from "./app/controllers/UserController";

const app = express();
app.use(express.json());

const controller = new UserController();

app.get("/users", controller.index.bind(controller));
app.get("/users/:id", controller.show.bind(controller));
app.post("/users", controller.store.bind(controller));
app.put("/users/:id", controller.update.bind(controller));
app.delete("/users/:id", controller.destroy.bind(controller));
app.patch("/users/:id/restore", controller.restore.bind(controller));
Only expose the restore route when soft-delete recovery is part of the API.
export class UserService {
  async create(data: Record<string, unknown>) {
    return User.create(data);
  }

  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 delete(id: number | string) {
    return User.deleteById(id);
  }

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

Soft deletes

If the model supports soft deletes:
  • destroy() should stay a soft delete path
  • restore() should be exposed only when recovery is intentional
Use one schema declaration, not both:
deleted_at: column("softDeletes")
or:
softDeletes: mixin("SoftDeletes")

Serialization, eager loading, and cache

  • keep toObject() normalization in the service boundary when needed
  • keep eager loading in services
  • keep cache logic in services

Driver note

The controller contract stays the same for SQL and Mongo models. Keep driver-specific behavior in the model and service layers.