Skip to content

Naming & vocabulary

A name should tell you the shape of a thing before you open the file. This page fixes which role-nouns exist and what each promises, which names are banned, and what the verbs make, get, find and <verb>Many mean at every layer.

Impact: HIGH the name is the contract; open vocabularies drift

  • A role-noun promises a shape, and only these carry one: a plain plural or agent noun is a feature service (LabelingRules, Authenticator); <Entity>Repo is SQL rows through SqlSchema, with Option lookups and migrations; <Thing>Store is non-relational (key-value, blob, file); <Thing>Registry is an in-memory lookup that owns no lifecycle; <Vendor>Client wraps one vendor’s API and turns its nulls into Option.
  • Also in the glossary: policy.ts with pure can<Action> predicates, a <Name>Worker from makeWorker, a job (recurring, built with Jobs.make, named <slice>.<verb-phrase>), a subscriber (one outbox handler per file, <slice>/on-<event>.ts), and a process (a status column on a slice’s row, advanced by chained outbox events). workflow is reserved for an engine-run workflow; none exists, so the word names nothing in our code.
  • A new role-noun gets a glossary line before it appears in code. If a Store moves to SQL, it becomes a Repo in the same diff. Anything without a glossary noun is named for what it does (normalizeLabel, make<Thing>).

❌ Incorrect — Store over SQL rows, so the reader can’t tell it needs a migration:

export class LabelingRulesStore extends Context.Service<
LabelingRulesStore,
LabelingRulesStoreShape
>()("@app/labeling/LabelingRulesStore") {} // backed by the labeling_rules table

✅ Correct — Repo means SQL rows and nothing else:

export class LabelingRulesRepo extends Context.Service<
LabelingRulesRepo,
LabelingRulesRepoShape
>()("@app/labeling/LabelingRulesRepo") {}
// file: labeling-rules-repo.ts, the kebab form of the main identifier

Source: notes/03-naming-and-language/naming-and-vocabulary.md · Decision 1, amended

Impact: MEDIUM each banned word names a role without saying what it does

  • Banned in identifiers and filenames: Manager, Helper/Helpers, Util/Utils, Factory, Wrapper, Mapper, Impl, and a Service suffix on a service class. Prose may still call fromRow “the mapper”.
  • A function belongs next to the type it works on, not in a utils file. A lifecycle owner is named for what it holds (TerminalSessions, not TerminalManager).
  • The ban is held by AGENTS.md and review, not a lint rule: a bad name is visible in the diff.

❌ Incorrect — names that say nothing about the shape:

labeling/rule-utils.ts
export class RulesManager …
export class LabelingRulesService …
RuleMapper.toDomain(row)

✅ Correct — name the thing, put the function by its type:

// labeling/label.ts → normalizeLabel next to Label
export class LabelingRules …
fromRow(row) / toRow(rule) / toPublicRule(rule)

Source: notes/03-naming-and-language/naming-and-vocabulary.md · Decision 2, amended

Reserve Worker for the makeWorker queue consumer

Section titled “Reserve Worker for the makeWorker queue consumer”

Impact: MEDIUM the name keeps promising the queue and the drain

  • <Name>Worker is only what makeWorker returns: a queue, a bounded drain and a per-item boundary. A service that serves requests and never forks is not a Worker, whatever it syncs.
  • A Cloudflare isolate or thread entry is an isolate. On the Worker profile the platform entry file is isolate.ts; on the Node and Bun profiles it is bin.ts. Cloudflare.Worker(…) appears only as alchemy’s constructor inside that file.
  • A job is never called a worker, and neither is a cron handler.

❌ Incorrect — Worker for any background service, and for the isolate entry:

export class DiscordSyncWorker … // serves requests, never forks
// src/worker.ts // the Cloudflare entry file

✅ Correct — the name matches the shape:

export class DiscordSync …
makeWorker("RuleVerdictWorker", …) // a queue, a bounded drain, a per-item boundary
// src/isolate.ts // Cloudflare.Worker(…) lives only in here

Source: notes/03-naming-and-language/naming-and-vocabulary.md · Decision 3, amended

Impact: MEDIUM create at a call site always means a business operation

  • make constructs everything we construct: a service, a scoped helper (makeWorker), an id brand (makeEntityId), a pure assembly (makeRulePrompt). The return type says whether it is pure or effectful.
  • from<Source> / to<Target> convert representations: fromRow, toRow, toPublicRule.
  • create, insert, archive and other domain verbs belong to service and repository methods only. build, init and create-as-constructor are not used.

❌ Incorrect — a constructor that reads like a domain operation:

export const createRulePrompt = (rule: LabelingRule) => …
export const buildCommitMessagePrompt = (…) => …

✅ Correct — make constructs, create persists:

export const makeRulePrompt = (rule: LabelingRule) => …
yield* rules.create(input) // always a business operation that leaves a trace

Source: notes/03-naming-and-language/naming-and-vocabulary.md · Decision 4

