workspace boundaries
rule
dependencies point inward through published package APIs: applications consume shared contracts and generated clients; they never import backend source.
why
consumers must survive internal refactors they do not control. importing backend source means any file move or internal rename silently breaks the build or 404s the route, and every refactor becomes cross-package coordination. fixing the boundary at the package edge keeps only published contracts (routes, schemas, query keys) depended on, so each workspace moves freely while the contract stays stable.
goal and consequence
- goal: decouple internal implementation from public consumers.
- without the boundary: a backend file move or internal rename silently breaks the frontend import path — the build fails to resolve the path, or worse succeeds while the route 404s in production.
- import paths tied to internal structure make every refactor cross-package coordination; rollbacks cascade.
- with the boundary: frontend and backend move freely as long as the published contract (routes, schemas, query keys) stays stable.
package roles
_baseowns cross-boundary domain schemas, enum-like values, identifiers, constants, shared query-key factories._clientis generator-owned transport output.- backend owns routes, business operations, persistence, platform adapters.
- frontends own route composition and user interaction.
dependency direction
frontend/application -> _base + _clientbackend -> _base + _client where transport consumption is required_client -> generator input/tooling only_base -> no application or backend runtime
arrows describe public consumption, not filesystem proximity. a frontend may use a generated client and the canonical query-key/schema surface; it never reaches into a backend handler. backend business logic may use the same base facts; it never depends on route composition or browser state.
choose the owning package
| concern | owner | excluded dependency |
|---|---|---|
| stable cross-runtime domain values and schemas | _base | server runtime bindings, UI framework, route handler. |
| generated transport request/response client | _client | handwritten business policy or consumer-specific wrapper. |
| authentication, route validation, business operation, persistence | backend | frontend component or browser-only state. |
| route layout, presentation, interaction, local ephemeral state | frontend | backend implementation source. |
| a one-off composition helper | existing application/package | premature shared package. |
decision process
- same meaning across two consumers → move the canonical schema/value or query-key factory into
_base. - same transport surface → generate it into
_client. - only shared implementation syntax → keep it near the real owner until a stable public responsibility exists.
- this prevents
_basebecoming a utility attic and_clientbecoming a handwritten adapter layer.
good and bad
bad
frontend imports directly from backend source to reuse validation logic:
// apps/frontend/src/hooks/useCreateOrder.ts// WRONG — imports backend internal pathsimport { validateOrderAmount } from "@scope/backend/src/domains/order/validateOrderAmount";export function useCreateOrder() {const validate = (amount: number) => {return validateOrderAmount(amount); // build passes, route 404s after refactor};}
later the backend moves validation into services/ and renames it Order.validateAmount(). the import path no longer exists; the build silently fails to resolve it, or the frontend keeps calling the old route and gets 404. deployment appears successful — no build errors — but the feature breaks in production.
good
validation schema lives in _base with a published export; both runtimes import it through the declared interface:
// apps/_base/src/schemas/order.tsexport const OrderAmountSchema = z.object({amount: z.number().min(1).max(1000000),});export type OrderAmount = z.infer<typeof OrderAmountSchema>;// apps/backend/src/domains/order/createOrder.tsimport { OrderAmountSchema } from "@scope/base";export async function createOrder(input: unknown) {const data = OrderAmountSchema.parse(input);// backend logic}// apps/frontend/src/hooks/useCreateOrder.ts// CORRECT — imports through declared package exportimport { OrderAmountSchema } from "@scope/base";export function useCreateOrder() {const validate = (amount: number) => {return OrderAmountSchema.safeParse({ amount }).success;};}
backend can refactor internals freely — the published _base schema stays stable, and both consumers keep working because they depend on the declared contract, not internal paths.
verification
- inspect every cross-workspace import: it must target a declared package export, not a source path.
- typecheck the producer and direct consumer after a contract change.
- search for duplicate handwritten types, query keys, and generated method wrappers bypassing the intended package owner.
do not
- add authored wrappers around every generated client method.
- let
_basedepend on an application or backend runtime.