Skip to content

Standalone Services — query(), mutation(), defineServices() #26

@codewizdave

Description

@codewizdave

1. Context & Motivation

Problem

Current DRPC couples service definition to initDRPC() and the router system. To test a single service or call it from a script, you need to:

  1. Initialize initDRPC()
  2. Configure .innerContext(), .createContext(), .services()
  3. Call via createCaller() or HTTP adapter

This makes unit testing painful and prevents using DRPC services as simple utility functions in non-RPC contexts (scripts, batch jobs, other microservices).

Goal

Decouple service definition from the DRPC instance. A service should be:

  • Defined independently (query() / mutation() factories)
  • Callable standalone with (ctx, args) for scripts and testing
  • Bindable to a context for repeated calls (bind().call())
  • Groupable into a local API with defineServices() for convenience

This enables:

  • Unit testing — Call services directly without HTTP
  • Scripts/CLI — Use DRPC services without initializing a server
  • Micro-services — Share service logic across projects without coupling

2. High-Level Design (The "Golden Path")

Service Definition

import { query, mutation, defineServices } from '@deessejs/server';
import { z } from 'zod';  // Zod implements StandardSchemaV1

// Define services — independent of any server
// args uses any StandardSchemaV1 implementation (Zod, Valibot, etc.)
const isPremium = query({
  args: z.object({ userId: z.number() }),  // StandardSchemaV1 compatible
  handler: async (ctx, args) => {
    const user = await ctx.db.findUser(args.userId);
    return ok({ premium: user?.isPremium ?? false });
  },
});

const submitChallenge = mutation({
  args: z.object({ challengeId: z.string() }),
  handler: async (ctx, args) => {
    await ctx.db.submit(args.challengeId);
    return ok({ success: true });
  },
});

Consumption Patterns

Pattern A — Standalone (Scripts, Testing):

// Create context manually
const ctx = { db: myDatabase };

// Bind context, then call
const result = await isPremium.bind(ctx).call({ userId: 123 });
const result2 = await submitChallenge.bind(ctx).call({ challengeId: 'abc' });

Pattern B — Local API (Repeated Calls):

// Bind context once, call multiple times
const api = defineServices({
  services: { isPremium, submitChallenge },
  context: { db: myDatabase },
});

const result = await api.isPremium({ userId: 123 });
const result2 = await api.submitChallenge({ challengeId: 'abc' });

Pattern C — Via DRPC Builder (for ctx.services):

// Register in DRPC builder for ctx.services injection
const d = initDRPC()
  .innerContext({ db: myDatabase })
  .services({ isPremium, submitChallenge })
  .create();

// Access via ctx.services in other services/procedures
const router = d.router({
  challenges: {
    submit: d.mutation({
      handler: async (ctx, args) => {
        const result = await ctx.services.isPremium({ userId: ctx.userId });
        return result;
      },
    }),
  },
});

Key insight: Same service definition works in all three patterns. The difference is only in how you access it.


3. Technical Specification

Core Types

import type { StandardSchemaV1 } from '@standard-schema/spec';

// Configuration for a service
interface ServiceConfig<TCtx, TArgs, TOutput> {
  args?: StandardSchemaV1<TArgs>;  // Zod, Valibot, etc. — any StandardSchemaV1 implementation
  handler: (ctx: TCtx, args: TArgs) => Promise<Result<TOutput>>;
}

// Internal service representation (what query()/mutation() return)
interface ServiceInternal<TCtx, TArgs, TOutput> {
  readonly type: 'query' | 'mutation';
  readonly args?: StandardSchemaV1<TArgs>;
  readonly handler: (ctx: TCtx, args: TArgs) => Promise<Result<TOutput>>;
  // Chainable binding API
  bind(ctx: TCtx): BoundService<TArgs, TOutput>;
}

// Result of .bind(ctx) — holds context, awaits .call(args)
interface BoundService<TArgs, TOutput> {
  call(args: TArgs): Promise<Result<TOutput>>;
}

// defineServices input
interface DefineServicesConfig<TCtx, TServices extends Record<string, ServiceFunction>> {
  services: TServices;
  context: TCtx;
}

// defineServices output — context already bound
type LocalAPI<TCtx, TServices> = {
  [K in keyof TServices]: (
    args: Parameters<TServices[K]>[1]
  ) => Promise<ReturnType<TServices[K]>>;
};

The Bound Pattern

