Skip to content

Writing a Custom Resource Provider

A provider is what gives meaning to a Resource declaration. When you yield* a resource inside a Stack, Alchemy looks up the provider for that resource’s type and calls the appropriate lifecycle method — create, update, delete, and optionally diff, read, precreate, tail, logs.

Providers are just Effect Layers, which means adding support for a new cloud or third-party API is “declare a type, implement a Layer” — no codegen, no registry, no schema.

This guide walks through building a Stripe Product provider end-to-end: declaring props and attributes, defining the resource type, implementing the lifecycle, bundling it into a providers() layer, and writing a test.

See Provider for the conceptual overview and 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.effect(ResourceClass, effect). The inner Effect constructs a ProviderService — an object with create, update, delete (required) and 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.effect(
StripeProduct,
Effect.gen(function* () {
return StripeProduct.Provider.of({
create: () => Effect.die("not implemented"),
update: () => Effect.die("not implemented"),
delete: () => Effect.die("not implemented"),
});
}),
);

A few patterns worth knowing:

  • Provider.effect wraps an Effect that returns 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.
  • The outer Effect.gen runs once when the layer is built. Use it to acquire shared dependencies (clients, credentials, HTTP).

The Stripe API needs a client, and the client needs an API key. Don’t take the key as a constructor argument — that puts it in your code, your env, or your CI secret store, and ignores the profile/login system that makes Alchemy ergonomic across stages.

Instead, declare a StripeCredentials service. Later we’ll implement an AuthProvider that supplies it from the configured profile (or env on CI).

Create src/stripe/Credentials.ts:

src/stripe/Credentials.ts
import * as Context from "effect/Context";
import * as Redacted from "effect/Redacted";
export class StripeCredentials extends Context.Tag("StripeCredentials")<
StripeCredentials,
{ apiKey: Redacted.Redacted<string> }
>() {}

Yield it inside the provider’s outer Effect, then build the SDK client once. Anything you yield there becomes a requirement on the resulting Layer:

src/stripe/Product.ts
import * as Provider from "alchemy/Provider";
import { Resource } from "alchemy";
import * as Effect from "effect/Effect";
import * as Redacted from "effect/Redacted";
import Stripe from "stripe";
import { StripeCredentials } from "./Credentials.ts";
// ...
export const StripeProductProvider = () =>
Provider.effect(
StripeProduct,
Effect.gen(function* () {
const { apiKey } = yield* StripeCredentials;
const stripe = new Stripe(Redacted.value(apiKey));
return StripeProduct.Provider.of({
create: () => Effect.die("not implemented"),
update: () => Effect.die("not implemented"),
delete: () => Effect.die("not implemented"),
});
}),
);

The provider Layer now has type Layer<Provider<StripeProduct>, never, StripeCredentials> — Alchemy won’t let you use it in a stack without supplying credentials. We’ll provide them via an AuthProvider in a later step.

create is called when a resource is declared but not yet in state. It receives news (resolved input props), id (logical ID), instanceId (deterministic suffix), and bindings. It returns the output attributes.

It must be idempotent — Alchemy may retry it if state persistence fails. Use instanceId as part of any externally-visible identifier so retries find the same row.

return StripeProduct.Provider.of({
create: () => Effect.die("not implemented"),
create: Effect.fnUntraced(function* ({ news }) {
const product = yield* Effect.tryPromise(() =>
stripe.products.create({
name: news.name,
description: news.description,
active: news.active,
}),
);
return {
productId: product.id,
created: product.created,
};
}),
update: () => Effect.die("not implemented"),
delete: () => Effect.die("not implemented"),
});

update runs when the resource exists and at least one input property changed (and the diff didn’t classify it as a replacement). It receives news, olds, and the previous output, and returns the new output attributes.

If your update is a partial PATCH, return the merged shape so downstream resources see the full attributes:

return StripeProduct.Provider.of({
create: Effect.fnUntraced(function* ({ news }) { /* ... */ }),
update: () => Effect.die("not implemented"),
update: Effect.fnUntraced(function* ({ news, output }) {
yield* Effect.tryPromise(() =>
stripe.products.update(output.productId, {
name: news.name,
description: news.description,
active: news.active,
}),
);
return output;
}),
delete: () => Effect.die("not implemented"),
});

