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.
Use only role-nouns from the glossary
Section titled “Use only role-nouns from the glossary”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>Repois SQL rows throughSqlSchema, withOptionlookups and migrations;<Thing>Storeis non-relational (key-value, blob, file);<Thing>Registryis an in-memory lookup that owns no lifecycle;<Vendor>Clientwraps one vendor’s API and turns itsnulls intoOption. - Also in the glossary:
policy.tswith purecan<Action>predicates, a<Name>WorkerfrommakeWorker, a job (recurring, built withJobs.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 identifierSource: notes/03-naming-and-language/naming-and-vocabulary.md · Decision 1, amended
Never use grab-bag names
Section titled “Never use grab-bag names”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 aServicesuffix on a service class. Prose may still callfromRow“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, notTerminalManager). - The ban is held by
AGENTS.mdand review, not a lint rule: a bad name is visible in the diff.
❌ Incorrect — names that say nothing about the shape:
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 Labelexport 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>Workeris only whatmakeWorkerreturns: 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 isbin.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 hereSource: notes/03-naming-and-language/naming-and-vocabulary.md · Decision 3, amended
make constructs; from/to convert
Section titled “make constructs; from/to convert”Impact: MEDIUM create at a call site always means a business operation
makeconstructs 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,archiveand other domain verbs belong to service and repository methods only.build,initandcreate-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 traceSource: 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 returnsOption.get…never does, and never returns| undefinedor| null. Collections are read withlist…, which never fails on empty.- The key goes in the name only when it is not the primary id:
get(id)butfind(label). A repository has noget, spells every key (findById,findByLabel), and on a tenant table takesorgIdfirst. The service reads the org fromCurrentOrginRand liftsNoneintoXNotFound. - 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:
// servicereadonly get: (id: RuleId) => Effect.Effect<LabelingRule, RuleNotFound | PersistenceError, CurrentOrg>readonly find: (label: Label) => Effect.Effect<Option.Option<LabelingRule>, PersistenceError, CurrentOrg>// repository — tenant table, orgId firstreadonly 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.Structis<Verb><DomainType>Input, mirroring the wire’s<Verb><DomainType>Request. One grep forCreateLabelingRulefinds 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,ArgsorPayloadas a type suffix. Accepted cost: long names, andLabelingRules.LabelingRulesOptionsstutters.
❌ 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: CreateLabelingRuleRequestexport 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. NoLive,DefaultorTestsuffix anywhere: the library renamedLivetoLayeron purpose, and 4.0.3 has no*Live. Code copied from older app repos is renamed on the way in. This is held byAGENTS.mdand 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 ruleapp/tag-segment-matches-folderchecks 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:
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:
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
Deferred
Section titled “Deferred”- A
Coordinatorglossary line — trigger: the first service that serializes work per key. - Renaming our
WorkertoConsumer— trigger: we adopteffect/workers(the Cloudflare half of the trigger has fired and is handled by theisolate.tsnaming). - Lint rules
app/no-banned-namesandapp/no-live-layer-names— trigger: review catches the same mistake a second time (see linting and formatting).