Build a platform service
What this is: the complete path for an internal engineer adding a new service to the Loop Platform.
Who it’s for: anyone on the platform team or contracted into it.
Time: half a day to a deployable skeleton; a few days to feature-complete.
Before you start
Read these:
Then look at an existing similar-shape service. Patterns to mimic, not invent.
Step 1: Scaffold from _template
cp -r services/_template services/<your-service>
cd services/<your-service>
# Update package.json: name → @services/<your-service>
# Update service.yaml: name, display_name, owner, status
# Update src/index.ts: schema, service nameThe template has everything: routes barrel, services barrel, db schema, events barrel, openapi script, drizzle config, tests directory.
Step 2: Update service.yaml
name: <your-service>
display_name: "<Your Service>"
owner: "@your-handle"
team: platform
status: alpha # alpha | beta | stable
description: "One sentence on what this service owns."
contracts:
openapi: ./openapi.yaml
events_published: []
events_consumed: []
data:
database: aurora
schemas_owned: [<your_schema>]
brand_scope: [loop, loopbio]CI validates this. Missing fields fail the build.
Step 3: Define your domain
Three files set the contract:
src/db/schema.ts — Drizzle table definitions. Every table needs a brand_id column (the migration-brand-id-required convention check enforces it).
src/db/<entity>.repository.ts — One repository class per aggregate. Implements ADR-0037 (class-based services). Always filter by brand_id.
src/services/<entity>.service.ts — Business logic. Calls the repository. Publishes events via publishEvent().
// src/services/example.service.ts
import { EVENT_NAMES, validateEvent } from "@platform/contracts";
import { publishEvent } from "@platform/events";
export class ExampleService {
constructor(private readonly repo: ExampleRepository) {}
async create(input: CreateExampleInput, brandId: string) {
return this.repo.db().transaction(async (tx) => {
const row = await this.repo.create({ ...input, brandId }, tx);
await publishEvent(
EVENT_NAMES.EXAMPLE_CREATED_V1,
{ id: row.id, brand_id: brandId },
{ tx },
);
return row;
});
}
}Step 4: Add HTTP routes
src/routes/<resource>.routes.ts — uses @hono/zod-openapi for typed routes. Every route MUST call requireScope(...) (enforced by the require-scope-enforcement check):
import { OpenAPIHono, createRoute, z } from "@hono/zod-openapi";
import { SCOPES, requireScope } from "@platform/scopes";
export function createExampleRoutes(svc: ExampleService): OpenAPIHono {
const app = new OpenAPIHono();
app.use("/v1/examples/*", requireScope(SCOPES.READ_EXAMPLES));
app.openapi(
createRoute({
method: "get",
path: "/v1/examples/{id}",
tags: ["examples"],
description: "Fetch an example by id",
request: { params: z.object({ id: z.string() }) },
responses: {
200: { description: "OK", content: { "application/json": { schema: ExampleSchema } } },
404: { description: "Not found", content: { "application/json": { schema: ErrorSchema } } },
},
}),
async (c) => {
const { id } = c.req.valid("param");
const auth = c.get("auth")!;
const result = await svc.findById(id, auth.brand_id);
if (!result) return c.json({ error: "not_found", message: "..." }, 404);
return c.json(result, 200);
},
);
return app;
}Step 5: Register events
If you publish events:
- Add the Zod schema to
packages/contracts/src/events/<your-family>.ts. - Register in
packages/contracts/src/registry.tsEVENT_SCHEMAS. - Run
pnpm --filter @platform/contracts gento regenerateEVENT_NAMES. - Update your
service.yamlevents_publishedlist.
If you consume events:
- Add to
service.yamlevents_consumed. - Create
src/events/<event-name>.tsexportinghandler(theevent-handler-file-requiredcheck enforces the file’s existence).
Step 6: Wire infra
infra.ts — SST stack fragment. Copy from _template/infra.ts and adjust:
- Service name (used in hostname)
- Resources linked (database, NATS, alarm topic)
- Scaling (min/max tasks per stage)
- Health endpoint paths
sst.config.ts (repo root) — add:
const yourService = await import("./services/<your-service>/infra").then((m) =>
m.yourService(shared),
);And to the return object:
yourServiceService: yourService,Step 7: Generate OpenAPI + SDK
pnpm --filter @services/<your-service> openapi:gen
pnpm sdk:genThis produces openapi.yaml and a @platform/sdk-<your-service> package with typed clients. Commit both — drift is caught in CI.
Step 8: Write tests
Three buckets (per convention):
- Unit —
tests/unit/<file>.test.tsfor pure functions and isolated service classes. - Integration —
tests/integration/<flow>.test.tsfor route handlers with real DB and event publishing. - Conformance —
tests/conformance/*.test.tsshared platform-wide assertions (audit, brand scoping, scope enforcement) that every service must pass.
pnpm --filter @services/<your-service> testStep 9: Write the runbook + README
RUNBOOK.md — alarms, dashboards, remediation. Required by service-runbook-required check.
README.md — one paragraph on what it does, key entry points, run-locally instructions.
Step 10: Pre-merge checks
CI runs:
- Convention checks (23+ rules)
- Typecheck
- Tests
- Build
- OpenAPI drift
- SDK drift
- Service-page drift
- AI overview staleness
- Docs dead-link check
- Cross-link audit
- Required runbook / README presence
All must pass. Changesets must include a user-facing summary.
Step 11: Open PR + deploy
Squash-merge to main → auto-deploys to dev stage at https://<your-service>.dev.platform.loop.health.
Verify:
- Healthz returns 200
- Smoke test hits a real endpoint with a dev M2M token
- Alarms wired in CloudWatch
- Event publish round-trip works (see services/events EventBridge round-trip canary)
Step 12: Promote to staging → prod
Staging is auto on merge. Prod is a manual gate. Production-readiness checklist must be checked (every box). See Production readiness.