Version 3 of the kit: - Radix UI replaced with Base UI (using the Shadcn UI patterns) - next-intl replaces react-i18next - enhanceAction deprecated; usage moved to next-safe-action - main layout now wrapped with [locale] path segment - Teams only mode - Layout updates - Zod v4 - Next.js 16.2 - Typescript 6 - All other dependencies updated - Removed deprecated Edge CSRF - Dynamic Github Action runner
319 lines
9.9 KiB
Markdown
319 lines
9.9 KiB
Markdown
---
|
|
name: service-builder
|
|
description: Build pure, interface-agnostic services with injected dependencies. Use when creating business logic that must work across server actions, MCP tools, CLI commands, or tests. Invoke with /service-builder.
|
|
---
|
|
|
|
# Service 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.
|
|
|
|
## Workflow
|
|
|
|
When asked to create a service, follow these steps:
|
|
|
|
### Step 1: Define the Contract
|
|
|
|
Start with the input/output types. These are plain TypeScript — no framework types.
|
|
|
|
```typescript
|
|
// _lib/schemas/project.schema.ts
|
|
import * as 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
|
|
|
|
The service receives all dependencies through its constructor. It never imports framework-specific modules (
|
|
`getSupabaseServerClient`, `getLogger`, `revalidatePath`, etc.).
|
|
|
|
```typescript
|
|
// _lib/server/project.service.ts
|
|
import type { SupabaseClient } from '@supabase/supabase-js';
|
|
|
|
import type { CreateProjectInput, Project } from '../schemas/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
|
|
|
|
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 { enhanceAction } from '@kit/next/actions';
|
|
import { getLogger } from '@kit/shared/logger';
|
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
|
import { revalidatePath } from 'next/cache';
|
|
|
|
import { CreateProjectSchema } from '../schemas/project.schema';
|
|
import { createProjectService } from './project.service';
|
|
|
|
export const createProjectAction = enhanceAction(
|
|
async function (data, user) {
|
|
const logger = await getLogger();
|
|
logger.info({ name: 'create-project', userId: user.id }, 'Creating project');
|
|
|
|
const client = getSupabaseServerClient();
|
|
const service = createProjectService(client);
|
|
const result = await service.create(data);
|
|
|
|
revalidatePath('/home/[account]/projects');
|
|
|
|
return { success: true, data: result };
|
|
},
|
|
{
|
|
auth: true,
|
|
schema: CreateProjectSchema,
|
|
},
|
|
);
|
|
```
|
|
|
|
**Route Handler adapter:**
|
|
|
|
```typescript
|
|
// app/api/projects/route.ts
|
|
import { enhanceRouteHandler } from '@kit/next/routes';
|
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
|
import { NextResponse } from 'next/server';
|
|
|
|
import { CreateProjectSchema } from '../_lib/schemas/project.schema';
|
|
import { createProjectService } from '../_lib/server/project.service';
|
|
|
|
export const POST = enhanceRouteHandler(
|
|
async function ({ body, user }) {
|
|
const client = getSupabaseServerClient();
|
|
const service = createProjectService(client);
|
|
const result = await service.create(body);
|
|
|
|
return NextResponse.json(result);
|
|
},
|
|
{
|
|
auth: true,
|
|
schema: CreateProjectSchema,
|
|
},
|
|
);
|
|
```
|
|
|
|
**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);
|
|
};
|
|
```
|
|
|
|
### Step 4: Write Tests
|
|
|
|
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' });
|
|
});
|
|
});
|
|
```
|
|
|
|
## Rules
|
|
|
|
1. **Services are pure functions over data.** Plain objects/primitives in, plain objects/primitives out. No `Request`/
|
|
`Response`, no MCP context, no `FormData`.
|
|
|
|
2. **Inject dependencies, never import them.** The service receives its database client, storage client, or any I/O
|
|
capability as a constructor argument. Never call `getSupabaseServerClient()` inside a service.
|
|
|
|
3. **Adapters are trivial glue.** A server action resolves the client, calls the service, and handles `revalidatePath`.
|
|
An MCP tool resolves the client, calls the service, and formats the response. No business logic in adapters.
|
|
|
|
4. **One service, many callers.** If two interfaces do the same thing, they call the same service function. Duplicating
|
|
logic is a violation.
|
|
|
|
5. **Testable in isolation.** Pass a mock client, assert the output. If you need a running database to test a service,
|
|
refactor until you don't.
|
|
|
|
## What Goes Where
|
|
|
|
| Concern | Location | Example |
|
|
|------------------------|-------------------------------------------|-------------------------------------------|
|
|
| Input validation (Zod) | `_lib/schemas/` | `CreateProjectSchema` |
|
|
| Business logic | `_lib/server/*.service.ts` | `ProjectService.create()` |
|
|
| Auth check | Adapter (`enhanceAction({ auth: true })`) | Server action wrapper |
|
|
| Logging | Adapter | `logger.info()` before/after service call |
|
|
| Cache revalidation | Adapter | `revalidatePath()` after mutation |
|
|
| Redirect | Adapter | `redirect()` after creation |
|
|
| MCP response format | Adapter | Return service result as MCP content |
|
|
|
|
## 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.tsx
|
|
```
|
|
|
|
## Anti-Patterns
|
|
|
|
```typescript
|
|
// ❌ BAD: Service imports framework-specific client
|
|
class ProjectService {
|
|
async create(data: CreateProjectInput) {
|
|
const client = getSupabaseServerClient(); // coupling!
|
|
// ...
|
|
}
|
|
}
|
|
|
|
// ❌ BAD: Business logic in the adapter
|
|
export const createProjectAction = enhanceAction(
|
|
async function (data, user) {
|
|
const client = getSupabaseServerClient();
|
|
// 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;
|
|
},
|
|
{ auth: true, schema: CreateProjectSchema },
|
|
);
|
|
|
|
// ❌ 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()
|
|
```
|
|
|
|
## Reference
|
|
|
|
See `[Examples](examples.md)` for more patterns including services with multiple dependencies, services that compose
|
|
other services, and testing strategies.
|