Quick reference
Every rule from every topic, one line each. The impact pill on the topic page says how much it hurts to get wrong. This page is generated from the topic pages; edit those, not this.
🗂️ 01 · Code organization
Section titled “🗂️ 01 · Code organization”- Group folders by feature, not by technical role – One folder per area of the domain; no models/, services/, routes/ split; shared/ only gets a module once a second feature needs it.
- Reach into another feature only through its public files – Any feature may import another’s service, errors, schemas, types; its *-repo.ts and policy.ts stay private, checked by lint.
- Classify each feature as foundation or domain, and keep folders flat – infra/, identity/, orgs/, authz/ (+ @app/domain, contracts) are foundation; foundation never imports a domain; no foundation/ parent folder.
- Name files in plain kebab-case – The file is the kebab form of its main identifier; no PascalCase, no role or transport suffix; .test.ts is the one tool suffix.
- No fixed file set, no depth limit – A feature folder holds what it needs; no required template or subfolders, no maximum nesting depth.
- Give each package a one-line purpose, not a size limit – A package needs a purpose stateable in one line without “and”; no file-count threshold for splitting.
- Export through a wildcard on the package name – package.json is “type”: “module” with exports “./”: “./src/.ts”; the package name is the only way in; no tsconfig paths alias.
- Cross a package boundary only by package name – A relative path that leaves its own package is a lint error; inside apps/server, features are guarded by lint.
- Keep three graph invariants, checked by a script – domain depends on nothing internal; no app is ever a dependency; contracts depends on @app/domain only. scripts/workspace-graph.ts checks.
- Keep the tag and its implementation in one file – One file per service: shape type, bare tag, make, layer as sibling exports; no Services/ + Layers/ split, no static members.
- Give a configured service a
configexport and alayerfed from it – With options: make(options), layerWithOptions(options), config, and layer = Layer.unwrap(Effect.map(config, layerWithOptions)). - Export
makeso tests have a seam – make is the seam: tests call it with fakes, build Layer.effect(Tag, make), or call layerWithOptions with plain values. - Keep service contracts out of the domain package – Don’t move contracts into a shared package with implementations in features; domain must stay small and dependency-free.
- Import the file, never a barrel – No index.ts that re-exports a directory; import the file that defines the thing. Enforced in review.
- Import Effect modules as namespaces from deep paths – import * as Effect from “effect/Effect”, not import { Effect } from “effect”. Enforced in review.
- Write the
.tsextension – Relative imports spell the extension: from “../errors.ts”, not from “../errors”; tsc enforces it (TS2835).
- Colocate each test beside the file it tests –
.test.ts sits beside .ts; no test/ directory in any app or package; a contract test lives in the package that can build it. - Put shared test support in the owning feature’s
testing.ts– Fakes start inline; when shared they move to testing.ts in the feature that owns the faked thing; TestDatabase is @app/db/testing.ts. - Keep fixture data beside its test, and test code out of production – Generated, recorded or binary data goes in fixtures/ beside the test that reads it; only tests and testing.ts may import testing.ts or fixtures/.
- Name tests
<stem>.test.ts, with an optional aspect and never a kind – One test file per module; split by aspect as. .test.ts; never .spec.ts, never .unit/.integration/.e2e. - Keep one generated
previous.jsonfor cross-release contract tests – scripts/compat-snapshot.ts writes fixtures/compat/previous.json at the deployed SHA after each release; api.compat.test.ts reads it both ways. - Use one root
vitest.config.ts, with a project per workspace package – One root config merges shared options (include, passWithNoTests, retry: 0) into a project per apps/* and packages/* folder.
🧭 02 · Design principles
Section titled “🧭 02 · Design principles”- Copy when the reasons to change differ – Two definitions are fine if they change for different reasons; one definition if a change must always reach both. Leaves are always shared.
- Move a shared thing at the second consumer – Same-driver code moves to its nearest common owner in the PR that adds the second caller; between domains that owner is foundation.
- Give every deliberate copy a guard – Each copy names its strongest guard: share, compile error, a test of both, or a CI diff. Prose never counts.
- Make row mappers exhaustive – fromRow/toRow destructure every field into a rest and assert
rest satisfies Exhausted; app/exhaustive-row-mapper checks the shape.
- Start minimal; build ahead only what retrofits badly – Build what is needed now, plus stored formats, wire identity and security boundaries; everything else waits for a signal.
- Name a signal from the closed list – Only 2C, 2A, 2D, a measured cost with a number, an incident, an external event, or the subject’s first appearance.
- Write every deferral as one fixed-form line – “Deferred: X. Trigger (kind): condition” in the owning note; the PR that crosses it builds it or says why not.
- Ship the smaller day-one shape, and write down what brings the bigger one back – Day one gets shared wire errors, User | System principals, no previews, PITR + one drill; each bigger shape has a trigger.
- Keep another feature’s repo and policy private – Any feature may import another’s service, errors and schemas; its
*-repo.tsandpolicy.tsonly from inside it (no-restricted-imports). - Pin infrastructure imports to the files that own them – Platform, SQL, server HTTP, the IdP SDK and test support may only be imported from the files that own them.
- Foundation never imports a domain – infra/, identity/, orgs/, authz/, @app/domain, contracts are foundation; acyclic among themselves; never import a domain feature.
- Between two domains, one direction is direct; the other is an event or a port – In a domain pair only one side imports the other; the reverse uses a domain event or a port tag owned by the imported side.
- Check for cycles; fix them by moving down, a port or event, or merging – madge fails on value-import cycles; fix by moving down to foundation, then event/port for a domain pair, then merging; never await import().
🏷️ 03 · Naming & language
Section titled “🏷️ 03 · Naming & language”- Use only role-nouns from the glossary – Repo = SQL rows, Store = non-relational, Registry = in-memory lookup, Client = one vendor; a new noun needs a glossary line first.
- Never use grab-bag names – No Manager, Helper(s), Util(s), Factory, Wrapper, Mapper, Impl, or a Service suffix in identifiers and filenames.
- Reserve
Workerfor themakeWorkerqueue consumer – Worker = what makeWorker returns (queue, bounded drain, per-item boundary); a Cloudflare isolate’s entry is isolate.ts. makeconstructs;from/toconvert – make for every constructor, from/to for conversions; create, insert, archive are domain methods only. getfails,findreturnsOption, batches are<verb>Many– get fails with XNotFound, find returns Option, list never fails on empty; repos only find, orgId first; batch =Many. Inputfor the domain,Requestfor the wire,Optionsalways named –Input in the domain, …Request in contracts, Options and Options as named types. - Name layers
<Name>Layerand tags by their owning folder – Assembled layers areLayer, never Live/Default/Test; a tag’s middle segment is the folder or package that owns the file.
- Keep one glossary per context, with an avoid list – A GLOSSARY.md per bounded context: Term, one sentence of meaning, then Avoid: the synonyms that must not be used.
- List every cross-context collision in one map – A root GLOSSARY-MAP.md lists the contexts and every word that differs between them; an unlisted second meaning is a bug.
- Root code names in glossary terms – A glossary term fills the unsuffixed service name, the table and the type: Labeling Rule → LabelingRules, labeling_rules, LabelingRule.
- Use the terms in prose, and add missing ones in the same PR – Glossary terms in issues, tests, PRs and commits; an Avoid synonym is a review comment; a concept with no term gets one in its PR.
🏛️ 04 · Architecture & API design
Section titled “🏛️ 04 · Architecture & API design”- Give every context a README in one template – One README per context with fixed rows: Problem, Owns, Does not own, Assumptions, Entry criteria, Non-goals, future pressure.
- Map every feature folder to exactly one context – A context owns one or more feature folders, each folder belongs to one context, and a context is either domain or foundation.
- Meet the entry criteria before writing code – A context with no evidence behind its entry criteria stays a README; code comes after the evidence.
- Split only on several strong signals from five tests – Before splitting a concept or context ask vocabulary, rules, lifecycle, ownership, change; one “yes” is not a split.
- Take at most two parameters:
(primary, options?)– primary is one id or one input Struct; options? holds optional behaviour modifiers; actor and org live in R, never a third parameter. getfails withXNotFound;findreturnsOptionwhere absence is normal – Repos return Option, the service lifts None into XNotFound; add find only for a caller that treats absence as normal; no X | null.- Say “no” with a typed error – The success channel carries what the name promises; a negative answer fails with a namespaced error, even for validate/check/can.
- Start single; add an all-or-nothing
<verb>Manybeside it – Every method starts single-item; a batch takes NonEmptyReadonlyArray, runs in one transaction, fails listing every missing id. - Split a service by its dependencies and callers – One primary service per feature; a method group becomes its own service when it brings its own dependencies or callers.
- Make only combinators dual, in predicate form – Function.dual only on methods that wrap an Effect; dispatch with (args) => Effect.isEffect(args[0]), never on arity.
- Document ownership, and only what the type can’t say – One doc comment per shape saying what it owns and does not own; member comments only for idempotency, ordering, atomicity, empty input.
- Change the wire additively; break through expand/contract – Add fields, never rename or retype in place; a breaking change is expand → migrate → contract across releases, with a removal issue.
- Read tolerantly: new enum members and errors aren’t breaking – Growable response enums use ForwardCompatibleNullable (unknown → null); every client maps unknown errors to one generic failure.
- Flag old surfaces in OpenAPI and remove them by audience – Deprecation text names the replacement and removal issue; with only our client, remove in the release after the client moved.
- Replace an internal method in the same PR as its callers – The PR adding a replacement moves every caller and fake and deletes the old method; @deprecated coexistence only past 3 slices.
- No stability tiers; hide unfinished endpoints – Everything under /api/v1 follows the rules from release one; an unfinished endpoint is excluded from OpenAPI, not labelled beta.
⚡ 05 · Effect fundamentals
Section titled “⚡ 05 · Effect fundamentals”- Declare services with
Context.Serviceand a scoped id – Class form of Context.Service with an “@app// ” id; Effect.Service and Context.Tag are v3 carry-over. - Export
makeandlayeras consts beside a bare tag – Bare tag, then export const make and export const layer in the same file; consumers use a namespace import. - Feed options from a
configexport, added with the first option – make(options) → layerWithOptions(options) → config → layer = Layer.unwrap(Effect.map(config, layerWithOptions)); only once an option exists. - Name the shape as an exported
type– export typeShape, referenced by the tag and by make; tenant-table methods carry CurrentOrg in R; inline only for 1–2 methods.
- Use
Layer.provideby default – provide satisfies a requirement and hides it; provideMerge only when the service must stay visible downstream. - Assemble the graph in one file per deployable, infrastructure last – One main.ts per deployable: named
Layer consts, peers as an array, database and other infra provided at the bottom.
- Provide a shared layer wherever it is needed – Memo key is the Layer object; a module-level const provided in five places is built once.
- Provide a layer shared with a sibling outside
HttpRouter.serve– serve builds its app with a forked MemoMap; a stateful layer also used by a dispatcher or cron loop is provided outside serve. - Hoist a parameterised layer to a const before use – Call layerWithOptions(…) once per configuration at the assembly site, bind it to a name, reuse the name.
- Build infrastructure once, at the root – Database, Redis, HTTP client are provided at the deployable’s root, not by each consumer.
- Split
bin.ts(the process) frommain.ts(the graph) – bin.ts holds platform, config provider, launch and runMain; main.ts exports MainLayer and runs nothing at import. - Run the server as
Layer.launch(MainLayer)→runMain– The server program is “build the graph and wait”; startup work is a Layer.effectDiscard; runMain takes no options. - Keep platform code in the platform entry file – @effect/platform-, node:, alchemy and cloudflare:* imports live only in bin.ts, isolate.ts, migrate.ts or alchemy.run.ts.
- Run background work as layers in the same process – Outbox dispatcher, recurring jobs and consumers are scoped layers merged into MainLayer, built on the worker boundary.
- Pick a runtime profile: Node (default), Bun, or a Cloudflare Worker – main.ts is the same pure graph on every profile; only the platform entry differs: bin.ts for Node/Bun, isolate.ts for a Worker.
- On a Worker, build the graph once per isolate, in init – Build ServicesLayer once in alchemy’s init closure, share it with toHttpEffect(RoutesLayer) and every cron; acquire nothing disposable there.
- Acquire at the narrowest lifetime tier – Four owners: the layer (process), the request, Effect.scoped (operation), or a keyed map held by a layer. Start narrow.
- Build keyed maps only through
infra/keyed.ts– An expensive resource keyed by an open-ended value is a LayerMap/RcMap built by Keyed.layerMap or Keyed.rcMap, never directly. - No
Scope.makein feature code; a long-lived resource is a keyed entry – A resource that outlives its call and is stopped individually is a Keyed.rcMap entry; start = get + scoped, stop = invalidate. - Give every fiber an owner; enqueue, never detach – No forkDetach in feature code; work that outlives a request is offered to a worker layer; loops are forkScoped via the helper.
- A release never fails: it logs, and it times out – acquireRelease by default; wrap a fallible release in ignoreCause({ log: “Warn” }); I/O releases carry timeoutOption(“2 seconds”).
- Sequence with
Effect.gen; use.pipeto decorate one effect – A step that needs an earlier value goes in a generator; .pipe only applies combinators (map, catchTag, retry, timeout) to one effect. - In an
Effect.fn, the body is the happy path and the pipeables are its errors – Effect.fn body = one yield* per step, no error handling; pipeable arguments hold catchTag, mapError, retry, timeout. - Handle inline only for a divergent strategy or an invariant – A pipeable may catch a tag only if one yield produces it; two yields with the same tag, or an invariant orDie, are handled at the yield.
- Write private helpers as
fnUntracedor a plain arrow – Public method: Effect.fn(“Service.method”); helper with steps: fnUntraced; one expression: arrow; no unnamed Effect.fn, no () => Effect.gen. - Never add
withSpaninside anEffect.fn– Effect.fn’s pipeables already run inside its span; a withSpan with the same name doubles it. Use { attributes } or annotateCurrentSpan. - Nest a generator one level at most – One nested Effect.gen (a forEach callback, a Match arm, a local const) is fine; deeper becomes a fnUntraced helper in make.
🚨 06 · Errors
Section titled “🚨 06 · Errors”- Use
Schema.TaggedErrorby default – Expected failures are Schema.TaggedError so they encode across HTTP/RPC; Data.TaggedError only for errors that never leave the process. - Namespace every tag as
@app/<folder>/<Name>– Error tags are global keys with no collision check; write “@app/labeling/RuleNotFound”, never a bare “RuleNotFound”. - Derive
messagewith a getter; never store it – Structured fields are what code matches on; message is a computed getter over them, so the two cannot drift. - Brand valid ids; keep rejected input as a
raw*string – Valid entity ids in an error use the branded schema; input that failed decoding is a raw* Schema.String, never cast into the brand. - Put a required
causeonly on an error that wraps one – An error wrapping another failure has cause: Schema.Defect() (required); an error that wraps nothing has no cause field. - Expose
isRetryableas a getter on upstream errors – An upstream error a caller must classify has an isRetryable getter computed from its fields; it never answers “is a replay safe?”.
- A defect means a bug; infrastructure failures stay typed – die/orDie only for a broken invariant, boot config, or an op that cannot fail; PersistenceError stays typed until the surface.
- Catch by tag; a catch-all only where a failure ends – Remap with catchTag/catchTags; Effect.catch only at a terminal boundary, and even there as catchTags’ orElse. No catchCause.
- Keep error unions per method and narrow – Each method declares inline only the errors it can produce: domain errors plus PersistenceError or an upstream error. No Feature.Error.
- Log once, at the place a failure ends – Only the code that answers, swallows or re-fails a failure logs it; the API catchDefect boundary logs defects; disable HttpMiddleware.logger.
- Give every background loop a per-item boundary in its helper – No loop is forked bare; makeWorker, Jobs.run and the waitUntil helper catch + catchDefect per item, log once, let interruption pass.
- Let
runMainbe the last resort – NodeRuntime.runMain logs a failed main program and sets the exit code; no process.on(“unhandledRejection”) handlers.
🧬 07 · Schema & data
Section titled “🧬 07 · Schema & data”- Use
Schema.optionalKeyunlessundefinedis a real value – JSON-decoded fields use optionalKey (present or absent); optional only for JS values that can hold undefined. Needs exactOptionalPropertyTypes. - Use
Schema.Structby default;Schema.Classfor identity or methods – Struct for data shapes; Class only when you want derived accessors, nominal identity or instanceof. Errors are classes already. - Build branded ids through a shared factory – packages/domain/src/ids.ts: makeEntityId (UUID, ids we mint) and makeExternalId (trimmed, non-empty); no other Schema.brand creates an id.
- Don’t use
Modelyet; map rows by hand – effect/schema/Model is still @stability unstable; close the row/domain gap with a Row schema and explicit fromRow/toRow.
- Decode every row through a
Rowschema, in both directions – Each repository declares a Row in column shape; reads decode via SqlSchema Result: Row, writes encode via Request: Row; never cast. - Always map row ↔ domain with an exhaustive mapper – fromRow/toRow always exist, only rearrange fields, destructure every field and end with
rest satisfies Exhausted. - Pick each column codec for the database’s dialect – Only column codecs in Row depend on dialect: BooleanFromBit vs Boolean, epoch-ms vs Date, fromJsonString vs jsonb + sql.json.
- Give the wire its own schemas, mapped by the server – Contracts hold Public*, CreateRequest, PatchRequest structs; never the domain entity; server maps with toPublic* beside the handler.
- Use
SqlClient+SqlSchemawith hand-written SQL, one client per database – Tagged-template SQL through SqlSchema; no ORM; one SqlClient layer per database at the root, never Layer.fresh or a read client. - Keep column names in the
Row; rename in the mapper – No transformQueryNames/ResultNames/Json, no AS aliases; Row keys are snake_case columns; fromRow/toRow rename to camelCase. - Bind values in their column’s encoded form – On Postgres wrap each jsonb field in sql.json on write; bind time as Date (timestamptz) or epoch ms (INTEGER), from now, never the DB clock.
- Let the use-case own the transaction, through a
Transactionsport – Use-cases call Transactions.withTransaction around their own repos and other slices’ services; repos never take tx; publish after commit. - On D1, make an atomic unit one repository method running one
batch– D1 has no transactions: the Worker graph omits Transactions; multi-statement atomicity is one repo method with one d1.batch. - Write conflicts into the SQL; everything else is
PersistenceError– Domain conflicts via ON CONFLICT DO NOTHING RETURNING * and a zero-row check; other SQL/Schema failures map to one PersistenceError.
- Write migrations as plain
.sqlfiles, one sequence per database – packages/db/migrations/NNNN__ .sql, one statement per breakpoint chunk, loaded by our fromSqlDirectory; no id or time DEFAULTs. - Guard the ledger by
(id, name); let CI own the numbering – Migrator.make keeps lock/transaction/ledger; our guard fails on a recorded id with another name; CI checks contiguous ids and immutability. - Run
migratebefore rollout; the app only checks – A separate migrate program with owner credentials runs first in each deploy job; the DML-only app runs requireApplied and never migrates. - Never write a down migration – Forward-only: a mistake is fixed by the next migration; a rollback redeploys N−1 against schema N; data loss is restore-from-backup.
- Ship breaking changes as expand → backfill → contract – A migration in release N must be safe for N−1’s code; renames and drops span releases; no CONCURRENTLY or VACUUM in a migration.
- Keep small set-based backfills in the sequence – A backfill is a migration only if set-based SQL, idempotent by its WHERE, and seconds long; otherwise a batched job between expand and contract.
🔌 08 · Application surfaces
Section titled “🔌 08 · Application surfaces”- One group per feature, with a thin handler – Each feature has one contract file and one handler file; handlers yield the service, call it, map the result and errors.
- Map domain errors onto shared status-shaped wire errors – Contracts declare NotFound, Conflict, Forbidden with a status; domain errors carry no HTTP; handlers translate with catchTag.
- Attach one error boundary at API level – Request decode failures become a typed 400 naming the field; response encode failures and defects become a 500 with a reference only.
- Attach auth at group level, and call
.middleware()last – Auth middleware goes on the group after every .add; public endpoints live in a named *Public group; the last .middleware runs first. - Run one
HttpApi, versioned with/api/v1– One HttpApi with an API-level /api/v1 prefix after every .add; webhooks, OAuth callbacks, SSE, health stay raw routes.
- Serve every request/response operation through
HttpApi– Every req/resp operation for every client is an HttpApi endpoint; a second client reuses HttpApiClient; RPC waits for server push. - Use one WebSocket with JSON; on Workers, end it in a Durable Object – RpcServer.layerHttp over websocket with layerJson; on the Worker profile, auth then forward the upgrade to one RpcDurableObject per org.
- One RPC group per feature, merged once, middleware last –
Rpcs beside Api in contracts; AppRpcs merges all, then .middleware(RpcAuthentication), then RpcErrorBoundary last. - Make every feed a bounded, resumable snapshot-then-tail stream – Events carry a monotonic sequence; client resumes from its last sequence; stale cursor gets a snapshot; buffer bounded at 1,000, fails with FeedOverflow.
- Wrap growable unions; a breaking change is a new tag – API-evolution rules plus two: wrap every growable/stream-event union forward-compatibly; treat per-call defects as one generic failure.
- Read config in the feature’s
layer, never inmake– Each feature exports aconfig; onlylayerreads it;make(options)stays pure of Config; tests pass plain values. - Write every variable name as a literal, once – Config.all with literal SCREAMING_SNAKE names and the narrowest constructor; Config.schema per field; cross-field rules in Config.mapEffect.
- Make every secret
Redactedfrom the first read – Config.Redacted for secrets, Redactedin every type, Redacted.value only on the line that needs the raw bytes. - Default only tunables, never environment values – Config.withDefault only for values identical everywhere (timeouts, pool sizes, TTLs); URLs, hosts and secrets have no default.
- Layer
.envunder the process environment – Container profiles: ConfigProviderLayer puts process env over .env, only if the file exists. Workers: no .env and no provider layer. - Test config through string values – One config test per feature via ConfigProvider.fromEnv strings; one test per deployable parses .env.example through every feature’s config.
- Enforce in the service; decide in a pure
policy.ts– Every entry point calls the service; the service loads the resource, resolves permissions, and asks a pure policy function. - Require the actor in
R; provide it only at an entry point – Service methods require CurrentPrincipal | CurrentOrg; entry points provide them per request or job run; jobs use a named System principal. - Check permissions, not roles; map roles in one table – Permissions are
: ; one table in authz/permissions.ts maps roles to them; ownership is a policy rule, never a permission. - Can’t see it → NotFound; can see it but can’t act → Forbidden – One shared @app/authz/Forbidden carrying the permission; another org’s or an invisible resource is the feature’s NotFound (404).
- Make every public service method
Effect.fn("Service.method")– Public service, repository and adapter methods are Effect.fn(“. ”); helpers and handlers use Effect.fnUntraced. - Put context on spans; log only what a human must read – Principal and org are annotated once by middleware; domain ids by the method; keys are OTel semconv or app.
. . - Provide one
infra/observabilitylayer, outermost – Built-in Otlp.layer with our own Config.all, opt-in by endpoint; JSON logs with trace_id; keep tracerLogger; container profile only. - Write a metric only for what a span cannot say – RED comes from server spans; Metric only for gauges, business counters, ratios; defined in
/metrics.ts with closed labels. - Let a 5xx mark the server span
Error; keep noise out – 4.0.3 fails the server span on any 5xx; alerts read server spans only; NoTraceLayer in http.ts drops health, OPTIONS, OAuth callback.
- Make durable work an outbox row; let loseable work go to the helper – If losing it on a deploy breaks state or a promise, it is an outbox event recording a fact; otherwise makeWorker or waitUntil.
- Model a process as a status column plus chained events – Process state is a status column on the slice’s row; each step is a guarded UPDATE plus the next event; outbound calls go before the commit.
- Make a wait a column read by a job – A timer is a
_at column read by a job as of its slot; a callback or human decision is a handler doing a guarded transition. - Define the work in the slice that owns the state – Subscribers are
/on- .ts, jobs live in their slice; infra/ holds only machinery; main.ts exports subscribers beside jobs. - Read stored payloads tolerantly; break with a new tag – event_tag is the identity, no version field; new fields optional; unknown fields ignored; a breaking change is a new tag drained by a count.
- Fail at once only what can never succeed – A SchemaError sets failed_at at once; an unknown tag retries with backoff; decode with the Effect decoder; requeue is one SQL statement.
- Name every retry policy in
infra/retry.tsand wrap exactly the replayed unit – Policies and predicates are named exports of infra/retry.ts; the retry sits on the Effect.fn of the atomic unit, never in a repository. - Retry contention always, connection loss only on an idempotent unit – Deadlock/serialization/lock-timeout: retry any unit. ConnectionError: idempotent units only. Timeouts, D1, domain, 4xx: never.
- Cap every policy, jitter it, and keep one retry layer per failure – Every policy is capped(base, cap, retries) with jitter; work under a durable retrier retries in process on contention only.
- Define a recurring job once in its slice, keyed by its slot – A job is a Jobs.make value in its slice; the container loop and Worker Cron Triggers fire the same list; run(slot) processes what is due.
- Claim an exclusive job’s slot with one lease upsert – exclusive: true jobs claim a jobs_leases row per slot in one portable upsert; losers do nothing; the job stays idempotent per item.
- Back off a poll loop whose tick fails – A sub-minute poll loop doubles its wait on failed ticks up to 1 minute and resets on success.
- Run every Effect test with
it.effect– @effect/vitest everywhere; it.effect by default, it.live only for real OS behaviour; layer per test; no runPromise in tests; no retries. - Fake collaborators with a partial
Layer.mock– Real service under test; collaborators are Layer.mock with only the methods the path uses; calls recorded in a Ref; never vi.mock a service. - Test repositories against a real embedded database, fresh per test – Repository tests run on an embedded DB in the production dialect, built per test with migrations; every tenant method gets a two-org test.
- Drive time with
TestClock, never with the wall clock – Code reads time via Clock; tests setTime first, fork → adjust → assert → join; skew, DST and TZ=America/New_York; Random.withSeed. - Test handlers through
HttpApiTest.groupswith the service mocked – Handler tests use HttpApiTest.groups with the feature service as Layer.mock and auth swapped via authenticatedAs(principal). - Pin the API contract once: boundary, structure and compatibility – Per endpoint: happy path + one test per non-1:1 mapping. Once: boundary, structural and cross-release compat tests.
🚦 09 · Production concerns
Section titled “🚦 09 · Production concerns”- Let layer order be teardown order – What a layer uses is provided beneath it and closes after it; observability is outermost; mergeAll siblings close in parallel.
- Treat “the handler answers” as ready; add only
draining– No booting flag: serve attaches the handler after every layer builds. Readiness reads Lifecycle.isDraining; liveness never does. - On SIGTERM, keep serving for
SHUTDOWN_DELAY, then drain HTTP for 10s – drainOnShutdown is outermost in MainLayer: mark draining, wait SHUTDOWN_DELAY (no default; 5s deployed, 0 in dev), then a 10s HTTP drain. - End a worker’s queue and drain it for up to 5s – makeWorker uses Queue.end, not shutdown; drains for 5s by default; logs one Warn with the dropped count; refused offers Warn too.
- Keep every stage inside the budget; bound a release that does I/O – Shutdown stages sum to 25s against a ≥30s grace; a release doing I/O carries a 2s timeoutOption and Warns; no process watchdog.
- Report a boot failure with
runMain’s default until something alerts on it – Day 1: every provide before Layer.launch and runMain’s default log. ProcessFailed + reportFailure wait for a boot-failure alert.
- Deliver every domain-pair event through the outbox – A fact another domain depends on is an outbox row; in-process PubSub only for signals whose loss leaves no state wrong.
- Write the outbox row in the same atomic unit as the change – infra/outbox owns outbox_events; Postgres/SQLite append inside withTransaction, D1 adds appendStatement to the repository’s batch.
- Relay with a claim lease, dispatch in-process, promise no order – One UPDATE … RETURNING claims 10 rows with a 2-min lease; 30s per row; durableDelay backoff, 10 attempts, then failed_at.
- Dedup on the producer’s own identity – Webhook: upstream event id. Create: client-generated entity id with ON CONFLICT (id) DO NOTHING. Subscriber: outbox row id.
- Make a consumer idempotent by its write, or write a receipt with it – A set-state handler needs nothing; an “add” handler writes a
_receipts row in the same atomic unit; never claim the receipt first. - Keep processed rows 7 days, receipts 30 days, failed rows until handled – Idempotent DELETEs with no lease: processed outbox rows after 7 days, receipts after 30; failed rows are the dead letters.
- Classify every rule as invariant, policy or preference – Every rule is an invariant, a policy or a preference; name the kind next to the rule; the kind decides enforcement.
- Enforce an invariant where the value is made – An invariant lives in the smart constructor or Schema check (makeEffect, not make), plus a DB constraint where possible.
- Fail input as a typed error, stored data as a defect – A broken invariant in input is a typed error at the boundary; in data we stored ourselves it is a defect (orDie).
- Write a policy as a named pure function – A policy is a pure function in policy.ts (access) or
-policy.ts; it fails with a typed domain error. - Treat a preference as a default, never a check – A preference lives in config or a stored setting and applies a default; it is never a constructor check or constraint.
- Mint every id as a UUIDv7 through one helper – Ids are UUIDv7 from Crypto.randomUUIDv7, called only inside newId in packages/domain/src/ids.ts.
- Let the side that creates the entity mint its id – Client mints for its retryable creates, server mints with newId otherwise; never DEFAULT gen_random_uuid, SERIAL or AUTOINCREMENT.
- Never read time from an id – A v7 id orders only roughly (no in-ms counter, replica clocks differ); created_at is the timestamp.
- Brand every id through one of two factories – makeEntityId (UUID-checked) for ids we mint, makeExternalId for ids others mint; every brand has a check; never
as. - Carry the stored id on the wire, and decode it at the edge – The wire carries the stored UUID, no prefix; path params are branded ids, so a malformed id is a 400 before any query.
- Log ids freely, but never treat one as a secret – Ids go on logs/spans as app.
. _id; access is authorization plus org_id, never an unguessable id.
- Read “now” from
Clockonce, and pass it down – Backend reads time only via DateTime.now / Clock.currentTimeMillis, once per use-case; domain functions takenowas a parameter. - Store instants as the app wrote them, never with a database clock – Domain instants are DateTime.Utc; timestamptz / INTEGER ms per dialect, no DEFAULT, no now() in SQL; infra/ tables use epoch ms.
- Send instants as ISO 8601 UTC strings – Wire instants are Schema.DateTimeUtcFromString; zones are a separate IANA field; dates are YYYY-MM-DD; durations carry their unit.
- Keep the server zone-free; the user’s zone is data – Never read the host zone; TZ=UTC in the image; user zone is an IANA name passed explicitly to calendar computations.
- Take order from the database, never from two clocks – Races resolve by row version, exact order by a DB sequence; leases get a margin and far-future clause; external times a 60s tolerance.
- Test time under
TestClock, in a non-UTC zone – setTime before anything reads now; a skew test per lease; DST tests per calendar function; suite runs under TZ=America/New_York.
- Serve
/healthfor liveness and/readyfor routing – /health = the process answers (restart checks); /ready = route to me, 503 while draining (load balancer). Exact paths, no /v1. - Mount
/healthin every profile,/readyinside the container’s serve – Raw routes in infra/health.ts; livenessLayer in RoutesLayer (both profiles), readinessLayer inside the container serve under Lifecycle. - Never let a probe read a shared dependency – No query, pool ping or service call in a probe; boot proved the DB; only a fault that can differ between replicas belongs in /ready.
- Answer
OKpublicly, with the revision in a header – Unauthenticated, body is OK/draining only, x-app-revision = SERVICE_VERSION (the deployed SHA); excluded from tracing. - Make the smoke check assert the deployed revision – After alchemy deploy (on always()), assert 200 and x-app-revision == $SHA: /ready on containers, /health on Workers; 6 x 10s.
🛠️ 11 · Repo operations
Section titled “🛠️ 11 · Repo operations”- Use pnpm, with its default isolation – pnpm on every runtime profile; never override hoist or nodeLinker; commands are
pnpm run <script>. - Add no task runner and no build cache – Root scripts only; per-package via pnpm –filter, repo-wide via pnpm -r; no turbo.json, no tasks: block, no build cache.
- Keep shared versions in one catalog – One default catalog; a dep enters when a 2nd package declares it (effect from day 1); every reference is then
catalog:, overrides too. - Run the graph and cycle checks first inside
check– scripts/workspace-graph.ts (4 invariants) and scripts/circular.ts (madge) run first incheck, so local check equals CI.
- Keep one root
tsconfig.jsonand no other – One program over every file: no base file, no per-package tsconfig, no references, no composite, nopaths. - Turn on
strictplus four flags – strict, exactOptionalPropertyTypes, noUncheckedIndexedAccess, noImplicitOverride, noFallthroughCasesInSwitch, plus noErrorTruncation. - Resolve like Node:
nodenext,.tsspecifiers, erasable syntax – module nodenext, allowImportingTsExtensions + noEmit, verbatimModuleSyntax + erasableSyntaxOnly, “type”: “module” in every package.json. - Run
tsc --noEmitfromtypescript@7, patched byeffect-tsgo– The compiler is typescript@7’s tsc patched in prepare by effect-tsgo; never @typescript/native-preview or tsgo; bump both together. - Write
typesfor the runtime profile; never addDOM– types is always written: [“node”], [“bun”], or [“node”, “@cloudflare/workers-types”]; lib is [“esnext”] in every profile.
- Split the checks into two gates – oxlint runs AST-only; Effect diagnostics run at error level inside the patched
tsc --noEmit, never as warnings. - Use
erroronly; every suppression gives a reason – No rule is everwarn; a disable names exactly one rule and ends in-- reason; old code gets a per-file ceiling. - Write a custom rule only for silent failures – Nineteen
app/*rules, each guarding a decided note against a mistake that compiles and fails silently. - Enforce import boundaries with built-in rules – Architecture boundaries are
no-restricted-importsoverrides, with globs owned by coupling-and-cohesion. - Keep the plugin as a tested workspace package –
packages/oxlint-pluginwith a test per rule; each rule’s message names the note it enforces. - Format with oxfmt defaults and a format-only hook – oxfmt with default settings;
.githooks/pre-commitformats fully staged files only;oxfmt --checkstays incheck.
- Run
install,setuponce, thendev–pnpm install, thenpnpm run setuponce (idempotent), thenpnpm run dev, which migrates before it starts the server. - Run production’s dialect, and a real server where production has one – Compose Postgres of production’s major with owner and DML-only roles; a file on SQLite; alchemy’s local D1 on Workers.
- Pin versions in
.node-versionanddevEngines, nowhere else –.node-versionholds an exact patch read by laptop, CI and image; pnpm viadevEngines; no second copy of any version. - Install the hook with
core.hooksPath, not a dependency – A committed.githooks/pre-commitformats fully staged files only;preparesetscore.hooksPath; no hook runner. - Seed from one synthetic
.sqlfile that is safe to re-apply –packages/db/seed.sql: fixed ids, invented names, two orgs,ON CONFLICT DO NOTHING; refuses production; tested by applying twice. - Develop Workers with
alchemy dev --stage devand local state – Worker profile: one alchemy program for dev and deploy;devstage only underALCHEMY_DEV, local state, everything emulated. - Differ from production only in values and machine limits – Same code, driver, dialect, migrations and Node locally; every difference is a config value or a limit, listed in a table.
- Squash-merge every change through a PR – Squash-only, linear history, required checks
ci-passed+pr-title, no direct pushes, no bypass actors, admins included. - Title PRs
type(scope)!: behaviour in plain language– Conventional type, slice as scope, summary says what is different afterwards;!only when someone outside the PR must act. - Check the PR title in CI, with no exemptions – The rule lives in AGENTS.md; a
pr-titlejob fails on a bad shape and on a!title with no upgrade steps; no commitlint. - Generate release notes from squash subjects – Notes = subjects in the range, grouped by type by
scripts/release-notes.ts; no changesets, no per-PR file. - Let branch names be free-form – No branch naming rule; head branches are deleted on merge; nothing keys off a branch-name pattern; slugify before use.
- One concern per PR, with no size gate – One concern per PR; a rename, a dependency bump or a format change is its own PR; no size label, no size gate.
- Write a four-section body;
!must carry upgrade steps – Template: Why, What changes, Upgrade steps (only for!), Review notes; thepr-titlejob fails a!PR with empty upgrade steps. - Require no approval; give every PR a human owner –
required_approving_review_count: 0, no CODEOWNERS; the author merges; an agent PR’s owner reads the whole diff first. - Run an advisory AI review on every ready PR – AI review on every ready same-repo PR; inline comments plus a sticky summary naming the SHA; never approves, never required.
- Label the PRs that carry judgement calls –
actions/labeleraddsmigration,contractandauthfrom paths; informational, never required. - Self-merge, ask on risk, answer the same working day – Merge your own PR when green and AI findings are answered; ask first on
!,migration,contract,auth; reply the same day.
- Run two parallel jobs behind one gate, with no path filters –
ci.ymlrunscheckandtestin parallel on every PR andmainpush;ci-passedfolds them; skipped counts as failure. - Require exactly
ci-passedandpr-title– The ruleset names two stable checks; job names insideci.ymlcan change freely; on-request jobs are never required. - Let
main’s CI be the backstop, and never cancel it – No up-to-date rule, no merge queue; a redmainfreezes merges until a fix PR lands;mainruns are never cancelled. - Deploy from green
mainruns only –deploy.ymlruns on CI completed on main: deploy-stg → deploy-prd → release; superseded runs grey, skipped or failed red. - Keep slow and scheduled work off the per-push path – Load tests and benchmarks are
workflow_dispatchonly; no nightly suite; no cron on day 1; informational jobs never fail. - Test on production’s runtime only, from one version file – No runtime or OS matrix;
setup-nodereads.node-version, the same file the image reads; Bun profile pins.bun-version. - Never retry a test; fix or quarantine it the same day –
retry: 0everywhere, noit.flakyTest; a flake is fixed or skipped with#<issue>in its name;app/no-unlinked-flakychecks it.
- Treat a successful production deploy from main as the release – One deploy-prd success on the workflow_run path is one release; failed, cancelled or rollback deploys are not.
- Name releases with a CalVer tag; the app keeps reporting its SHA – Tag YYYY.MM.DD.N (UTC, N per day, no v prefix, no -N) on the deployed SHA; SERVICE_VERSION stays the SHA.
- Create the tag in a separate, idempotent release job – A release job after deploy-prd (workflow_run success only) tags, writes the GitHub Release and opens the compat-snapshot PR.
- Generate notes from squash subjects with our own script – scripts/release-notes.ts groups first-parent subjects since the previous tag by title prefix; no CHANGELOG.md, nothing dropped.
- Surface
!as Breaking with the upgrade steps copied in – A ! title goes under Breaking with the PR’s Upgrade steps quoted, and the release name gets “ · breaking”; it bumps nothing. - Add SemVer and changesets only when something is published – Nothing leaves the repo as a versioned artifact, so no SemVer and no changesets until a package or installed client exists.
- Run production and staging; keep previews as a written plan – Day 1 is production (N ≥ 2 replicas, PITR) and staging (1 replica, synthetic data); label-gated pr-
previews are deferred. - Describe every environment in one alchemy program with a parsed stage – infra/alchemy.run.ts parses the stage into a union (prd | stg | dev); every difference is a switch on stage.kind, nothing in a dashboard.
- Promote one SHA: CI, then staging, then an approval – Deploys start from CI’s workflow_run on head_sha; deploy-prd needs deploy-stg green and a production reviewer; smoke asserts the revision.
- Run migrate as a step in the deploy job, before the deploy – pnpm run migrate runs first in each deploy job with the owner URL in that step’s env only; a failed migrate leaves the old release serving.
- Keep secrets in one GitHub Environment per stage – production (reviewer, main only), staging and release environments; alchemy writes runtime secrets to the platform store; SECRET_KEYS per environment.
- Run production mode everywhere and tell each environment its name – APP_ENV=production in every deployed stage; DEPLOYMENT_ENVIRONMENT names it; the stack sets SHUTDOWN_DELAY and SERVICE_VERSION=SHA.
- Undo code by redeploying an earlier SHA; undo data with PITR into a new database – Rollback dispatch redeploys a past production SHA without migrate, then a mandatory revert PR; restores never overwrite production in place.
🔐 12 · Security & trust
Section titled “🔐 12 · Security & trust”- Model the caller as a tagged union of ids – Principal = User | System from day 1, ids only: no roles, scopes, org or profile fields; ApiKey arrives with the first machine client.
- Read CurrentPrincipal from R; provide it only at entry points – Services require CurrentPrincipal in R; HTTP/RPC middleware, job runners and tests provide it with Effect.provideService, never in a Layer.
- Prove humans with an external IdP, behind one Authenticator – Verify the IdP JWT on every request (JWKS, exp, aud, iss) inside Authenticator; no feature imports the vendor SDK.
- Use one bearer middleware; dispatch API keys on a fixed prefix – One Authentication middleware with HttpApiSecurity.bearer; day 1 sends any non-empty token to the Authenticator; app_ak_ prefix dispatch is deferred.
- Keep “who are you?” apart from “you may not” – Principal in domain, Authentication tag and Unauthenticated (401) / AuthenticationUnavailable (503) in contracts, Authenticator in identity/.
- Seal read-back secrets with one SecretBox – Secrets we send back are sealed by SecretBox: AES-256-GCM, versioned keyring, mandatory table:column:rowId AAD; a failed open is a defect.
- Open in the repository, on demand; carry Redacted, never string – Rows hold Sealed; only the method that needs plaintext opens it; domain types, service shapes and error fields hold Redacted
. - Keep secrets out of URLs, headers in telemetry, and error text – One CurrentRedactedNames list (repeat the 8 defaults), no secret in a URL we design, fixed error text, ids not secrets in logs and spans.
- Refresh OAuth tokens on demand, one refresh per connection at a time – Refresh 60s before expiry inside a PartitionedSemaphore keyed by ConnectionId; re-read on invalid_grant; only invalid_grant marks needs_reauth.
- Compare secrets through one constant-time helper; fail closed – Every secret comparison goes through Secrets.equals / equalsBytes (hash both sides, timingSafeEqual); comparison secrets are required Config.Redacted.
- Put the org in the path; OrgScope proves membership – Tenant groups use .prefix(“/orgs/:orgId”) and OrgScope, which checks membership, provides CurrentOrg, and answers every failure with 404 OrgNotFound.
- Read CurrentOrg from R in services; pass orgId first to repositories – Services require CurrentOrg in R, provided only at entry points; repositories have no CurrentOrg and take orgId as their first argument.
- Pin org_id in every tenant query – Tenant tables have org_id NOT NULL REFERENCES orgs; every read, update, delete and by-id lookup pins org_id; unique indexes lead with org_id.
- Prove isolation with a two-org test per repository method – Each tenant repository method has a “
never sees another org” test on the real embedded database, using the shared seedTwoOrgs fixture. - Name cross-org reads …AcrossOrgs; return ids and counts only – The only reads without an org predicate end in AcrossOrgs, return ids/counts, take a required justification, and are called by System only.