Toast
ContributorPatterns

Adding API Features

Adding API Features

Step-by-step guide for adding new endpoints to the Toast API.

Quick Start

The code generator scaffolds the full structure:

pnpm td generate feature <name>

For manual setup, follow the checklist below.

Checklist

When adding a new resource/endpoint:

  1. Database schema (if needed)
  2. Repository factory
  3. Service factory
  4. Schemas (Zod + OpenAPI)
  5. Controller factory
  6. Route factory
  7. Barrel exports and mount
  8. Tests (100% coverage required)

1. Database Schema

Edit packages/db/src/schema.ts:

export const myTable = pgTable(
  'my_table',
  {
    id: uuid('id').defaultRandom().primaryKey(),
    siteId: uuid('site_id')
      .notNull()
      .references(() => sites.id),
    // ... fields
    createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
    updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
  },
  (table) => [index('idx_my_table_site_id').on(table.siteId)]
);

Then generate and apply migration:

pnpm db:generate
pnpm db:migrate

See database.md for schema conventions and migration details.

2. Repository Layer

Create apps/api/src/repositories/{resource}.repository.ts:

import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js';
import { and, eq } from 'drizzle-orm';
import { myTable } from '@toast/db';

export type MyTableRow = typeof myTable.$inferSelect;
export type NewMyTableRow = typeof myTable.$inferInsert;

export function createMyResourceRepository(db: PostgresJsDatabase) {
  return {
    async findBySiteId(siteId: string): Promise<MyTableRow[]> {
      return db.select().from(myTable).where(eq(myTable.siteId, siteId));
    },

    async findById(id: string, siteId: string): Promise<MyTableRow | null> {
      const results = await db
        .select()
        .from(myTable)
        .where(and(eq(myTable.id, id), eq(myTable.siteId, siteId)));
      return results[0] ?? null;
    },

    async create(data: NewMyTableRow): Promise<MyTableRow> {
      const results = await db.insert(myTable).values(data).returning();
      return results[0]!;
    },
  };
}

export type MyResourceRepository = ReturnType<typeof createMyResourceRepository>;

Key rules:

  • Every query must filter by siteId for multi-tenancy
  • Factory accepts a PostgresJsDatabase instance (no global getDb() calls)
  • Export the ReturnType for use in service dep interfaces

Add to apps/api/src/repositories/index.ts:

export { type MyResourceRepository, createMyResourceRepository } from './my-resource.repository.js';

3. Service Layer

Create apps/api/src/services/{resource}.service.ts:

import type { MyResourceRepository } from '../repositories/my-resource.repository.js';

export interface MyResourceResponse {
  id: string;
  name: string;
  createdAt: string; // ISO string
}

export interface MyResourceServiceDeps {
  myResourceRepository: MyResourceRepository;
}

export function createMyResourceService(deps: MyResourceServiceDeps) {
  const { myResourceRepository: repo } = deps;

  function toResponse(row: MyTableRow): MyResourceResponse {
    return {
      id: row.id,
      name: row.name,
      createdAt: row.createdAt.toISOString(),
    };
  }

  return {
    async list(siteId: string): Promise<MyResourceResponse[]> {
      const items = await repo.findBySiteId(siteId);
      return items.map(toResponse);
    },

    async create(siteId: string, input: CreateInput): Promise<MyResourceResponse> {
      const row = await repo.create({ ...input, siteId });
      return toResponse(row);
    },
  };
}

export type MyResourceService = ReturnType<typeof createMyResourceService>;

Key rules:

  • Deps interface declares repository dependencies
  • toResponse transforms database rows to API types (Date → ISO string, etc.)
  • All methods receive siteId for tenant scoping
  • Export ReturnType for use in controller dep interfaces

Add to apps/api/src/services/index.ts:

export { type MyResourceService, createMyResourceService } from './my-resource.service.js';

4. Schemas

Create apps/api/src/routes/{resource}/schemas.ts:

import { z } from '@hono/zod-openapi';

export const CreateMyResourceSchema = z
  .object({
    name: z.string().min(1),
  })
  .openapi('CreateMyResource');

export const MyResourceSchema = z
  .object({
    id: z.string().uuid(),
    name: z.string(),
    createdAt: z.string().datetime(),
  })
  .openapi('MyResource');

// Shared error schemas
export const NotFoundErrorSchema = z.object({ error: z.string() }).openapi('NotFoundError');

// Inferred types for use in controllers
export type CreateMyResource = z.infer<typeof CreateMyResourceSchema>;
export type MyResource = z.infer<typeof MyResourceSchema>;

