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:
- Initialize
initDRPC()
- Configure
.innerContext(), .createContext(), .services()
- 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
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:
- Verbosity is similar, but
bind().call() allows context reuse
bind() can be called once and the bound service passed around
- Consistent with JavaScript
Function.bind() mental model
- The
{ call } object allows future extension (e.g., .abort())
Factory Classes
We considered class-based services:
class Service<TCtx, TArgs, TOutput> { ... }
Rejected because:
- More boilerplate for simple use cases
- Function factory pattern is idiomatic TypeScript
- Service objects can still be callable via the bind pattern
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:initDRPC().innerContext(),.createContext(),.services()createCaller()or HTTP adapterThis 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:
query()/mutation()factories)(ctx, args)for scripts and testingbind().call())defineServices()for convenienceThis enables:
2. High-Level Design (The "Golden Path")
Service Definition
Consumption Patterns
Pattern A — Standalone (Scripts, Testing):
Pattern B — Local API (Repeated Calls):
Pattern C — Via DRPC Builder (for ctx.services):
Key insight: Same service definition works in all three patterns. The difference is only in how you access it.
3. Technical Specification
Core Types
The Bound Pattern
The
bind().call()pattern solves the closure problem:Implementation approach:
defineServices Implementation
defineServices()creates a proxy that binds context on each service call:The proxy allows
api.isPremium({ ... })while binding context automatically.4. Detailed Requirements
Factory Functions (
query(),mutation())Responsibility: Create a
ServiceInternalobject from a config.Same logic for
mutation()— onlytypediffers.Binding Logic
The
bind()method:ServiceInternalBoundServiceobjectctxvia closure (no mutation of original service)BoundService.call()invokeshandler(ctx, args)with captured contextdefineServices Proxy
Proxyto intercept property accessproxy[serviceName]returns a function that callsservice.bind(context).call(args)LocalAPImatches the service names5. Scope & Roadmap
Files Affected
Impact on Ecosystem
.services()on DRPCBuilder) — Depends onServiceInternaltypes from this issuectx.serviceslazy proxy) — Uses same service objects, different access patternBackward Compatibility
Not a breaking change — This is entirely new functionality. Existing
defineContext()andinitDRPC()APIs are unaffected.Non-Goals
d.query()via the router system.d.query()/d.router()are for. Standalone services are internal-only by design.ctx.services— Call services viactx.servicesafter.services()registration, not viadefineServices.6. Definition of Done
query({ handler })returns aServiceInternalwithbind().call()mutation({ handler })returns aServiceInternalwithbind().call()service.bind(ctx).call(args)works correctly.call()on same bound service reuse the contextdefineServices({ services, context })returns aLocalAPIapi.serviceName(args)calls the bound service with contextinitDRPC()— works completely standalonePromise<Result<TOutput>>Alternative Considered
Direct Call Signature:
service(ctx, args)We considered
service(ctx, args)directly:Rejected in favor of
bind().call()because:bind().call()allows context reusebind()can be called once and the bound service passed aroundFunction.bind()mental model{ call }object allows future extension (e.g.,.abort())Factory Classes
We considered class-based services:
Rejected because: