Custom Providers
Alchemy allows you to create custom resource providers for any cloud service or API. This guide shows you how to implement custom resources following Alchemy’s conventions and patterns.
Resource Structure
A custom resource consists of three parts:
- Props Interface - Input configuration
- Output Type - Resource attributes after creation
- Resource Implementation - Lifecycle management (create, update, delete)
Creating a Basic Resource
Step 1: Define Interfaces
import type { Context } from "alchemy";
import { Resource } from "alchemy";
/**
* Input properties for MyResource
*/
export interface MyResourceProps {
/**
* Name of the resource
* @default {app}-{stage}-{id}
*/
name?: string;
/**
* Description of the resource
*/
description: string;
/**
* Optional API key for authentication
*/
apiKey?: string | Secret;
/**
* Whether to adopt existing resources
* @default false
*/
adopt?: boolean;
/**
* Internal resource ID (for state management)
* @internal
*/
resourceId?: string;
}
/**
* Output type for MyResource
* Uses Omit pattern to separate input from output
*/
export type MyResource = Omit<MyResourceProps, "adopt"> & {
/**
* Alchemy resource ID
*/
id: string;
/**
* Physical name of the resource
*/
name: string;
/**
* Provider-assigned resource ID
*/
resourceId: string;
/**
* API key (always wrapped as Secret in output)
*/
apiKey?: Secret;
/**
* Resource URL
*/
url: string;
/**
* Creation timestamp
*/
createdAt: number;
};
Step 2: Implement Resource Lifecycle
import { Secret } from "alchemy";
import { logger } from "alchemy/util/logger";
/**
* Creates and manages MyResource instances
*
* @example
* const resource = await MyResource("my-resource", {
* description: "Example resource",
* apiKey: alchemy.secret.env.API_KEY
* });
*/
export const MyResource = Resource(
"custom::MyResource",
async function (
this: Context<MyResource>,
id: string,
props: MyResourceProps
): Promise<MyResource> {
// Get resource ID from props or previous state
const resourceId = props.resourceId ?? this.output?.resourceId;
const adopt = props.adopt ?? this.scope.adopt;
// Generate physical name
const name = props.name
?? this.output?.name
?? this.scope.createPhysicalName(id);
// Handle deletion phase
if (this.phase === "delete") {
if (!resourceId) {
logger.warn(`No resourceId for ${id}, skipping delete`);
return this.destroy();
}
try {
// Call provider API to delete
await deleteResource(resourceId);
} catch (error) {
if (error.code !== "NOT_FOUND") {
throw error;
}
}
return this.destroy();
}
// Prepare request payload (unwrap secrets)
const requestBody = {
name,
description: props.description,
apiKey: Secret.unwrap(props.apiKey)
};
let result;
if (resourceId) {
// UPDATE existing resource
result = await updateResource(resourceId, requestBody);
} else {
try {
// CREATE new resource
result = await createResource(requestBody);
} catch (error) {
if (error.code === "ALREADY_EXISTS") {
if (!adopt) {
throw new Error(
`Resource "${name}" already exists. Use adopt: true to adopt it.`,
{ cause: error }
);
}
// Adopt existing resource
const existing = await findResourceByName(name);
if (!existing) {
throw new Error(
`Resource "${name}" not found for adoption`
);
}
result = await updateResource(existing.id, requestBody);
} else {
throw error;
}
}
}
// Return output matching MyResource type
return {
id,
name: result.name,
resourceId: result.id,
description: props.description,
apiKey: props.apiKey ? Secret.wrap(props.apiKey) : undefined,
url: result.url,
createdAt: result.created_at
};
}
);
Step 3: Add Type Guard
import { ResourceKind } from "alchemy";
/**
* Type guard to check if a value is MyResource
*/
export function isMyResource(resource: any): resource is MyResource {
return resource?.[ResourceKind] === "custom::MyResource";
}
Advanced Patterns
For resources accepting flexible input types (e.g., string | Secret), use a wrapper function:
/**
* Public interface - accepts flexible types
*/
export function MyResource(
id: string,
props: MyResourceProps
): Promise<MyResource> {
return _MyResource(id, {
...props,
// Normalize secret to always be Secret type
apiKey: typeof props.apiKey === "string"
? secret(props.apiKey)
: props.apiKey
});
}
/**
* Internal implementation - guaranteed normalized types
*/
const _MyResource = Resource(
"custom::MyResource",
async function (
this: Context<MyResource>,
id: string,
props: Omit<MyResourceProps, "apiKey"> & { apiKey?: Secret }
): Promise<MyResource> {
// Implementation with guaranteed Secret type
}
);
Resource Replacement
Handle immutable properties with resource replacement:
export const MyResource = Resource(
"custom::MyResource",
async function (this: Context<MyResource>, id, props) {
const name = props.name ?? this.output?.name ?? this.scope.createPhysicalName(id);
// Trigger replacement if immutable property changed
if (this.phase === "update" && this.output?.name !== name) {
return this.replace();
}
// Normal lifecycle logic...
}
);
Conditional Deletion
Support opt-out deletion for data resources:
export interface MyDatabaseProps {
/**
* Whether to delete the database when removed from Alchemy
* @default true
*/
delete?: boolean;
}
export const MyDatabase = Resource(
"custom::MyDatabase",
async function (this: Context<MyDatabase>, id, props) {
if (this.phase === "delete") {
if (props.delete !== false && this.output?.resourceId) {
try {
await deleteDatabase(this.output.resourceId);
} catch (error) {
if (error.code !== "NOT_FOUND") throw error;
}
}
return this.destroy();
}
// Create/update logic...
}
);
Only use conditional deletion for data resources (databases, storage). Always delete compute resources (workers, functions).
Local Development Support
Support local development mode:
export const MyResource = Resource(
"custom::MyResource",
async function (this: Context<MyResource>, id, props) {
const name = props.name ?? this.scope.createPhysicalName(id);
// Return mock data in local mode
if (this.scope.local) {
return {
id,
name,
resourceId: "local-mock-id",
description: props.description,
apiKey: props.apiKey ? Secret.wrap(props.apiKey) : undefined,
url: `http://localhost:8080/${id}`,
createdAt: Date.now()
};
}
// Production logic...
}
);
Retry with Exponential Backoff
Handle transient errors:
import { withExponentialBackoff } from "alchemy/util/retry";
const result = await withExponentialBackoff(
async () => {
return await createResource(requestBody);
},
(error) => {
// Retry on specific transient errors
return error.code === "RATE_LIMITED" || error.code === "TIMEOUT";
},
30, // max attempts
100 // initial delay (ms)
);
API Client Pattern
Create a reusable API client:
interface MyProviderApiOptions {
apiKey: string;
baseUrl?: string;
}
class MyProviderApi {
constructor(private options: MyProviderApiOptions) {}
private async request<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(
`${this.options.baseUrl ?? "https://api.provider.com"}${path}`,
{
...init,
headers: {
"Authorization": `Bearer ${this.options.apiKey}`,
"Content-Type": "application/json",
...init?.headers
}
}
);
if (!response.ok) {
throw new Error(`API Error: ${response.statusText}`);
}
return response.json();
}
async createResource(data: any) {
return this.request("/resources", {
method: "POST",
body: JSON.stringify(data)
});
}
async updateResource(id: string, data: any) {
return this.request(`/resources/${id}`, {
method: "PUT",
body: JSON.stringify(data)
});
}
async deleteResource(id: string) {
return this.request(`/resources/${id}`, {
method: "DELETE"
});
}
async getResource(id: string) {
return this.request(`/resources/${id}`);
}
}
Type Patterns
Flat Properties
Prefer flat properties over nested objects:
// ✅ Good: Flat properties
export interface MyResourceProps {
name?: string;
region: string;
timeout: number;
memory: number;
}
// ❌ Avoid: Nested configuration
export interface MyResourceProps {
name?: string;
config: {
region: string;
timeout: number;
memory: number;
};
}
Omit Pattern for Outputs
// Output type removes input-only fields and adds computed fields
export type MyResource = Omit<MyResourceProps, "adopt" | "resourceId"> & {
id: string;
resourceId: string;
url: string;
createdAt: number;
};
Internal Types
Mark internal API types:
/**
* Raw API response
* @internal
*/
interface MyResourceApiResponse {
id: string;
name: string;
created_at: number;
}
Testing Custom Resources
Write comprehensive tests:
import { describe, expect } from "vitest";
import { destroy } from "alchemy/test";
import "alchemy/test/vitest";
import { MyResource } from "./my-resource";
const test = alchemy.test(import.meta, {
prefix: "test"
});
describe("MyResource", () => {
test("creates, updates, and deletes", async (scope) => {
let resource;
try {
// CREATE
resource = await MyResource("test", {
description: "Test resource",
apiKey: "test-key"
});
expect(resource.id).toBe("test");
expect(resource.url).toBeTruthy();
// UPDATE
resource = await MyResource("test", {
description: "Updated description",
apiKey: "test-key"
});
expect(resource.description).toBe("Updated description");
} finally {
// DELETE
await destroy(scope);
// Verify deletion
await expect(
getResource(resource.resourceId)
).rejects.toThrow();
}
});
});
Best Practices
Follow Naming Conventions
Use provider-specific naming:
// Provider prefix :: Resource name
"aws::S3Bucket"
"cloudflare::Worker"
"vercel::Project"
"custom::MyResource"
Always handle create, update, and delete:
if (this.phase === "delete") {
// Delete logic
return this.destroy();
}
if (resourceId) {
// Update logic
} else {
// Create logic
}
Check for required fields and valid values:
if (!props.description) {
throw new Error("description is required");
}
if (props.timeout && props.timeout < 0) {
throw new Error("timeout must be positive");
}
Use Secret for Sensitive Values
// Input accepts string | Secret
apiKey?: string | Secret;
// Output always wraps as Secret
apiKey: Secret.wrap(props.apiKey);
// API calls unwrap
apiKey: Secret.unwrap(props.apiKey)
Allow adopting existing resources:
if (error.code === "ALREADY_EXISTS" && adopt) {
const existing = await findResourceByName(name);
result = await updateResource(existing.id, requestBody);
}
Provide Clear Error Messages
throw new Error(
`Resource "${name}" already exists. Use adopt: true to adopt it.`,
{ cause: error }
);
Complete Example
Here’s a complete custom resource implementation:
import type { Context } from "alchemy";
import { Resource, ResourceKind, Secret } from "alchemy";
import { logger } from "alchemy/util/logger";
// Props interface
export interface MyApiResourceProps {
name?: string;
endpoint: string;
apiKey?: string | Secret;
timeout?: number;
adopt?: boolean;
resourceId?: string;
}
// Output type
export type MyApiResource = Omit<MyApiResourceProps, "adopt"> & {
id: string;
name: string;
resourceId: string;
apiKey?: Secret;
url: string;
createdAt: number;
};
// Type guard
export function isMyApiResource(resource: any): resource is MyApiResource {
return resource?.[ResourceKind] === "custom::MyApiResource";
}
// Resource implementation
export const MyApiResource = Resource(
"custom::MyApiResource",
async function (
this: Context<MyApiResource>,
id: string,
props: MyApiResourceProps
): Promise<MyApiResource> {
const resourceId = props.resourceId ?? this.output?.resourceId;
const adopt = props.adopt ?? this.scope.adopt;
const name = props.name ?? this.output?.name ?? this.scope.createPhysicalName(id);
// Local development mode
if (this.scope.local) {
return {
id,
name,
resourceId: "local-mock",
endpoint: props.endpoint,
apiKey: props.apiKey ? Secret.wrap(props.apiKey) : undefined,
timeout: props.timeout,
url: `http://localhost:3000/${id}`,
createdAt: Date.now()
};
}
// Delete phase
if (this.phase === "delete") {
if (!resourceId) {
logger.warn(`No resourceId for ${id}, skipping delete`);
return this.destroy();
}
try {
await fetch(`https://api.example.com/resources/${resourceId}`, {
method: "DELETE"
});
} catch (error) {
logger.error(`Error deleting ${id}:`, error);
}
return this.destroy();
}
// Prepare payload
const payload = {
name,
endpoint: props.endpoint,
apiKey: Secret.unwrap(props.apiKey),
timeout: props.timeout ?? 30000
};
let result;
if (resourceId) {
// Update
const response = await fetch(
`https://api.example.com/resources/${resourceId}`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
}
);
result = await response.json();
} else {
// Create
const response = await fetch(
"https://api.example.com/resources",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
}
);
result = await response.json();
}
return {
id,
name: result.name,
resourceId: result.id,
endpoint: props.endpoint,
apiKey: props.apiKey ? Secret.wrap(props.apiKey) : undefined,
timeout: props.timeout,
url: result.url,
createdAt: result.created_at
};
}
);
Next Steps