Coupling & cohesion
Features are folders inside apps/server, not packages, so nothing in the resolver keeps them
apart. This page answers which imports are blocked, by what mechanism, which way imports may
point between foundation and domain features, and what to do when two features need each
other.
Keep another feature’s repo and policy private
Section titled “Keep another feature’s repo and policy private”Impact: HIGH a repo import skips the owner’s policy
- Any feature may import another feature’s service, errors, schemas and types.
- A feature’s
*-repo.tsandpolicy.tsare imported only from inside that feature. A cross-feature read or write goes through the owner’s service, so the owner’s policy runs. - A transaction that spans features calls the other feature’s service inside the same
withTransaction. The transaction is ambient, so the service call joins it. - The check is oxlint’s built-in
no-restricted-imports, one glob for every feature, because private files are named by role. Never re-export a repo from a public file to get around it. - Accepted cost: it is string matching, so an alias or re-export gets past it; owners add service methods for writes another feature needs.
❌ Incorrect — billing reaches into labeling’s storage and policy:
import * as LabelingRulesRepo from "../labeling/labeling-rules-repo.ts" // lint errorimport * as Policy from "../labeling/policy.ts" // lint error✅ Correct — billing calls labeling’s service; the lint keeps it that way:
// .oxlintrc.json — overrides{ "files": ["apps/server/src/**/*.ts"], "rules": { "no-restricted-imports": ["error", { "patterns": [{ "group": ["../*/*-repo.ts", "../*/policy.ts"], "message": "Another feature's repo and policy are private. Call its service (coupling-and-cohesion §1)." }]}]}}Source: notes/02-design-principles/coupling-and-cohesion.md · Decision 1
Pin infrastructure imports to the files that own them
Section titled “Pin infrastructure imports to the files that own them”Impact: MEDIUM decided rules finally get a mechanism
- Same built-in rule, one override per row.
**/*.test.tsand**/testing.tsare exempt unless stated; root tool configs such asvitest.config.tsare outside every row.@effect/platform-*,node:*,alchemy,cloudflare:*: platform entry files only (bin.ts,isolate.ts,alchemy.run.ts,packages/db/src/migrate.ts).node:cryptoalone is also allowed ininfra/secrets.tsandinfra/secret-box.ts.effect/sql*,@effect/sql-*:*-repo.ts,infra/,packages/db,main.ts,isolate.ts.effect/http-api*and server-sideeffect/http/(HttpRouter,HttpServer*,HttpMiddleware):http.ts,contracts,main.ts, entry files. Never inpackages/domain.- The IdP vendor SDK:
identity/authenticator.ts. A relative path out of its package: nowhere. - In
policy.ts: noEffect,Layer,Context, SQL, HTTP or repo imports. **/testing.ts,**/fixtures/**: only from**/*.test.tsand**/testing.ts.
- Match with
patterns, notpaths, so subpaths are caught.HttpClientstays free: every vendor client makes outbound calls.
❌ Incorrect — SQL in the service file:
import * as SqlClient from "effect/sql/SqlClient" // lint error✅ Correct — SQL only in the feature’s repository:
import * as SqlClient from "effect/sql/SqlClient"Source: notes/02-design-principles/coupling-and-cohesion.md · Decision 2 (amended)
Foundation never imports a domain
Section titled “Foundation never imports a domain”Impact: HIGH a kernel importing a feature cycles with all of them
- Foundation supplies mechanisms and stable primitives:
@app/domain,contracts, andinfra/,identity/,orgs/,authz/inapps/server/src. Every other feature is a domain. Domains import foundation freely; foundation never imports a domain. - Foundation imports foundation in a fixed, acyclic order.
infra/is a leaf.orgs/ownsorg_membersand theOrgMembersservice;authz/’sPermissions.resolvereads roles through it (authz → orgs), andorgs/never importsauthz/. - A capability enters foundation when it is useful on its own, or when two domains need the same
meaning. “Looks reusable” is not enough. Adding a folder edits the list and
.oxlintrc.jsonin the same PR. - Override order matters: a later override replaces an earlier one’s options, so each foundation override repeats the repo and policy patterns, and stricter kernel overrides come last.
❌ Incorrect — a kernel reaches up into a domain:
import * as Billing from "../billing/billing.ts" // foundation → domain: lint error✅ Correct — the direction, and the lint that holds it:
infra/ → nothing in apps/server outside itselfidentity/ → infra/, @app/domainorgs/ → infra/, @app/domain owns org_members, OrgMembers, OrgScopeLayerauthz/ → infra/, orgs/, @app/domaindomains → foundation, other domains' public filesSource: notes/02-design-principles/coupling-and-cohesion.md · Decision 4 (amended)
Between two domains, one direction is direct; the other is an event or a port
Section titled “Between two domains, one direction is direct; the other is an event or a port”Impact: HIGH keeps the Layer graph a DAG without merging domains
- Any domain may depend on any other, but within one pair only one direction is a direct import
or
yield*. The reverse goes through a port (aContext.Servicetag owned by the domain that needs the information, implemented by the other, wired inmain.ts) or a domain event. - What the reverse direction uses is owned by the imported side (or
@app/domain). Otherwise the subscriber imports the publisher and the cycle is back. - A port’s implementation uses only the implementing domain’s read side (its repo), never a service that itself needs the other domain.
- Choose by meaning: a port is a synchronous question answered consistently; an event is a fact to react to after commit, delivered through the outbox at least once, to an idempotent, order-tolerant subscriber.
❌ Incorrect — both domains import each other directly:
import * as Billing from "../billing/billing.ts" // labeling → billing// billing/billing.tsimport * as LabelingRules from "../labeling/labeling-rules.ts" // billing → labeling: a cycle✅ Correct — labeling → billing is direct; billing asks through a port it owns:
export class ActiveRuleCount extends Context.Service<ActiveRuleCount, { readonly forOrg: (orgId: OrgId) => Effect.Effect<number, PersistenceError>}>()("@app/billing/ActiveRuleCount") {}
// labeling/active-rule-count.ts — labeling implements it from its own repoexport const layer = Layer.effect(Billing.ActiveRuleCount, Effect.gen(function* () { const repo = yield* LabelingRulesRepo.LabelingRulesRepo return Billing.ActiveRuleCount.of({ forOrg: repo.countActive })}))Source: notes/02-design-principles/coupling-and-cohesion.md · Decision 4 (amended: domain pairs)
Check for cycles; fix them by moving down, a port or event, or merging
Section titled “Check for cycles; fix them by moving down, a port or event, or merging”Impact: HIGH a hidden cycle loads in an unchosen order
- A module-cycle check (madge,
scripts/circular.ts) runs beside the package-graph script. Value-import cycles fail; type-only cycles are allowed. - A feature owns a set of tables plus the services over them. The migration prefix
NNNN_<module>_already names each table’s owner. A slice becomes two features when a service group owns tables nobody else in it writes, and has its own callers. - When two domains need each other: (1) move a domain-agnostic piece down into foundation; (2) otherwise keep one direction direct and use an event or a port for the other; (3) merge only when the two fail the split tests in DDD: bounded contexts. Between foundation features, only moving down or merging applies.
- Never break a cycle with
await import(): it keeps the cycle and hides it from the reader and the check.
❌ Incorrect — a lazy import hides the cycle instead of removing it:
// billing/charge.ts imports labeling, so labeling "breaks" the cycle lazilyconst Charge = await import("../billing/charge.ts")✅ Correct — a cycle check that fails the build:
import { globSync } from "node:fs"madge(globSync(["apps/*/src/**/*.ts", "packages/*/src/**/*.ts"]), { detectiveOptions: { ts: { skipTypeImports: true } },}).then((res) => { if (res.circular().length) { console.error(res.circular()); process.exit(1) } })Source: notes/02-design-principles/coupling-and-cohesion.md · Decision 3, 5 (amended)
Deferred
Section titled “Deferred”- Where a membership write (invite, remove, change a role) is authorized — trigger: the first membership-write endpoint.
- A feature core as a workspace package — trigger: it must run in a second process or app, or a direction violation is caught in review a second time.
- dependency-cruiser — trigger: an import laundered through a re-export past
no-restricted-importsis found.