Skip to content

Module shape

How to size a module, when a package is justified, and what an entry point promises

A deep module puts substantial behavior behind a small public interface. Private files can separate contracts, implementations, and policies without widening that interface. Judge package depth and file readability separately; neither line count nor export count proves that a file is easy to understand.

Read this page before you add a package, add an entry point, split a file, or create a new directory.

Package layout

packages/<name>/
  package.json
  tsconfig.json
  <entry>.ts        one file per public seam
  lib/              private implementation
  tests/            unit and integration tests
  scripts/          executables the package owns

Root files are entry points. They are the only files a caller outside the package can import.

lib/ is private. Files inside the package can import it. Nothing outside can.

tests/ can import its own package's lib/ directly. A unit test of an internal function does not need to make that function public first. Tests still reach other packages only through entry points, like any other caller.

This was not always the rule. The earlier rule sent tests through entry points too, and the result was that packages/auth published ten entry points no application imported, so that its unit tests can satisfy the boundary. The public surface described the test suite instead of the product.

.dependency-cruiser.ts enforces all three rules.

A relative import can climb at most one folder (../). A deeper path such as ../../lib/value.ts breaks when a file moves, and it hides which seam the import crosses. To reach a deeper file in the same workspace, use an alias from the imports field in the workspace package.json, such as #lib/*, #scripts/*, or #generated/*. A Next.js app uses its @/ path alias. Code in another package uses that package's entry points. scripts/check-relative-imports.node.ts enforces this rule. It skips vendor and generated files, because nobody edits them by hand.

pnpm lint:boundaries runs both checks.

Entry points are promises

Every entry point is a public commitment. Declare the smallest set that real consumers import.

Count the consumers before you add one. If the count is zero, it is not an entry point.

A test that needs internals is not a reason to add an entry point. A package's own tests can import its lib/ directly, so there is never a need to publish a module for a test. When a test needs something private, import it privately. Do not widen the package.

Do not create a barrel that re-exports a subtree. Several small entry points beat one large index.

Backend service modules

When a backend service has one canonical implementation, keep its contract and construction effect in one Context.Service class. Declare the contract as the second type argument of Context.Service, and document each method there. Build the service value in static readonly make. Expose an open static .layer field that uses Layer.effect(this, this.make). The Layer must retain replaceable repositories and external capabilities in its requirements. packages/services/lib/projects/service.ts shows this form. The same class shape covers these cases:

  • make can acquire a resource with Effect.acquireRelease. Layer.effect then releases it when the Layer closes, as Concourse's ApiHandler does.
  • An adapter with no dependencies may define make as a plain service value built with X.of({...}) and expose Layer.succeed(this, this.make).
  • A Layer that needs input exposes make(input) and layer(input).

Prefer the declared shape. A service can instead pass make in the Context.Service options and let Effect infer the shape. Then document each method where make defines it, as packages/services/lib/access.ts does.

Use a standalone named Layer when a capability has alternative adapters. Keep a generic Layer factory separate when no single service class owns the construction. Use the Services domains as the worked example:

lib/access.ts           shared policy service, canonical implementation, and open Layer
lib/projects/
  service.ts           domain capability, canonical implementation, and open Layer
  composition.ts       default assembly of domain and access Layers
  validation.ts        input decoding and safe validation failures
  access/service.ts    Project access capability, checks, and open Layer
  access/rules.ts      pure role calculations kept in the capability folder
  rules.ts             transactional target eligibility

The ProjectRepository port and its Drizzle adapter live in Persistence, in packages/db/lib/repositories/projects.ts. A repository module holds the storage capability, its native implementation, and its row mapping. See ADR 0017.

Create files for responsibilities present in the domain, not to fill a template. Use a concrete noun file when a capability folder would contain only its service module. Keep the folder when it contains other focused modules. Do not add redundant nesting, empty template files, or folder barrels. Applications has its own access capability. Both domains depend on the ServiceCommandBuilder contract. Its open Layer binds Services policy through AuditWriter.commands(policy); default assemblies supply that Layer. Keep types beside the contract that owns them. Use a separate type module only when multiple modules share a cohesive set. Keep storage mappings beside the storage implementation. Put the main exported implementation before its private mapping helpers.

Static Layers capture dependencies during construction. Extract cohesive checks behind a private service when they need those dependencies across operations. Keep implementation Layers open. Put default internal wiring in composition.ts, exported as a named default assembly through an existing package entry point. Runtime roots and integration tests use these assemblies; domain tests can supply substitute access services. Project and Application implementation Layers depend on their domain access contracts for all caller permission decisions. Collection loading stays in the domain. Access returns request-bound projections; target eligibility and current-state invariants stay transactional. Private adapter composition, API route assembly, and operation-scoped service provision remain valid uses of Layer.provide. Permission facts and transaction context remain operation-scoped. Audited commands declare authorize, prepare, and execute explicitly through Persistence's command builder. Command-builder Layers capture the selected writer and bind their package session policy and safe failure classification. Domain Layers capture the builder contract. Authorization and preparation run once per execution. Only execution runs again on a transaction retry. Use one business event name for the command span and audit event. Keep current target state and invariants in execution. See ADR 0010. Domain callbacks receive a plain AuditContext handle and call audit.addFacts. Helpers receive the handle explicitly; there is no audit service lookup or provision. The command builder provides a context for pretransaction phases; the writer provides a fresh context for every attempt, seeded with prepared facts. Do not capture the handle during Layer construction or share it application-wide. AuditWriter.write takes only a callback, invoked lazily inside each attempt. Only the writer persists events. Short helpers that use captured dependencies can remain inside the service construction effect.

