Skip to content

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.ts and policy.ts are 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:

billing/charge.ts
import * as LabelingRulesRepo from "../labeling/labeling-rules-repo.ts" // lint error
import * 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.ts and **/testing.ts are exempt unless stated; root tool configs such as vitest.config.ts are 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:crypto alone is also allowed in infra/secrets.ts and infra/secret-box.ts.
    • effect/sql*, @effect/sql-*: *-repo.ts, infra/, packages/db, main.ts, isolate.ts.
    • effect/http-api* and server-side effect/http/ (HttpRouter, HttpServer*, HttpMiddleware): http.ts, contracts, main.ts, entry files. Never in packages/domain.
    • The IdP vendor SDK: identity/authenticator.ts. A relative path out of its package: nowhere.
    • In policy.ts: no Effect, Layer, Context, SQL, HTTP or repo imports.
    • **/testing.ts, **/fixtures/**: only from **/*.test.ts and **/testing.ts.
  • Match with patterns, not paths, so subpaths are caught. HttpClient stays free: every vendor client makes outbound calls.

❌ Incorrect — SQL in the service file:

labeling/labeling-rules.ts
import * as SqlClient from "effect/sql/SqlClient" // lint error

✅ Correct — SQL only in the feature’s repository:

labeling/labeling-rules-repo.ts
import * as SqlClient from "effect/sql/SqlClient"

Source: notes/02-design-principles/coupling-and-cohesion.md · Decision 2 (amended)

Impact: HIGH a kernel importing a feature cycles with all of them

  • Foundation supplies mechanisms and stable primitives: @app/domain, contracts, and infra/, identity/, orgs/, authz/ in apps/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/ owns org_members and the OrgMembers service; authz/’s Permissions.resolve reads roles through it (authz → orgs), and orgs/ never imports authz/.
  • 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.json in 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:

authz/permissions.ts
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 itself
identity/ → infra/, @app/domain
orgs/ → infra/, @app/domain owns org_members, OrgMembers, OrgScopeLayer
authz/ → infra/, orgs/, @app/domain
domains → foundation, other domains' public files

Source: 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 (a Context.Service tag owned by the domain that needs the information, implemented by the other, wired in main.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:

labeling/labeling-rules.ts
import * as Billing from "../billing/billing.ts" // labeling → billing
// billing/billing.ts
import * as LabelingRules from "../labeling/labeling-rules.ts" // billing → labeling: a cycle

✅ Correct — labeling → billing is direct; billing asks through a port it owns:

billing/active-rule-count.ts
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 repo
export 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:

labeling/labeling-rules.ts
// billing/charge.ts imports labeling, so labeling "breaks" the cycle lazily
const Charge = await import("../billing/charge.ts")

✅ Correct — a cycle check that fails the build:

scripts/circular.ts
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)

  • 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-imports is found.