Architecture Best Practices
Optimize for change isolation, not premature distribution. These practices keep Node.js backends evolvable without microservice tax on day one.
How to Use This List
- Treat unchecked items as architecture debt - prioritize tier A before new features
- Revisit after major milestones: Series B scale, PCI scope, or squad split
- Pair with Refactoring Checklist quarterly
- Assign an owner per module boundary rule, not "the team"
A - Start Simple, Stay Evolvable
- Default to a modular monolith. One deployable with feature modules beats five services nobody can debug on call. Revisit when deploy blocking is measured.
- Package by feature, not by technical layer.
modules/orders/owns orders end-to-end; avoid globalcontrollers/andservices/folders that hide ownership. - Draw module boundaries before drawing microservice boxes. Clean
index.tsexports are cheaper than Kubernetes YAML. - Prefer strangler extraction over big-bang rewrites. Move one capability behind an HTTP or event adapter; keep transactions in the monolith until data ownership is clear.
- Measure DORA metrics before splitting. Deploy frequency, lead time, and change failure rate tell you if architecture is the bottleneck.
B - Dependency Direction
- Domain and application layers import no Express, Fastify, Nest decorators, or ORM clients. Framework code stays in
infrastructure/. - Cross-module imports go through public
index.tsonly. Ban deep imports into peerinfrastructure/with ESLint ordependency-cruiserin CI. - Use ports for anything you will fake in tests or swap in prod. Repositories, gateways, clocks, and id generators are ports.
- Keep the composition root thin.
main.tswires adapters; it does not contain business rules. - Avoid circular module dependencies. Break cycles with events or by inverting dependencies through interfaces.
C - Data and Consistency
- Assign one owning module per table or aggregate. Other modules call APIs or events, not shared Prisma models.
- Document transaction boundaries explicitly. Know which use cases require ACID and which tolerate eventual consistency.
- Plan outbox or idempotent consumers before async handoffs. At-least-once delivery is the default in distributed Node workers.
- Do not share writable databases between future services. Schema-per-module inside a monolith rehearses extraction.
- Version event and API payloads. Include
schemaVersionfields; consumers must ignore unknown versions safely.
D - Operations and Observability
- Ship OpenTelemetry traces before service #2. You cannot debug what you cannot see across HTTP calls.
- Structured logs with
requestIdon every line.pinoor equivalent; no unstructuredconsole.login production paths. - Separate liveness and readiness probes. Readiness checks Postgres/Redis; liveness stays lightweight for kube rollouts.
- Implement graceful shutdown on SIGTERM. Drain HTTP server and close DB pools before exit.
- Define SLOs per critical user journey. Orders checkout p95 matters more than average CPU across pods.
E - Decisions and Governance
- Write ADRs for monolith vs services, framework choice, and data store forks. Include negatives and a review trigger date.
- Supersede ADRs instead of deleting them. Future engineers need the history of why you reversed course.
- Align modules to team ownership (Conway's Law). If two squads own one module, expect boundary erosion.
- Run architecture reviews when crossing 10 engineers or 3 squads on one repo. Boundaries slip without explicit enforcement.
- Reject slides that say "microservices for scale" without RPS and team data. Evidence beats fashion.
F - Node.js Runtime Fit
- Profile the event loop before horizontal scaling. Sync JSON on hot paths does not fix itself with more pods.
- Offload CPU-bound work to worker threads or job queues. Keep HTTP processes I/O-bound.
- Pin Node LTS in
enginesand CI. Node 24.18.0 Active LTS; test upgrades in a dedicated release. - Standardize one HTTP framework per deployable. Express 5 or Fastify 5 or Nest 11 - not all three in one process.
- Type boundaries with TypeScript 5.6+ strict mode. Shared DTO types are a coupling vector - version them intentionally.
FAQs
Which tier should a two-person startup prioritize?
Tier A and B. Skip microservice extraction items until you have a second squad blocked on deploys.
How do I enforce module boundaries without slowing devs?
Automate in CI with dependency-cruiser. Local npm run lint:arch should match CI exactly.
Is a shared npm package for DTOs OK?
Yes if versions are semver'd and consumers tolerate lag. Avoid importing another service's internal types from a monolithic shared package that changes daily.
When does NestJS change these rules?
Nest modules map to feature modules, but domain folders should still avoid @Injectable() if you want fast pure unit tests.
Should architecture best practices block MVP shipping?
No. Use a spike folder with explicit tech-debt ticket to refactor into modules before hiring squad #2.
What is the top anti-pattern you see?
Prisma client imported from route handlers with 200 lines of business logic - untestable and unextractable.
How do ADRs relate to this list?
ADRs record decisions; this list records ongoing hygiene. Link ADRs when a checkbox implies a major fork.
Can serverless replace modular monolith practices?
Module boundaries still matter in repo layout. Each Lambda is a nano-service - ops cost shifts to IAM and cold starts.
How often should staff engineers review this list?
Quarterly for repos past 12 months old, or after any incident blamed on "unexpected coupling."
What metric proves modular monolith is working?
Cross-module deep imports trending to zero in CI, and deploy lead time stable as engineer count grows.
Related
- Architecture Basics - introductory examples
- Modular Monolith - boundary implementation
- ADR: Monolith vs Services - decision template
- Refactoring Checklist - pre-refactor audit
- Microservices When Worth It - when to split
Stack versions: This page was written for Node.js 24.18.0 (Active LTS), npm 10+, TypeScript 5.6+, Express 5, Fastify 5, and NestJS 11.