5. Controller Factory

Create apps/api/src/routes/{resource}/{resource}.controller.ts:

import type { Context } from 'hono';
import { getAuthContext } from '../../middleware/index.js';
import type { MyResourceService } from '../../services/index.js';
import type { CreateMyResource } from './schemas.js';

/** Dependencies for the controller factory — use Pick for narrow interfaces */
export interface MyResourceControllerDeps {
  myResourceService: Pick<MyResourceService, 'list' | 'create'>;
}

/**
 * Create a controller with injected dependencies.
 */
export function createMyResourceController(deps: MyResourceControllerDeps) {
  const { myResourceService: service } = deps;

  return {
    async list(c: Context) {
      const { siteId } = getAuthContext(c);
      const items = await service.list(siteId);
      return c.json(items, 200);
    },

    async create(c: Context, body: CreateMyResource) {
      const { user, siteId } = getAuthContext(c);
      const created = await service.create(siteId, body);
      return c.json(created, 201);
    },
  };
}

/** Inferred type for dependency injection */
export type MyResourceController = ReturnType<typeof createMyResourceController>;

Key rules:

  • Use Pick<ServiceType, 'method1' | 'method2'> for narrow dep interfaces — only declare what you use
  • Extract siteId and user from getAuthContext(c)
  • Controllers are thin: extract input, call service, format response
  • Export ReturnType for type inference

6. Route Factory

Create apps/api/src/routes/{resource}/routes.ts:

import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi';
import { requirePermission } from '../../middleware/require-permission.js';
import type { ApiEnv } from '../../types/hono.js';
import {
  createMyResourceController,
  type MyResourceControllerDeps,
} from './my-resource.controller.js';
import { CreateMyResourceSchema, MyResourceSchema, NotFoundErrorSchema } from './schemas.js';

/** Dependencies for the route factory */
export type MyResourceRoutesDeps = MyResourceControllerDeps;

const listRoute = createRoute({
  method: 'get',
  path: '/',
  tags: ['MyResource'],
  responses: {
    200: {
      description: 'List of resources',
      content: { 'application/json': { schema: z.array(MyResourceSchema) } },
    },
  },
});

const createResourceRoute = createRoute({
  method: 'post',
  path: '/',
  tags: ['MyResource'],
  request: {
    body: { content: { 'application/json': { schema: CreateMyResourceSchema } } },
  },
  responses: {
    201: {
      description: 'Created resource',
      content: { 'application/json': { schema: MyResourceSchema } },
    },
  },
});

/**
 * Create routes with injected dependencies.
 */
export function createMyResourceRoutes(deps: MyResourceRoutesDeps) {
  // eslint-disable-next-line toast/require-route-permission -- applied below per-route
  const routes = new OpenAPIHono<ApiEnv>();
  const controller = createMyResourceController(deps);

  routes.use('*', requirePermission({ myResource: ['read'] }));

  routes.openapi(listRoute, async (c) => {
    return await controller.list(c);
  });

  routes.openapi(createResourceRoute, async (c) => {
    const body = c.req.valid('json');
    return await controller.create(c, body);
  });

  return routes;
}

Key rules:

  • Route deps type aliases the controller deps (or extends it if routes need extra deps)
  • requirePermission middleware applied to all routes (or per-route for mixed permissions)
  • Route handlers extract validated input via c.req.valid('json') / c.req.valid('param') and pass to controller
  • ESLint guard toast/require-route-permission fires on new OpenAPIHono() — place disable comment on that line

7. Barrel Exports and Mount

Barrel file

Create apps/api/src/routes/{resource}/index.ts:

export { createMyResourceRoutes, type MyResourceRoutesDeps, myResourceRoutes } from './routes.js';
export {
  type MyResource,
  MyResourceSchema,
  type CreateMyResource,
  CreateMyResourceSchema,
} from './schemas.js';

Route aggregation

Add to apps/api/src/routes/index.ts:

export {
  createMyResourceRoutes,
  type MyResource,
  type MyResourceRoutesDeps,
  MyResourceSchema,
  myResourceRoutes,
} from './my-resource/index.js';

Mount in app

In apps/api/src/app.ts:

import { myResourceRoutes } from './routes/my-resource/index.js';

app.route('/api/my-resource', myResourceRoutes);

8. Tests

New code must achieve 100% test coverage. Run pnpm test:coverage locally before pushing.

Factory tests (controller + routes)

Create apps/api/src/routes/{resource}/{resource}-factory.test.ts:

