Skip to content

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.

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:

src/stripe/Product.ts
export interface StripeProductProps {
name: string;
description?: string;
active?: boolean;
}
export interface StripeProductAttributes {
productId: string;
created: number;
}

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.

src/stripe/Product.ts
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.

src/stripe/Product.ts
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.

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:

src/stripe/Product.ts
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.succeed wraps a ProviderService into a Layer<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.effect exists for services that need a dependency resolved once at construction (a logger, FileSystem) — never a live one; see the caution below.

Lifecycle handlers that call an authenticated API depend on a lazy credentials service — a Context.Service whose value is an Effect<{ apiKey: Redacted<string> }>, so nothing resolves until a handler double-yields it while an operation is actually running. The Custom Auth Provider guide builds src/stripe/Credentials.ts (the StripeCredentials service the snippets below import) and wires it to alchemy login and Profiles; the concept is covered in Auth Providers. Providers that wrap unauthenticated or locally-configured APIs don’t need one at all.

reconcile is the single lifecycle method that converges the cloud’s actual state to the desired state. It runs on every apply — first-time provisioning, routine updates, and adoption takeovers — so its body must work correctly for all three.

It receives news (resolved input props), id (logical ID), instanceId (deterministic suffix), bindings, plus two context-dependent inputs:

  • output: Attributes | undefinedundefined on greenfield creates; defined on updates and on adoption (where the engine imported an existing cloud resource via read).
  • olds: Props | undefinedundefined on greenfield AND on adoption (the engine has no prior props for a resource it just discovered); defined only on routine updates.

A reconciler is shaped like:

1. Observe — derive the physical id; read live cloud state
2. Ensure — if missing, create it; tolerate AlreadyExists/etc.
3. Sync — for each mutable aspect: read observed, diff vs
desired, apply only the delta
4. Return — fresh Attributes

Don’t branch the body on output === undefined

Writing if (output === undefined) { /* create body */ } else { /* update body */ } just renames the old create/update split. The reconciler must be a single flow that produces correct cloud state regardless of starting point. Trust observed cloud state, not olds.

For the Stripe product, the SDK gives us retrieve, create, and update. The reconciler observes via retrieve, ensures via create (catching the cached-id case), and syncs the mutable name

  • description + active flag in a single update call:
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));
// Observe — fetch live state if we have a cached id.
let product = output?.productId
? yield* Effect.tryPromise(() =>
stripe.products.retrieve(output.productId),
).pipe(Effect.catchAll(() => Effect.succeed(undefined)))
: undefined;
// Ensure — create if missing.
if (!product) {
product = yield* Effect.tryPromise(() =>
stripe.products.create({
name: news.name,
description: news.description,
active: news.active,
}),
);
}
// Sync — patch any mutable field that drifted from desired.
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,
}),
);
}
return {
productId: product.id,
created: product.created,
};
}),
delete: () => Effect.die("not implemented"),
});

The reconciler is idempotent by construction — running it twice with the same news and a fresh output cache produces the same end state. Alchemy may retry it if state persistence fails, and the same body recovers gracefully. Adoption (where output is set but olds is undefined) goes through the same path: retrieve finds the resource, the sync step rewrites any fields that drifted from news, and we’re converged.

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),
),
);
}),
});

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 recreate
  • undefined / 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 Stripe example only finds resources by an ID we previously saved (output.productId) — without a prior output it can’t locate anything, so it returns undefined. That’s a fine default.

Ownership-aware reads. If your provider can detect an existing resource from props alone (e.g. by tag-aware lookup or deterministic naming), brand the returned attributes with Unowned when they belong to someone else:

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);
}),

The engine uses this to decide:

  • plain attrs → silently import the resource as our own
  • Unowned(attrs) → fail with OwnedBySomeoneElse unless the user passed --adopt (or wrapped the effect in adopt(true)), in which case it’s a takeover.

See Resource Lifecycle › Adoption for the full flow.

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.

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:

src/stripe/Providers.ts
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):

src/stripe/index.ts
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:

alchemy.run.ts
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()),

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:

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.

If you’d rather start from a real provider:

  • Custom Auth Provider — build the StripeCredentials service and alchemy login flow 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.