# New API Development Workflow

Author: Nguyen Thi Nguyet Que Created on: May 2, 2026 Updated: May 2, 2026 — Migrated to Zod-based
contracts

Example domain: `product`

---

## ✅ A. Quick Start

> Read this section only. The rest of the doc is reference material.

```bash
# 1. Create migration file in sql/migrations/, then:
pnpm migrate

# 2. Generate model + provider
pnpm gen-db

# 3. Generate Zod contracts + controllers
pnpm gen-api Product

# 4. Fill in Zod schema validation rules  →  src/contracts/product.schema.ts
# 5. Customize controllers if needed  →  src/controllers/api/v1/products/
# 6. Add OpenAPI path registrations  →  see docs/swagger.md
# 7. Write tests  →  see docs/testing.md
```

**What the tools generate for you:**

| File                                       | Generated by   | Regenerate Behavior | Safe to Edit?                       |
| ------------------------------------------ | -------------- | ------------------- | ----------------------------------- |
| `src/models/Product.ts`                    | `pnpm gen-db`  | Always overwritten  | ❌ NO                               |
| `src/providers/ProductProvider.ts`         | `pnpm gen-db`  | Always overwritten  | ⚠️ Add methods only after first gen |
| `src/contracts/product/schema.ts`          | `pnpm gen-api` | Append if exists    | ✅ YES                              |
| `src/controllers/api/v1/products/index.ts` | `pnpm gen-api` | Always overwritten  | ❌ NO                               |
| `src/controllers/api/v1/products/{id}.ts`  | `pnpm gen-api` | Always overwritten  | ❌ NO                               |

**What you write by hand:**

