2026-01-14 13:13:14 +00:00
---
name: orpc-contract-first
2026-03-04 08:45:37 +00:00
description: Guide for implementing oRPC contract-first API patterns in Dify frontend. Trigger when creating or updating contracts in web/contract, wiring router composition, integrating TanStack Query with typed contracts, migrating legacy service calls to oRPC, or deciding whether to call queryOptions directly vs extracting a helper or use-* hook in web/service.
2026-01-14 13:13:14 +00:00
---
# oRPC Contract-First Development
2026-03-04 08:45:37 +00:00
## Intent
2026-01-14 13:13:14 +00:00
2026-03-04 08:45:37 +00:00
- Keep contract as single source of truth in `web/contract/*` .
- Default query usage: call-site `useQuery(consoleQuery|marketplaceQuery.xxx.queryOptions(...))` when endpoint behavior maps 1:1 to the contract.
- Keep abstractions minimal and preserve TypeScript inference.
## Minimal Structure
```text
2026-01-14 13:13:14 +00:00
web/contract/
2026-03-04 08:45:37 +00:00
├── base.ts
├── router.ts
├── marketplace.ts
└── console/
├── billing.ts
└── ...other domains
web/service/client.ts
```
## Core Workflow
1. Define contract in `web/contract/console/{domain}.ts` or `web/contract/marketplace.ts`
- Use `base.route({...}).output(type<...>())` as baseline.
- Add `.input(type<...>())` only when request has `params/query/body` .
- For `GET` without input, omit `.input(...)` (do not use `.input(type<unknown>())` ).
2. Register contract in `web/contract/router.ts`
- Import directly from domain files and nest by API prefix.
3. Consume from UI call sites via oRPC query utils.
```typescript
import { useQuery } from '@tanstack/react-query'
import { consoleQuery } from '@/service/client'
const invoiceQuery = useQuery(consoleQuery.billing.invoices.queryOptions({
staleTime: 5 * 60 * 1000,
throwOnError: true,
select: invoice => invoice.url,
}))
2026-01-14 13:13:14 +00:00
```
2026-03-04 08:45:37 +00:00
## Query Usage Decision Rule
1. Default: call site directly uses `*.queryOptions(...)` .
2. If 3+ call sites share the same extra options (for example `retry: false` ), extract a small queryOptions helper, not a `use-*` passthrough hook.
3. Create `web/service/use-{domain}.ts` only for orchestration:
- Combine multiple queries/mutations.
- Share domain-level derived state or invalidation helpers.
```typescript
const invoicesBaseQueryOptions = () =>
consoleQuery.billing.invoices.queryOptions({ retry: false })
const invoiceQuery = useQuery({
...invoicesBaseQueryOptions(),
throwOnError: true,
})
```
## Mutation Usage Decision Rule
1. Default: call mutation helpers from `consoleQuery` / `marketplaceQuery` , for example `useMutation(consoleQuery.billing.bindPartnerStack.mutationOptions(...))` .
2. If mutation flow is heavily custom, use oRPC clients as `mutationFn` (for example `consoleClient.xxx` / `marketplaceClient.xxx` ), instead of generic handwritten non-oRPC mutation logic.
## Key API Guide (`.key` vs `.queryKey` vs `.mutationKey`)
2026-01-14 13:13:14 +00:00
2026-03-04 08:45:37 +00:00
- `.key(...)` :
- Use for partial matching operations (recommended for invalidation/refetch/cancel patterns).
- Example: `queryClient.invalidateQueries({ queryKey: consoleQuery.billing.key() })`
- `.queryKey(...)` :
- Use for a specific query's full key (exact query identity / direct cache addressing).
- `.mutationKey(...)` :
- Use for a specific mutation's full key.
- Typical use cases: mutation defaults registration, mutation-status filtering (`useIsMutating`, `queryClient.isMutating` ), or explicit devtools grouping.
2026-01-14 13:13:14 +00:00
2026-03-04 08:45:37 +00:00
## Anti-Patterns
2026-01-14 13:13:14 +00:00
2026-03-04 08:45:37 +00:00
- Do not wrap `useQuery` with `options?: Partial<UseQueryOptions>` .
- Do not split local `queryKey/queryFn` when oRPC `queryOptions` already exists and fits the use case.
- Do not create thin `use-*` passthrough hooks for a single endpoint.
- Reason: these patterns can degrade inference (`data` may become `unknown` , especially around `throwOnError` /`select`) and add unnecessary indirection.
2026-01-14 13:13:14 +00:00
2026-03-04 08:45:37 +00:00
## Contract Rules
2026-01-14 13:13:14 +00:00
- **Input structure**: Always use `{ params, query?, body? }` format
2026-03-04 08:45:37 +00:00
- **No-input GET**: Omit `.input(...)` ; do not use `.input(type<unknown>())`
2026-01-14 13:13:14 +00:00
- **Path params**: Use `{paramName}` in path, match in `params` object
2026-03-04 08:45:37 +00:00
- **Router nesting**: Group by API prefix (e.g., `/billing/*` -> `billing: {}` )
2026-01-14 13:13:14 +00:00
- **No barrel files**: Import directly from specific files
- **Types**: Import from `@/types/` , use `type<T>()` helper
2026-03-04 08:45:37 +00:00
- **Mutations**: Prefer `mutationOptions` ; use explicit `mutationKey` mainly for defaults/filtering/devtools
2026-01-14 13:13:14 +00:00
## Type Export
```typescript
export type ConsoleInputs = InferContractRouterInputs< typeof consoleRouterContract >
```