delete runs when the resource is removed from code, replaced, or when alchemy destroy runs.

return StripeProduct.Provider.of({
create: Effect.fnUntraced(function* ({ news }) { /* ... */ }),
update: Effect.fnUntraced(function* ({ news, output }) { /* ... */ }),
delete: () => Effect.die("not implemented"),
delete: Effect.fnUntraced(function* ({ output }) {
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 update, and returns one of:

  • { action: "noop" } — change is trivial, skip update
  • { action: "update", stables?: [...] } — apply in place
  • { action: "replace", deleteFirst?: boolean } — destroy and recreate
  • undefined / void — fall back to default (treat as update)
import { isResolved } from "alchemy/Diff";
// ...
return StripeProduct.Provider.of({
diff: Effect.fnUntraced(function* ({ news, olds }) {
if (!isResolved(news)) return undefined;
if (news.description !== olds.description) {
return { action: "replace" } as const;
}
return undefined;
}),
create: Effect.fnUntraced(function* ({ news }) { /* ... */ }),
update: Effect.fnUntraced(function* ({ news, output }) { /* ... */ }),
delete: Effect.fnUntraced(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.fnUntraced(function* ({ news, olds }) { /* ... */ }),
// ...
});

Implement read (optional, for state recovery)

Section titled “Implement read (optional, for state recovery)”

If state is lost between create succeeding and Alchemy persisting the result, the next deploy uses read to look up the live resource and re-sync. Returning undefined tells Alchemy the resource doesn’t exist and should be re-created.

return StripeProduct.Provider.of({
stables: ["productId"],
diff: Effect.fnUntraced(function* ({ news, olds }) { /* ... */ }),
read: Effect.fnUntraced(function* ({ output }) {
if (!output?.productId) return undefined;
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 };
}),
create: Effect.fnUntraced(function* ({ news }) { /* ... */ }),
update: Effect.fnUntraced(function* ({ news, output }) { /* ... */ }),
delete: Effect.fnUntraced(function* ({ output }) { /* ... */ }),
});

Alchemy ships a profile/login system: alchemy login walks the user through configuring credentials, stores them under ~/.alchemy/credentials/{profile}/{provider}.json, and resolves them per-stack at deploy time. CI runs read from environment variables instead.

Plug into it by implementing AuthProvider. It’s a five-method interface — configure, login, logout, prettyPrint, read — that Alchemy’s login command and credential-resolution layer both use.

Declare the config and resolved-credentials types

Section titled “Declare the config and resolved-credentials types”

Start with two types per supported method (env, stored, OAuth, etc.). The Config is what gets persisted under the profile; the Resolved shape is what your provider Layer consumes:

src/stripe/AuthProvider.ts
import * as Redacted from "effect/Redacted";
export type StripeAuthConfig =
| { method: "env" }
| { method: "stored" };
export type StripeStoredCredentials = {
apiKey: string;
};
export type StripeResolvedCredentials = {
apiKey: Redacted.Redacted<string>;
source: { type: StripeAuthConfig["method"] };
};
export const STRIPE_AUTH_PROVIDER_NAME = "Stripe";
const STORAGE_KEY = "stripe-stored";

AuthProviderLayer<Config, Credentials>()(name, impl) wraps your implementation in a Layer that registers itself into Alchemy’s AuthProviders registry. alchemy login discovers it by name.

src/stripe/AuthProvider.ts
import * as Console from "effect/Console";
import * as Effect from "effect/Effect";
import * as Match from "effect/Match";
import * as Redacted from "effect/Redacted";
import {
AuthError,
AuthProviderLayer,
type ConfigureContext,
} from "alchemy/Auth/AuthProvider";
import {
deleteCredentials,
displayRedacted,
readCredentials,
writeCredentials,
} from "alchemy/Auth/Credentials";
import { getEnvRedacted, retryOnce } from "alchemy/Auth/Env";
import * as Clank from "alchemy/Util/Clank";
// ... config / credential types unchanged ...
export const StripeAuth = AuthProviderLayer<
StripeAuthConfig,
StripeResolvedCredentials
>()(STRIPE_AUTH_PROVIDER_NAME, {
configure: (profileName, ctx) => configureCredentials(profileName, ctx),
login: (profileName, config) => login(profileName, config),
logout: (profileName, config) => logout(profileName, config),
prettyPrint: (profileName, config) => prettyPrint(profileName, config),
read: (profileName, config) => resolveCredentials(profileName, config),
});

configure — pick a method (with Clank prompts)

Section titled “configure — pick a method (with Clank prompts)”

configure runs once when the user runs alchemy login for a profile that doesn’t yet have a Stripe entry. Use alchemy/Util/Clank for terminal prompts — it wraps @clack/prompts in Effect with proper cancellation handling.

const configureCredentials = (profileName: string, ctx: ConfigureContext) =>
Effect.gen(function* () {
if (ctx.ci) {
return { method: "env" as const };
}
const method = yield* Clank.select({
message: "Stripe authentication method",
options: [
{
value: "env" as const,
label: "Environment Variables",
hint: "STRIPE_API_KEY",
},
{
value: "stored" as const,
label: "API Key",
hint: "enter interactively, stored in ~/.alchemy/credentials",
},
],
}).pipe(retryOnce);
return yield* Match.value(method).pipe(
Match.when("env", () => Effect.succeed({ method: "env" as const })),
Match.when("stored", () => loginStored(profileName)),
Match.exhaustive,
);
}).pipe(
Effect.mapError(
(e) => new AuthError({ message: "configure failed", cause: e }),
),
);
const loginStored = Effect.fnUntraced(function* (profileName: string) {
const apiKey = yield* Clank.password({
message: "Stripe API Key",
validate: (v) =>
v.length === 0
? "Required"
: v.startsWith("sk_") || v.startsWith("rk_")
? undefined
: "Expected a key starting with sk_ or rk_",
}).pipe(retryOnce);
yield* writeCredentials<StripeStoredCredentials>(
profileName,
STORAGE_KEY,
{ apiKey: Redacted.value(apiKey) },
);
yield* Clank.success("Stripe: credentials saved.");
return { method: "stored" as const };
});

Clank provides select, text, password, confirm, multiselect, success, info, warn, error, and openUrl — enough to build any login flow including OAuth device codes. Wrap each prompt in retryOnce so a stray Ctrl+C doesn’t abort the whole login.

login / logout — handle re-auth and credential removal

Section titled “login / logout — handle re-auth and credential removal”

login runs whenever alchemy login is invoked for an already-configured profile (e.g. to refresh an expired token). logout removes stored credentials.

const login = (profileName: string, config: StripeAuthConfig) =>
Match.value(config).pipe(
Match.when({ method: "env" }, () => Effect.void),
Match.when({ method: "stored" }, () =>
readCredentials<StripeStoredCredentials>(profileName, STORAGE_KEY).pipe(
Effect.flatMap((creds) =>
creds == null ? loginStored(profileName).pipe(Effect.asVoid) : Effect.void,
),
),
),
Match.exhaustive,
);
const logout = (profileName: string, config: StripeAuthConfig) =>
Match.value(config).pipe(
Match.when({ method: "env" }, () => Effect.void),
Match.when({ method: "stored" }, () =>
deleteCredentials(profileName, STORAGE_KEY).pipe(
Effect.andThen(Clank.success("Stripe: stored credentials removed")),
),
),
Match.exhaustive,
);

read — resolve credentials at deploy time

Section titled “read — resolve credentials at deploy time”

read is called every deploy to materialize the credentials. For env, pull from environment variables (using getEnvRedacted so the value stays redacted); for stored, read from the credentials file under the profile.

const resolveCredentials = (
profileName: string,
config: StripeAuthConfig,
) =>
Match.value(config).pipe(
Match.when({ method: "env" }, () =>
Effect.gen(function* () {
const apiKey = yield* getEnvRedacted("STRIPE_API_KEY");
if (!apiKey) {
return yield* new AuthError({
message: "Stripe env credentials not found. Set STRIPE_API_KEY.",
});
}
return {
apiKey,
source: { type: "env" as const },
} satisfies StripeResolvedCredentials;
}),
),
Match.when({ method: "stored" }, () =>
readCredentials<StripeStoredCredentials>(profileName, STORAGE_KEY).pipe(
Effect.flatMap((creds) =>
creds == null
? Effect.fail(
new AuthError({
message:
"Stripe stored credentials not found. Run: alchemy login --configure",
}),
)
: Effect.succeed({
apiKey: Redacted.make(creds.apiKey),
source: { type: "stored" as const },
} satisfies StripeResolvedCredentials),
),
),
),
Match.exhaustive,
);

prettyPrint — show resolved credentials in alchemy auth

Section titled “prettyPrint — show resolved credentials in alchemy auth”
const prettyPrint = (profileName: string, config: StripeAuthConfig) =>
resolveCredentials(profileName, config).pipe(
Effect.tap((creds) =>
Effect.all([
Console.log(` apiKey: ${displayRedacted(creds.apiKey, 7)}`),
Console.log(` source: ${creds.source.type}`),
]),
),
Effect.catch((e) =>
Console.error(` Failed to retrieve credentials: ${e}`),
),
);

The provider needs StripeCredentials; the AuthProvider produces StripeResolvedCredentials. Bridge them with a fromAuthProvider layer that resolves the auth provider from the registry, runs read, and supplies the result as StripeCredentials:

// src/stripe/Credentials.ts (additions)
import * as Config from "effect/Config";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import { getAuthProvider } from "alchemy/Auth/AuthProvider";
import { ALCHEMY_PROFILE, loadOrConfigure } from "alchemy/Auth/Profile";
import {
STRIPE_AUTH_PROVIDER_NAME,
type StripeAuthConfig,
type StripeResolvedCredentials,
} from "./AuthProvider.ts";
export const fromAuthProvider = () =>
Layer.effect(
StripeCredentials,
Effect.gen(function* () {
const auth = yield* getAuthProvider<
StripeAuthConfig,
StripeResolvedCredentials
>(STRIPE_AUTH_PROVIDER_NAME);
const profileName = yield* ALCHEMY_PROFILE;
const ci = yield* Config.boolean("CI").pipe(Config.withDefault(false));
const config = yield* loadOrConfigure(auth, profileName, { ci });
const creds = yield* auth.read(profileName, config as StripeAuthConfig);
return { apiKey: creds.apiKey };
}),
);

loadOrConfigure reads any existing config for the profile, and falls back to configure (interactive in TTYs, env in CI) if nothing is stored yet. ALCHEMY_PROFILE resolves the active profile name (default unless ALCHEMY_PROFILE is set).

Users expect the same one-line ergonomics as the built-ins (Cloudflare.providers(), AWS.providers()). Bundle everything into a single layer: the resource collection, every provider implementation, the credentials bridge, and the AuthProviderLayer so alchemy login can discover it.

src/stripe/Providers.ts
import * as Provider from "alchemy/Provider";
import * as Layer from "effect/Layer";
import { StripeAuth } from "./AuthProvider.ts";
import { fromAuthProvider } from "./Credentials.ts";
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.provideMerge(fromAuthProvider()),
Layer.provideMerge(StripeAuth),
);

Layer.provide (private) wires each resource provider to the collection, while Layer.provideMerge (public) keeps the auth machinery in scope so the host stack can also use it.

Now 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() },
Effect.gen(function* () {
const pro = yield* Stripe.Product("Pro", {
name: "Pro plan",
description: "Everything in Free, plus...",
});
return { productId: pro.productId };
}),
);

The first time they deploy, Alchemy walks them through alchemy login (or reads STRIPE_API_KEY on CI), stores the result under their profile, and resolves it for every subsequent deploy.

To mix with another cloud, merge the layers:

import * as Layer from "effect/Layer";
providers: Layer.mergeAll(Cloudflare.providers(), Stripe.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
// same AuthProvider system as `alchemy deploy` — set STRIPE_API_KEY
// (with `method: "env"`) or run `alchemy login` against a test
// profile beforehand.
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.promise(() =>
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.promise(() =>
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: