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 ownsRoot 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:
makecan acquire a resource withEffect.acquireRelease.Layer.effectthen releases it when the Layer closes, as Concourse'sApiHandlerdoes.- An adapter with no dependencies may define
makeas a plain service value built withX.of({...})and exposeLayer.succeed(this, this.make). - A Layer that needs input exposes
make(input)andlayer(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 eligibilityThe 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.tswas 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.tsdeclared the retry limits, andverification-state.ts:474and:732wrote3and2again 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.tsis 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.tsselects and composes application Layers.lib/server/runtime.tsowns their shared lifetime.lib/server/api.tsdeclares the handler, its construction effect, and its static Layer with scoped disposal.lib/server/framework/execute.tsruns app Effect programs inappRuntimeat framework boundaries.lib/server/framework/client.tsbuilds one Promise SDK client per render with Reactcache. Itsfetchcarries only an allowlist of incoming request headers to the in-process API handler.lib/server/framework/configuration.tsholds the Promise readersgetAuthConfigandgetRackspaceReadiness, which run throughrunServerEffect.lib/configuration/separates descriptions and validation from configuration Layers and provider selection.lib/auth/framework/session.tsseparates authoritative and shell identity reads. Framework reads use the common refusal mapper inlib/reads/framework/execution.ts.lib/rackspace/reports/keeps the report service and its Layer inservice.ts, and validation and projection in their own files. Its storage port,RackspaceReportRepository, lives in@concourse/db/repositories.scripts/e2e-server.tsremains the executable. Itse2e-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:
- It decodes the request with a
@concourse/contractsschema. - It makes one call to a core port or an SDK client, with authoritative access.
- 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
AwaitedandReturnTypeto 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.
| File | Lines | Exports | Verdict |
|---|---|---|---|
packages/auth/lib/commands/memberships.ts | 499 | 1 | deep, correct |
packages/auth/lib/commands/sso-provider.ts | 422 | 1 | deep, correct |
packages/auth/lib/queries.ts | 882 | 16 | wide and long, accepted in that review |
packages/auth/lib/server.ts | 727 | 12 | wide and long, accepted in that review |
apps/concourse/lib/rackspace/contracts.ts | 184 | 20 | wide, 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
| Rule | Checked by | Status |
|---|---|---|
Entry point and lib/ boundaries, no cycles, tests stay in their package | pnpm lint:boundaries | Enforced |
| App code does not import test code or a sibling app | pnpm lint:boundaries | Enforced |
| Relative imports climb at most one folder | pnpm lint:boundaries | Enforced |
| API and Services production code does not import Node built-ins | pnpm lint:boundaries | Enforced |
| Only driven adapters import Drizzle or the application schema | pnpm lint:boundaries | Enforced |
| Services import no framework, vendor client, HTTP API, or Auth interface | pnpm lint:boundaries | Enforced |
| Driven adapters do not import the core or its callers | pnpm lint:boundaries | Enforced |
Pages, routes, and framework adapters do not import db or rsdb | pnpm lint:boundaries | Enforced |
Declared exports resolve to real files | pnpm test:root | Enforced |
| Dead exports and dependencies | pnpm lint:dead | Enforced |
| Module size and depth | Code review, against this page | Review 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.