Custom Provider
Providers are Effect Layers — adding support for a new cloud or
third-party API is “declare a type, implement a Layer”. See
Providers for the operation contract.
This guide walks through building a Stripe Product provider
end-to-end: declaring props and attributes, defining the resource
type, implementing the lifecycle (reconcile, delete, diff,
read), bundling it into a providers() layer, and writing a
test.
See Resource lifecycle for the semantics of when each lifecycle method fires.
Declare props and attributes
Section titled “Declare props and attributes”Every resource has two sides:
- Input properties — the desired configuration you pass in
- Output attributes — the values the cloud returns after creation
Start with two plain TypeScript types. Both are pure data, so they’re trivial to share between the provider and call sites.
Create src/stripe/Product.ts:
export interface StripeProductProps { name: string; description?: string; active?: boolean;}
export interface StripeProductAttributes { productId: string; created: number;}Declare the Resource type
Section titled “Declare the Resource type”A Resource<Type, Props, Attributes> is a phantom type that ties a
string Type to its props and attributes. The string Type (here
"Stripe.Product") is what Alchemy uses to look up the provider at
plan time — it must be globally unique.
import { Resource } from "alchemy";
export interface StripeProductProps { name: string; description?: string; active?: boolean;}
export interface StripeProductAttributes { productId: string; created: number;}
export type StripeProduct = Resource< "Stripe.Product", StripeProductProps, StripeProductAttributes>;Declare the Resource constructor (the “tag”)
Section titled “Declare the Resource constructor (the “tag”)”Resource<T>(type) returns the value users actually call —
StripeProduct("Pro", { ... }). It also doubles as the tag
the provider Layer registers itself against, so by convention the
type and the value share the same name.
import { Resource } from "alchemy";
// ... props / attributes / type unchanged ...
export type StripeProduct = Resource< "Stripe.Product", StripeProductProps, StripeProductAttributes>;
export const StripeProduct = Resource<StripeProduct>("Stripe.Product");You can already use this constructor in a stack — but with no
provider registered, planning will fail with Provider not found for Stripe.Product. Let’s fix that.
Scaffold the provider layer
Section titled “Scaffold the provider layer”A provider layer is a Layer<Provider<R>> produced by
Provider.succeed(ResourceClass, service). The service is an
object with reconcile, delete, and list (required) plus
optional hooks like diff, read, precreate.
Start with stubs so the types compile, then fill them in:
import { Resource } from "alchemy";import * as Provider from "alchemy/Provider";import { Resource } from "alchemy";import * as Effect from "effect/Effect";
// ... props / attributes / type / constructor unchanged ...
export const StripeProduct = Resource<StripeProduct>("Stripe.Product");
export const StripeProductProvider = () => Provider.succeed( StripeProduct, StripeProduct.Provider.of({ reconcile: () => Effect.die("not implemented"), delete: () => Effect.die("not implemented"), list: () => Effect.succeed([]), }), );A few patterns worth knowing:
Provider.succeedwraps aProviderServiceinto aLayer<Provider<StripeProduct>>.StripeProduct.Provider.of({...})is a typed constructor — it forces every method’s input/output to match the resource’s props and attributes.Provider.effectexists for services that need a dependency resolved once at construction (a logger,FileSystem) — never a live one; see the caution below.
Credentials
Section titled “Credentials”Handlers that call an authenticated API resolve a lazy credentials
service — nothing touches the network until an operation actually runs.
The Custom Auth Provider guide
builds the StripeCredentials service the snippets below import;
providers wrapping unauthenticated APIs skip this entirely.
Implement reconcile
Section titled “Implement reconcile”reconcile is the single lifecycle method that converges the cloud’s
actual state to the desired state (news). It runs on every apply, so
one body must handle three situations, distinguished by output and
olds:
output |
olds |
Situation |
|---|---|---|
undefined |
undefined |
Greenfield — nothing exists yet |
| defined | defined | Routine update |
| defined | undefined |
Adoption — the engine imported it via read |
Every reconciler follows the same shape:
1. Observe — derive the physical id; read live cloud state2. Ensure — if missing, create it; tolerate AlreadyExists/etc.3. Sync — for each mutable aspect: read observed, diff vs desired, apply only the delta4. Return — fresh AttributesDon’t branch the body on output === undefined
Writing if (output === undefined) { /* create */ } else { /* update */ }
just renames the old create/update split. Write one flow that
converges from any starting point — trust observed cloud state, not olds.
Let’s build the Stripe reconciler step by step.
Resolve the client
Section titled “Resolve the client”import * as Redacted from "effect/Redacted";import Stripe from "stripe";import { StripeCredentials } from "./Credentials.ts";
return StripeProduct.Provider.of({ reconcile: () => Effect.die("not implemented"), reconcile: Effect.fn(function* ({ news, output }) { const { apiKey } = yield* yield* StripeCredentials; const stripe = new Stripe(Redacted.value(apiKey)); }), delete: () => Effect.die("not implemented"),});The double-yield resolves credentials lazily, inside the handler — the provider layer builds without ever touching the auth chain.
Observe
Section titled “Observe”reconcile: Effect.fn(function* ({ news, output }) { const { apiKey } = yield* yield* StripeCredentials; const stripe = new Stripe(Redacted.value(apiKey));
let product = output?.productId ? yield* Effect.tryPromise(() => stripe.products.retrieve(output.productId), ).pipe(Effect.catchAll(() => Effect.succeed(undefined))) : undefined;}),If a previous run cached a productId, look up its live state — a failed
retrieve just means “missing”. output is a cache of identifiers, never
proof the resource still exists.
Ensure
Section titled “Ensure” let product = output?.productId ? // ... observe ... : undefined;
if (!product) { product = yield* Effect.tryPromise(() => stripe.products.create({ name: news.name, description: news.description, active: news.active, }), ); }}),Create only when observation came back empty — greenfield creates and “the cached id points at nothing” recover through the same line.
if (!product) { // ... ensure ... }
if ( product.name !== news.name || product.description !== (news.description ?? null) || product.active !== (news.active ?? true) ) { product = yield* Effect.tryPromise(() => stripe.products.update(product!.id, { name: news.name, description: news.description, active: news.active, }), ); }}),Compare each mutable field of the observed product against news and
write only when something drifted — a no-drift deploy makes zero write
calls.
Return fresh Attributes
Section titled “Return fresh Attributes” if (/* drifted */) { // ... sync ... }
return { productId: product.id, created: product.created, };}),The returned Attributes are persisted to state and become the next run’s
output.
Why this shape works
Section titled “Why this shape works”The body is idempotent by construction: Alchemy can retry it after a
failed state write and it re-converges. Adoption needs no special case —
retrieve finds the imported resource and the sync step rewrites whatever
drifted from news.
Implement delete
Section titled “Implement delete”delete runs when the resource is removed from code, replaced, or
when alchemy destroy runs.
return StripeProduct.Provider.of({ reconcile: Effect.fn(function* ({ news, output }) { /* ... */ }), delete: () => Effect.die("not implemented"), delete: Effect.fn(function* ({ output }) { const { apiKey } = yield* yield* StripeCredentials; const stripe = new Stripe(Redacted.value(apiKey)); yield* Effect.tryPromise(() => stripe.products.del(output.productId), ).pipe( Effect.catchAll((cause) => cause instanceof Error && cause.message.includes("No such product") ? Effect.void : Effect.fail(cause), ), ); }),});Implement diff (optional)
Section titled “Implement diff (optional)”Some property changes can’t be applied in place. For Stripe
products the name is mutable but (hypothetically) the
description is not — changing it requires recreating the
product. Implement diff to tell Alchemy which kind of change to
plan.
diff runs at plan time, before reconcile, and returns one of:
{ action: "noop" }— change is trivial, skip reconciling{ action: "update", stables?: [...] }— apply in place{ action: "replace", deleteFirst?: boolean }— destroy and recreateundefined/void— fall back to default (plan an in-place update)
import { isResolved } from "alchemy/Diff";
// ...
return StripeProduct.Provider.of({ diff: Effect.fn(function* ({ news, olds }) { if (!isResolved(news)) return undefined; if (news.description !== olds.description) { return { action: "replace" } as const; } return undefined; }), reconcile: Effect.fn(function* ({ news, output }) { /* ... */ }), delete: Effect.fn(function* ({ output }) { /* ... */ }),});For attributes that are immutable across all updates (e.g.
the Stripe productId, an ARN), declare them in
stables at the top level:
return StripeProduct.Provider.of({ stables: ["productId"], diff: Effect.fn(function* ({ news, olds }) { /* ... */ }), // ...});Implement read (optional, for recovery and adoption)
Section titled “Implement read (optional, for recovery and adoption)”The engine calls read whenever a resource has no prior state, both
to recover from interrupted reconciles and to import pre-existing
cloud resources into a fresh state store. Returning undefined tells
Alchemy the resource doesn’t exist and should be created.
return StripeProduct.Provider.of({ stables: ["productId"], diff: Effect.fn(function* ({ news, olds }) { /* ... */ }), read: Effect.fn(function* ({ output }) { if (!output?.productId) return undefined; const { apiKey } = yield* yield* StripeCredentials; const stripe = new Stripe(Redacted.value(apiKey)); const product = yield* Effect.tryPromise(() => stripe.products.retrieve(output.productId), ).pipe( Effect.catchAll(() => Effect.succeed(undefined)), ); if (!product) return undefined; return { productId: product.id, created: product.created }; }), reconcile: Effect.fn(function* ({ news, output }) { /* ... */ }), delete: Effect.fn(function* ({ output }) { /* ... */ }),});This example only finds resources by an ID we previously saved — without
a prior output it returns undefined, and that’s a fine default.
Ownership-aware reads
Section titled “Ownership-aware reads”import { Unowned } from "alchemy/AdoptPolicy";
read: Effect.fn(function* ({ id, olds }) { const live = yield* lookupByName(olds.name); if (!live) return undefined; const attrs = { productId: live.id, created: live.created }; // Compare tags/owner against this stack/stage/id. return ownsResource(id, live.tags) ? attrs : Unowned(attrs);}),If your provider can find an existing resource from props alone (tags,
deterministic naming), brand foreign ones with Unowned. Plain attrs are
silently imported as ours; Unowned(attrs) fails with OwnedBySomeoneElse
unless the user opted into a takeover with --adopt — see
Resource Lifecycle › Adoption.
Implement list
Section titled “Implement list”list enumerates every product in the account, returning the same
Attributes shape as read — it powers account-wide operations
like alchemy unsafe nuke and must paginate
exhaustively (see Providers › list):
return StripeProduct.Provider.of({ stables: ["productId"], list: () => Effect.succeed([]), list: Effect.fn(function* () { const { apiKey } = yield* yield* StripeCredentials; const stripe = new Stripe(Redacted.value(apiKey)); const products = yield* Effect.tryPromise(() => stripe.products.list({ limit: 100 }).autoPagingToArray({ limit: 10_000 }), ); return products.map((p) => ({ productId: p.id, created: p.created })); }), // ...});Resources with no enumeration API (singletons, existence-only
resources) keep the Effect.succeed([]) stub instead.
Bundle into a providers() layer
Section titled “Bundle into a providers() layer”Users expect the same one-line ergonomics as the built-ins
(Cloudflare.providers(), AWS.providers()). Bundle the resource
collection and every provider implementation into a single layer:
import * as Provider from "alchemy/Provider";import * as Layer from "effect/Layer";import { StripeProduct, StripeProductProvider } from "./Product.ts";
export class Providers extends Provider.ProviderCollection<Providers>()( "Stripe",) {}
export const providers = () => Layer.effect( Providers, Provider.collection([StripeProduct]), ).pipe(Layer.provide(StripeProductProvider()));Layer.provide wires each resource provider to the collection
privately. A provider with no credentials service is done here.
Ours isn’t — the handlers’ StripeCredentials requirement is still
unmet. The Custom Auth Provider
guide finishes this bundle by Layer.provideMerge-ing in the
credential bridge and the alchemy login registration.
Re-export the public surface, dropping the redundant service prefix
(the namespaced-export convention: callers write Stripe.Product):
export { StripeProduct as Product } from "./Product.ts";export { Providers, providers } from "./Providers.ts";Either way, users plug your providers in like any built-in — no API key in sight:
import * as Alchemy from "alchemy";import * as Effect from "effect/Effect";import * as Stripe from "./src/stripe";
export default Alchemy.Stack( "MyApp", { providers: Stripe.providers(), state: Alchemy.localState() }, Effect.gen(function* () { const pro = yield* Stripe.Product("Pro", { name: "Pro plan", description: "Everything in Free, plus...", }); return { productId: pro.productId }; }),);On first deploy, Alchemy walks them through alchemy login (or
reads env vars on CI) — see
Auth Providers.
To mix with another cloud, merge the layers:
import * as Layer from "effect/Layer";
providers: Layer.mergeAll(Cloudflare.providers(), Stripe.providers()),Test the lifecycle
Section titled “Test the lifecycle”Full test.provider reference and patterns: Testing Providers.
Alchemy’s test harness (alchemy/Test/Vitest or alchemy/Test/Bun)
configures providers + state once at the top of the file, then
exposes test.provider(name, (stack) => ...) for provider-level
tests. Each test.provider body receives a fresh in-memory scratch
stack with .deploy(effect) and .destroy() helpers.
Create test/Product.test.ts:
import * as Test from "alchemy/Test/Vitest";import { expect } from "@effect/vitest";import * as Effect from "effect/Effect";import Stripe from "stripe";import * as StripeProvider from "../src/stripe";
// Configure providers once per file. Credentials resolve through the// Auth Provider — see /environments/custom-auth-provider.const { test } = Test.make({ providers: StripeProvider.providers() });
const stripe = new Stripe(process.env.STRIPE_API_KEY!);
test.provider( "create, update, delete a product", (stack) => Effect.gen(function* () { // Create const created = yield* stack.deploy( Effect.gen(function* () { return yield* StripeProvider.Product("TestProduct", { name: "v1", description: "first version", }); }), ); expect(created.productId).toBeDefined();
const live1 = yield* Effect.tryPromise(() => stripe.products.retrieve(created.productId), ); expect(live1.name).toBe("v1");
// Update (in place) const updated = yield* stack.deploy( Effect.gen(function* () { return yield* StripeProvider.Product("TestProduct", { name: "v2", description: "first version", }); }), ); expect(updated.productId).toBe(created.productId);
const live2 = yield* Effect.tryPromise(() => stripe.products.retrieve(updated.productId), ); expect(live2.name).toBe("v2");
// Destroy yield* stack.destroy(); }),);To verify replacement semantics, change a stables field (or the
field your diff flags as replace) and assert that
updated.productId !== created.productId.
Reference implementations
Section titled “Reference implementations”If you’d rather start from a real provider:
Axiom/VirtualField.ts— minimal CRUD withdiffandreadCloudflare/R2/Bucket.ts— production provider with bindings and replace semantics
Where next
Section titled “Where next”- Local Providers — give your
resource a second, dev-mode implementation that emulates it locally
(
ProviderLayer.dual,LocalProvider.make). - Custom Auth Provider — build
the
StripeCredentialsservice andalchemy loginflow this guide consumes. - Auth Providers — how credentials resolve: lazy Effects, Profiles, auto-refresh.
- Actions — deploy-time work that isn’t a resource.
- Testing Providers — the harness
patterns behind
test.provider. - Providers — the operation contract this guide implements.
- Bindings — next up: connecting Resources to Functions.