Use Effect's platform services for secure randomness, digests, files, paths, and processes inside Effect implementations. Keep these services in the open Layer requirements. Runtime roots select Node implementations. If Effect does not provide a shared technical capability, define its contract in @concourse/platform, put Node adapters in its node entry point, and put deterministic Layers in its test entry point. Do not import a Node capability directly into API or Services production code.

Keep a Node adapter when the host behavior is the point of the module. Examples include an O_NOFOLLOW read from one file handle, symlink inspection, and detached process file descriptors. Use a visible static node: import. Keep the adapter small, and document its host requirement. Use import.meta.resolve for package resolution. Do not hide host selection inside a business Layer.

Update the package entry point and private test imports together. Remove the old standalone Layer export when a static Layer replaces it. Do not add a compatibility alias. Avoid folder barrels. Document declarations under Comments; names follow Naming.

When a package is justified

A package earns its own workspace when at least one is true:

  • Two or more workspaces consume it.
  • It needs its own dependency set, lint configuration, or test runner.
  • It owns a boundary that must be enforced mechanically.

packages/project failed all three, and was removed. It was package.json, tsconfig.json, an entry shim, a five-line lib/index.ts, a test file, a workspace entry, and a dependency edge, all to hold three strings that two files in one package read. One of the three was never read at all. The constants now live in packages/auth/lib/plugin.ts, next to the identifier they derive.

A constant with two callers belongs next to the callers.

Merge a module when

  • It only forwards or renames. apps/concourse/lib/rackspace/admin-status.ts was six lines that aliased one schema to a second name. The alias bought one word of vocabulary and cost a file, an import, and a hop.
  • It holds constants that its main consumer redeclares anyway. apps/concourse/lib/rackspace/workflow-policy.ts declared the retry limits, and verification-state.ts:474 and :732 wrote 3 and 2 again by hand. A policy module that its own subject ignores is worse than no module.
  • It is one small function with its own test file. Group small helpers into a domain module with one test file. apps/concourse/lib/auth/framework/http.ts is the right size.
  • Two call sites are not a framework. Write the direct version. Add options when a real third caller needs them, and delete options no caller uses.

Application backend modules

Apply the same private module conventions to application backend code. Concourse is the worked example:

  • lib/runtime.ts selects and composes application Layers. lib/server/runtime.ts owns their shared lifetime.
  • lib/server/api.ts declares the handler, its construction effect, and its static Layer with scoped disposal.
  • lib/server/framework/execute.ts runs app Effect programs in appRuntime at framework boundaries. lib/server/framework/client.ts builds one Promise SDK client per render with React cache. Its fetch carries only an allowlist of incoming request headers to the in-process API handler. lib/server/framework/configuration.ts holds the Promise readers getAuthConfig and getRackspaceReadiness, which run through runServerEffect.
  • lib/configuration/ separates descriptions and validation from configuration Layers and provider selection.
  • lib/auth/framework/session.ts separates authoritative and shell identity reads. Framework reads use the common refusal mapper in lib/reads/framework/execution.ts.
  • lib/rackspace/reports/ keeps the report service and its Layer in service.ts, and validation and projection in their own files. Its storage port, RackspaceReportRepository, lives in @concourse/db/repositories.
  • scripts/e2e-server.ts remains the executable. Its e2e-server/ folder holds process contracts, Layers, configuration, and cleanup ordering.

Framework routes, server actions, and server route handlers are driving adapters under ADR 0017. Each one does three things:

  1. It decodes the request with a @concourse/contracts schema.
  2. It makes one call to a core port or an SDK client, with authoritative access.
  3. It maps the result to a framework response, a redirect, or a refusal.

A driving adapter does not contain a branch that has business meaning, and it does not read storage. Keep substantial helpers in focused private files. A framework function does not need a service wrapper just because it uses Effect. Browser-safe schemas and path helpers stay separate from server resources. React components and presentation have their own responsibilities.

Effect ownership

Effect's recommended lint rules apply to backend packages, SDK internals, the root, the CLI, and Effect-owned app lib/ modules. Root and CLI tests follow the same rules. Mixed-app scripts follow their purpose. In Concourse, the e2e/acceptance/ harness and the tests/integration/ tests are Effect-owned. The file conventions classify mixed-app code by directory. A root contract flags files with no scope.

