# Module Pattern: types → repository → routes

> A consistent, agent-friendly structure for service modules. Every feature follows the same 3-file pattern, making it trivial for any AI agent to add new endpoints.

## When to Use

- Adding a new feature domain to a backend service
- Structuring any REST API module
- When you want AI agents to produce consistent code across modules

## The Pattern

Every module in a service follows this structure:

```
src/modules/<feature>/
├── types.ts        ← Zod schemas, TypeScript interfaces, constants
├── repository.ts   ← Database operations (CRUD, queries)
├── routes.ts       ← HTTP endpoints (wiring schemas to repository)
└── <feature>.test.ts ← Tests for the module
```

This pattern was used across 25+ modules with 847+ tests in a single service.

## File 1: types.ts

**Purpose:** Define the data shapes. No business logic, no I/O.

```typescript
import { z } from 'zod';

// ── Document type (what's stored in DB) ──────────────────────
export interface NotificationDoc {
  id: string;
  userId: string;
  productId: string;
  type: 'email' | 'push' | 'in-app';
  title: string;
  body: string;
  read: boolean;
  createdAt: string;
  updatedAt: string;
}

// ── Request schemas (validated by Zod) ───────────────────────
export const CreateNotificationSchema = z.object({
  type: z.enum(['email', 'push', 'in-app']),
  title: z.string().min(1).max(200),
  body: z.string().min(1).max(2000),
});

export const QueryNotificationsSchema = z.object({
  type: z.enum(['email', 'push', 'in-app']).optional(),
  read: z.coerce.boolean().optional(),
  limit: z.coerce.number().min(1).max(100).default(20),
  offset: z.coerce.number().min(0).default(0),
});

export const UpdateNotificationSchema = z.object({
  read: z.boolean().optional(),
  title: z.string().min(1).max(200).optional(),
});

// ── Constants ────────────────────────────────────────────────
export const NOTIFICATION_TYPES = ['email', 'push', 'in-app'] as const;
export const MAX_NOTIFICATIONS_PER_USER = 1000;
```

**Key rules:**

- Zod schemas for ALL request/response validation
- TypeScript interfaces for document shapes
- Export everything — routes and tests import from here
- No imports from other modules (types are self-contained)

## File 2: repository.ts

**Purpose:** All database operations. Pure data access, no HTTP concerns.

```typescript
import { Container } from '@azure/cosmos';
import type { NotificationDoc } from './types.js';

export class NotificationRepository {
  constructor(private container: Container) {}

  async create(
    userId: string,
    productId: string,
    data: { type: string; title: string; body: string }
  ): Promise<NotificationDoc> {
    const doc: NotificationDoc = {
      id: crypto.randomUUID(),
      userId,
      productId,
      ...data,
      read: false,
      createdAt: new Date().toISOString(),
      updatedAt: new Date().toISOString(),
    };
    const { resource } = await this.container.items.create(doc);
    return resource as NotificationDoc;
  }

  async getById(id: string, userId: string): Promise<NotificationDoc | null> {
    try {
      const { resource } = await this.container.item(id, userId).read<NotificationDoc>();
      return resource ?? null;
    } catch {
      return null;
    }
  }

  async list(
    userId: string,
    productId: string,
    opts: { type?: string; read?: boolean; limit: number; offset: number }
  ): Promise<{ items: NotificationDoc[]; total: number }> {
    const conditions = ['c.userId = @userId', 'c.productId = @productId'];
    const params: { name: string; value: unknown }[] = [
      { name: '@userId', value: userId },
      { name: '@productId', value: productId },
    ];

    if (opts.type) {
      conditions.push('c.type = @type');
      params.push({ name: '@type', value: opts.type });
    }
    if (opts.read !== undefined) {
      conditions.push('c.read = @read');
      params.push({ name: '@read', value: opts.read });
    }

    const where = conditions.join(' AND ');

    // Count query
    const { resources: countRes } = await this.container.items
      .query<number>({
        query: `SELECT VALUE COUNT(1) FROM c WHERE ${where}`,
        parameters: params,
      })
      .fetchAll();

    // Data query with pagination
    const { resources: items } = await this.container.items
      .query<NotificationDoc>({
        query: `SELECT * FROM c WHERE ${where} ORDER BY c.createdAt DESC OFFSET @offset LIMIT @limit`,
        parameters: [
          ...params,
          { name: '@offset', value: opts.offset },
          { name: '@limit', value: opts.limit },
        ],
      })
      .fetchAll();

    return { items, total: countRes[0] ?? 0 };
  }

  async update(
    id: string,
    userId: string,
    updates: Partial<NotificationDoc>
  ): Promise<NotificationDoc | null> {
    const existing = await this.getById(id, userId);
    if (!existing) return null;

    const updated = { ...existing, ...updates, updatedAt: new Date().toISOString() };
    const { resource } = await this.container.item(id, userId).replace(updated);
    return resource as NotificationDoc;
  }

  async delete(id: string, userId: string): Promise<boolean> {
    try {
      await this.container.item(id, userId).delete();
      return true;
    } catch {
      return false;
    }
  }
}
```

