Vinova Lab · Engineering
Using the service template
What the template already does, and what you should not rewrite.
Version 0.9draft, not for publication.SHA-256 ec8d39073ac7e7964ba030ff093fd73040fd002ae09e1c94deb50c50010dc3ba
This version is not published. It is here to be read and commented on, and no challenge is reviewed against it. Rules marked pendingcannot be cited in a review.
Using the service template
Every backend service you deliver starts from the same template. This document explains what the template already does for you, and — more importantly — what you are expected not to rewrite.
The rule behind all of it: a service contains what is peculiar to it, and
nothing else. Logging, configuration, health, Redis, graceful shutdown and the
/status/* endpoints are solved once, in a shared package, and every service
gets them by extending it. Reimplementing any of them is not extra effort we
admire — it is a divergence we have to maintain.
Every rule has a number, like the engineering guidelines. A review that returns work cites the number.
§1 — What you start from
§1.1 The two files that matter
A service from the template has exactly two files you are expected to touch first, and they are both short:
server.js the wiring: which class, which port, which routes
modules/main.js your service: two hooks and whatever it does
server.js is complete as it stands:
const { createMicroserviceServer } = require("@vinovalab/backbone-shared/serverFactory");
const MainService = require("./modules/main");
const { app, getService, logger } = createMicroserviceServer({
ServiceClass: MainService,
microservice: "my-service",
moduleName: "RESTServer",
moduleVersion: "1.0.0",
defaultPort: 5099,
routes: [],
});
modules/main.js is your service, and it extends BaseService:
const BaseService = require("@vinovalab/backbone-shared/BaseService");
class MyService extends BaseService {
constructor() {
super({ microservice: "my-service", moduleName: "main", moduleVersion: "1.0.0" });
}
async _onInit() { /* what this service needs at startup */ }
async _onShutdown() { /* what it must release */ }
}
That is the whole skeleton. If your server.js grows past about thirty lines,
something belongs in a route file instead — see §4.
§1.2 What you get without writing it
createMicroserviceServer already gives every service:
/status/* |
health, readiness, info, log level — the platform reads these |
/settings, /settings/reload |
configuration, reloadable without a restart |
/release |
the contents of release.json |
| CORS | from CORS_ORIGIN, comma-separated |
| graceful shutdown | SIGTERM/SIGINT call your _onShutdown() |
| a logger | already tagged with service, module and version |
| a Redis bus | connected, with the standard channels |
Do not re-add any of these. A service that defines its own /status/health
answers a different shape from every other service, and the control plane that
polls it has no way to know.
§1.3 The order of startup, and why it bites
createMicroserviceServer calls listen() after your _onInit() returns.
Until then nothing answers on the port — a proxy in front of it returns 502, not
a connection error.
This matters when _onInit() waits on something unreachable. Keep it short, and
do not block on a dependency the service can live without: read what you
need, and if it is optional, degrade instead of waiting.
§2 — The shared package
@vinovalab/backbone-shared is published to GitHub Packages and installed like
any dependency. It is the single place where cross-service behaviour lives.
§2.1 What is in it
The modules you will actually use:
| Import | What it is for |
|---|---|
serverFactory |
builds the HTTP server and wires everything below |
BaseService |
the class your service extends |
logger |
levelled logging, console + bus + database |
redisBus |
key/value, publish–subscribe, and streams |
loadSettings |
configuration read from the platform, not from files |
internalAuth |
signing and verifying service-to-service tokens |
helpers |
asBool, asInt, and similar coercions |
segreti |
masking secrets before they reach a log or a record |
Import by name, always:
const BaseService = require("@vinovalab/backbone-shared/BaseService");
§2.2 Never import it by path
// wrong, even when it works
const BaseService = require("../../packages/backbone-shared/BaseService");
A relative path binds your service to a checkout layout. It breaks the moment the service is built in a container whose context is a different directory — and it breaks at build time, in CI, not on your machine.
§2.3 Pin it, do not float it
The dependency is declared with a tilde:
"@vinovalab/backbone-shared": "~1.5.0"
The tilde accepts patches and refuses minors. That is deliberate: a minor can change behaviour shared by forty services, and each of them adopts it when it is rebuilt on purpose, not when it happens to be rebuilt for something else.
§3 — Logging
§3.1 Use the logger you were given
BaseService exposes this.logger, already tagged. In a route file it arrives
as an argument. Neither has to be constructed.
this.logger.info("[import] batch applied", { rows: 1204 });
console.log is not a substitute: it carries no service name, no module, no
level, and it does not reach the platform’s log reader.
§3.2 The six levels, and what each is for
trace · debug · log · info · warning · error
The level is set per service and changed at runtime from the admin interface — so choose the level by how often you would want to see the line, not by how important it feels while writing it.
trace— one line per item in a loop. Turned on for one service, for a few minutes, to answer a specific question.debug— the outcome of an operation and its counters. One line per operation.info— something happened that a person would want to see in the normal log: an import completed, a job started.warning— something is wrong but the operation continued.error— the operation did not complete.
warndoes not exist. Callinglogger.warn?.()writes nothing, throws nothing, and you discover it by not finding the log. The method iswarning.
§3.3 What must never reach a log
Tokens, passwords, authorisation headers, cookies, API keys. The logger removes values held under keys that name a credential, but that is a net, not a licence: do not hand it a secret and rely on it.
For anything you are unsure about, mask it yourself:
const { mascheraValore } = require("@vinovalab/backbone-shared/segreti");
this.logger.info("[callback] payload", mascheraValore(body));
§3.4 Say what happened, not that something happened
A log line is read by someone who was not there. "[import] done" costs a line
and answers nothing. Write the numbers that let a reader tell success from
silent failure:
this.logger.info(`[import] rows=${scritte} skipped=${invariate} failed=${falliti}`);
§4 — Adding functionality
§4.1 New behaviour goes in a new file
This is the rule most often broken, and the one we care about most.
When you add an endpoint, do not add it to server.js. Write a router in
its own file and declare it:
// routes/reports.js
const { Router } = require("express");
module.exports = function reportsRoutes({ logger, getService }) {
const router = Router();
router.get("/", async (req, res) => {
const data = await getService().buildReport(req.query);
res.json({ ok: true, data });
});
return router;
};
// server.js
routes: [
{ path: "/reports", router: require("./routes/reports"), protected: true },
]
The factory receives { logger, getService }, so a route file never imports the
service instance directly and never constructs a logger.
§4.2 Why this is a rule and not a preference
A server.js that accumulates handlers becomes the file every change touches:
two people working on unrelated features conflict in it, and a reviewer reading
a diff cannot tell which endpoint changed. A file per subject means the diff
names the subject.
It also means the endpoint can be tested without starting a server — the factory is a plain function returning a router.
§4.3 Domain logic goes under modules/, not in routes
A route reads the request, calls something, and shapes the response. It does not
compute. Anything that decides, calculates or transforms belongs in
modules/<subject>.js, exported as plain functions.
The test for whether you got this right: can the logic be tested without an HTTP request? If not, it is in the wrong place.
§4.4 One file, one subject
utils.js and helpers.js are where unrelated code goes to become
unfindable. Name the file after what it is about — importDiff.js,
timesheetTemplate.js — even if it starts with one function.
§5 — Redis: cache, events, queues
this.bus is connected before _onInit() runs. It does three different jobs,
and choosing the wrong one is the usual mistake.
§5.1 Key/value — state that may be lost
await this.bus.set(this.bus.key("job", id), stato, { EX: 7200 });
const stato = await this.bus.get(this.bus.key("job", id));
Always set an expiry. A key without one stays until someone deletes it by hand, and nobody ever does.
Use it for state you can rebuild: progress of a job, a short-lived cache. Never as the only copy of something that matters.
§5.2 Publish–subscribe — announcements nobody must miss twice
await this.bus.publish(this.redisEventsChannel, { type: "IMPORT_DONE", id });
await this.bus.subscribe(channel, (msg) => { /* … */ });
Publish–subscribe is best effort and has no memory: a subscriber that is down when the message is sent never sees it. Use it to tell the world something happened, never to hand over work.
§5.3 Streams — real queues, with acknowledgement
When work must be done exactly once, and must survive a consumer restarting, use a stream and a consumer group:
await this.bus.xaddJson("documents:incoming", { documentId, tenant });
await this.bus.consumeLoop({
stream: "documents:incoming",
group: "extractor",
consumer: process.env.HOSTNAME,
onMessage: async ({ json }) => {
await this.process(json);
return true; // acknowledged; false or a throw leaves it pending
},
onError: (err) => this.logger.error(`[consume] ${err.message}`),
});
consumeLoop creates the group if missing, blocks while waiting, and
acknowledges only when onMessage returns true. Returning true when the work
failed silently loses the message — return false, or throw, and it stays
pending for another consumer.
§5.4 Which of the three
| You need | Use |
|---|---|
| state you could recompute | key/value, with an expiry |
| to announce something happened | publish–subscribe |
| work that must be done once | a stream with a consumer group |
If losing the message would be a bug, it is §5.3. When in doubt it is §5.3.
§5.5 Redis requires a password outside development
The connection reads REDIS_PASSWORD. In development the server usually has
none and an empty value works; in the deployed environments it does not, and the
failure is NOAUTH Authentication required at the first operation — not at
startup, because the client connects lazily.
So: do not assume a working Redis because the service started.
§6 — Configuration
§6.1 Read settings, do not read files
const { getConfigString } = require("@vinovalab/backbone-shared/loadSettings");
const livello = getConfigString("LOG_LEVEL", "info");
getConfigString reads the platform’s settings, falling back to the environment
and then to the default you pass. Always pass a default: a service that will not
start because a setting is missing is a service that cannot be deployed first.
§6.2 Settings change without a restart
POST /settings/reload re-reads them. If your service caches a setting in a
field, override afterSettingsReload() to pick the new value up — otherwise the
reload succeeds and changes nothing, which is worse than failing.
§6.3 Secrets are not settings
Credentials arrive as environment variables, from the deployment. They are never
written into release.json, never committed, and never logged (§3.3).
§7 — What the platform expects back
§7.1 release.json is the version, and it is read by machines
{ "microservice": "my-service", "version": "1.0.0", "lastUpdate": "01/09/2026" }
The deployment pipeline tags the image with this number. A version already published is never republished: the pipeline refuses it, because overwriting a tag destroys the binary that number used to mean. Bump it — a patch is enough — for every delivery.
§7.2 Answer shapes are uniform
{ "ok": true, "data": … }
{ "ok": false, "error": "a sentence a person can act on" }
An error message is read by whoever has to fix it. "Invalid input" names
nothing; "month must be YYYY-MM, received '2026-13'" names the field, the rule
and the value.
§7.3 Status codes carry meaning
400 the request is wrong · 401 not authenticated · 403 authenticated but
not allowed · 404 it does not exist · 409 it exists but the state forbids
this · 502 a service you depend on failed.
A 500 means we have a defect. Using it for a rejected input hides real
defects among expected ones.
§8 — Before you deliver
npm install && npm testis green on a clean checkout.server.jswires; it does not implement (§4.1).- No
console.log, nologger.warn(§3.1, §3.2). - The shared package is imported by name (§2.2) and pinned (§2.3).
- Every Redis key has an expiry (§5.1); every queue acknowledges honestly (§5.3).
release.jsoncarries a version that has never been published (§7.1).- A
README.mdsaying what surprised you — not what the code does.
Version 0.9 — draft, not for publication.