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 newslettersToday, that command creates most API boilerplate, not the entire feature.
It scaffolds:
shared/contracts/src/newsletters.ts— contract stubapps/api/src/repositories/newsletters.repository.tsapps/api/src/services/newsletters.service.tsapps/api/src/routes/newsletters/routes.tsapps/api/src/routes/newsletters/newsletters.controller.tsapps/api/src/routes/newsletters/schemas.tsapps/api/src/routes/newsletters/index.ts- 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, andapp.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 migrateIf 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 schemasThe route factory lives in routes.ts and the folder’s index.ts re-exports it.
At this layer:
schemas.tsdefines request/response validation- the controller extracts auth context and delegates to the service
routes.tsdefines 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:coverageFor current examples, see Testing.
8. Verify it end to end
td routes | grep newsletters
td statusThen hit the route with your browser, curl, or the generated API docs in apps/docs.
What to read next
- 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