Toast
Contributor

Your First Feature

Add a complete API feature from contract and schema through to a tested route.

This walkthrough adds a Newsletters resource to the Toast API. By the end you’ll have a repository, service, route factory, and tests that match the current codebase.


The fast way

td generate feature newsletters

Today, that command creates most API boilerplate, not the entire feature.

It scaffolds:

  1. shared/contracts/src/newsletters.ts — contract stub
  2. apps/api/src/repositories/newsletters.repository.ts
  3. apps/api/src/services/newsletters.service.ts
  4. apps/api/src/routes/newsletters/routes.ts
  5. apps/api/src/routes/newsletters/newsletters.controller.ts
  6. apps/api/src/routes/newsletters/schemas.ts
  7. apps/api/src/routes/newsletters/index.ts
  8. Factory tests for the route, service, and repository

It also wires the generated files into the route/service/repository barrel exports and updates shared/contracts/package.json exports.

You still need to:

  • add any table changes in shared/db/src/schema.ts
  • generate and apply a migration
  • wire the new feature into the API entrypoints (container.ts, routes/index.ts, index.ts, and app.ts)
  • fill in the business logic and tests

The current mental model

For API features, Toast follows this flow:

Contract + Schema

Repository

Service

Route factory + controller + schemas

Top-level wiring (container.ts → routes/index.ts → index.ts → app.ts)

The generator helps with the middle of that stack. The top-level wiring is still explicit.


1. Add the database table

Toast’s table definitions currently live in shared/db/src/schema.ts.

Add a newsletters table there:

export const newsletters = pgTable(
  'newsletters',
  {
    id: uuid('id').defaultRandom().primaryKey(),
    siteId: uuid('site_id')
      .notNull()
      .references(() => sites.id, { onDelete: 'cascade' }),
    name: varchar('name', { length: 255 }).notNull(),
    createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
    updatedAt: timestamp('updated_at', { withTimezone: true })
      .defaultNow()
      .notNull()
      .$onUpdate(() => new Date()),
  },
  (table) => [index('idx_newsletters_site_id').on(table.siteId)]
);

Then generate and apply the migration:

td db generate add-newsletters
td db migrate

If you are starting from the generator output, this is the main database step it does not do for you.


2. Add the contract

The generator creates a contract stub in shared/contracts/src/newsletters.ts. Update it so the API and admin can share the same shapes.

For example:

import { z } from 'zod';

export const newsletterSchema = z.object({
  id: z.string().uuid(),
  siteId: z.string().uuid(),
  name: z.string(),
  createdAt: z.string().datetime(),
  updatedAt: z.string().datetime(),
});

export type Newsletter = z.infer<typeof newsletterSchema>;

Treat the contract as the external shape. The repository can return raw rows; the service maps them into this contract shape.


3. Implement the repository

Repositories receive the injected Drizzle database instance. They never import getDb().

import { eq, type PostgresJsDatabase } from '@toast/drizzle';
import { newsletters } from '@toast/db';

export function createNewsletterRepository(db: PostgresJsDatabase) {
  return {
    findBySiteId(siteId: string) {
      return db.select().from(newsletters).where(eq(newsletters.siteId, siteId));
    },

    async create(data: typeof newsletters.$inferInsert) {
      const rows = await db.insert(newsletters).values(data).returning();
      return rows[0]!;
    },
  };
}

export type NewsletterRepository = ReturnType<typeof createNewsletterRepository>;

Rules:

  • every query is scoped by siteId
  • repositories own all Drizzle usage
  • repositories return raw rows; they do not shape HTTP responses

4. Implement the service

Services receive repositories and infrastructure via deps.

import type { EventBus } from '@toast/core';
import type { NewsletterRepository } from '../repositories/newsletters.repository.js';

export function createNewsletterService(deps: {
  newsletterRepository: NewsletterRepository;
  eventBus: EventBus;
}) {
  return {
    async listNewsletters(siteId: string) {
      const rows = await deps.newsletterRepository.findBySiteId(siteId);
      return rows.map((row) => ({
        id: row.id,
        siteId: row.siteId,
        name: row.name,
        createdAt: row.createdAt.toISOString(),
        updatedAt: row.updatedAt.toISOString(),
      }));
    },

    async createNewsletter(input: { name: string }, siteId: string) {
      const row = await deps.newsletterRepository.create({ ...input, siteId });

      // Emit a domain event here if the feature needs subscribers or audit hooks.

      return {
        id: row.id,
        siteId: row.siteId,
        name: row.name,
        createdAt: row.createdAt.toISOString(),
        updatedAt: row.updatedAt.toISOString(),
      };
    },
  };
}

Use the event bus only when something domain-meaningful happened. If the feature has no event yet, it is fine to ship the first pass without one.


5. Implement the route factory

Route folders currently use this shape:

apps/api/src/routes/newsletters/
├── index.ts                  # barrel export
├── routes.ts                 # OpenAPI route factory
├── newsletters.controller.ts # HTTP controller
└── schemas.ts                # Zod/OpenAPI schemas

The route factory lives in routes.ts and the folder’s index.ts re-exports it.

At this layer:

  • schemas.ts defines request/response validation
  • the controller extracts auth context and delegates to the service
  • routes.ts defines OpenAPI, middleware, and handlers

For a complete reference, see Routing.


6. Wire it into the app

This is the step the generator does not complete yet.

In container.ts

Create the repository and service and include the service in the return value from buildStacks().

In routes/index.ts

Export the new route factory from the route barrel.

In index.ts

Create the route instance and add it to the routes object passed into createApp().

In app.ts

Add the route to the CreateAppDeps['routes'] type and mount it with app.route('/api/newsletters', deps.routes.newsletters).

Toast keeps this top-level wiring explicit on purpose. It makes the runtime graph easy to read and prevents hidden magic.


7. Test the feature

Use the current testing pattern:

  • service tests inject plain mock objects directly into the factory
  • route factory tests mock middleware and service dependencies, not modules deep in the graph
  • integration tests use the real test database helpers from shared/db/integration-tests/
pnpm --filter @toast/api test src/services/newsletters.service.test.ts
pnpm --filter @toast/api test src/routes/newsletters/newsletters-factory.test.ts
pnpm test:coverage

For current examples, see Testing.


8. Verify it end to end

td routes | grep newsletters
td status

Then hit the route with your browser, curl, or the generated API docs in apps/docs.


  • Routing — route/controller/schema reference
  • Data Access — schema and repository conventions
  • Request Lifecycle — how requests flow through the runtime
  • Events — when and how to emit domain events
  • Testing — current service/route/integration patterns

On this page