Rails-inspired controller, policy, service, serializer, and job primitives for Express applications.
If you are asking yourself, "Should I be using Rails instead?", the answer is yes. This library is a focused shim for teams that need Express and TypeScript but still want a few boring Rails conventions.
npm install express-light-railPeer dependencies:
npm install express @sequelize/coreThe package publishes ESM and CommonJS builds from dist/, plus TypeScript declarations.
Detailed convention docs live beside the source:
src/base-controller/README.mdsrc/base-policy/README.mdsrc/base-service/README.mdsrc/base-serializer/README.mdsrc/base-job/README.md
Use the package to keep an Express API on a predictable request path:
Route → Controller → Policy → Service → Model → Serializer → Response
- Controllers map resource routes to instance actions:
index,show,create,update, anddestroy, while route files stay normal Expressrouter.route(...).get(...).post(...)declarations. - Policies keep authorization and permitted attributes out of controllers.
- Services hold business logic and multi-step mutations behind
ServiceName.perform(args). - Serializers own response shape, association preload checks, and list/detail/reference views.
- Jobs provide
perform,performNow,performLater, andqueueAswithout choosing a queue backend for the host application.
Routes should stay visibly Express-shaped. express-light-rail gives controllers Rails-like
instance actions, but it does not hide Router behind a DSL:
router.route("/api/users").get(UsersController.index).post(UsersController.create)
router
.route("/api/forms/:formId/estimates/generate")
.post(Forms.Estimates.GenerateController.create)The second route is still a create action on a namespaced controller. That keeps migration
small for Express users while nudging route design toward Rails resources.
These conventions are intentionally aligned with WRAP, travel-authorization, and ELCC-style APIs:
- Keep the main request path explicit: controller → policy → service → serializer.
- Use serializer output types named like
UserAsIndex,UserAsShow, andUserAsReference. - Destructure required associations at the top of serializers, then fail with an explicit preload message before building the response.
- Serialize nested associations into named locals before the final returned object.
- Put complex database reads in query objects in the host app; expose reusable request helper primitives from this package.
- Treat front-end API contract types as a separate layer. This package exports backend primitives that make those contracts stable, but it does not own Vue/React component patterns.
The next host-agnostic pieces worth extracting from WRAP, travel-authorization, and ELCC are:
- API router middleware: shared 404 and error-handler factories that preserve Express routing and host-specific authentication/database error mapping.
- Query conventions: small helpers for naming, composing, and testing SQL literal builders without forcing class-based query objects.
- Job argument serialization: optional Sequelize model argument serialization for
BaseJob, while keeping queue persistence and workers host-owned. - Front-end API contracts: shared TypeScript query/envelope types that mirror controller helper behavior without pulling in Vue or React.
Keep these out until they remove duplicated code in at least two host apps. Pub/sub channels, workers, Auth0/JWT details, and app bootstrapping still belong in host applications.
import { type Request } from "express"
import { API, BaseService, PolicyFactory } from "express-light-rail"
import { User } from "@/models/index.js"
type ControllerRequest = Request & {
currentUser: User
}
export class UsersPolicy extends PolicyFactory<User, User>(User) {
static policyScope(currentUser: User) {
if (currentUser.isAdmin) return {}
return {
where: {
companyId: currentUser.companyId,
},
}
}
permittedAttributes() {
return ["name", "email"]
}
}
export class CreateUserService extends BaseService<Promise<User>> {
constructor(private attributes: Partial<User>) {
super()
}
perform() {
return User.create(this.attributes)
}
}
export class UsersController extends API<User, ControllerRequest> {
async create() {
const policy = new UsersPolicy(this.request.currentUser, User.build())
const attributes = policy.permitAttributesForCreate(this.request.body)
const user = await CreateUserService.perform(attributes)
return this.response.status(201).json({
user,
policy,
})
}
}import { BaseSerializer } from "express-light-rail"
type UserAsShow = {
id: number
name: string
organization: {
id: number
name: string
}
}
export class ShowSerializer extends BaseSerializer<User, UserAsShow> {
perform(): UserAsShow {
const organization = this.requiredAssociation(
this.record.organization,
"Expected user organization association to be preloaded."
)
return {
id: this.record.id,
name: this.record.name,
organization: {
id: organization.id,
name: organization.name,
},
}
}
}import { BaseJob, type JobBackend, type JobPayload } from "express-light-rail"
const backend: JobBackend = {
enqueue(payload: JobPayload) {
return BackgroundJob.create({
queueName: payload.queueName,
jobName: payload.jobName,
jobData: payload.jobData,
})
},
}
export class SendWelcomeEmailJob extends BaseJob<void> {
static override queueName = "mailers"
constructor(private readonly userId: number) {
super()
}
perform(): void {
// send email
}
}
SendWelcomeEmailJob.configure({ backend })
await SendWelcomeEmailJob.performLater(1)
await SendWelcomeEmailJob.queueAs("slow-mailers").performLater(1)npm install
npm run check-types
npm run test -- --run
npm run build
npm pack --dry-runThis repo still has the local bin/dev wrapper, but Docker is optional for package work.
- Set up
asdfand installruby. - Set up
direnvwith an.envrccontainingPATH_add bin. - Build with
dev build. - Test with
dev test.
Publishing is intentionally boring npm:
-
Confirm you are authenticated:
npm whoami
-
Confirm the registry version and choose the next semver version:
npm view express-light-rail version
-
Generate a version commit and tag:
npm version patch -m ":bookmark: Release %s." npm version minor -m ":bookmark: Release %s." npm version major -m ":bookmark: Release %s."
-
Publish:
npm publish
-
Push the version commit and tag:
git push origin main --follow-tags
prepublishOnly runs type checks, the Vitest suite, and the package build. Run
npm pack --dry-run --json first when changing package contents.