Skip to content

refactor: build services with make and layer, and never pass a service as an argument - #24

Merged
humanlayerdev[bot] merged 4 commits into
mainfrom
analyze-layer-vs-make-pattern-in-service-graph-zzlfwq
Oct 15, 2026
Merged

humanlayerdev[bot] merged 4 commits into
mainfrom
analyze-layer-vs-make-pattern-in-service-graph-zzlfwq

Conversation

@K-Mistele

@K-Mistele K-Mistele commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

What

Every service now follows one pattern: a make gets its dependencies with yield*, and its layer is built from that make (Layer.effect(this, this.make)). No function takes a service instance as an argument. A make's arguments are only plain config, such as options, the app's handler functions, and the providers' processors.

Each bot used to build its own API client (Layer.build(GitHubApiLive) inside GitHubBot.make), so one app could hold several GitHub clients, each with its own installation-token cache. Now the app provides each API once.

Breaking changes

Delivery

  • MailboxProcessing.make(options, processors) and MailboxProcessing.layer(options, processors) replace MailboxProcessingLive, makeMailboxProcessing, and makeMailboxProcessingWith. The make leaves what the processors need in processReady's requirements, so a host can supply it on each call, as a Durable Object alarm must. The layer takes it once, when built.
  • The ProviderEventDispatcher and ProviderOutputDispatcher services, with their Live and make versions, are removed. processProviderOutput(processors) sits beside processProviderEvent.
  • processClaim and processOutputClaim take processors in their input and read the backend and Crypto from the context. Every *With(service, …) function is removed, including makeBatchIdWith, makeDeliveryAccessTokenWith, and makeDeliveryIdempotencyKeyWith.
  • ChannelsProvider drops runtimeEventProcessor and runtimeOutputProcessor. Each provider has one event processor and one output processor, and both leave process requirements open.
  • DeliveryControlLive is now DeliveryControl.layer.

Providers (GitHub, Slack, Linear)

  • The gitHubApi, slackApi, and linearApi options are removed. The app provides GitHubApiLive, SlackApiLive, or LinearApiLive (or makeLinearApiLive(...)). The README samples show where.
  • The GitHubCallbacks, SlackCallbacks, and LinearCallbacks services are removed. Event processors take the handlers, as in makeGitHubEventProcessor({ namespace, bot, handlers }), and wrap them with makeGitHubCallbacks and its Slack and Linear versions.
  • Output processors read the provider's API on each call, not when they are built.
  • GitHubApiClient, GitHubAppCredentials, GitHubGitCredentials, SlackHttpClient, and LinearHttpClient carry their own make and static layer. Their *Live constants are removed; GitHubApiLive, SlackApiLive, and LinearApiLive stay.
  • makeFixedCredentialLinearHttpClient({ organizationId, accessToken }) reads HttpClient from the context. LinearCredentialResolver moved to LinearCredential.ts to break an import loop, and is still re-exported.
  • Slack's webhook half calls Slack while it checks a webhook, so SlackApi is a build requirement.

Cloudflare

  • makeMailboxAlarmHandler(options) reads MailboxProcessing on each pass; makeMailboxAlarmHandlerWith is removed. mailbox() supplies processing to each alarm from that call's services.
  • layers.mailbox.processing now needs the providers' APIs when built. A split Durable Object doesn't use it; the example and README don't either.

Constructors live on the tag