The bind().call() pattern solves the closure problem:

// Service is defined once
const isPremium = query({ ... });

// Context is captured at bind() time
const bound = isPremium.bind(ctx);
delete ctx; // Original ctx can be garbage collected

// Multiple calls reuse the same bound context
await bound.call({ userId: 1 });
await bound.call({ userId: 2 }); // ctx is still available

Implementation approach:

function bind(this: ServiceInternal<TCtx, TArgs, TOutput>, ctx: TCtx) {
  return {
    call: (args: TArgs) => this.handler(ctx, args),
  };
}

defineServices Implementation

defineServices() creates a proxy that binds context on each service call:

function defineServices<TCtx, TServices extends Record<string, ServiceFunction>>(
  config: DefineServicesConfig<TCtx, TServices>
): LocalAPI<TCtx, TServices> {
  return new Proxy({} as LocalAPI<TCtx, TServices>, {
    get(_, serviceName: string) {
      const service = config.services[serviceName];
      if (!service) throw new Error(`Service ${serviceName} not found`);

      return (args: any) => service.bind(config.context).call(args);
    },
  });
}

The proxy allows api.isPremium({ ... }) while binding context automatically.


4. Detailed Requirements

Factory Functions (query(), mutation())

Responsibility: Create a ServiceInternal object from a config.

function query<TCtx, TArgs, TOutput>(
  config: ServiceConfig<TCtx, TArgs, TOutput>
): ServiceInternal<TCtx, TArgs, TOutput> {
  return {
    type: 'query',
    args: config.args,
    handler: config.handler,
    bind(ctx: TCtx) {
      return { call: (args) => this.handler(ctx, args) };
    },
  };
}

Same logic for mutation() — only type differs.

Binding Logic

The bind() method:

  • Is defined on ServiceInternal
  • Returns a BoundService object
  • Captures ctx via closure (no mutation of original service)
  • BoundService.call() invokes handler(ctx, args) with captured context

defineServices Proxy

  • Uses Proxy to intercept property access
  • proxy[serviceName] returns a function that calls service.bind(context).call(args)
  • TypeScript types ensure LocalAPI matches the service names

5. Scope & Roadmap

Files Affected

packages/server/src/
├── services/
│   ├── index.ts       # Export query, mutation, defineServices
│   ├── query.ts        # query() factory
│   ├── mutation.ts    # mutation() factory
│   ├── bound.ts        # BoundService implementation
│   └── api.ts          # defineServices() proxy
└── index.ts            # Re-export

Impact on Ecosystem

  • server-009 (.services() on DRPCBuilder) — Depends on ServiceInternal types from this issue
  • server-011 (ctx.services lazy proxy) — Uses same service objects, different access pattern

Backward Compatibility

Not a breaking change — This is entirely new functionality. Existing defineContext() and initDRPC() APIs are unaffected.

Non-Goals

  • Middleware in standalone mode — Services bypass middleware intentionally (per DEP-0008). If you need middleware, use d.query() via the router system.
  • HTTP exposure for standalone services — That's what d.query() / d.router() are for. Standalone services are internal-only by design.
  • Service-to-service calls without ctx.services — Call services via ctx.services after .services() registration, not via defineServices.

6. Definition of Done

  • query({ handler }) returns a ServiceInternal with bind().call()
  • mutation({ handler }) returns a ServiceInternal with bind().call()
  • service.bind(ctx).call(args) works correctly
  • Multiple .call() on same bound service reuse the context
  • defineServices({ services, context }) returns a LocalAPI
  • api.serviceName(args) calls the bound service with context
  • TypeScript infers correct argument types from service definitions
  • No dependency on initDRPC() — works completely standalone
  • All services return Promise<Result<TOutput>>

Alternative Considered

Direct Call Signature: service(ctx, args)

We considered service(ctx, args) directly:

const result = await isPremium(ctx, { userId: 123 });

Rejected in favor of bind().call() because:

  1. Verbosity is similar, but bind().call() allows context reuse
  2. bind() can be called once and the bound service passed around
  3. Consistent with JavaScript Function.bind() mental model
  4. The { call } object allows future extension (e.g., .abort())

Factory Classes

We considered class-based services:

class Service<TCtx, TArgs, TOutput> { ... }

Rejected because:

  1. More boilerplate for simple use cases
  2. Function factory pattern is idiomatic TypeScript
  3. Service objects can still be callable via the bind pattern

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions