service-builder
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseService Builder
服务构建器
You are an expert at building pure, testable services that are decoupled from their callers.
您是构建与调用方解耦的纯可测试服务的专家。
North Star
核心准则
Every service is decoupled from its interface (I/O). A service takes plain data in, does work, and returns plain data out. It has no knowledge of whether it was called from an MCP tool, a server action, a CLI command, a route handler, or a test. The caller is a thin adapter that resolves dependencies and delegates.
每个服务都与其接口(I/O)解耦。 服务接收普通数据,执行处理,然后返回普通数据。它无需知晓自己是被MCP工具、Server Action、CLI命令、路由处理器还是测试调用的。调用方是一个轻量级适配器,负责解析依赖并委托执行。
Workflow
工作流程
When asked to create a service, follow these steps:
当需要创建服务时,请遵循以下步骤:
Step 1: Define the Contract
步骤1:定义契约
Start with the input/output types. These are plain TypeScript — no framework types.
typescript
// _lib/schema/project.schema.ts
import { z } from 'zod';
export const CreateProjectSchema = z.object({
name: z.string().min(1),
accountId: z.string().uuid(),
});
export type CreateProjectInput = z.infer<typeof CreateProjectSchema>;
export interface Project {
id: string;
name: string;
account_id: string;
created_at: string;
}从输入/输出类型开始。这些是纯TypeScript类型——不包含框架类型。
typescript
// _lib/schema/project.schema.ts
import { z } from 'zod';
export const CreateProjectSchema = z.object({
name: z.string().min(1),
accountId: z.string().uuid(),
});
export type CreateProjectInput = z.infer<typeof CreateProjectSchema>;
export interface Project {
id: string;
name: string;
account_id: string;
created_at: string;
}Step 2: Build the Service
步骤2:构建服务
The service receives all dependencies through its constructor. It never imports framework-specific modules (, , , etc.).
createClientloggerrevalidatePathtypescript
// _lib/server/project.service.ts
import type { SupabaseClient } from '@supabase/supabase-js';
import type { CreateProjectInput, Project } from '../schema/project.schema';
export function createProjectService(client: SupabaseClient) {
return new ProjectService(client);
}
class ProjectService {
constructor(private readonly client: SupabaseClient) {}
async create(data: CreateProjectInput): Promise<Project> {
const { data: result, error } = await this.client
.from('projects')
.insert({
name: data.name,
account_id: data.accountId,
})
.select()
.single();
if (error) throw error;
return result;
}
async list(accountId: string): Promise<Project[]> {
const { data, error } = await this.client
.from('projects')
.select('*')
.eq('account_id', accountId)
.order('created_at', { ascending: false });
if (error) throw error;
return data;
}
async delete(projectId: string): Promise<void> {
const { error } = await this.client
.from('projects')
.delete()
.eq('id', projectId);
if (error) throw error;
}
}服务通过构造函数接收所有依赖项。它从不导入框架特定模块(、、等)。
createClientloggerrevalidatePathtypescript
// _lib/server/project.service.ts
import type { SupabaseClient } from '@supabase/supabase-js';
import type { CreateProjectInput, Project } from '../schema/project.schema';
export function createProjectService(client: SupabaseClient) {
return new ProjectService(client);
}
class ProjectService {
constructor(private readonly client: SupabaseClient) {}
async create(data: CreateProjectInput): Promise<Project> {
const { data: result, error } = await this.client
.from('projects')
.insert({
name: data.name,
account_id: data.accountId,
})
.select()
.single();
if (error) throw error;
return result;
}
async list(accountId: string): Promise<Project[]> {
const { data, error } = await this.client
.from('projects')
.select('*')
.eq('account_id', accountId)
.order('created_at', { ascending: false });
if (error) throw error;
return data;
}
async delete(projectId: string): Promise<void> {
const { error } = await this.client
.from('projects')
.delete()
.eq('id', projectId);
if (error) throw error;
}
}Step 3: Write Thin Adapters
步骤3:编写轻量级适配器
Each interface is a thin adapter — it resolves dependencies, calls the service, and handles interface-specific concerns (revalidation, redirects, MCP formatting, CLI output).
Server Action adapter:
typescript
// _lib/server/server-actions.ts
'use server';
import { z } from 'zod';
import { revalidatePath } from 'next/cache';
import { createClient } from '@/lib/supabase/server';
import { getSession } from '@/lib/auth';
import { logger } from '@/lib/logger';
import { CreateProjectSchema } from '../schema/project.schema';
import { createProjectService } from './project.service';
export async function createProjectAction(formData: z.infer<typeof CreateProjectSchema>) {
const session = await getSession();
if (!session) throw new Error('Unauthorized');
const data = CreateProjectSchema.parse(formData);
logger.info({ name: 'create-project', userId: session.user.id }, 'Creating project');
const client = await createClient();
const service = createProjectService(client);
const result = await service.create(data);
revalidatePath('/home/[account]/projects');
return { success: true, data: result };
}Route Handler adapter:
typescript
// app/api/projects/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { getSession } from '@/lib/auth';
import { CreateProjectSchema } from '../_lib/schema/project.schema';
import { createProjectService } from '../_lib/server/project.service';
export async function POST(request: NextRequest) {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await request.json();
const data = CreateProjectSchema.parse(body);
const client = await createClient();
const service = createProjectService(client);
const result = await service.create(data);
return NextResponse.json(result);
}MCP Tool adapter:
typescript
// mcp/tools/kit_project_create.ts
import { createProjectService } from '../../_lib/server/project.service';
export const kit_project_create: McpToolHandler = async (input, context) => {
const client = context.getSupabaseClient();
const service = createProjectService(client);
return service.create(input);
};每个接口都是一个轻量级适配器——它负责解析依赖、调用服务,并处理接口特定的事项(缓存重新验证、重定向、MCP格式化、CLI输出)。
Server Action适配器:
typescript
// _lib/server/server-actions.ts
'use server';
import { z } from 'zod';
import { revalidatePath } from 'next/cache';
import { createClient } from '@/lib/supabase/server';
import { getSession } from '@/lib/auth';
import { logger } from '@/lib/logger';
import { CreateProjectSchema } from '../schema/project.schema';
import { createProjectService } from './project.service';
export async function createProjectAction(formData: z.infer<typeof CreateProjectSchema>) {
const session = await getSession();
if (!session) throw new Error('Unauthorized');
const data = CreateProjectSchema.parse(formData);
logger.info({ name: 'create-project', userId: session.user.id }, 'Creating project');
const client = await createClient();
const service = createProjectService(client);
const result = await service.create(data);
revalidatePath('/home/[account]/projects');
return { success: true, data: result };
}路由处理器适配器:
typescript
// app/api/projects/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { getSession } from '@/lib/auth';
import { CreateProjectSchema } from '../_lib/schema/project.schema';
import { createProjectService } from '../_lib/server/project.service';
export async function POST(request: NextRequest) {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await request.json();
const data = CreateProjectSchema.parse(body);
const client = await createClient();
const service = createProjectService(client);
const result = await service.create(data);
return NextResponse.json(result);
}MCP工具适配器:
typescript
// mcp/tools/kit_project_create.ts
import { createProjectService } from '../../_lib/server/project.service';
export const kit_project_create: McpToolHandler = async (input, context) => {
const client = context.getSupabaseClient();
const service = createProjectService(client);
return service.create(input);
};Step 4: Write Tests
步骤4:编写测试
Because the service accepts dependencies, you can test it with stubs — no running database, no framework runtime.
typescript
// _lib/server/__tests__/project.service.test.ts
import { describe, it, expect, vi } from 'vitest';
import { createProjectService } from '../project.service';
function createMockClient(overrides: Record<string, unknown> = {}) {
const mockChain = {
insert: vi.fn().mockReturnThis(),
select: vi.fn().mockReturnThis(),
single: vi.fn().mockResolvedValue({
data: { id: 'proj-1', name: 'Test', account_id: 'acc-1', created_at: new Date().toISOString() },
error: null,
}),
delete: vi.fn().mockReturnThis(),
eq: vi.fn().mockReturnThis(),
order: vi.fn().mockResolvedValue({ data: [], error: null }),
...overrides,
};
return {
from: vi.fn(() => mockChain),
mockChain,
} as unknown as SupabaseClient;
}
describe('ProjectService', () => {
it('creates a project', async () => {
const client = createMockClient();
const service = createProjectService(client);
const result = await service.create({
name: 'Test Project',
accountId: 'acc-1',
});
expect(result.id).toBe('proj-1');
expect(client.from).toHaveBeenCalledWith('projects');
});
it('throws on database error', async () => {
const client = createMockClient({
single: vi.fn().mockResolvedValue({
data: null,
error: { message: 'unique violation' },
}),
});
const service = createProjectService(client);
await expect(
service.create({ name: 'Dup', accountId: 'acc-1' }),
).rejects.toEqual({ message: 'unique violation' });
});
});由于服务接收依赖项,您可以使用存根对其进行测试——无需运行数据库或框架运行时。
typescript
// _lib/server/__tests__/project.service.test.ts
import { describe, it, expect, vi } from 'vitest';
import { createProjectService } from '../project.service';
function createMockClient(overrides: Record<string, unknown> = {}) {
const mockChain = {
insert: vi.fn().mockReturnThis(),
select: vi.fn().mockReturnThis(),
single: vi.fn().mockResolvedValue({
data: { id: 'proj-1', name: 'Test', account_id: 'acc-1', created_at: new Date().toISOString() },
error: null,
}),
delete: vi.fn().mockReturnThis(),
eq: vi.fn().mockReturnThis(),
order: vi.fn().mockResolvedValue({ data: [], error: null }),
...overrides,
};
return {
from: vi.fn(() => mockChain),
mockChain,
} as unknown as SupabaseClient;
}
describe('ProjectService', () => {
it('creates a project', async () => {
const client = createMockClient();
const service = createProjectService(client);
const result = await service.create({
name: 'Test Project',
accountId: 'acc-1',
});
expect(result.id).toBe('proj-1');
expect(client.from).toHaveBeenCalledWith('projects');
});
it('throws on database error', async () => {
const client = createMockClient({
single: vi.fn().mockResolvedValue({
data: null,
error: { message: 'unique violation' },
}),
});
const service = createProjectService(client);
await expect(
service.create({ name: 'Dup', accountId: 'acc-1' }),
).rejects.toEqual({ message: 'unique violation' });
});
});Rules
规则
The user configured these rules because each addresses a real failure mode that has caused bugs or maintenance problems in this codebase.
-
Services are pure functions over data. Plain objects/primitives in, plain objects/primitives out. No/
Request, no MCP context, noResponse. Accepting framework types couples the service to one interface — the user loses the ability to reuse it from MCP tools, CLI commands, or tests.FormData -
Inject dependencies, never import them. Services that import framework-specific clients directly (like) cannot be tested in isolation — the user depends on dependency injection to maintain test coverage across server actions, MCP tools, and CLI commands.
createClient() -
Adapters are trivial glue. A server action resolves the client, calls the service, and handles. An MCP tool resolves the client, calls the service, and formats the response. Business logic in adapters means the user must duplicate changes across every interface when logic evolves.
revalidatePath -
One service, many callers. If two interfaces do the same thing, they call the same service function. Duplicating logic means the user fixes a bug in one place but it persists in another — leading to inconsistent behavior across interfaces.
-
Testable in isolation. Pass a mock client, assert the output. Services that require a running database force the user to rely on slow integration tests for every change, making TDD impractical.
用户配置这些规则是因为每条规则都针对代码库中导致过bug或维护问题的实际故障模式。
-
服务是基于数据的纯函数。 输入为普通对象/原始值,输出为普通对象/原始值。不使用/
Request、MCP上下文或Response。接受框架类型会将服务与单一接口耦合——用户将无法在MCP工具、CLI命令或测试中复用它。FormData -
注入依赖,而非导入依赖。 直接导入框架特定客户端(如)的服务无法独立测试——用户依赖依赖注入来维持Server Action、MCP工具和CLI命令的测试覆盖率。
createClient() -
适配器是简单的粘合层。 Server Action负责解析客户端、调用服务并处理。MCP工具负责解析客户端、调用服务并格式化响应。业务逻辑出现在适配器中意味着当逻辑演进时,用户必须在每个接口中重复修改。
revalidatePath -
一个服务,多个调用方。 如果两个接口执行相同操作,它们应调用同一个服务函数。逻辑重复意味着用户在一个地方修复了bug,但bug在另一个地方仍然存在——导致不同接口之间的行为不一致。
-
可独立测试。 传入模拟客户端,断言输出。需要运行数据库的服务迫使用户对每个更改都依赖缓慢的集成测试,使得测试驱动开发(TDD)不切实际。
What Goes Where
职责划分
| Concern | Location | Example |
|---|---|---|
| Input validation (Zod) | | |
| Business logic | | |
| Auth check | Adapter (Server Action with | Manual auth verification |
| Logging | Adapter | |
| Cache revalidation | Adapter | |
| Redirect | Adapter | |
| MCP response format | Adapter | Return service result as MCP content |
| 事项 | 位置 | 示例 |
|---|---|---|
| 输入验证(Zod) | | |
| 业务逻辑 | | |
| 权限校验 | 适配器(使用 | 手动权限验证 |
| 日志 | 适配器 | 服务调用前后的 |
| 缓存重新验证 | 适配器 | 数据变更后的 |
| 重定向 | 适配器 | 创建后的 |
| MCP响应格式 | 适配器 | 将服务结果作为MCP内容返回 |
File Structure
文件结构
feature/
├── _lib/
│ ├── schemas/
│ │ └── feature.schema.ts # Zod schemas + TS types
│ └── server/
│ ├── feature.service.ts # Pure service (dependencies injected)
│ ├── server-actions.ts # Server action adapters
│ └── __tests__/
│ └── feature.service.test.ts # Unit tests with mock client
└── _components/
└── feature-form.tsxfeature/
├── _lib/
│ ├── schemas/
│ │ └── feature.schema.ts # Zod模式 + TS类型
│ └── server/
│ ├── feature.service.ts # 纯服务(依赖注入)
│ ├── server-actions.ts # Server Action适配器
│ └── __tests__/
│ └── feature.service.test.ts # 使用模拟客户端的单元测试
└── _components/
└── feature-form.tsxAnti-Patterns
反模式
typescript
// BAD: Service imports framework-specific client
class ProjectService {
async create(data: CreateProjectInput) {
const client = await createClient(); // coupling!
// ...
}
}
// BAD: Business logic in the adapter
export async function createProjectAction(formData: FormData) {
const session = await getSession();
if (!session) throw new Error('Unauthorized');
const client = await createClient();
// Business logic directly in the action — not reusable
if (data.name.length > 100) throw new Error('Name too long');
const { data: result } = await client.from('projects').insert(data);
return result;
}
// BAD: Two interfaces duplicate the same logic
// server-actions.ts
const result = await client.from('projects').insert(...).select().single();
// mcp-tool.ts
const result = await client.from('projects').insert(...).select().single();
// Should be: both call projectService.create()typescript
// 错误:服务导入框架特定客户端
class ProjectService {
async create(data: CreateProjectInput) {
const client = await createClient(); // 耦合!
// ...
}
}
// 错误:业务逻辑在适配器中
export async function createProjectAction(formData: FormData) {
const session = await getSession();
if (!session) throw new Error('Unauthorized');
const client = await createClient();
// 业务逻辑直接写在Action中——无法复用
if (data.name.length > 100) throw new Error('Name too long');
const { data: result } = await client.from('projects').insert(data);
return result;
}
// 错误:两个接口重复相同逻辑
// server-actions.ts
const result = await client.from('projects').insert(...).select().single();
// mcp-tool.ts
const result = await client.from('projects').insert(...).select().single();
// 正确做法:两者都调用projectService.create()Troubleshooting
问题排查
Service cannot be tested without running database
服务无法脱离运行的数据库进行测试
Cause: The service imports or other framework-specific modules directly instead of receiving them as constructor arguments.
createClient()Fix: Refactor to accept as a constructor parameter. The adapter (server action, route handler) resolves the client and passes it in.
SupabaseClient原因: 服务直接导入或其他框架特定模块,而非通过构造函数参数接收它们。
createClient()解决方法: 重构为通过构造函数参数接收。适配器(Server Action、路由处理器)负责解析客户端并传入。
SupabaseClientMissing import 'server-only'
import 'server-only'缺少import 'server-only'
import 'server-only'Cause: Service file can be accidentally imported by client-side code, leaking server logic and credentials to the browser bundle.
Fix: Add as the first import in every service file. This causes a build error if client code tries to import it.
import 'server-only';原因: 服务文件可能被客户端代码意外导入,导致服务端逻辑和凭证泄露到浏览器包中。
解决方法: 在每个服务文件的第一行添加。如果客户端代码尝试导入它,这会导致构建错误。
import 'server-only';Service method returns { success, error }
wrapper
{ success, error }服务方法返回{ success, error }
包装对象
{ success, error }Cause: Inconsistent with codebase pattern where services throw on error and return data directly.
Fix: Services should throw errors (let the adapter handle error formatting) and return the result directly. The adapter decides how to present success/failure to its interface.
原因: 与代码库中服务抛出错误并直接返回数据的模式不一致。
解决方法: 服务应抛出错误(让适配器处理错误格式化)并直接返回结果。适配器决定如何向其接口展示成功/失败状态。
Business logic leaking into adapters
业务逻辑渗透到适配器中
Cause: Logic was written directly in the server action instead of a service method. Other interfaces (MCP, CLI) cannot reuse it.
Fix: Move all business logic into the service. The adapter should only: resolve dependencies, call the service, handle revalidation/redirects/formatting.
原因: 逻辑直接写在Server Action中,而非服务方法里。其他接口(MCP、CLI)无法复用该逻辑。
解决方法: 将所有业务逻辑移到服务中。适配器应仅负责:解析依赖、调用服务、处理缓存重新验证/重定向/格式化。
Reference
参考
See Examples for more patterns including services with multiple dependencies, services that compose other services, and testing strategies.
查看示例了解更多模式,包括具有多个依赖的服务、组合其他服务的服务以及测试策略。