Every single-implementation service builds itself in its tag's make option, with static readonly layer = Layer.effect(this, this.make). No module-level constant or layer builds a service that the tag then wraps.

  • GitHubApi, SlackApi, LinearApi: their implementations moved into the tags. Each tag module's errors, schemas, and request types moved to *ApiContract.ts modules, which the operation modules import, so the tag module can import the operations without a cycle. The tag modules re-export the contracts, so public exports don't change.
  • Linear and Slack resource classes read their API through LinearApiHandle and SlackApiHandle, which share the tag's key, because the tags' constructors build those classes. GitHub's resource module still forms a cycle with GitHubApi, but every module loads safely whichever is imported first.
  • Renames:
    • LinearHttpClient.makeFixedCredential / layerFixedCredential replace makeFixedCredentialLinearHttpClient.
    • GitHubAppSigner.layer replaces layerWebCrypto.
    • MailboxStorage.layer replaces MailboxStorageFromDurableObjectState.
  • The example:
    • These services now build in make: Workspace, AutoLabel, RunRecovery, ActiveDelivery, DeliveryTurns, SessionExpiry, DeliveryApi, AgentSessions, and AgentConversation.
    • AgentConversation's implementation moved from AgentSessionDO.ts into AgentConversation.ts, with its prompt and tools.
    • The message schema moved to AgentSessionMessage.ts and ActiveDelivery to ActiveDelivery.ts, so the example has no import cycles.
  • Unchanged: tags with several implementations (the storage ports, LinearCredentialResolver) keep a named constructor and layer per implementation.
  • Known issue, already on main: a load-order cycle between SlackApiContract and SlackModels (formerly SlackApi and SlackModels). Importing SlackModels first fails there too. This PR doesn't change it.

Example app

  • The Worker already provided GitHubApiLive, and that is now the app's only GitHub client.
  • The mailbox object logs why it could not start before it dies. Alchemy accepts no construction error, so the failure can't stay typed.

CI fix: Alchemy version

The Alchemy preview build pinned for auto-labeling (pkg.alchemy.run/alchemy/pr:2064:a8c9ecc) is gone, so bun install --frozen-lockfile failed on every job. The catalog now pins the published alchemy@2.0.0-beta.83. MailboxStorage.layer is typed MailboxStorageLayer, so the built type files keep referring to alchemy/Cloudflare, not to its internal alchemy/Cloudflare/index path.

Also in this PR

A second commit formats the 25 files bun run format already flagged before this change, so the format check passes. It changes layout only.

Checks

  • bun run check (lint, typecheck, scripts typecheck, typed-lint): passes.
  • bun run format: passes.
  • bun run test: 742 tests pass.
  • Redis backend suite: 87 tests pass.
  • Postgres backend suite: 86 tests pass. Port 55432 was taken by another container on my machine, so I ran this suite against a throwaway Postgres on another port.

🤖 Generated with Claude Code

K-Mistele and others added 4 commits October 15, 2026 15:36
…e as an argument

Each service now gets its dependencies with `yield*` in a `make`, and its layer is built from that
make. No function takes a service instance as a parameter; a make's arguments are only plain config,
such as options, handler functions, and provider processors.

Delivery:
- `MailboxProcessing.make(options, processors)` and `MailboxProcessing.layer(options, processors)`
  replace `MailboxProcessingLive`, `makeMailboxProcessing`, and `makeMailboxProcessingWith`. The make
  leaves what the processors need open, for hosts that supply it per call; the layer takes it once.
- The `ProviderEventDispatcher` and `ProviderOutputDispatcher` tags, with their `Live` and `make`
  versions, are gone. `processProviderOutput(processors)` sits beside `processProviderEvent`.
- `processClaim`, `processOutputClaim`, and `claimOrDeferReadyMailbox` read the backend and `Crypto`
  from the context; the `*With` variants that took them as arguments are gone, as are
  `makeBatchIdWith`, `makeDeliveryAccessTokenWith`, and `makeDeliveryIdempotencyKeyWith`.
- `ChannelsProvider` loses `runtimeEventProcessor` and `runtimeOutputProcessor`: providers have one
  event processor and one output processor, which leave `process` requirements open.
- `DeliveryControlLive` is now `DeliveryControl.layer`, built from the class's `make`.

Providers:
- `GitHubBot.make`, `SlackBot.make`, and `LinearBot.make` no longer build their API layer: the
  `gitHubApi`, `slackApi`, and `linearApi` options are gone, and the application provides
  `GitHubApiLive`, `SlackApiLive`, or `LinearApiLive` once.
- The `GitHubCallbacks`, `SlackCallbacks`, and `LinearCallbacks` tags are gone. Event processors take
  the handlers (`make*EventProcessor({ ..., handlers })`) and wrap them with `make*Callbacks`.