- ✅ **Zod schema validation rules** (business logic — the tool can't know these) —
  `src/contracts/<domain>/schema.ts`
- ✅ **Controller customizations** when default CRUD isn't enough — but beware: `gen-api` will
  overwrite
- ✅ **Service layer** when transactions or complex logic are needed — `src/services/`
- ✅ **OpenAPI path registrations** in `src/contracts/<domain>/paths.ts` for type-safe API
  documentation
- ✅ **Custom provider methods** (joins, subqueries) — add to generated Provider after first gen

**Important Rules:**

- ❌ **NEVER edit** `src/models/` files — they will be overwritten by `gen-db`
- ❌ **NEVER edit** controller files if you plan to run `gen-api` again — they will be overwritten
- ✅ **Edit Zod contracts freely** — they are appended to, never overwritten
- ✅ **Add custom methods to Provider** after first generation — safe if you don't re-run `gen-db`
- ⚠️ **If you customize controllers**, consider copying them to a backup or avoid re-running
  `gen-api` for that model

**Code Quality Conventions:**

- 📊 **Database table names**: Use **plural** form (e.g., `products`, `users`, `notifications`)
- 📁 **Model names**: Use PascalCase singular (e.g., `Product`, `User`, `Notification`)
- 📁 **Contract paths**: Use `src/contracts/<domain>/schema.ts` (directory structure, not hyphenated
  filenames)
- 🔐 **Foreign key columns**: Use `_<table>_id` suffix (e.g., `user_id`, `product_id`)
- 🕐 **Timestamp columns**: Use `created_at`, `updated_at`, `deleted_at` (snake_case)
- 🗑️ **Soft delete**: Include `deleted_at TIMESTAMP WITH TIME ZONE` column for soft delete support
- 🚫 **Database schema changes**: NEVER modify database structure directly (SQL console, GUI tools,
  etc.)
  - Always use migration files in `sql/migrations/`
  - In production, restrict schema changes via PostgreSQL permissions:
    ```sql
    -- Example: Revoke schema modification rights
    REVOKE CREATE ON SCHEMA public FROM app_user;
    REVOKE ALTER ON ALL TABLES IN SCHEMA public FROM app_user;
    ```
  - Use read-only database users for application connections where possible

**Validation Pattern:**

Controllers import Zod contracts directly and use `validateZod` middleware from
`src/middlewares/validators`:

```typescript
import { validateZod } from '#middlewares/validators';
import { ProductCreateBodySchema } from '#contracts/product/schema';

middleware: [validateZod(ProductCreateBodySchema)];
```

Common validators (`validateId`, `validateEmail`, `validateSlug`) are also available from
`#middlewares/validators`.

---

## 🤖 B. Auto-generated Parts

### B1. Database → Model & Provider

Create a SQL migration file in `sql/migrations/`:

```sql
-- sql/migrations/099_create_products.sql
CREATE TABLE IF NOT EXISTS products (
	id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
	name VARCHAR(200) NOT NULL,
	price NUMERIC(12, 2) NOT NULL,
	created_by UUID REFERENCES users(id) ON DELETE SET NULL,
	updated_by UUID REFERENCES users(id) ON DELETE SET NULL,
	created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
	updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
	deleted_at TIMESTAMP WITH TIME ZONE  -- soft delete
);
```

Then run (PostgreSQL must be running):

```bash
pnpm migrate
pnpm gen-db
```

`pnpm gen-db` generates — **do not edit by hand**, regenerate when the schema changes:

- `src/models/Product.ts` — Sequelize model
- `src/providers/ProductProvider.ts` — BaseProvider wrapper with singleton pattern

Generated provider structure:

```typescript
export class ProductProvider extends BaseProvider<Product> {
	public static instance: ProductProvider;

	public static getInstance(): ProductProvider {
		ProductProvider.instance ??= new ProductProvider();
		return ProductProvider.instance;
	}

	public static get model() {
		return Product;
	}

	constructor() {
		super('Product');
	}
}
```

If you need custom query methods (joins, subqueries), add them directly to the generated class
without regenerating.

#### Available provider methods

| Method                    | Description                                            |
| ------------------------- | ------------------------------------------------------ |
| `getAll(payload)`         | List with pagination/filter (use with `queryModifier`) |
| `getById({ id })`         | Get by primary key, returns null if not found          |
| `getOne({ where })`       | Get one record by condition                            |
| `create(body)`            | Create a new record                                    |
| `put(id, body)`           | Update by id                                           |
| `delete(id)`              | Delete by id (soft delete if model has `deleted_at`)   |
| `bulkCreate(array)`       | Create multiple records                                |
| `bulkUpdate(where, body)` | Update multiple records                                |
| `exists(where)`           | Check if record exists                                 |
| `count(where)`            | Count records                                          |

---

### B2. API Scaffold → Validator + Controllers

```bash
pnpm gen-api <Model> [domain] [apiPath] [swaggerTag]
```

| Argument     | Required | Description                                            | Default               |
| ------------ | -------- | ------------------------------------------------------ | --------------------- |
| `Model`      | ✅       | PascalCase model name matching `src/models/<Model>.ts` | —                     |
| `domain`     | ❌       | Sub-folder inside `providers/` for grouping            | _(none)_              |
| `apiPath`    | ❌       | URL path segment under `/api/v1/`                      | Pluralized model name |
| `swaggerTag` | ❌       | Swagger tag group name                                 | Model name            |

```bash
pnpm gen-api Product
```

Generated files and behavior:

| File                                       | Behavior                                 |
| ------------------------------------------ | ---------------------------------------- |
| `src/providers/ProductProvider.ts`         | Created only if it doesn't already exist |
| `src/middlewares/validators/product.ts`    | Created only if it doesn't already exist |
| `src/controllers/api/v1/products/index.ts` | Always regenerated (overwritten)         |
| `src/controllers/api/v1/products/{id}.ts`  | Always regenerated (overwritten)         |

Pluralization rules for the default `apiPath`:

| Model name | Generated path |
| ---------- | -------------- |
| `Product`  | `/products`    |
| `Category` | `/categories`  |
| `Bus`      | `/buses`       |

For bulk-operation APIs (no `{id}.ts`):

```bash
pnpm gen-api-bulk <Model>
```

> ⚠️ **Important:** Only route files may exist inside `src/controllers/api/v1/`. Any non-route `.ts`
> file (helpers, utils) will cause a startup crash. Place utilities in `src/services/` instead.

---

## ✍️ C. Manual Required Parts

### C1. Fill Zod Schema Validation Rules

`pnpm gen-api` created `src/contracts/product.schema.ts` with TODO placeholders. Replace them with
actual business rules — the tool cannot infer these.

The project uses **Zod** with `validateZod` from `#middlewares/validators`. The same Zod schemas are
used for both runtime validation and OpenAPI documentation generation.

```typescript
import { z } from 'zod';
import { extendZodWithOpenApi } from '@asteasolutions/zod-to-openapi';

extendZodWithOpenApi(z);

export const ProductCreateBodySchema = z
	.object({
		name: z.string().min(1).max(200),
		price: z.number().positive(),
	})
	.openapi('ProductCreateBody');

export const ProductUpdateBodySchema = z
	.object({
		name: z.string().min(1).max(200).optional(),
		price: z.number().positive().optional(),
	})
	.openapi('ProductUpdateBody');
```

The controller templates automatically use `validateZod(ProductCreateBodySchema)` middleware.
`validateZod` validates `req.body` and throws a standardized `VALIDATION_ERROR` response on failure.

---

### C2. Customize Controller (if needed)

`pnpm gen-api` generated complete controllers at `src/controllers/api/v1/products/`. For standard
CRUD, **use them as-is**.

Only edit if you need: custom permissions, additional data joins, conditional response shape, or
extra business checks.

`express-automatic-routes` auto-mounts files by path:

- `products/index.ts` → `GET /api/v1/products`, `POST /api/v1/products`
- `products/{id}.ts` → `GET /api/v1/products/:id`, `PUT /api/v1/products/:id`,
  `DELETE /api/v1/products/:id`

#### `index.ts` — GET list and POST create

```typescript
import { Application } from 'express';
import { Resource } from 'express-automatic-routes';
import { ProductProvider } from '#providers/ProductProvider';
import { Req, Res } from '#interfaces/IApi';
import { queryModifier } from '#middlewares/query-modifier';
import verify from '#middlewares/auth';
import { validateZod } from '#middlewares/validators';
import { ProductCreateBodySchema } from '#contracts/product.schema';

export default (_express: Application) => {
	const provider = ProductProvider.getInstance();
	return <Resource>{
		get: {
			middleware: [verify, queryModifier],
			handler: async (req: Req, res: Res) => {
				try {
					const data = await provider.getAll(req.payload || {});
					return res.sendOk({ data });
				} catch (error) {
					return res.error(error);
				}
			},
		},
		post: {
			middleware: [verify, validateZod(ProductCreateBodySchema)],
			handler: async (req: Req, res: Res) => {
				try {
					const data = await provider.create({
						...req.body,
						created_by: req.user?.id || null,
					});
					return res.sendOk({ data });
				} catch (error) {
					return res.error(error);
				}
			},
		},
	};
};
```

#### `{id}.ts` — GET by id, PUT update, DELETE

```typescript
import { Application } from 'express';
import { Resource } from 'express-automatic-routes';
import { ProductProvider } from '#providers/ProductProvider';
import { Req, Res } from '#interfaces/IApi';
import verify from '#middlewares/auth';
import { validateId } from '#middlewares/validators';
import { validateZod } from '#middlewares/validators';
import { ProductUpdateBodySchema } from '#contracts/product.schema';
import { Errors } from '#interfaces/error';

export default (_express: Application) => {
	const provider = ProductProvider.getInstance();
	return <Resource>{
		get: {
			middleware: [verify, validateId],
			handler: async (req: Req, res: Res) => {
				try {
					const data = await provider.getById({ id: req.params.id! });
					if (!data) throw Errors.notFound('Product');
					return res.sendOk({ data });
				} catch (error) {
					return res.error(error);
				}
			},
		},
		put: {
			middleware: [verify, validateId, validateZod(ProductUpdateBodySchema)],
			handler: async (req: Req, res: Res) => {
				try {
					const data = await provider.put(req.params.id!, {
						...req.body,
						updated_by: req.user?.id || null,
					});
					return res.sendOk({ data });
				} catch (error) {
					return res.error(error);
				}
			},
		},
		delete: {
			middleware: [verify, validateId],
			handler: async (req: Req, res: Res) => {
				try {
					await provider.delete(req.params.id!);
					return res.sendOk({ data: true });
				} catch (error) {
					return res.error(error);
				}
			},
		},
	};
};
```

---

### C3. Zod Contracts (API Documentation)

The project uses **Zod schemas** with `@asteasolutions/zod-to-openapi` for type-safe API
documentation. See [swagger.md](swagger.md) for the full guide.

**Quick steps:**

1. Create `src/contracts/product.schema.ts` with Zod schemas:

```typescript
import { z } from 'zod';
import { ApiResponse } from './shared';

export const ProductSchema = z
	.object({
		id: z.uuid(),
		name: z.string().min(1).max(200),
		price: z.number().positive(),
		created_at: z.iso.datetime(),
	})
	.openapi('Product');

export const ProductResponseSchema = ApiResponse(z.object({ product: ProductSchema })).openapi(
	'ProductResponse',
);
```

2. Create `src/contracts/product.paths.ts` to register paths:

```typescript
import { type OpenAPIRegistry } from '@asteasolutions/zod-to-openapi';
import { ProductResponseSchema } from './product.schema';

export function registerProductPaths(registry: OpenAPIRegistry) {
	registry.registerPath({
		method: 'post',
		path: '/api/v1/products',
		operationId: 'createProduct',
		tags: ['Products'],
		request: {
			body: { content: { 'application/json': { schema: CreateProductBodySchema } } },
		},
		responses: {
			200: {
				description: 'Product created',
				content: { 'application/json': { schema: ProductResponseSchema } },
			},
		},
	});
}
```

3. Add to `lib/generator/openapi/generate-openapi.ts`:

```typescript
import { registerProductPaths } from '../src/contracts/product.paths';
registerProductPaths(registry);
```

4. Use `satisfies` in controller for type safety:

```typescript
import type { ProductResponse } from '#contracts/product.schema';

const payload = {
	success: true,
	message: null,
	message_en: null,
	trace_id: requestId,
	timeStamp: new Date().toISOString(),
	violations: null,
	responseData: { product: createdProduct },
} satisfies ProductResponse;

return res.json(payload);
```

5. Generate spec:

```bash
pnpm swagger:generate
```

---

### C4. Tests

See [testing.md](testing.md) for the full testing guide.

Quick rule: test any behavior-changing code. Skip tests for typo fixes, renames, swagger docs, and
pure refactors.

---

## 🧠 D. Optional Advanced

### D1. Service Layer

Only create a Service when you need:

- Transactions across multiple tables
- Complex business logic
- External API calls
- Multi-platform handling

For simple CRUD on a single table, use the Provider directly in the controller — no Service needed.

```typescript
// src/services/productService.ts
export class ProductService {
	static async createWithInventory(data: CreateProductDTO): Promise<Product> {
		return sequelize.transaction(async (t) => {
			const product = await Product.create(data, { transaction: t });
			await Inventory.create({ product_id: product.id, qty: 0 }, { transaction: t });
			return product;
		});
	}
}
```

---

## 🔀 E. Decision Tree

```
Need simple CRUD on one table?
  → Use Provider + Generated Controller (no Service)

Need transaction / multiple tables?
  → Add Service layer

Need custom validation rules?
  → Edit validator (src/middlewares/validators/<model>.ts)

Need custom response shape or business checks?
  → Edit controller

Need helper functions shared across controllers?
  → Put in src/services/<domain>/helpers/ (NOT inside controllers/)
```

---

## 🔗 Related Documentation

- **Error Handling**: See [error-handling.md](error-handling.md) for complete error response
  formats, error types, and best practices
- **Configuration**: See [configuration.md](configuration.md) for environment setup, configuration
  priority, and adding new config options