**Key rules:**

- Constructor takes a `Container` (dependency injection)
- Every method takes `userId` for ownership scoping
- Every document includes `productId` for multi-tenancy
- Returns `null` on not-found (don't throw)
- `SELECT VALUE COUNT(1)` returns `number`, not `{count: number}` (common gotcha)

## File 3: routes.ts

**Purpose:** HTTP layer. Wires validation to repository. Auth, logging, error handling.

```typescript
import type { FastifyInstance } from 'fastify';
import { NotificationRepository } from './repository.js';
import {
  CreateNotificationSchema,
  QueryNotificationsSchema,
  UpdateNotificationSchema,
} from './types.js';

export default async function notificationRoutes(app: FastifyInstance) {
  const container = app.cosmos.container('notifications');
  const repo = new NotificationRepository(container);

  // List notifications
  app.get('/notifications', { preHandler: [app.authenticate] }, async (request, reply) => {
    const query = QueryNotificationsSchema.parse(request.query);
    const result = await repo.list(request.user.sub, request.user.productId, query);
    return result;
  });

  // Get single notification
  app.get('/notifications/:id', { preHandler: [app.authenticate] }, async (request, reply) => {
    const { id } = request.params as { id: string };
    const item = await repo.getById(id, request.user.sub);
    if (!item) return reply.code(404).send({ error: 'Notification not found' });
    return item;
  });

  // Create notification
  app.post('/notifications', { preHandler: [app.authenticate] }, async (request, reply) => {
    const body = CreateNotificationSchema.parse(request.body);
    const item = await repo.create(request.user.sub, request.user.productId, body);
    return reply.code(201).send(item);
  });

  // Update notification
  app.put('/notifications/:id', { preHandler: [app.authenticate] }, async (request, reply) => {
    const { id } = request.params as { id: string };
    const body = UpdateNotificationSchema.parse(request.body);
    const item = await repo.update(id, request.user.sub, body);
    if (!item) return reply.code(404).send({ error: 'Notification not found' });
    return item;
  });

  // Delete notification
  app.delete('/notifications/:id', { preHandler: [app.authenticate] }, async (request, reply) => {
    const { id } = request.params as { id: string };
    const deleted = await repo.delete(id, request.user.sub);
    if (!deleted) return reply.code(404).send({ error: 'Notification not found' });
    return reply.code(204).send();
  });
}
```

**Key rules:**

- `preHandler: [app.authenticate]` on every route (except public endpoints)
- Zod `.parse()` on request body/query (throws 400 on invalid input)
- Repository handles all DB logic; routes only do HTTP concerns
- Use `request.user.sub` for user ID (from JWT), `request.user.productId` for tenancy

## Registration

In `server.ts`, register the module:

```typescript
await server.register(import('./modules/notifications/routes.js'), { prefix: '/api' });
```

In `cosmos-init.ts`, register the container:

```typescript
{ id: 'notifications', partitionKey: { paths: ['/userId'] } },
```

## Why This Pattern Works with AI Agents

1. **Predictable** — Agent knows exactly which files to create/modify
2. **Isolated** — Changes to one module don't affect others
3. **Testable** — Repository can be tested with mocked Container
4. **Copy-able** — "Create a new module like notifications" works perfectly
5. **Searchable** — Agent can grep for patterns across all modules

## Telling the Agent

```
Create a new "comments" module in src/modules/comments/ following the
exact pattern of src/modules/notifications/ (types.ts → repository.ts → routes.ts).

Endpoints needed:
- GET /comments?itemId=xxx (list by item)
- POST /comments (create, authenticated)
- PUT /comments/:id (update, owner only)
- DELETE /comments/:id (delete, owner or admin)

Cosmos container: comments, partition key: /itemId
Include 15+ tests.
```

## Anti-Patterns

- **Business logic in routes** — Routes should be thin; logic goes in repository
- **Database calls in types** — Types are pure data shapes, no I/O
- **Shared repository instances** — Create per-request or per-module, not global singletons
- **Missing Zod validation** — Every input must be validated
- **God modules** — If a module has 20+ endpoints, split it

## Related Skills

- [14 — Shared Package Extraction](./14-shared-packages.md)
- [08 — Test-Driven Bug Fixing](./08-test-driven-fixing.md)
- [01 — AGENTS.md Pattern](./01-agents-md-pattern.md)