get fails, find returns Option, batches are <verb>Many

Section titled “get fails, find returns Option, batches are <verb>Many”

Impact: HIGH one rule at every layer removes the ambiguity

  • find… always returns Option. get… never does, and never returns | undefined or | null. Collections are read with list…, which never fails on empty.
  • The key goes in the name only when it is not the primary id: get(id) but find(label). A repository has no get, spells every key (findById, findByLabel), and on a tenant table takes orgId first. The service reads the org from CurrentOrg in R and lifts None into XNotFound.
  • A batch is the single’s name plus Many (archiveMany, getMany, findManyById), so one grep finds both. A batch never spans orgs.

❌ Incorrect — get that returns Option, and a batch named differently:

readonly get: (id: RuleId) => Effect.Effect<Option.Option<LabelingRule>, PersistenceError>
readonly findByIds: (ids: ReadonlyArray<RuleId>) => …

✅ Correct — the same verbs mean the same thing at every layer:

// service
readonly get: (id: RuleId) => Effect.Effect<LabelingRule, RuleNotFound | PersistenceError, CurrentOrg>
readonly find: (label: Label) => Effect.Effect<Option.Option<LabelingRule>, PersistenceError, CurrentOrg>
// repository — tenant table, orgId first
readonly findById: (orgId: OrgId, id: RuleId) => Effect.Effect<Option.Option<LabelingRule>, PersistenceError>
readonly findManyById: (orgId: OrgId, ids: …) => …

Source: notes/03-naming-and-language/naming-and-vocabulary.md · Decisions 5 and 6, amended

Input for the domain, Request for the wire, Options always named

Section titled “Input for the domain, Request for the wire, Options always named”

Impact: MEDIUM the suffix says which side of a boundary a type is on

  • The domain input Schema.Struct is <Verb><DomainType>Input, mirroring the wire’s <Verb><DomainType>Request. One grep for CreateLabelingRule finds both.
  • Method options are a named, exported <Verb><DomainType>Options, never inline { … }. Constructor options are <Service>Options.
  • Never New<Entity>, a bare <Verb><Entity>, Params, Args or Payload as a type suffix. Accepted cost: long names, and LabelingRules.LabelingRulesOptions stutters.

❌ Incorrect — names that don’t say which side they’re on:

export const NewRule = Schema.Struct({ … })
export const CreateRule = Schema.Struct({ … })
readonly get: (id: RuleId, options?: { readonly includeArchived?: boolean }) => …

✅ Correct — suffixed by boundary, options named for their owner:

export const CreateLabelingRuleInput = Schema.Struct({ … }) // contracts: CreateLabelingRuleRequest
export type LabelingRulesOptions = { readonly timeout: Duration.Duration }
export type GetLabelingRuleOptions = { readonly includeArchived?: boolean }
readonly get: (id: RuleId, options?: GetLabelingRuleOptions) => …

Source: notes/03-naming-and-language/naming-and-vocabulary.md · Decision 7

Name layers <Name>Layer and tags by their owning folder

Section titled “Name layers <Name>Layer and tags by their owning folder”

Impact: MEDIUM follows the library’s rename; tags become checkable

  • Assembled layers are <Name>Layer. No Live, Default or Test suffix anywhere: the library renamed Live to Layer on purpose, and 4.0.3 has no *Live. Code copied from older app repos is renamed on the way in. This is held by AGENTS.md and review.
  • A tag’s middle segment is the folder or package that owns the file: "@app/labeling/RuleNotFound", "@app/infra/PersistenceError", "@app/db/LedgerDiverged". Shared wire errors are @app/http/NotFound, @app/http/Conflict, @app/http/Forbidden. The lint rule app/tag-segment-matches-folder checks it.
  • Accepted cost: moving a file changes its tag, which breaks a client that matches its _tag.

❌ Incorrect — the old suffix, and a tag segment by concept:

infra/persistence-error.ts
const SqlLive = PgClient.layerConfig(…)
const MainLive = Layer.mergeAll(HttpLive, WorkersLive).pipe(Layer.provide(SqlLive))
"@app/persistence/PersistenceError"

✅ Correct — <Name>Layer, and the segment is the owning folder:

infra/persistence-error.ts
const SqlLayer = PgClient.layerConfig(…)
const MainLayer = Layer.mergeAll(HttpLayer, WorkersLayer).pipe(Layer.provide(SqlLayer))
Layer.launch(MainLayer)
"@app/infra/PersistenceError"

Source: notes/03-naming-and-language/naming-and-vocabulary.md · Decisions 8 and 9, amended

  • A Coordinator glossary line — trigger: the first service that serializes work per key.
  • Renaming our Worker to Consumer — trigger: we adopt effect/workers (the Cloudflare half of the trigger has fired and is handled by the isolate.ts naming).
  • Lint rules app/no-banned-names and app/no-live-layer-names — trigger: review catches the same mistake a second time (see linting and formatting).