Docs

Engineering · Plan · Phase 0 not started

Codebase Organization Plan

One plan for the backend: seven library layers, twelve domains, two HTTP servers. Six phases, each with an exit measured by a command.

192 libraries146 layering violations8 servers to 2
base develop @ c1a372e908 · measured 2026-10-04 · nothing committed

Outcome

MeasureTodayTargetReached in
Layering violations1460Phase 4
Longest dependency chain36 libraries15Phase 4
Libraries affected by a typical change133Phase 4
Libraries affected by the worst change above core10042Phase 4
HTTP server processes82Phase 2
Heap for those servers at boot1,810 MB685 MBPhase 2
Server bundle size85.5 MBabout 70 MBPhase 5
Libraries in a domain folder41 of 192allPhase 6

The heap figures are one copy of each process. Every replica of a merged server carries its full heap.

Problems

  1. No layering. Domain libraries import each other sideways. media → requests → chats → user → phone → comms is one chain of six. A change to entity affects 96 libraries.
  2. Eight HTTP servers that load the same code. api-server alone carries 142 libraries. The other seven add 27.
  3. Dead weight loaded at boot. Eval fixtures, the whole Google API surface and eagerly built schemas are in every server's heap.
  4. ES5 output. Our code ships as ES5 because no compile target is set. Two file-level import cycles prevent changing it.
  5. Flat folders. 151 libraries sit directly under libs/. The folder tree says nothing about what may depend on what.

Target

Layers

A library may import from any lower layer. Within its own layer it may import only libraries of its own domain. Imports the compiler erases are free.

LayerLibrariesHolds
feature26Orchestration across domains: Command Center, front desk, website backend
domain64One business area each
platform17AI runtime and messaging
core14Identity: entity, auth, user, location
integration37One third-party client each
contracts6, growingTypes, constants, pure functions, port and queue definitions
foundation28config, monitoring, drizzle-orm, bull, llm

Domains

LayerDomainLibraries
coreidentity14
platformai6
platformcomms11
domainscheduling13
domaingbp13
domainlocal-seo9
domainwebsite8
domaincontent7
domainbrain6
domainclients3
domainscraping3
domainbilling2
featurecommand-center10
featurefrontdesk6
featurebackoffice5
featureplatforms, website-backend, business-profile, evals5

The library-by-library assignment is in Library Layers and in tools/lib-layers/layers.json.

Three ways across a boundary

A library that needs something from another domain uses one of these. It never imports the other domain's library.

Contracts
Types, constants and pure functions live in <domain>-contracts. Anyone may import it.
Port
An interface and an injection token in the contracts library. The owning library binds it. The consumer injects the token. Use it when the caller needs the answer.
Queue
A queue name and payload type in the contracts library. The producer enqueues a Bull job. The owning library's processor runs it. Use it when the caller does not need the answer.

Folders

libs/
  foundation/<lib>
  contracts/<domain>
  integration/<vendor>
  core/identity/<lib>
  platform/<domain>/<lib>
  domain/<domain>/<lib>
  feature/<group>/<lib>

Import aliases keep their current names during the move. @mononest/scheduling still resolves. Only tsconfig.base.json paths change.

Servers

ProcessContainsURLs
unified-serverapi, tasks, scp, auth, internalunchanged. api at the root, the others under their existing prefixes
unified-public-serverpublic, leadsunchanged. Both at the root
workerssync, website, content, platformunchanged
relay-serveritselfunchanged. Not evaluated

links-server is retired. public-server already serves all five of its paths.

Phases

Phase 0 — Guardrails and decisionsMerge the tooling. Make the checks required. Settle four decisions. No behaviour change.
Phase 1 — Remove dead weightFour changes that shrink every server and touch no behaviour.
Phase 2 — Two serversShip unified-server and unified-public-server. Retire the eight.
Phase 3 — Layering, mechanical fixes51 edges: contracts-only, queue-only and one-method ports.
Phase 4 — Layering, owning libraries95 edges across 40 libraries, then split the four widest.
Phase 5 — Modern build targetBreak two import cycles. Compile to ES2022.
Phase 6 — Folders and lockMove libraries into layer and domain folders. Delete the baseline. Add Nx tags.

Phases 1, 2 and 3 do not depend on each other and can run in parallel after Phase 0. Phase 4 follows 3. Phase 5 can start any time. Phase 6 is last.

Phase 0 — Guardrails and decisions

Nothing else is safe to start until new violations are blocked.

1

