Files
Giancarlo Buomprisco 7ebff31475 Next.js Supabase V3 (#463)
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
2026-03-24 13:40:38 +08:00

9.9 KiB

name, description
name description
service-builder 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.

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

// _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:

// _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:

// 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:

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

  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

// ❌ 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.