getting-startedBuild a platform service

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:

  1. System overview
  2. Service architecture
  3. Auth model
  4. Events

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 name

The 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:

  1. Add the Zod schema to packages/contracts/src/events/<your-family>.ts.
  2. Register in packages/contracts/src/registry.ts EVENT_SCHEMAS.
  3. Run pnpm --filter @platform/contracts gen to regenerate EVENT_NAMES.
  4. Update your service.yaml events_published list.

If you consume events:

  1. Add to service.yaml events_consumed.
  2. Create src/events/<event-name>.ts exporting handler (the event-handler-file-required check 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:gen

This 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):

  • Unittests/unit/<file>.test.ts for pure functions and isolated service classes.
  • Integrationtests/integration/<flow>.test.ts for route handlers with real DB and event publishing.
  • Conformancetests/conformance/*.test.ts shared platform-wide assertions (audit, brand scoping, scope enforcement) that every service must pass.
pnpm --filter @services/<your-service> test

Step 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:

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.