Skip to main content

Monorepo

Refract is a pnpm workspace monorepo. All the code — backend, frontend, shared contracts, pluggable tool implementations — lives in a single repo, managed by a single pnpm-lock.yaml. Once you understand the workspace layout, the architecture becomes much easier to navigate.

Workspace layout

This is defined in pnpm-workspace.yaml:
The glob apps/tools/*/* means any directory two levels deep under apps/tools/ is a workspace package. Adding a new tool implementation is as simple as creating the directory — pnpm picks it up automatically on the next make deps-install.

How packages reference each other

There are two kinds of dependencies here: Static dependencies (solid lines) — declared in package.json with workspace:*. The backend and all tool packages depend on apps/shared this way. pnpm resolves workspace:* to the local package, so you always get the version in the repo. Dynamic dependencies (dashed lines) — tool packages are not statically imported. The backend’s loaders (tools/queue/loader.ts, tools/logger/loader.ts, etc.) use dynamic import() to load the right implementation at runtime, based on the client value in the environment’s config. This is what makes the tool system pluggable — swap the config value and nothing else changes.

apps/shared — the contract layer

apps/shared is the source of truth for types and Zod schemas that both the backend and tool packages need to agree on. Things like QueueType, LoggerType, and MetricsType live here. If you’re adding a new pluggable tool:
  1. Define its contract interface and Zod config schema in apps/shared/src/.
  2. Build the tool package in apps/tools/<tool>/<implementation>/ satisfying that interface.
  3. Write the loader in apps/backend/src/tools/<tool>/loader.ts.
This separation means tool packages never import from the backend, and the backend only knows about tool packages through the shared interface — not their implementation details.
💡 Tip: after changing anything in apps/shared, run make build module=shared (or make build-all) before running backend tests. The backend’s TypeScript compilation needs the compiled apps/shared/dist/ to resolve the types.

Adding a new tool implementation

Let’s say you want to add a Redis-backed queue as an alternative to BullMQ:
  1. Create the package directory:
  2. Implement the interface from apps/shared:
  3. Run make deps-install — pnpm detects the new package and wires up the workspace links.
  4. Add the package name to the loader in apps/backend/src/tools/queue/loader.ts.
  5. Add the new client value to the config schema in apps/shared and apps/backend/src/configuration/validate.ts.
  6. Build and test:
See Tooling System for the full loader pattern.

Dependency management

Never run pnpm install directly on the host. make deps-install runs inside Docker, uses the frozen lockfile when possible, and syncs both the host node_modules/ (for IDE types) and each service’s runtime volumes.
Runtime vs IDE dependencies: make deps-install syncs the host tree and both runtime volumes. Backend and consumer each run pnpm install on boot; after pulling new tooling-* packages, run make deps-install if consumer logs show TS2307 or MODULE_NOT_FOUND. For a full volume reset, use make deps-fix.

Dependency troubleshooting FAQ


What’s next?