Review the layer and domain assignment

Each owner confirms their rows. A reassigned library changes which edges count as violations. Re-run pnpm layers:plan after any edit.

tools/lib-layers/layers.json · one owner per domain

2

Settle four decisions

Port module convention. Whether workers join a merged server. Whether the cached redirect moves into public-server. Whether public-facing hosts keep separate load balancer targets.

see Open decisions

3

Merge the tooling and the two worked fixes

Baseline at 146. From this merge a pull request cannot add a violation.

tools/lib-layers, tools/lib-footprint, two contracts libraries, the workflow

4

Make the layer check required

branch protection · status libs/layers

5

Fix the pre-existing type errors that block library builds

nx build for any library that depends on bull fails today. Every later phase needs these builds green.

libs/bull 14 errors, libs/chats 1

Exit: pnpm check:layers is a required check and passes at 146.

Phase 1 — Remove dead weight

Each item is one pull request and changes no behaviour.

ChangeWhereSaves per server
Stop importing agent-eval in tasks-server and scp-servertwo AppModulesabout 14 MB heap
Build the agents zod schemas on first uselibs/agentsabout 13 MB heap
Replace googleapis with per-API packages8 files in 6 librariesabout 6 MB heap, 10 MB bundle
Move the three contracts-only symbolsWave 1 of the layering work list3 violations

agent-eval holds eval scenario fixtures. Confirm why the two servers import it before removing it. libs/auth is among the most imported libraries, so the googleapis pull request lists its dependents.

Exit: a booted server's heap is at least 30 MB lower. Baseline at 143.

Phase 2 — Two servers

The merge is built and boots. Routes, status codes and response shapes match the standalone servers for unauthenticated GET requests. These steps take it from evaluation to production.

1

Move each server's root module into a library

The unified apps import other apps by relative path, which @nx/enforce-module-boundaries rejects. Each AppModule and its controllers move to a library that the standalone app and the unified app both import.

fixes 13 lint errors

2

Unit-test the mount helper

It scopes each server's global guards, interceptors and filters to that server's routes, and re-mounts controller modules that several servers share. It has no tests.

apps/unified-server/src/app/server-mount.ts

3

Extend the equivalence check to authenticated requests and writes

The current check sends no credentials and only GET. Add a token per server and a set of safe writes against a seeded entity. Compare standalone against merged.

tools/lib-footprint/boot.js --probe

4

Exercise what the check cannot reach

Three servers install a WebSocket adapter. Confirm each gateway connects and each cron and processor is registered once.

WebSocket gateways, crons, queue processors

5

Move the cached redirect into public-server

links-server caches the link in Redis and sends Cache-Control: no-store. public-server's handler for the same path does neither.

GET /r/:alias

6

Infrastructure

Two new services. Each hostname that pointed at a standalone server points at the merged one. Environment variables are the union of the merged servers'.

task definitions, load balancer rules, env vars

7

Roll out one server at a time

Move one hostname or prefix to the merged process, watch error rate and latency, then the next. Keep the standalone service running until its traffic is zero.

route by load balancer rule

One process means one event loop and one heap limit. Size the task for the sum of the merged servers' load, not for one of them.

Exit: two services carry all HTTP traffic and the eight standalone services are deleted.

Phase 3 — Layering, mechanical fixes

51 edges. Each is a small, repeated change.

WaveEdgesFixBaseline after
13Move symbols to a contracts library143
26Replace a side-effect call with a Bull job137
342Add a one-method port95

The procedure for each fix and every edge by name are in Library Layers. Two fixes are already done and serve as the template: scheduling → booking-agent for contracts and gateway → scheduling for a port.

A queue fix changes delivery from inline to eventual. Confirm with the domain owner that the caller does not rely on the effect having happened. The processor must be idempotent.

Exit: baseline at 95.

Phase 4 — Layering, owning libraries

95 edges. Work one owning library at a time.

1

Multi-method ports

List what other layers call on the library. Group the methods into ports of two to six by purpose. Move shared types to contracts. Convert side-effect calls to jobs. Repoint each importer in its own pull request. Start with media and gbp-location, ten importers each.

83 edges · 40 owning libraries

2

Manual edges

mcp → auth and mcp → agents are a foundation library importing upward. Either the symbols move down or mcp moves up a layer. frontdesk-agent-tools → win reads 17 methods and is a split, not a port.

12 edges

3

Split the widest libraries

along their ports

