30-backend-service-module-pattern
Canonical pattern for new backend service modules in `app/backend/src/services/<domain>/<archetype>/` — action registry, status machine, pure naming helpers, external-API client, background poller, and mirrored Jest tests. Load this skill when introducing a new multi-verb domain service, replacing a
Install
mkdir -p .claude/skills/30-backend-service-module-pattern && curl -L -o skill.zip "https://agentskills.codes/api/skills/download/15020" && unzip -o skill.zip -d .claude/skills/30-backend-service-module-pattern && rm skill.zipInstalls to .claude/skills/30-backend-service-module-pattern
Activation
This is the description your AI agent reads to decide when to run this skill — the better it matches your request, the more reliably it fires.
Canonical pattern for new backend service modules in `app/backend/src/services/<domain>/<archetype>/` — action registry, status machine, pure naming helpers, external-API client, background poller, and mirrored Jest tests. Load this skill when introducing a new multi-verb domain service, replacing a legacy `switch (action)` block in `routes/`, or adding a sibling archetype to an existing service.About this skill
Backend Service Module Pattern
Reference implementation: app/backend/src/services/deployment/docker/ Mirrored tests: app/backend/src/tests/services/deployment/docker/
When to apply this pattern
Use it when a route handler in app/backend/src/routes/ dispatches on a body.action (or similar verb) field across more than ~3 branches, or when a single domain owns multiple verbs plus a background reconciliation loop. Do not apply it to simple CRUD endpoints — those stay inline in the route file.
Pre-requisites
- A spec or design note (e.g., under
specs/<NNN>-<slug>/) listing the action verbs, status enum, and external systems involved. Action handlers and tests reference its FR / US / AS identifiers inline (see existing JSDoc headers for shape). - The legacy route handler exists and is currently in
routes/functions.tsor a sibling file. The cutover is incremental — the registry'sfallbackkeeps legacy verbs working until each verb is migrated.
Directory layout
app/backend/src/services/<domain>/<archetype>/
├── types.ts # Status enum + predicates, action verb union, context interface, row shape, error taxonomy
├── <archetype>Service.ts # Entry point: action registry + `handle(req,res,body,fallback)`
├── statusMachine.ts # Pure guards + custom error classes (e.g., ConcurrentDeployError)
├── naming.ts # Pure, deterministic resource-name helpers
├── <external>Client.ts # Thin wrapper over fetch/SDK (one per external system)
├── poller.ts # Optional: background reconciliation loop
├── _armContext.ts # Optional: shared sub-context builder, underscore-prefixed
└── actions/
├── _failure.ts # Shared internal helpers — underscore-prefixed, not exported from index
├── create.ts # Exports `createAction(ctx): Promise<void>`
├── deploy.ts
├── destroy.ts
├── status.ts
└── … # One file per verb (small verb families may share a file, e.g. start/stop/restart → lifecycleArm.ts)
Mirror exactly under app/backend/src/__tests__/services/<domain>/<archetype>/. Each source file has a matching .test.ts.
Required conventions
- File header JSDoc — every file opens with: purpose, the legacy block it replaces (if any), spec/FR/US references, and an
@example. Match the style of actions/create.ts. - Action registry entry point — see dockerDeploymentService.ts:
actions: Partial<Record<ActionVerb, Handler>>populated by imports at top of file.WIRE_ALIASES: Record<string, ActionVerb>for legacy wire names (e.g.,delete→destroy). Apply at the boundary so the frontend wire format is unchanged.handle(req, res, body, fallback)resolves the verb, dispatches, or invokesfallback()for verbs not yet migrated.- Export
register…Action,_reset…ForTests,_getRegistered…(underscore = test-only).
- Handler signature —
export async function <verb>Action(ctx: <Archetype>Context): Promise<void>. Handlers respond directly viactx.resand returnvoid; they never throw to the registry for expected error paths. - Context shape — defined once in
types.ts:export interface <Archetype>Context { req: Request; res: Response; body: Record<string, unknown> & { action: <ActionVerb>; <primaryId>: string; … }; } - Status machine —
types.tsexports the status string-literal union plusTRANSITIONAL/TERMINALReadonlySets andisTransitional/isTerminalpredicates.statusMachine.tsexportsassertCanAccept<Action>(currentStatus)returning hints (e.g.,{ clearFailureAttrs: boolean }) and a customErrorsubclass carrying the offending status. See statusMachine.ts. - Pure helpers —
naming.tsand similar pure modules take a plain options object, return a plain result object, and contain zero I/O. They are pinned by exhaustiveit.each([...])tables innaming.test.ts. - External client — one
<external>Client.tsper external system (e.g.,genappWorkflowClient.ts). Takes the auth token as a parameter; never reads credentials itself. Exports narrow…Paramsinterfaces alongside each function. Module-levelconstreads env vars with sensible defaults documented in a comment. - Shared failure helpers — extract repeated
UPDATE … status='failed' … + broadcastblocks intoactions/_failure.ts. See actions/_failure.ts forrecordFailureandformatDispatchHttpCause. - Logging — every module declares
const LOG_PREFIX = "[<domain>-<archetype>:<verb>]"and logs throughutils/logger, neverconsole. - Broadcasts — DB status changes are followed by
broadcast(\<channel>-${projectId}`, "<event>", payload)fromutils/websocket` so the frontend receives push updates. - Poller (when applicable) — selects transitional rows, processes each in a per-row transaction wrapped in
pg_try_advisory_xact_lock(hashtextextended(id, 0))for multi-replica safety. Exportstick…Poller()for deterministic test ticks plusstart…Poller()/stop…Poller()forindex.ts. See poller.ts. - No coupling to the legacy file — the new module MUST NOT import from
routes/functions.ts. The only bridge is the runtimefallbackclosure passed tohandle().
Test conventions (Jest)
Each handler test in __tests__/services/<domain>/<archetype>/actions/<verb>.test.ts follows the create.test.ts shape:
jest.mock()every I/O dependency at the top:utils/database,utils/logger,utils/rpcHelpers,utils/githubAuth,websocket, the external client.- A
makeCtx(overrides?)factory that returns{ ctx, res }whereres.statusandres.jsonare chainablejest.fn().mockReturnThis(). - One
describeper handler;ittitles reference the spec/FR/US (e.g.,"persists resource names before dispatch (FR-002)"). - For state-machine and naming modules, use parametric
it.each([...])tables to cover every enum × action pair. - Reset registries between tests with
_reset…ForTests()to avoid cross-test bleed.
Incremental cutover procedure
- Scaffold
types.ts,<archetype>Service.tswith an emptyactionsmap, and the test directory mirror. - Wire
handle(req, res, body, fallback)into the legacy route, passing the existingswitchbody asfallback. Land + ship — behavior is unchanged. - For each verb, in its own commit: add
actions/<verb>.ts, register it in the entry point, mirror the test, delete the legacycase. - When the legacy
switchis empty, delete the fallback closure and drop the redundant route plumbing.
Validation
Run skill 20.build-and-lint and 21.test-all after every cutover commit. New action files must ship with their mirrored test file in the same commit.
Rollback
Each verb cutover is reversible by reverting the single commit that registered it — the legacy fallback closure remains intact during the migration, so the verb falls back to the legacy branch on revert.
Ownership
Backend team. Updates to this pattern require updating both this skill and the reference implementation in lockstep.
Anti-patterns to avoid
- Putting business logic in
<archetype>Service.ts— it is dispatch-only. - Adding a verb to the action registry without a mirrored test file in the same commit.
- Importing
routes/functions.tsfrom any file underservices/<domain>/. - Inlining external-API
fetchcalls inside an action handler — go through<external>Client.ts. - Mutating status in an action handler without first calling
assertCanAccept<Action>(currentStatus). - Reading credentials inside
<external>Client.ts— the token is always a parameter. - Using
console.*instead ofutils/logger. - Forgetting to
broadcast()after a status change — the frontend depends on it.