Skip to Content
Documentation

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 notes module with its list route from Generate an endpoint, with a terminal in plugins/site-notes.
  • The site_notes table and a few rows in it: create src/database/notes.ts, migrate and add rows as in the first steps of Database.

Generate the service

pnpm vitnode g service notes

This 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.
plugins/site-notes/src/api/modules/notes/notes.service.ts
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:

plugins/site-notes/src/api/modules/notes/notes.service.ts
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:

plugins/site-notes/src/api/modules/notes/routes/list.route.ts
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:

DependencyLifetimeWhere it comes from
The database clientShared by the whole appc.get("db") in a route, or in a cron or queue handler, which gets a context too
Cache, events, queueOne requestc.get("cache"), c.get("events"), c.get("queue"), typed with Pick<EnvVariablesVitNode["cache"], "entry"> and the like
The acting userOne requestRead in the route, passed as an argument such as actor: { id }
Permission decisionsOne requestChecked in the route (adminStaffPermission, checkStaffPermission)
A transactionOne operationOpened 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 of db. 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:

plugins/site-notes/src/api/modules/notes/notes.service.ts
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 };
  },
});
plugins/site-notes/src/api/modules/notes/routes/show.route.ts
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.