Tutorial · Step 5 of 14
Custom modules
Build your own forRoot-style modules: options in, lifecycle managed, typed out.
On this page
A module of your own#
Every @rhythmjs module — config, events, schedule, queue — is built from the same three pieces you already know: an options interface, a service factory, and a forRoot factory that wraps them in a Rhythm module with disposal. Nothing is reserved for the framework; a mailer module of your own is the identical shape:
// src/mailer/mailer.module.ts
import { Rhythm } from "@rhythmjs/rhythm";
export interface MailerOptions {
from: string;
transport?: "smtp" | "console";
}
export function createMailerService(options: MailerOptions) {
const transport = options.transport ?? "console";
return {
async send(to: string, subject: string, body: string): Promise<void> {
if (transport === "console") console.log(`[mail] ${options.from} -> ${to}: ${subject}`);
else await smtpSend({ from: options.from, to, subject, body });
},
close(): Promise<void> {
return transport === "smtp" ? smtpClose() : Promise.resolve();
},
};
}
export type MailerService = ReturnType<typeof createMailerService>;
export const mailerModule = {
forRoot(options: MailerOptions) {
return new Rhythm({ type: "module", name: "mailer" }).provide(
() => ({ mailerService: createMailerService(options) }),
(value) => value.mailerService.close(),
);
},
};Consumers register it exactly like the built-ins — options at the composition root, the service wherever the context flows:
appModule.register(
mailerModule.forRoot({ from: "notes@example.com" }),
({ mailerService }) => ({ mailerService }),
);
// in any handler below:
await ctx.mailerService.send(user.email, "Welcome", "…");Modules that ship routes#
A module is a full kernel, so it can carry controllers and middleware of its own — a self-contained feature the parent mounts as one line. Register it without an exportValue and its internals stay sealed; only its routes are observable:
// src/admin/admin.module.ts
const adminController = new RhythmRouter<AdminContext>({ prefix: "/admin" })
.use(requireRoles(["admin"]))
.get("/stats", (ctx) => {
ctx.json(ctx.adminService.stats());
});
export const adminModule = {
forRoot(options: AdminOptions) {
return new Rhythm<RhythmHttpContext>({ name: "admin", type: "module" })
.provide(() => ({ adminService: createAdminService(options) }))
.use(adminController.middleware());
},
};
// the parent sees routes, not services:
appModule.register(adminModule.forRoot({ retainDays: 30 }));This is how healthRoutes and the OpenAPI docs middleware work under the hood: functionality arrives as a mountable unit, and the boundary is the module, not a folder convention.
forFeature for per-use wiring#
When one shared core serves many features — one storage client, many buckets — pair forRoot with a forFeature that returns middleware instead of a module. It reads the root's exported service off the context at request time and derives the feature-scoped view, typed end to end:
import { derive } from "@rhythmjs/rhythm";
export const storageModule = {
forRoot(options: StorageOptions) {
return new Rhythm({ type: "module", name: "storage" }).provide(
() => ({ storageService: createStorageService(options) }),
(value) => value.storageService.close(),
);
},
forFeature(bucket: string) {
return derive((ctx: { storageService: StorageService }) => ({
bucket: ctx.storageService.bucket(bucket),
}));
},
};// composition root: the shared client, once
appModule.register(storageModule.forRoot({ root: "./data" }), (v) => v);
// each feature wires its own slice where it needs it
uploadsController
.use(storageModule.forFeature("uploads"))
.post("/", multipart({ maxBytes: 10_000_000 }), async (ctx) => {
await ctx.bucket.put(crypto.randomUUID(), ctx.form.file("file")!);
ctx.response.status = 201;
});The division of labor is the convention across the ecosystem: forRoot owns lifecycle (built once, disposed on teardown), forFeature owns per-request derivation — and because it is just derive(), the extension is visible to the type system downstream.
Packaging conventions#
If a module outgrows the app, it publishes like any @rhythmjs package. The conventions that keep it composable:
mailer/
package.json exports: { "./mailer": { types, default } } — one subpath
per module, no barrel export
src/mailer.ts PascalCase types (MailerOptions, MailerService),
lowerCamelCase instances (mailerModule, createMailerService)
src/mailer.test.tsDepend on @rhythmjs/rhythm as a peer dependency so the consumer's kernel is the only kernel, keep options serializable (they arrive at the composition root, often straight from the config service of step 7), and test the module in isolation the way step 14 tests everything else — register it into a throwaway kernel and assert on the context:
test("mailer sends through the console transport", async () => {
const seen: string[] = [];
console.log = (line: string) => void seen.push(line);
const app = new Rhythm<{}>({ name: "test" })
.register(mailerModule.forRoot({ from: "t@example.com" }), (v) => v)
.use(async (ctx) => {
await ctx.mailerService.send("u@example.com", "hi", "…");
});
await app.run({});
await app.teardown();
expect(seen[0]).toContain("t@example.com -> u@example.com");
});