arch-scaffold-service · git:20260922.a3c1e91 · 2026-09-22 · sha256 4db2181d3a6a75d8
arch-scaffold-service git:20260922.a3c1e91A
Immutable. This exact content is served forever at /api/v1/blob/4db2181d3a6a75d8.
---
name: arch-scaffold-service
description: "Create a web service: the app factory, container, gateway, routers and wire types, service interfaces, health endpoints, the ops CLI, the image, and tests. Python (FastAPI)."
allowed-tools: Read, Grep, Glob, Write, Edit, Bash(make check), Bash(make openapi), Bash(uv run:*), Bash(uv sync:*), Bash(git status:*)
---
# arch-scaffold-service
Conventions: `${CLAUDE_SKILL_DIR}/../_shared/scaffold-conventions.md`.
Sections of `${CLAUDE_SKILL_DIR}/../../architecture.md`: Interfaces
(Composition by decoration), OpContext (The Operator Context), The
Business Layer (Shape of an
Operation), The Network Layer (How It Starts and Where It Goes, Web Services as Scalability Units, Domain Services vs
App-Specific Services, Service Interfaces and Impls, The Gateway,
Intra-Service Communication, Public Types, Realtime at the Edge),
Deployment (Cloud: AWS, Infrastructure as Code), Operations (Operator
Roles, Operational Skills), Monorepo Folder Structure (Layout
Conventions), Cross-Cutting Conventions (Exceptions, Configuration,
The App Container).
## Input
`[service-name] [--namespaces ns1,ns2] [--app portal|admin|cli] [--realtime] [--container]`
`<service-name>` defaults to `api`. With no flags the service hosts
every existing namespace: the first API process of a system.
`--namespaces` makes a domain service hosting only those namespaces.
The first form of a split is not this skill: it is the API process's
own image with its `namespaces` setting naming the routers it mounts,
as The Network Layer (Web Services as Scalability Units) states. Run
this skill when the service needs code of its own.
`--app` makes an app-specific service that composes domain operations
for one app; it sets `AppType.<APP>` on every context it builds and the
gateway rejects a request whose app header names another app.
`--realtime` adds the realtime channel; without it the service has no
socket, and the portal cannot connect to it until one is added.
`--container` adds the service to the second compose file, the one
that runs the application in containers for the case that asks for it;
`scripts/dev.sh` starts it on the host either way, as Deployment
(Local: Docker Compose) states.
`<svc>` is the service name in snake case, and `<ns_singular>` a
namespace's singular in snake case, as `arch-scaffold-namespace`
names it.
## Created
Under `services/<service-name>/`. The file-by-file lists are long, so
each one lives beside this file and is read by the step that names it,
when that step runs and not before.
| Reference | Holds | Read by |
|------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------|
| `${CLAUDE_SKILL_DIR}/references/process.md` | the distribution and its settings, the container, the app factory and its middleware order, the console entry point and its subcommands, the image, and the process tests | step 1 |
| `${CLAUDE_SKILL_DIR}/references/gateway.md` | the `gateway/` package: credential parsing and the context dependencies, the operator gate, the error handlers, the rate limit, edge idempotency, and the request-id middleware | step 1 |
| `${CLAUDE_SKILL_DIR}/references/routes.md` | the routers, the wire types, the service interfaces and their in-process impls, and the tests that drive them | step 2 |
| `${CLAUDE_SKILL_DIR}/references/realtime.md` | the `realtime/` package (ticket, socket, send buffer, envelopes), the replay route, and their tests | step 2, with `--realtime` |
A reference file is detail. These are the lines a run must never miss,
so they stay here:
- The gateway is written once. A later service imports the root
`gateway/` distribution the `Changed` table moves it into and writes
none of those files, because an edge concern is done once.
- The service interface and its impl exist from the single-process
start, so a split later moves a module instead of extracting one;
routers hold no logic of their own at any stage.
- Every creating route declares the gateway's `Idempotency-Key`
dependency, in every hosted namespace and not only the first's, as
the conventions state.
- `/healthz` is liveness and `/readyz` is readiness. The readiness
route awaits the storage healthcheck under a deadline of its own,
shorter than the interval it is polled on, and answers negative
rather than hanging; every image's healthcheck is on `/healthz`.
- Admission is the process defending itself, so it fails closed where
the rate limit fails open, and the health, readiness, and metrics
routes are outside both budgets.
- The interactive API documentation is served only when the settings
name the `local` environment, and is `None` in every deployed one.
- No socket frame carries a field of the entity. A frame is a hint,
and a client reads the entity through the authorized read.
## Changed
| File | Change |
|-----------------------------------------|---------------------------------------------------------------------------------|
| `pyproject.toml` (root) | the member added to `[tool.uv.workspace] members` |
| `Makefile` | the `openapi` target emits this service's document into `services/<service-name>/openapi.json`, one file per service, so no service overwrites another's; the document of the service a browser app calls is the one the app skill copies into `apps/<portal>/openapi.json`, and a later service never writes that file |
| `scripts/dev.sh` | starts the service on its port |
| `README.md` (root) | a row in the `Local URLs` table: the service's interactive API docs at `http://localhost:<port>/docs` |
| `deployment/local/docker-compose.full.yml` (with `--container`) | the service as a container |
| `deployment/realtime-timeouts.json` (with `--realtime`, when absent) | the ping interval and the load balancer idle timeout, `{"ping_interval_seconds": 25, "idle_timeout_seconds": 60}`, the ping well inside the timeout, the one shared file a service test and a client test both assert against; the portal asserts its half from the client side |
| `.env.example` | every field of the service's settings under its prefix, with its local value, which `tests/test_settings.py` holds |
| `deployment/terraform/modules/`, `deployment/terraform/environments/*/` (unless the environment already declares this process) | one instance of the service module per environment for this process: its ECS service with rollout limits, its log group with retention, its target-tracking autoscaling behind the root switch `autoscaling_enabled`, its running-tasks-below-desired alarm on the environment's alarm topic, its load balancer route, the runtime login's and the system login's database URLs wired as secrets, never the migration login's, which the one-off migration task alone holds, and every setting without a local default passed as a variable or wired as a secret, as Deployment (Infrastructure as Code) states for every process |
| `.github/workflows/deploy-staging.yml`, `deploy-production.yml` | the service's image: built and pushed under the commit by the staging workflow's push-only build job, which records its digest on the repository host's deployment record; promoted by that digest for the release commit in the production workflow's plan, checked against the record, never rebuilt; a new service's image repository is a bootstrap root change, applied by a create run in each account |
| `services/<existing>/gateway/`, `gateway/pyproject.toml`, `pyproject.toml` (root) (when a service already exists) | the first service's `gateway/` package is moved into a root distribution `gateway/` of its own, `<root>-gateway`, with its `pyproject.toml`, added to `[tool.uv.workspace] members` and declared under `[tool.uv.sources]` by both services, which import it; it is moved, never copied, as The Gateway states, so this service writes no `gateway/` package of its own and the `Created` rows for one are skipped |
## Procedure
1. Read `${CLAUDE_SKILL_DIR}/references/process.md`. Then write
`pyproject.toml`, add the member to the workspace, and run
`uv sync`; then the container before the app, the app before the
routers. Write the gateway before the routers that depend on it,
and only when this is the first service: when one already exists,
the `Changed` table's `gateway/` move is what happens instead, this
service imports the root `gateway/` distribution, and
`${CLAUDE_SKILL_DIR}/references/gateway.md` is skipped. With
`--realtime`, write `deployment/realtime-timeouts.json` from the
`Changed` table in this step, since the app reads its ping interval
and the tests of step 2 assert against it.
2. Read `${CLAUDE_SKILL_DIR}/references/routes.md`, and, with
`--realtime`, `${CLAUDE_SKILL_DIR}/references/realtime.md`.
A router function declares the route (path, verb, status, and the
dependencies that mint the context and the idempotency key), calls
one operation of its service impl with the context, the request
type, and, on a creating route, the id the `Idempotency-Key`
dependency minted before the marker, and returns what the impl
returns; it translates nothing and
decides nothing. The impl translates: it builds the entity or the
arguments from the request, calls one manager, and projects the
result onto a view. The router calls through the service interface
from the first day, so a split is a wiring change and never a
rewrite of the routers. A partial update is the impl's translation:
it reads the current entity through the manager's `get_*`, copies
the request's set fields onto it, an absent field meaning unchanged
and an explicit null meaning cleared where the field is optional,
and hands the whole entity to the manager; that policy is the
request type's contract, as The Business Layer (Shape of an
Operation) states. When a router or an impl starts deciding, move
the decision into a manager and say so in the output.
3. An app-specific service composes managers or sibling domain service
clients for one app only and never calls another app-specific
service.
4. Apply the rest of the `Changed` table: the workspace member, the
`Makefile`'s `openapi` target, `scripts/dev.sh`, the README's
`Local URLs` row, every settings field in `.env.example`, the
compose file with `--container`, one instance of the service module
per environment under `deployment/terraform/`, and this service's
image in both deploy workflows.
5. Emit the OpenAPI document with `make openapi` so the consuming apps
regenerate their types.
## Output
As `${CLAUDE_SKILL_DIR}/../_shared/scaffold-conventions.md` states.