Next.js app/ files, React components, browser tests, documentation UI, and other framework harnesses use ordinary TypeScript. Keep async where Next.js requires it. The default @concourse/sdk client returns Promises; @concourse/sdk/effect serves Effect programs. Use a small Promise adapter when a React component calls an Effect-owned module. Do not wrap ordinary Promise code only to satisfy an Effect rule. Ordinary lint and type checks still apply to every file.

Some vendor and RSDB APIs take a callback that must return a Promise. Run the Effect inside that callback. Use Effect.runPromise when the Effect needs no services. Use Effect.runPromiseWith(services) when it needs the caller's services, such as its span. Identity also runs some Better Auth callbacks on a scoped ManagedRuntime, so that a test Clock reaches them.

Use Effect platform services inside Effect-owned code. If Effect lacks a required Node operation, keep it in a narrow file named *.node.ts with a static node: import. Tests with a direct Node import use *.node.test.ts. Only the conflicting Node import diagnostic is excluded in these files. Other Effect rules and ordinary lint still apply. Do not use process.getBuiltinModule or an inline rule suppression.

Reuse named Layer values so the app graph shares resources. Keep request credentials, current permissions, and operation resources at execution time. File moves must preserve render-local cache boundaries, Next background-task ownership, and the smaller CLI graph.

Split a module when

  • One function runs past about 100 lines.
  • One file runs past about 400 lines and holds more than one concern.
  • An exported factory returns an object with no declared type. Give callers a named interface instead of requiring Awaited and ReturnType to recover it.

Before a split, remove behavior that the product does not need. Rackspace verification once kept an application step ledger and singleton lease beside native Workflow storage. Independent runs and a final report made those application records unnecessary. The change removed the duplicate lifecycle instead of dividing it across more modules.

These are review triggers, not hard limits. First separate contracts, implementation, and unrelated helpers. A long operation module can remain when it holds one cohesive responsibility. Explain its invariants in the Layer JSDoc.

Wide and long is the shape to avoid

The counts describe the 2 September 2026 foundation review. They are historical examples, not current file-size limits.

FileLinesExportsVerdict
packages/auth/lib/commands/memberships.ts4991deep, correct
packages/auth/lib/commands/sso-provider.ts4221deep, correct
packages/auth/lib/queries.ts88216wide and long, accepted in that review
packages/auth/lib/server.ts72712wide and long, accepted in that review
apps/concourse/lib/rackspace/contracts.ts18420wide, accepted in that review

Do not copy a package helper into an app

If an app needs something a package has, export it from the package and import it. The app once carried byte-identical copies of the audit helpers from @concourse/auth. They drifted the moment one side changed.

Before the September 2026 cleanup, RackspaceOperationError was declared in both packages/db/lib/rackspace-contracts.ts and apps/concourse/lib/rackspace/contracts.ts. Same name, two definitions.

No dead exports

Export a symbol only when another module imports it. When a symbol is used only inside its own file, remove export. Delete symbols nothing uses.

Only the compiler can force an exception, such as a type that a consumer must name. Say so in a comment.

Factor repeated configuration

When the same list or block repeats across a configuration file, move it to the shared level.

Environment variables that most tasks read belong under global.env in turbo.json, not copied into each task. This repository enables the globalConfiguration future flag, so the key is global.env, not the older top-level globalEnv.

Shared lint rules belong in one configuration that others extend. Reused dependencies belong in the catalog: section of pnpm-workspace.yaml. Single-use dependencies go directly in the workspace package.json.

How this is checked

RuleChecked byStatus
Entry point and lib/ boundaries, no cycles, tests stay in their packagepnpm lint:boundariesEnforced
App code does not import test code or a sibling apppnpm lint:boundariesEnforced
Relative imports climb at most one folderpnpm lint:boundariesEnforced
API and Services production code does not import Node built-inspnpm lint:boundariesEnforced
Only driven adapters import Drizzle or the application schemapnpm lint:boundariesEnforced
Services import no framework, vendor client, HTTP API, or Auth interfacepnpm lint:boundariesEnforced
Driven adapters do not import the core or its callerspnpm lint:boundariesEnforced
Pages, routes, and framework adapters do not import db or rsdbpnpm lint:boundariesEnforced
Declared exports resolve to real filespnpm test:rootEnforced
Dead exports and dependenciespnpm lint:deadEnforced
Module size and depthCode review, against this pageReview only

pnpm lint:dead runs Knip, in pnpm verify and in CI. It began as a report while the cleanup worked through its backlog. The backlog is clear, and it gates a merge. The reports it is told to ignore are listed in knip.ts, each with the reason.

tests/scripts/boundaries.test.ts checks that dependency-cruiser follows TypeScript entry points into domain and repository implementations. This regression test prevents an unsupported parser from silently skipping those sources. It also cruises a small fixture tree in which each forbidden rule must reject one import. dependency-cruiser does not warn when a rule matches no file, so a rule that a rename makes stale fails this test. tests/scripts/check-relative-imports.test.ts runs the relative import check against each import form.

Current exceptions

packages/auth/server.ts is a wide entry point. Its exports form one server interface, and a split would change existing callers without changing behavior.

packages/ui uses a wildcard export for one-line component entries. Its private lib/ path is closed in the package manifest.