Skip to content

RPC Worker Style Guide

This guide describes our approach to creating microservices using Cloudflare Workers’ RPC capabilities via service bindings.

  • Workers can expose methods to other workers through RPC (Remote Procedure Call)
  • Service bindings allow direct worker-to-worker communication without public URLs
  • Methods are exposed by extending the WorkerEntrypoint class
  • Each RPC service should focus on a specific domain/capability
import { WorkerEntrypoint } from 'cloudflare:workers'
export class MyService extends WorkerEntrypoint<Env> {
// Required fetch handler
async fetch() {
return new Response('Service running')
}
// Public methods that can be called via RPC
async doSomething(param1: string, param2: number) {
// Implementation
return result
}
}

The newly created service needs to be added to our Env interface. This can be done by running pnpm cf-typegen in the apps/api directory. This will update the generated Env interface in apps/api/worker-configuration.d.ts. In order to have working code completion from Typescript, we need to import and reference the service in the type definition like so:

MY_SERVICE: Fetcher<import('./src/rpc-services/MyService').MyService>

Additionally add any other bindings such as environment variables or secrets to the Env interface.

interface Env {
// Define required bindings
SOME_SECRET: string
// ... other bindings
}

In the calling worker’s wrangler.json:

services = [
{
"binding": "MY_SERVICE",
"service": "honeygrid",
"entrypoint": "MyService"
}
]
// In another worker
export default {
async fetch(request: Request, env: Env) {
const result = await env.MY_SERVICE.doSomething('test', 123)
return new Response(JSON.stringify(result))
},
}
  1. Single Responsibility

    • Each service should handle one domain/capability
    • Example: CallRailService focuses only on CallRail API interactions
  2. Method Naming

    • Use clear, action-based names (e.g., getCall, updateCallDetails)
    • Be consistent with CRUD operations
  3. Type Safety

    • Define strict interfaces for parameters and returns
    • Use Zod or similar for runtime validation
    const result = MyValidator.parse(data)
  1. Throw Meaningful Errors

    if (!response.ok) {
    throw new Error('Failed to fetch data: ' + (await response.text()))
    }
  2. Validate Inputs/Outputs

    const validated = InputSchema.parse(input)
  1. Mock External Services

    fetchMock.get('https://api.example.com').intercept({ path: '/resource' }).reply(200, mockData)
  2. Test Happy & Error Paths

    it('handles API errors', async () => {
    fetchMock.get('https://api.example.com').reply(500)
    await expect(service.method()).rejects.toThrow()
    })
  1. Use KV for Response Caching

    const cached = await this.env.CACHE.get(key)
    if (cached) return JSON.parse(cached)
  2. Cache Invalidation

    • Set appropriate TTLs
    • Implement cache busting when data changes

See CallRailService for a complete example implementing these patterns:

export class CallRailService extends WorkerEntrypoint<Env> {
async getCall(callId: string) {
// Input validation
if (!callId) throw new Error('Call ID required')
// External API call with error handling
const response = await fetch(`${API_URL}/calls/${callId}`, {
headers: this.getHeaders(),
})
if (!response.ok) throw new Error('Failed to fetch call')
// Response validation
return EnrichedCall.parse(await response.json())
}
}