- Output processors read the provider's API on each call instead of when they are built.
- `GitHubApiClient`, `GitHubAppCredentials`, `GitHubGitCredentials`, `SlackHttpClient`, and
  `LinearHttpClient` carry their `make` and `static layer`; the `*Live` constants are gone.
- `makeFixedCredentialLinearHttpClient({ organizationId, accessToken })` reads `HttpClient` from the
  context. `LinearCredentialResolver` and `LinearVerifiedCredential` move to `LinearCredential.ts`
  (still re-exported) to break an import loop.

Cloudflare:
- `makeMailboxAlarmHandler(options)` reads `MailboxProcessing` on each pass; the
  `makeMailboxAlarmHandlerWith(options, processing)` variant is gone. `ChannelsCloudflare.make`'s
  `mailbox()` supplies processing to each alarm from that call's services.
- The example's Worker already provided `GitHubApiLive`; it is now the only GitHub client. The
  mailbox object logs why it could not start before it dies.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
HumanLayer-Session: https://app.dev.codelayer.gg/sessions/01a1418a-45ea-7a36-87bf-d2e8c4e465fe
`bun run format` now passes across the repo. Layout only; no code changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
HumanLayer-Session: https://app.dev.codelayer.gg/sessions/01a1418a-45ea-7a36-87bf-d2e8c4e465fe
A single-implementation service now builds itself in its tag's `make` option, with
`static readonly layer = Layer.effect(this, this.make)`. No module-level constant or layer builds a
service that the tag then wraps.

- GitHubApi, SlackApi, LinearApi: the implementations that lived in GitHubApiOperationsLive,
  SlackApiService, and makeLinearApi move into the tags. Each tag module's errors, schemas, and request
  types move to GitHubApiContract, SlackApiContract, and LinearApiContract, which the operation modules
  import, so the tag module can import the operations without a cycle; the tag modules re-export them.
  SlackApiWire holds the Slack response schemas and plain helpers.
- The Linear and Slack resource classes read their API through LinearApiHandle and SlackApiHandle,
  which share the tag's key, since the tags' constructors build those classes.
- SlackHttpClient and LinearHttpClient build in `make`. A fixed-credential Linear client is
  `LinearHttpClient.makeFixedCredential` and `layerFixedCredential`, replacing
  `makeFixedCredentialLinearHttpClient`.
- GitHubAppSigner builds in `make`; `layerWebCrypto` is now `layer`.
- MailboxStorage builds in `make`; `MailboxStorageFromDurableObjectState` is now `MailboxStorage.layer`.
- Example: Workspace, AutoLabel, RunRecovery, ActiveDelivery, DeliveryTurns, SessionExpiry, DeliveryApi,
  AgentSessions, and AgentConversation build in `make`. AgentConversation's implementation moves from
  AgentSessionDO into AgentConversation.ts with its prompt and tools; the message schema moves to
  AgentSessionMessage.ts and ActiveDelivery to ActiveDelivery.ts, so the example has no import cycles.

Tags with several implementations (the storage ports, LinearCredentialResolver) keep a named
constructor and layer per implementation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
HumanLayer-Session: https://app.dev.codelayer.gg/sessions/01a1418a-45ea-7a36-87bf-d2e8c4e465fe
…xStorage.layer's type named

The alchemy preview build pinned for auto-labeling (pkg.alchemy.run pr:2064:a8c9ecc) is gone, so
`bun install --frozen-lockfile` failed on every CI job. Use the published 2.0.0-beta.83.

`MailboxStorage.layer` lost the named type the old `MailboxStorageFromDurableObjectState` carried, so
the emitted declarations referred to `alchemy/Cloudflare/index`, which alchemy does not export, and
release validation failed. `MailboxStorageLayer` names it again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
HumanLayer-Session: https://app.dev.codelayer.gg/sessions/01a1418a-45ea-7a36-87bf-d2e8c4e465fe
@humanlayerdev
humanlayerdev Bot merged commit de3c4b4 into main Oct 15, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant