DevPluginsREST API
Add a service
Generate a plain service factory for a VitNode API module, give it explicit dependencies, and call it from a route or a background job without the Hono context.
A service holds a module's business operations, such as listing notes or publishing one, as plain functions with explicit inputs. Routes keep the HTTP work: validating the request, checking permissions and choosing a status code. vitnode g service creates the file. There is no base class, decorator or container to register it with.
Before you begin
- The
notesmodule with itslistroute from Generate an endpoint, with a terminal inplugins/site-notes. - The
site_notestable and a few rows in it: createsrc/database/notes.ts, migrate and add rows as in the first steps of Database.
Generate the service
pnpm vitnode g service notesThis creates src/api/modules/notes/notes.service.ts next to notes.module.ts and changes nothing else, so existing routes keep working:
ā created src/api/modules/notes/notes.service.ts service for module "notes"
createNotesService has no operations yet. Add them in src/api/modules/notes/notes.service.ts, then build it in a route:
const service = createNotesService({ db: c.get("db") });
Existing routes were not changed.import type { EnvVariablesVitNode } from "@vitnode/core/api/middlewares/global.middleware";
/** What the notes service needs, passed in by whoever builds it. */
export interface NotesServiceDeps {
/** In a route: `c.get("db")`. A transaction works too. */
db: Omit<EnvVariablesVitNode["db"], "$client">;
}
/**
* Business operations for the `notes` module. Build it where it is used:
* `createNotesService({ db: c.get("db") })`.
*/
export const createNotesService = (_deps: NotesServiceDeps) => ({
// Add operations here. Each takes plain input (and the acting user when it
// matters), never the Hono context, and returns data or a typed result the
// route turns into a status. Rename `_deps` to `{ db }` once one queries.
});The scaffold has no operations yet, so its dependencies are named _deps until one uses them. db leaves out the driver client ($client), so a caller can pass a transaction as well as c.get("db").
Add an operation
Rename _deps to { db } and replace the comment with operations that take plain, typed arguments. When a route already validates an input with a Zod schema, type the argument with z.infer<typeof schema> instead of writing a second interface. This list operation reads the site_notes table:
import type { EnvVariablesVitNode } from "@vitnode/core/api/middlewares/global.middleware";
import { desc } from "drizzle-orm";
import { site_notes } from "@/database/notes";
export interface NotesServiceDeps {
db: Omit<EnvVariablesVitNode["db"], "$client">;
}
export const createNotesService = ({ db }: NotesServiceDeps) => ({
list: async ({ limit }: { limit: number }) =>
db
.select({ id: site_notes.id, title: site_notes.title, body: site_notes.body })
.from(site_notes)
.orderBy(desc(site_notes.id))
.limit(limit),
});Call it from a route
In routes/list.route.ts, import the factory with import { createNotesService } from "../notes.service"; and build the service inside the handler:
handler: async c => {
const notes = await createNotesService({ db: c.get("db") }).list({ limit: 20 });
return c.json(notes, 200);
},Replace the route's sample 200 schema with z.array(z.object({ id: z.number(), title: z.string(), body: z.string() })), which is what list returns.
Check the result
Reuse the CheckNotes button from Generate an endpoint, changed to path: "/" with no args. Pressing it now shows the rows from site_notes instead of sample data. If the service returned a shape the 200 schema does not allow, TypeScript would report it in the handler.
Where dependencies come from
Build a service where its dependencies are, and pass them in:
| Dependency | Lifetime | Where it comes from |
|---|---|---|
| The database client | Shared by the whole app | c.get("db") in a route, or in a cron or queue handler, which gets a context too |
| Cache, events, queue | One request | c.get("cache"), c.get("events"), c.get("queue"), typed with Pick<EnvVariablesVitNode["cache"], "entry"> and the like |
| The acting user | One request | Read in the route, passed as an argument such as actor: { id } |
| Permission decisions | One request | Checked in the route (adminStaffPermission, checkStaffPermission) |
| A transaction | One operation | Opened by the service on db, or by the caller and passed in place of db |
- Pass values, not the context.
createNotesService({ db })works in a route, a cron job, a queue task or a script alike, and a caller can hand it a transaction instead ofdb. A service that takes the whole context ties every caller to it. - Type each dependency as narrowly as you use it.
Pick<EnvVariablesVitNode["events"], "emit">says the service only emits, and a test can pass a small fake. - Keep request state out of module scope. A service built once at import time would share one user, or one transaction, between requests. Building it per call is cheap: it is an object of functions.
- Pass the actor explicitly when an operation depends on who does it, rather than reading a session inside the service.
Return results, not HTTP errors
A service does not know it runs behind HTTP, so it never throws HTTPException. An expected outcome, such as "that article does not exist", is part of its return type, and the route turns it into a status:
import { desc, eq } from "drizzle-orm";
export type ShowNoteResult =
| { note: { body: string; id: number; title: string }; ok: true }
| { error: "note_not_found"; ok: false };
export const createNotesService = ({ db }: NotesServiceDeps) => ({
// list, as above
show: async ({ id }: { id: number }): Promise<ShowNoteResult> => {
const [note] = await db
.select({ id: site_notes.id, title: site_notes.title, body: site_notes.body })
.from(site_notes)
.where(eq(site_notes.id, id))
.limit(1);
return note ? { note, ok: true } : { error: "note_not_found", ok: false };
},
});handler: async c => {
const { id } = c.req.valid("param");
const result = await createNotesService({ db: c.get("db") }).show({ id });
if (!result.ok) return c.json({ error: result.error }, 404);
return c.json(result.note, 200);
},The route declares the 404 body in its responses, so the fetcher types both outcomes. An unexpected failure, such as a row an insert did not return, can stay a plain Error and become a 500. A service with many callers can also throw its own error class, as the generated resource stubs do with NotesNotImplementedError, which their routes map to 501.
Write a complete operation puts this together with a transaction, a queued task, the cache and an event.
The generator never rewrites existing handlers to use a service. Which route calls which operation, with which arguments, is your decision.