Effect Control Plane Architecture Guide
This document is the canonical Effect guide for the API control plane in this repository.
Use it when you are adding or reviewing API work from DB repositories to HTTP handlers.
Scope and current status
- The live Effect HTTP path today is the GitHub control-plane domain.
- Legacy Hono route files still exist in
apps/api/src/routes/*but are intentionally disabled. - New route domains should follow the same Effect architecture used by GitHub.
SOP: five services and the wiring order
Use this as the default blueprint for every new API domain
Always wire from persistence to transport in this exact order. Keep each service focused and composable.
Service 1: DB service
- Role: provide one typed Drizzle DB service tag.
- File:
packages/db/src/client.ts - Output:
SealantDBtag +SealantDBLivelayer.
Service 2: repository services
- Role: expose one service per aggregate/table boundary.
- Files:
packages/db/src/repositories/*.ts - Output: repo tags +
*Livelayers + repo error types.
Service 3: package/use-case capability service
- Role: encapsulate external/provider logic (GitHub is example only).
- Files:
packages/source-integrations/src/github/service.ts,packages/source-integrations/src/github/layer.ts - Output:
GitHubSourceIntegrationServiceand live provider layer.
Service 4: HTTP contract service
- Role: define boundary schemas, endpoint IDs, and HTTP error contracts.
- Files:
packages/api-contracts/src/core-api/github.ts,packages/api-contracts/src/core-api/control-plane.ts - Output:
HttpApicontract with OpenAPI metadata and no runtime side effects.
Service 5: per-domain HTTP implementation service
- Role: implement endpoint behavior as raw Effects and bind endpoint handlers.
- Files:
apps/api/src/routes/github/github.module.ts,apps/api/src/routes/github/github.http-api.ts - Output: thin handler binding over Effect use-cases.
const dbClientLayer = DBServiceLive.pipe(Layer.provide(RuntimeSqlClientLayer));
const repoLayer = DomainDataAccessLive.pipe(Layer.provide(dbClientLayer));
const packageLayer = packageCapabilityLayer(runtimeOptions);
const apiLayer = makeDomainHttpApiLayer().pipe(
Layer.provide(packageLayer),
Layer.provide(repoLayer),
);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(sourceIntegrationLayer),
Layer.provide(databaseLayer),
);Wiring contract (consumes -> provides)
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`)SOP pass criteria
If a new route can be explained with the same 5-service consumes/provides chain, your wiring is correct for this repo.
Concrete GitHub examples (end to end)
The snippets below are minimal extracts from the current implementation so the architecture is concrete, not abstract.
A) Repository service and live layer (@sealant/db)
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
};
}),
);B) Repo bundle layer (@sealant/db)
packages/db/src/layers.ts:
export const GitHubDataAccessLive = Layer.mergeAll(
GitHubInstallationRepoLive,
GitHubInstallationRepositoryCacheRepoLive,
GitHubWebhookDeliveryRepoLive,
RepositoryProfileRepoLive,
);C) HTTP contract and route prefix (@sealant/api-contracts)
packages/api-contracts/src/core-api/github.ts and
packages/api-contracts/src/core-api/control-plane.ts:
export const GitHubGroup = HttpApiGroup.make("github").add(
HttpApiEndpoint.get("listInstallations", "/installations")
.setUrlParams(githubInstallationsQuerySchema)
.addSuccess(listGitHubInstallationsResponseSchema)
.addError(GitHubServiceUnavailableError)
.addError(GitHubInternalServerError),
);
export const ControlPlaneAPI = HttpApi.make("sealantControlPlaneApi").add(
GitHubGroup.prefix("/v1/github"),
);D) Domain use-case implementation (apps/api)
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;
});
};E) Thin handler binding (apps/api)
apps/api/src/routes/github/github.http-api.ts:
const GitHubHandlersLive = HttpApiBuilder.group(ControlPlaneAPI, "github", (handlers) => {
return handlers
.handle("listInstallations", ({ urlParams }) => listInstallations(urlParams))
.handle("importInstallation", ({ payload }) => importInstallation(payload));
});F) Runtime composition (apps/api)
apps/api/src/index.ts:
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),
);This is the full stack from Drizzle repository implementation to served HTTP handlers.
Layer responsibilities
1) Data access package (packages/db)
@sealant/db owns typed persistence contracts and implementations.
Expected shape:
- one service tag per repository (
Context.Tag) - one
*Livelayer per repository (Layer.effect) - typed repository errors (invariant/unexpected)
- optional bundle layers (
GitHubDataAccessLive,ControlPlaneDataAccessLive)
Rules:
- no HTTP concerns
- no provider API calls
- no API contract error types
2) Capability packages (packages/*)
Capability packages own external system behavior.
Current reference: @sealant/source-integrations for GitHub App operations.
Expected shape:
service.ts: capability contract and errorslayer.ts: live implementation and layer constructors- pure helpers in
utils.ts
Rules:
- no imports from
apps/api - no direct HTTP transport mapping
3) API boundary contract (packages/api-contracts)
api-contracts defines HTTP shape only.
Expected shape:
- request/query/header schemas
- response schemas
- boundary API errors (status + payload)
HttpApigroup and endpoint definitions- OpenAPI metadata
Rules:
- no side effects
- no repository access
- no runtime layer composition
4) API behavior and binding (apps/api)
Expected shape per domain:
*.module.ts- endpoint use-cases as Effects
- consumes repository/capability services
- maps internal errors to boundary API errors
*.http-api.ts- thin endpoint-to-use-case binding only
Dependency direction (must hold)
packages/dband capability packages do not depend onapps/apipackages/api-contractsdoes not depend onapps/apiapps/apidepends onpackages/api-contracts,packages/db, and capability packages
In short: contracts + capabilities + repositories are reusable; apps/api composes them.
GitHub route reference (current production pattern)
Contract
packages/api-contracts/src/core-api/github.tspackages/api-contracts/src/core-api/control-plane.ts
Defines endpoint IDs, params/payload schemas, success models, and typed API errors.
Use-cases
apps/api/src/routes/github/github.module.ts
Implements business operations such as:
- installation listing and access checks
- installation import/sync orchestration
- webhook validation, idempotency, and processing
- mapping repository/capability failures into contract errors
Binding
apps/api/src/routes/github/github.http-api.ts
Maps each endpoint to one Effect function from github.module.ts and nothing more.
App composition
apps/api/src/index.ts
Builds and provides layers in this order:
PgClient.layer(...)SealantDBLiveGitHubDataAccessLivegitHubSourceIntegrationLayer(...)makeGitHubHttpApiLayer()- OpenAPI and Scalar middleware
- CORS middleware
HttpApiBuilder.serve()+NodeHttpServer.layer(...)
Error strategy
Use a two-stage error model:
-
Internal errors
- repository errors (
*RepoInvariantError,*RepoUnexpectedError) - capability errors (
GitHubSourceIntegration*Error)
- repository errors (
-
Boundary errors
- API contract errors in
@sealant/api-contracts(400/401/403/404/500/503)
- API contract errors in
Mapping is done in apps/api/src/routes/*/*.module.ts.
Service granularity guidance
Use this default:
- repository service per repository in
@sealant/db - capability service per provider/integration package
- endpoint behavior as plain exported Effects in
*.module.ts
Introduce an additional domain service in apps/api only when behavior must be shared across
multiple runtimes (for example HTTP + worker + scheduled job).
Do not create one service per endpoint handler.
Route implementation checklist (new domains)
- Add or extend repository services in
@sealant/dbas needed. - 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 required layers in
apps/api/src/index.ts. - Add tests for success paths and error mapping.
What to avoid
- putting business logic directly in
*.http-api.ts - importing app route code from capability or db packages
- returning raw internal repository/capability errors over HTTP
- mixing runtime composition into
api-contracts
Related references
apps/api/src/index.tspackages/db/src/client.tspackages/db/src/layers.tspackages/api-contracts/src/core-api/control-plane.tsapps/docs/contents/packages/db-effect-service-layer.md