Effect and API conventions
The five-service wiring pattern every control-plane API route follows, from Postgres repository to HTTP handler.
This is the conventions guide for API work in apps/api — from @sealant/db repositories to HTTP
handlers. Use it when adding or reviewing any new route domain. The GitHub route group
(apps/api/src/routes/github/*) is the current production example; new domains should follow the
same shape.
The five services
Wire every domain from persistence to transport, in this order:
- DB service (
packages/db/src/client.ts) — one typed Drizzle DB service tag. ProvidesSealantDBviaSealantDBLive. No route or business logic here. - Repository services (
packages/db/src/repositories/*.ts) — one service per aggregate/table boundary. Each exports a service interface, aContext.Tag, and a*Livelayer built withLayer.effect. Persistence only — no HTTP or provider calls. - Capability services (e.g.
packages/source-integrations/src/github/*) — external/provider logic behind a service tag and a live layer (GitHub App auth is the current example). No imports fromapps/api. - HTTP contract service (
packages/api-contracts/src/core-api/*) — endpoint IDs, request/query schemas, response schemas, and typed API errors as anHttpApigroup. Schemas and types only — no side effects, no repository access, no layer wiring. - Per-domain HTTP implementation (
apps/api/src/routes/<domain>/*) — endpoint behavior as plain Effects in<domain>.module.ts, bound to the contract in a thin<domain>.http-api.tsviaHttpApiBuilder.group(...).
Consumes/provides chain:
Service 1 (DB) -> Service 2 (Repos) -> Service 5 (HTTP impl)
\-> Service 3 (Capability) -/
Service 4 (HTTP contract) -----------------------------> Service 5 (HTTP impl)
Service 5 (HTTP impl) ---------------------------------> Runtime bootstrap (apps/api/src/index.ts)If a new route can be explained with this same five-service chain, the wiring is correct for this repo.
Concrete example: the GitHub route
Repository service and live layer (packages/db/src/repositories/github-installations.ts):
export class GitHubInstallationRepo extends Context.Tag("GitHubInstallationRepo")<
GitHubInstallationRepo,
GitHubInstallationRepoService
>() {}
export const GitHubInstallationRepoLive = Layer.effect(
GitHubInstallationRepo,
Effect.gen(function* () {
const db = yield* SealantDB;
return {
getInstallationById: (id) =>
withGitHubInstallationRepoError(
"getInstallationById",
Effect.gen(function* () {
const [installation] = yield* db
.select()
.from(githubAppInstallations)
.where(eq(githubAppInstallations.id, id))
.limit(1);
return installation;
}),
),
// ...other repository operations
};
}),
);Repo bundle layer (packages/db/src/layers.ts):
export const GitHubDataAccessLive = Layer.mergeAll(
GitHubInstallationRepoLive,
GitHubInstallationRepositoryCacheRepoLive,
GitHubWebhookDeliveryRepoLive,
RepositoryProfileRepoLive,
);HTTP contract (packages/api-contracts/src/core-api/github.ts and control-plane.ts):
export const GitHubGroup = HttpApiGroup.make("github").add(
HttpApiEndpoint.get("listInstallations", "/installations", {
query: githubInstallationsQuerySchema,
success: listGitHubInstallationsResponseSchema,
error: [GitHubServiceUnavailableError, GitHubInternalServerError],
}),
);
export const ControlPlaneAPI = HttpApi.make("sealantControlPlaneApi").add(
GitHubGroup.prefix("/v1/github"),
);Domain use-case (apps/api/src/routes/github/github.module.ts):
export const listInstallations = (query: GitHubInstallationsQuery) => {
return Effect.gen(function* () {
const installationRepo = yield* GitHubInstallationRepo;
const installations = yield* withInternalError(
installationRepo.listInstallationsForUser({ userId: query.userId, status: "active" }),
"Failed to list GitHub installations.",
);
return {
items: installations.map(toInstallationSummary),
} satisfies ListGitHubInstallationsResponse;
});
};Thin handler binding (apps/api/src/routes/github/github.http-api.ts):
const GitHubHandlersLive = HttpApiBuilder.group(ControlPlaneAPI, "github", (handlers) => {
return handlers
.handle("listInstallations", ({ query }) => listInstallations(query))
.handle("importInstallation", ({ payload }) => importInstallation(payload));
});Runtime composition (apps/api/src/index.ts) provides layers bottom-up:
const databaseClientLayer = SealantDBLive.pipe(
Layer.provide(PgClient.layer({ url: Redacted.make(env.DATABASE_URL) })),
);
const databaseLayer = GitHubDataAccessLive.pipe(Layer.provide(databaseClientLayer));
const apiLayer = makeGitHubHttpApiLayer().pipe(
Layer.provide(gitHubSourceIntegrationLayer({ apiBaseUrl: env.GITHUB_API_BASE_URL })),
Layer.provide(databaseLayer),
);That is the full stack, from Drizzle repository implementation to served HTTP handler:
PgClient.layer(...) → SealantDBLive → GitHubDataAccessLive → the capability layer → the
HttpApi layer → OpenAPI/Scalar and CORS middleware → HttpApiBuilder.serve() +
NodeHttpServer.layer(...).
The DB service tag (packages/db/src/client.ts)
@sealant/db exposes Postgres access as one Effect service, SealantDB:
SealantDB— aContext.Servicetag whose shape is the typed Drizzle instance (schema- and relations-aware, built viaPgDrizzle.makeWithDefaults).SealantDBLive—Layer.effect(SealantDB, dbEffect), the live layer. It still needs aPgClient.layer(...)provided underneath it for the actual connection.makeSealantDBLayer(databaseUrl)— convenience wrapper that providesPgClient.layer({ url: ... })for you.createSealantDB(databaseUrl)/createSealantDBFromEnv(env)— imperative escape hatches for non-Effect call sites (migration scripts, seed scripts) that need a plainDBhandle without building a full Effect runtime. These build the layer into a process-lifetimeScopeso the connection pool doesn't close underneath the returned handle.
Repository services (packages/db/src/repositories/*.ts) depend only on SealantDB, never on
PgClient directly — that keeps the Postgres/pooling concern isolated to one place while repository
implementations stay stable. Usage inside an Effect program:
import { Effect } from "effect";
import { SealantDB, makeSealantDBLayer } from "@sealant/db";
const program = Effect.gen(function* () {
const db = yield* SealantDB;
return yield* Effect.promise(() => db.query.repositories.findMany());
});
const runnable = program.pipe(Effect.provide(makeSealantDBLayer(databaseUrl)));packages/db/src/layers.ts then bundles repository layers into convenience aggregates —
GitHubDataAccessLive for the GitHub-only surface, ControlPlaneDataAccessLive as the "most
repositories" default most of apps/api provides. Neither constructs SealantDB itself; the app
boundary (apps/api/src/index.ts) provides it.
Error strategy
Two stages:
- Internal errors — repository errors (
*RepoInvariantError,*RepoUnexpectedError) and capability errors (GitHubSourceIntegration*Error). - Boundary errors — API contract errors in
@sealant/api-contracts(400/401/403/404/500/503).
Map internal errors to boundary errors inside apps/api/src/routes/*/*.module.ts. Never return raw
repository or capability errors over HTTP.
Service granularity
- One repository service per repository, in
@sealant/db. - One capability service per external provider/integration package.
- Endpoint behavior as plain exported Effects in
*.module.ts— not one service per handler.
Introduce an additional domain service in apps/api only when behavior must be shared across
multiple runtimes (HTTP + worker + scheduled job, for example).
Route implementation checklist
- Add or extend repository services in
@sealant/db. - Add or extend capability package services for external systems.
- Define HTTP schemas/errors/endpoints in
packages/api-contracts. - Implement domain behavior in
apps/api/src/routes/<domain>/<domain>.module.ts. - Bind handlers in
apps/api/src/routes/<domain>/<domain>.http-api.ts. - Provide the required layers in
apps/api/src/index.ts. - Add tests for success paths and error mapping.
What to avoid
- Business logic in
*.http-api.ts— keep it a thin binding layer. - Importing app route code from capability or
@sealant/dbpackages — the dependency direction only goes one way:apps/apidepends onpackages/api-contracts,packages/db, and capability packages, never the reverse. - Returning raw internal repository/capability errors over HTTP.
- Runtime layer composition inside
packages/api-contracts— it defines shape only.
Reference
apps/api/src/index.ts— layer composition and server bootstrap.packages/db/src/client.ts,packages/db/src/layers.ts, andpackages/db/src/repositories/*.ts.packages/api-contracts/src/core-api/control-plane.ts.- Effect patterns generally: consult
pnpm exec effect-solutions list/pnpm exec effect-solutions show <topic>before writing new Effect code — seeAGENTS.md.