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.
// _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
The service receives all dependencies through its constructor. It never imports framework-specific modules (createClient, logger, revalidatePath, etc.).
// _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
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:
// _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:
// 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:
// 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.
// _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/Response, no MCP context, noFormData. Accepting framework types couples the service to one interface — the user loses the ability to reuse it from MCP tools, CLI commands, or tests. -
Inject dependencies, never import them. Services that import framework-specific clients directly (like
createClient()) cannot be tested in isolation — the user depends on dependency injection to maintain test coverage across server actions, MCP tools, and CLI commands. -
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. Business logic in adapters means the user must duplicate changes across every interface when logic evolves. -
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.
What Goes Where
| Concern | Location | Example |
|---|---|---|
| Input validation (Zod) | _lib/schema/ |
CreateProjectSchema |
| Business logic | _lib/server/*.service.ts |
ProjectService.create() |
| Auth check | Adapter (Server Action with getSession()) |
Manual auth verification |
| 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
// 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()
Troubleshooting
Service cannot be tested without running database
Cause: The service imports createClient() or other framework-specific modules directly instead of receiving them as constructor arguments.
Fix: Refactor to accept SupabaseClient as a constructor parameter. The adapter (server action, route handler) resolves the client and passes it in.
Missing 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 import 'server-only'; as the first import in every service file. This causes a build error if client code tries to import it.
Service method returns { success, error } wrapper
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.
Reference
See Examples for more patterns including services with multiple dependencies, services that compose other services, and testing strategies.