Services
Five methods plus named actions
A service is the unit, not a route. It exposes find, get, create, patch and remove, plus any custom action — callable over HTTP, over WebSocket, or in-process.
createService({ name: 'invoices', model: 'invoice',
async markPaid(ctx) { … },
})
// same service, three ways in:
GET /api/invoices
ws { service: 'invoices', method: 'find' }
app.service('invoices').call('markPaid', id)
Replaces
Express routersFastify pluginsNestJS controllers
Hooks
around · before · after · error
Four phases, per method or for all of them, at app or service level. around wraps everything including failures, which is where timing and tracing belong.
hooks: {
around: { all: [timing] },
before: { create: [setOwner] },
after: { create: [broadcast] },
error: { all: [report] },
}
Replaces
Express middlewareFastify hooksNestJS interceptors
Context
One shape: auth · client · route · locals · query
Every hook and every method receives the same object, with the caller, the parsed query, the result and the error in known places — not a request object you decorate by convention.
Replaces
req/res + res.locals conventions
Envelope
Lists keep { data, total }; singles unwrap
One module decides the response shape for the entire framework, so pagination metadata is always in the same place and a single record is never wrapped for no reason.
GET /api/invoices → { object: "list", data: [...], total: 17 }
GET /api/invoices/8 → { id: 8, total: 500 }
Replaces
hand-rolled response wrappers
Transport
HTTP and WebSocket over one bridge
Both protocols enter through the same boundary and produce the same Context, so a service does not know or care which one a caller used.
Replaces
Express + Socket.IO wired separately
Directives
$limit $offset $orderBy $select, parsed at the edge
Result shaping is transport syntax, split from filters once at the boundary. Past that line nothing sees a $ — services receive clean filters and a directives object.
GET /api/invoices?status=open&$limit=20&$orderBy=-createdAt
ctx.query → { status: "open" }
ctx.directives → { limit: 20, orderBy: "-createdAt" }
Replaces
query-string parsersmanual pagination plumbing
Security
CORS · CSRF · headers · rate limiting
The usual middleware stack, configured rather than assembled, and ordered correctly by default — preflights short-circuit before CSRF, which is easy to get wrong by hand.
Replaces
helmetcorscsurfexpress-rate-limit
Channels
One announcement, two fan-outs, gated on the way out
Declare channel: on a service and every mutation announces once, reaching both the event bus and WebSocket subscribers. Gates still apply, so nobody is pushed a row they could not fetch.
createService({ name: 'invoices', channel: 'invoices' })
// subscribers receive:
{ type: 'invoices created', data: { id: 9 } }
Replaces
Socket.IO rooms + manual emit calls
Errors
Named classes; data denials normalize into them
Throw BadRequest or NotFound anywhere and the transport serializes it correctly. A data-layer denial becomes a 403 with the same shape, so clients match one contract.
Replaces
http-errorsBoomad-hoc error middleware
Client
Typed resources and live subscriptions in the browser
The same service API from the other side of the wire, including subscriptions and session handling — not a fetch wrapper you maintain alongside the server.
Replaces
axios wrappersgenerated API clients
Testing
Real apps, real pipelines, no port binding
Boot an app in-process, send real requests through the full hook pipeline, and stub only auth. Tests exercise the code that runs in production.
const app = await createTestApp({ db, auth: createStubAuth() })
await request(app).post('/api/invoices').send({ total: 5 })
Replaces
supertest + mock layers