import { beforeEach, describe, expect, it, vi } from 'vitest';

// Mock middleware to bypass auth in tests
const mockGetAuthContext = vi.hoisted(() =>
  vi.fn().mockReturnValue({
    user: { id: 'user-1', name: 'Test User' },
    siteId: 'site-1',
  })
);

const passthrough = vi.hoisted(() => async (_c: unknown, next: () => Promise<void>) => {
  await next();
});

vi.mock('../../middleware/index.js', () => ({
  getAuthContext: mockGetAuthContext,
  requireAuth: () => passthrough,
  requirePermission: () => passthrough,
}));

vi.mock('../../middleware/require-permission.js', () => ({
  requirePermission: () => passthrough,
}));

import { createMyResourceController } from './my-resource.controller.js';
import { createMyResourceRoutes } from './routes.js';

beforeEach(() => {
  vi.clearAllMocks();
});

function createMockDeps() {
  return {
    myResourceService: {
      list: vi.fn().mockResolvedValue([{ id: 'r-1', name: 'Test' }]),
      create: vi.fn().mockResolvedValue({ id: 'r-new', name: 'New' }),
    },
  };
}

describe('createMyResourceRoutes', () => {
  it('wires GET / to list', async () => {
    const deps = createMockDeps();
    const routes = createMyResourceRoutes(deps);

    const res = await routes.request('/');

    expect(res.status).toBe(200);
    expect(deps.myResourceService.list).toHaveBeenCalledWith('site-1');
  });

  it('wires POST / to create', async () => {
    const deps = createMockDeps();
    const routes = createMyResourceRoutes(deps);

    const res = await routes.request(
      new Request('http://localhost/', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ name: 'New' }),
      })
    );

    expect(res.status).toBe(201);
    expect(deps.myResourceService.create).toHaveBeenCalled();
  });
});

describe('createMyResourceController', () => {
  function createMockContext() {
    return {
      json: vi.fn((data: unknown, status: number) => {
        return new Response(JSON.stringify(data), {
          status,
          headers: { 'Content-Type': 'application/json' },
        });
      }),
      header: vi.fn(),
    };
  }

  it('list returns items from the injected service', async () => {
    const deps = createMockDeps();
    const controller = createMyResourceController(deps);
    const c = createMockContext();

    // biome-ignore lint/suspicious/noExplicitAny: test mock
    const res = await controller.list(c as any);

    expect(res.status).toBe(200);
    expect(deps.myResourceService.list).toHaveBeenCalledWith('site-1');
  });
});

Test pattern summary:

  • Mock middleware with vi.hoisted() + vi.mock() for auth bypass
  • Create mock deps with vi.fn() — only the methods declared in Pick<>
  • Test route wiring: call routes.request() and verify status + service calls
  • Test controller logic: call controller methods directly with mock context
  • Cover error paths (404, 409, etc.), not just happy paths

Service factory tests

Create apps/api/src/services/{resource}.service.factory.test.ts:

import { beforeEach, describe, expect, it, vi } from 'vitest';
import { createMyResourceService } from './my-resource.service.js';

function createMockDeps() {
  return {
    myResourceRepository: {
      findBySiteId: vi.fn().mockResolvedValue([]),
      create: vi.fn().mockResolvedValue({ id: 'r-1', name: 'Test', createdAt: new Date() }),
    },
  };
}

describe('createMyResourceService', () => {
  beforeEach(() => vi.clearAllMocks());

  it('list passes siteId to repository', async () => {
    const deps = createMockDeps();
    const service = createMyResourceService(deps);

    await service.list('site-1');

    expect(deps.myResourceRepository.findBySiteId).toHaveBeenCalledWith('site-1');
  });
});

See testing.md for more testing patterns.

Layer Responsibilities

LayerLocationResponsibility
Controllerroutes/*/*.controller.tsHTTP concerns: extract input, call service, format response
Serviceservices/*.service.tsBusiness logic, domain rules, orchestration
Repositoryrepositories/*.repository.tsData access, query construction, siteId scoping
@toast/dbpackages/db/Schema, migrations, connection management

See ADR-004 for the full rationale.

Dependency Flow

Route factory
  → creates Controller factory (with service deps via Pick<>)
    → Controller calls Service methods (with siteId from auth context)
      → Service calls Repository methods (with siteId for tenant isolation)
        → Repository queries database via Drizzle ORM

Each layer depends only on the interface of the layer below, never on concrete implementations. This enables isolated unit testing at every level.

On this page