Mongoose
The tasks API on MongoDB with Mongoose: connection, schema, controller and module.
This page finishes the Build a project guide with Mongoose and MongoDB. It keeps ids as UUID strings, so a task id never has to be converted to an ObjectId.
Install
bun add mongooseSet DATABASE_URL=mongodb://localhost:27017/riserbob_tasks in .env, or use a MongoDB Atlas connection string.
Connect to the database
A small class connects once and fails loudly when the URL is missing, so a misconfigured app never starts half-working.
import mongoose from "mongoose"; export class ConnectDB { private static isConnected = false; static async connect(): Promise<void> { if (this.isConnected) return; const databaseUrl = process.env.DATABASE_URL; if (!databaseUrl) { throw new Error("DATABASE_URL is missing from environment variables"); } await mongoose.connect(databaseUrl); this.isConnected = true; console.log("🍍 Connected to the database"); } static async disconnect(): Promise<void> { if (!this.isConnected) return; await mongoose.disconnect(); this.isConnected = false; }}A plugin for UUID ids
import { randomUUID } from "node:crypto";import type { Schema } from "mongoose"; export function uuidPlugin(schema: Schema) { schema.add({ _id: { type: String, default: randomUUID, }, });}The schema
import mongoose, { Schema } from "mongoose";import { uuidPlugin } from "../../../utils/plugins/uuid.plugin"; export interface ITask { _id: string; title: string; content?: string | null; isCompleted: boolean;} export class TaskSchema { public static readonly schema = new Schema<ITask>( { title: { type: String, required: true, minLength: 3, maxLength: 50, }, content: { type: String, default: null, }, isCompleted: { type: Boolean, default: false, }, }, { timestamps: true }, ); static { this.schema.plugin(uuidPlugin); } public static readonly model = mongoose.model<ITask>( "Task", TaskSchema.schema, );}The controller
The controller receives the Mongoose model through its constructor. Each handler validates, talks to the model, and always returns a Response.
import type { Model } from "mongoose";import { Validator } from "riser-bob";import type { SpongHandler } from "riser-bob";import { buildPagination, getPagination } from "../../utils/pagination";import { CreateTaskRule } from "./rules/create.rule";import { UpdateTaskRule } from "./rules/update.rule";import type { ITask } from "./schemas/task.schema"; type TaskInput = { title?: string; content?: string | null; isCompleted?: boolean;}; export class TasksController { constructor(private readonly taskModel: Model<ITask>) {} readonly create: SpongHandler = async (spongReq, spongRes) => { const body = await spongReq.json<Record<string, unknown>>(); const result = Validator.validate<TaskInput>(body, CreateTaskRule); if (!result.valid) { return spongRes.SpongUnprocessableContent({ success: false, message: "Validation failed", errors: result.errors, }); } const task = await this.taskModel.create({ title: result.data.title, content: result.data.content ?? null, isCompleted: result.data.isCompleted ?? false, }); return spongRes.SpongCreated({ success: true, data: task }); }; readonly findAll: SpongHandler = async (spongReq, spongRes) => { const { page, limit, skip } = getPagination(spongReq); const [tasks, total] = await Promise.all([ this.taskModel.find().sort({ createdAt: -1 }).skip(skip).limit(limit), this.taskModel.countDocuments(), ]); return spongRes.SpongOK({ success: true, data: tasks, pagination: buildPagination(total, page, limit), }); }; readonly findOne: SpongHandler = async (spongReq, spongRes) => { const id = spongReq.params.id; if (!id) { return spongRes.SpongBadRequest({ success: false, message: "Task ID is required", }); } const task = await this.taskModel.findById(id); if (!task) { return spongRes.SpongNotFound({ success: false, message: "Task not found", }); } return spongRes.SpongOK({ success: true, data: task }); }; readonly update: SpongHandler = async (spongReq, spongRes) => { const id = spongReq.params.id; if (!id) { return spongRes.SpongBadRequest({ success: false, message: "Task ID is required", }); } const body = await spongReq.json<Record<string, unknown>>(); const result = Validator.validate<TaskInput>(body, UpdateTaskRule); if (!result.valid) { return spongRes.SpongUnprocessableContent({ success: false, message: "Validation failed", errors: result.errors, }); } // Pick the fields explicitly. Never pass the whole body to the database. const changes: TaskInput = {}; if (result.data.title !== undefined) changes.title = result.data.title; if (result.data.content !== undefined) changes.content = result.data.content; if (result.data.isCompleted !== undefined) { changes.isCompleted = result.data.isCompleted; } if (Object.keys(changes).length === 0) { return spongRes.SpongBadRequest({ success: false, message: "No valid fields provided for update", }); } const task = await this.taskModel.findByIdAndUpdate( id, { $set: changes }, { new: true, runValidators: true }, ); if (!task) { return spongRes.SpongNotFound({ success: false, message: "Task not found", }); } return spongRes.SpongOK({ success: true, data: task }); }; readonly delete: SpongHandler = async (spongReq, spongRes) => { const id = spongReq.params.id; if (!id) { return spongRes.SpongBadRequest({ success: false, message: "Task ID is required", }); } const task = await this.taskModel.findByIdAndDelete(id); if (!task) { return spongRes.SpongNotFound({ success: false, message: "Task not found", }); } return spongRes.SpongOK({ success: true, message: "Task deleted" }); };}The module
import { RiserBob } from "riser-bob";import { TaskSchema } from "./schemas/task.schema";import { TasksController } from "./tasks.controller";import { TasksRouter } from "./tasks.route"; export class TasksModule { register(bob: RiserBob): void { const controller = new TasksController(TaskSchema.model); new TasksRouter(bob, controller).register(); }}The entry file
Connect before you register modules and start listening. If the database is unreachable the app exits with a clear error instead of serving requests that cannot work.
import { RiserBob, corsMiddleware, loggerMiddleware } from "riser-bob";import { ConnectDB } from "./config/database/connect-db";import { TasksModule } from "./modules/tasks/tasks.module"; async function bootstrap() { await ConnectDB.connect(); const bob = new RiserBob(); bob.spongUse( corsMiddleware({ origin: "http://localhost:5173", credentials: true, }), ); bob.spongUse(loggerMiddleware); bob.spongGet("/", (spongReq, spongRes) => { return spongRes.send("Welcome to RiserBob APIs"); }); new TasksModule().register(bob); bob.spongListen(4000, () => { console.log("🧽 RiserBob is running at http://localhost:4000 🍍"); });} bootstrap().catch((error: unknown) => { console.error("[RiserBob] Failed to start application:", error); process.exit(1);});