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 singlepnpm-lock.yaml. Once you understand the workspace layout, the architecture becomes much easier to navigate.
Workspace layout
pnpm-workspace.yaml:
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 inpackage.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:
- Define its contract interface and Zod config schema in
apps/shared/src/. - Build the tool package in
apps/tools/<tool>/<implementation>/satisfying that interface. - Write the loader in
apps/backend/src/tools/<tool>/loader.ts.
💡 Tip: after changing anything inapps/shared, runmake build module=shared(ormake build-all) before running backend tests. The backend’s TypeScript compilation needs the compiledapps/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:-
Create the package directory:
-
Implement the interface from
apps/shared: -
Run
make pnpm-install— pnpm detects the new package and wires up the workspace links. -
Add the package name to the loader in
apps/backend/src/tools/queue/loader.ts. -
Add the new
clientvalue to the config schema inapps/sharedandapps/backend/src/configuration/validate.ts. -
Build and test:
Dependency management
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?
- Tooling System — the loader pattern and how pluggable tools are swapped at runtime.
- Configuration System — how the
clientconfig value drives which tool gets loaded. - TypeScript — tsconfig setup per package.