Skip to main content

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:
  1. Props Interface - Input configuration
  2. Output Type - Resource attributes after creation
  3. 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

Input Normalization

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

1
Follow Naming Conventions
2
Use provider-specific naming:
3
// Provider prefix :: Resource name
"aws::S3Bucket"
"cloudflare::Worker"
"vercel::Project"
"custom::MyResource"
4
Handle All Phases
5
Always handle create, update, and delete:
6
if (this.phase === "delete") {
  // Delete logic
  return this.destroy();
}

if (resourceId) {
  // Update logic
} else {
  // Create logic
}
7
Validate Input
8
Check for required fields and valid values:
9
if (!props.description) {
  throw new Error("description is required");
}

if (props.timeout && props.timeout < 0) {
  throw new Error("timeout must be positive");
}
10
Use Secret for Sensitive Values
11
Always wrap secrets:
12
// 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)
13
Support Adoption
14
Allow adopting existing resources:
15
if (error.code === "ALREADY_EXISTS" && adopt) {
  const existing = await findResourceByName(name);
  result = await updateResource(existing.id, requestBody);
}
16
Provide Clear Error Messages
17
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

Build docs developers (and LLMs) love