OmniStore, a microservices monorepo
A microservices monorepo with a NestJS gateway, three Go services and two Node workers, where the entire topology is generated from a single registry file.
- NestJS
- Go
- Turborepo
- Docker
- PostgreSQL
OmniStore is the largest monorepo I have built on my own: one NestJS gateway, three Go services, two Node workers and two React/Next frontends, all living in a single pnpm workspace orchestrated by Turborepo.
The hard part is not writing services#
Adding another service is easy. Keeping the topology from drifting is not. A
single service's port shows up in at least five places: compose.yml, the Nginx
config, the APISIX routes, the gateway's environment variables, and the port table in
the docs. Changing one and forgetting the other four is not a risk, it is a
certainty — and it only surfaces when you run the full stack, which is the most
expensive moment to debug.
So my first architectural decision was not about services at all. It was about where the truth lives.
One file holds the whole topology#
config/services.yaml describes every component: runtime and local ports, the Docker
hostname, the health path, the working directory, the dev command, and how a public
API prefix maps down to a service.
components:
main-server:
kind: gateway
host: main-server
runtimePort: 3001
localPort: 3001
healthPath: /health
cwd: apps/server/gateways/main-server
devCommand: [pnpm, run, dev]
includeSideServices: true
apiMappings:
- prefix: /notifications
targets:
- service: notification-service
upstreamPrefix: /Everything else is generated#
From that registry a single script renders every infrastructure artifact. None of
them is edited by hand — they all carry a Do not edit header.
export function renderArtifacts(registry) {
validateRegistry(registry);
return new Map([
['.generated/compose.env', renderComposeEnv(registry)],
['apps/server/gateways/main-server/.env.services.generated', renderMainServerEnv(registry)],
['infra/apisix/apisix.generated.yaml', renderApisixRoutes(registry)],
['infra/nginx/nginx.generated.conf', renderNginx(registry)],
['docs/SERVICES.generated.md', renderDocs(registry)],
]);
}The part I like most is pnpm config:check: it re-renders every artifact in memory
and diffs it against what is on disk, failing on a single character of drift. CI runs
it before typecheck, lint, test and build. Forgetting to run config:generate
therefore breaks a pull request rather than a deploy.
A single way in#
Nginx is the only public edge, and APISIX takes /api and forwards it to
main-server. The side backends publish no ports to the host at all — they only talk
over an internal Docker network.
main-server reaches them through one client that resolves base URLs by service key
from the registry instead of hard-coded URLs, and, more importantly, translates
infrastructure failures into meaningful HTTP: ECONNABORTED becomes a 504, anything
else becomes a 502. My clients do not need to know which service is down; they only
need to tell "the upstream is broken" apart from "your request is wrong".
Both asynchronous paths sit behind that same facade: bullmq-worker owns a Redis
queue for scheduled email and then calls the Go mail-service, while
rabbitmq-worker consumes the notification_exchange topic exchange and hands the
work to notification-service.
Go and Node in one task graph#
Each Go service carries a package.json whose build, test and lint scripts
point at scripts/go-run.mjs. That thin shim makes the Go services genuine workspace
members: Turborepo schedules them alongside the Node packages, with no separate
Makefile or parallel pipeline to keep in sync.
In turbo.json, build depends on ^build and codegen, and codegen is marked
cache: false because it writes straight into node_modules and .next — caching a
task whose output lives outside the build directory is the fastest way to get a wrong
build. config/services.yaml sits in globalDependencies, so changing the topology
invalidates the whole workspace cache, which is exactly what I want.
Outcome#
- Adding a side backend means declaring it in the registry and running
config:generate; Nginx, APISIX, env and docs follow - CI rejects artifacts that have drifted from the registry before typecheck, lint, test and build even start
- No side service is exposed to the host; every public request passes through exactly one facade
- Real env files never reach git: a local guard script and a dedicated CI job both enforce it