Skip to content
Back to skills

1178 Name Skill 93b151e1

ASecurity

Build pure, interface-agnostic services with injected dependencies for Next.js/Supabase applications. Use when creating business logic, CRUD services, or data access layers ('create a service', 'build the project service', 'add a delete method', 'write the data access layer'). Services work across server actions, MCP tools, CLI commands, and tests. Do NOT use for server actions (use server-action-builder), forms (use react-form-builder), or database schemas (use postgres-expert).

  • 9 stars
  • 0 votes
  • 0 copies
  • 1 view
  • Added October 11, 2026
testingtypescriptgoreactnextjstestingapidatabase

Works with

  • cli
  • api
  • mcp

Security analysis

A100/100

Scanned October 11, 2026

npx -y skills add tools-only/X-Skills --skill 1178-name-skill_93b151e1 --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of 1178 Name Skill 93b151e1?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for 1178 Name Skill 93b151e1
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/tools-only-1178-name-skill-93b151e1/badge)](https://www.skillsdirectory.com/skills/tools-only-1178-name-skill-93b151e1)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

SKILL.md
---
name: service-builder
description: "Build pure, interface-agnostic services with injected dependencies for Next.js/Supabase applications. Use when creating business logic, CRUD services, or data access layers ('create a service', 'build the project service', 'add a delete method', 'write the data access layer'). Services work across server actions, MCP tools, CLI commands, and tests. Do NOT use for server actions (use server-action-builder), forms (use react-form-builder), or database schemas (use postgres-expert)."
metadata:
  version: 1.0.0
  category: code-building
  tags: [service, business-logic, dependency-injection, testing]
---

# 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/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.).

```typescript
// _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:**

```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);
};
```

### 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

The user configured these rules because each addresses a real failure mode that has caused bugs or maintenance problems in this codebase.

1. **Services are pure functions over data.** Plain objects/primitives in, plain objects/primitives out. No `Request`/`Response`, no MCP context, no `FormData`. Accepting framework types couples the service to one interface — the user loses the ability to reuse it from MCP tools, CLI commands, or tests.

2. **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.

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. Business logic in adapters means the user must duplicate changes across every interface when logic evolves.

4. **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.

5. **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

```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()
```

## 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](examples.md) for more patterns including services with multiple dependencies, services that compose other services, and testing strategies.

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.