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

apps/
├── backend/                  ← Node.js API server (Express + GraphQL)
├── frontend/                 ← React SPA (Vite + Apollo Client)
├── shared/                   ← Shared types and Zod schemas
├── tools/
│   ├── queue/
│   │   ├── bullmq/           ← BullMQ queue implementation
│   │   └── sqs/              ← AWS SQS queue implementation
│   ├── logger/
│   │   ├── pino/             ← Pino logger implementation
│   │   └── console/          ← Console logger implementation
│   └── metrics/
│       └── statsd/           ← StatsD metrics implementation
├── deployment/
│   └── railway/              ← Railway deployment scripts and config
└── boiler/                   ← Internal CLI tooling
This is defined in pnpm-workspace.yaml:
packages:
  - "apps/backend"
  - "apps/boiler"
  - "apps/frontend"
  - "apps/shared"
  - "apps/tools/*/*"
  - "apps/deployment/railway"
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 pnpm-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:
    apps/tools/queue/redis/
    ├── package.json
    ├── tsconfig.json
    ├── eslint.config.mjs
    └── src/
        └── index.ts
    
  2. Implement the interface from apps/shared:
    import type { QueueType } from 'tooling-shared';
    
    export const buildRedisQueueClient = async (config, tools): Promise<QueueType> => {
      // ...
    };
    
  3. Run make pnpm-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:
    make build-all
    make test module=backend
    
See Tooling System for the full loader pattern.

Dependency management

# Install all workspace deps (run inside Docker, writes to host node_modules for IDE use)
make pnpm-install

# Update a specific package across the workspace
make pnpm-update package=stripe

# Update all packages
make pnpm-update-all

# Update the lockfile without changing package.json
make pnpm-lockfile-update
Never run pnpm install directly on the host. The make pnpm-install command runs inside the Docker container, which ensures the right Node version is used and writes the node_modules/ to a volume that your IDE can read for type resolution.

What’s next?