LibrarySizeMethods read by other layersSplit into
win85k lines28call history and analytics, follow-ups, agent context, quiet hours
gbp-location30k lines21location core, insights, posts, activity
entity14k lines20entity reads, preferences, location data
media19k lines9media store, stock media bank, tagging

The split lines come from the service names other layers call. Owners have not reviewed them.

scheduling is the largest library at 137k lines, but other domains read it through two methods. Splitting it along its existing sub-packs is worthwhile and is not on this plan's critical path.

Exit: baseline at 0. Longest chain 15. A typical change affects 3 libraries.

Phase 5 — Modern build target

1

Find every file-level import cycle

Build with an ES2022 target and load each bundle. Each "Cannot access before initialization" error names one cycle. ChatbotService and PostService are the two found so far. ES5 output hides them because it hoists classes.

node tools/lib-footprint/bundle.js --variant=es2022 --target=es2022 --filter=apps/

2

Break each cycle

extract the shared piece, or inject lazily

3

Set the compile target

jsc.target in each server's rspack config

Exit: every server bundle loads under ES2022 and is 16–19% smaller.

Phase 6 — Folders and lock

1

Move libraries into layer and domain folders

git mv the library, update its project.json paths and its tsconfig.base.json entry. The import alias does not change, so no source file changes.

one domain per pull request

2

Delete the baseline

The check then allows no violation.

tools/lib-layers/baseline.json

3

Mirror the manifest into Nx tags

The editor then reports a violation as it is typed.

@nx/enforce-module-boundaries depConstraints

4

Decide on type-only imports across domains

They cost nothing at runtime and still couple the code.

optional

Exit: every library sits under libs/<layer>/… and the check runs with no baseline.

Done so far

ItemState
Layer and domain manifest for all 192 librariesBuilt. Not reviewed by owners
Layer check with ratchet, CI workflow, pnpm check:layersBuilt. Fails on a new violation and on a stale baseline entry
Planner that classifies every violation and simulates the resultBuilt
Contracts fix: scheduling → booking-agentDone. 17 unit tests pass
Port fix: gateway → schedulingDone. api-server boots. 372 of 372 probed routes unchanged
unified-server and unified-public-serverBuilt. Boot. 3,367 and 158 routes match the standalone servers
Bundle, memory, heap-profile and boot-probe toolsBuilt

Nothing is committed. All of it is in one worktree.

Rules for every pull request

  • One edge, one owning library, or one domain folder per pull request.
  • Run pnpm check:layers. When a violation is fixed, run node tools/lib-layers/check.js --update and commit the smaller baseline.
  • A library with a domain pack gets its pack updated in the same pull request.
  • Moving a symbol and re-exporting it does not change a contract. Changing delivery from inline to queued can. That needs an ADR when it touches a "must never break" entry.
  • New pure functions and new processors get mirrored unit tests.
  • Boot the affected server before and after and compare with boot.js --probe=true.

Measuring progress

$ pnpm check:layers $ pnpm layers:plan $ node tools/lib-footprint/graph.js $ node tools/lib-footprint/boot.js --main=dist/apps/unified-server/main.js --name=unified-server --port=8100 --env=.env --probe=true

check:layers gives the violation count. layers:plan gives chain length and blast radius. graph.js gives libraries carried per server. boot.js gives booted heap and the route comparison.

Risks

RiskWhereMitigation
A merged server applies one server's guard to another's routesPhase 2The mount helper scopes them. Step 3 tests it with real credentials
A background job starves API requests in a shared processPhase 2Workers stay separate. Size the task for combined load
A queued side effect was relied on as immediatePhases 3 and 4Owner sign-off per edge. Idempotent processors
A server mounts a consumer without the owner's ports modulePhases 3 and 4Fails at boot, not at request time
The layer assignment is wrong for a libraryPhase 0Owner review before the check becomes required
Local database is three migrations behind developAny local bootMigration 0617 fails because its table already exists. Unrelated to this plan. Needs a manual fix

Open decisions

  1. Is the layer and domain assignment right?
  2. Are port modules global, or imported by each consumer module?
  3. Do workers stay separate processes? The plan assumes yes.
  4. Does the cached short-link redirect move into public-server, or does links-server stay?
  5. Is relay-server merged? It was not evaluated because its native dependency does not load on macOS.
  6. Who owns each phase?

Evidence

  • Library Layers: the layer model, every violating edge, the fix procedures and the worked examples.
  • Server Footprint: bundle and memory measurements, heap composition, and the merged-server equivalence check.