Entity Scaffolding CLI: 90% Faster Development
The Problem
In a strict 4-layer architecture, creating a new domain entity requires manually creating 7 files across multiple packages:
- Schema (
packages/core/src/schemas/{entity}.ts) - Types (
packages/core/src/types/{entity}.ts) - DB Table (
packages/core/src/db/{entity}.ts) - Domain Service (
packages/core/src/domain/{entity}/{entity}Service.ts) - Repository (
packages/db/src/repositories/{entity}Repository.ts) - Application Service (
packages/services/src/{entity}ApplicationService.ts) - API Route (
apps/api-v2/src/routes/{entity}.ts)
Plus manually updating 3 index.ts files to export the new modules.
Time cost: 15-20 minutes of repetitive work, prone to copy-paste errors and inconsistencies.
The Solution
A single command that generates all 7 files with correct boilerplate:
pnpm run gen:entity product
Output:
Scaffolding entity: Product...
Created packages/core/src/schemas/product.ts
Created packages/core/src/types/product.ts
Created packages/core/src/db/product.ts
Created packages/core/src/domain/product/productService.ts
Created packages/db/src/repositories/productRepository.ts
Created packages/services/src/productApplicationService.ts
Created apps/api-v2/src/routes/product.ts
Updating index files...
Updated packages/core/src/schemas/index.ts
Updated packages/core/src/types/index.ts
Updated packages/core/src/db/index.ts
Done!
Time cost: ~1 second.
What Gets Generated
1. Schema with CRUD Variants
// packages/core/src/schemas/product.ts
import { z } from 'zod';
export const productSchema = z.object({
id: z.string().uuid(),
createdAt: z.date(),
updatedAt: z.date(),
// Add your fields here
});
export const createProductSchema = productSchema.omit({
id: true,
createdAt: true,
updatedAt: true
});
export const updateProductSchema = productSchema.partial().extend({
id: z.string().uuid(),
});
2. Type-Safe Types (z.infer)
// packages/core/src/types/product.ts
import { z } from 'zod';
import { productSchema, createProductSchema, updateProductSchema } from '../schemas/product';
export type Product = z.infer<typeof productSchema>;
export type CreateProductInput = z.infer<typeof createProductSchema>;
export type UpdateProductInput = z.infer<typeof updateProductSchema>;
3. Drizzle Table Definition
// packages/core/src/db/product.ts
import { pgTable, timestamp, uuid } from 'drizzle-orm/pg-core';
export const products = pgTable('products', {
id: uuid('id').primaryKey().defaultRandom(),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
// Add your columns here
});
4. Domain Service (Pure Business Logic)
// packages/core/src/domain/product/productService.ts
import { Product, CreateProductInput } from '../../types/product';
export class ProductService {
create(input: CreateProductInput): Omit<Product, 'id' | 'createdAt' | 'updatedAt'> {
return {
...input,
// Add domain logic here
};
}
}
5. Repository (Data Access)
// packages/db/src/repositories/productRepository.ts
import { db } from '../../index';
import { products } from '@workspace/core/db/product';
import { Product } from '@workspace/core/types/product';
import { eq } from 'drizzle-orm';
export class ProductRepository {
async create(data: Omit<Product, 'id' | 'createdAt' | 'updatedAt'>): Promise<Product> {
const [result] = await db.insert(products).values(data).returning();
return result;
}
async findById(id: string): Promise<Product | undefined> {
return await db.query.products.findFirst({
where: eq(products.id, id),
});
}
}
6. Application Service (Orchestration)
// packages/services/src/productApplicationService.ts
import { ProductService } from '@workspace/core/domain/product/productService';
import { ProductRepository } from '@workspace/db/repositories/productRepository';
import { CreateProductInput, Product } from '@workspace/core/types/product';
export class ProductApplicationService {
constructor(
private service: ProductService,
private repo: ProductRepository
) {}
async create(input: CreateProductInput): Promise<Product> {
const domainEntity = this.service.create(input);
return await this.repo.create(domainEntity);
}
async getById(id: string): Promise<Product | undefined> {
return await this.repo.findById(id);
}
}
7. Thin API Route (HTTP Handler)
// apps/api-v2/src/routes/product.ts
import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { createProductSchema } from '@workspace/core/schemas/product';
import { ProductApplicationService } from '@workspace/services/productApplicationService';
import { ProductService } from '@workspace/core/domain/product/productService';
import { ProductRepository } from '@workspace/db/repositories/productRepository';
const app = new Hono();
// Dependency Injection
const service = new ProductService();
const repo = new ProductRepository();
const appService = new ProductApplicationService(service, repo);
app.post('/', zValidator('json', createProductSchema), async (c) => {
const input = c.req.valid('json');
const result = await appService.create(input);
return c.json(result, 201);
});
app.get('/:id', async (c) => {
const id = c.req.param('id');
const result = await appService.getById(id);
if (!result) return c.notFound();
return c.json(result);
});
export default app;
After Generation
- Customize the schema - Add your entity-specific fields
- Update the DB table - Add columns matching your schema
- Implement domain logic - Add business rules to the service
- Generate migration -
drizzle-kit generate(creates SQL migration files) - Validate -
pnpm run check:all
Design Decisions
Why String Concatenation?
Initially used template literals, but encountered syntax errors with tsx/esbuild. String concatenation with + operator proved more robust for generating code with nested template literals.
Why Auto-Update Index Files?
Saves manual work and ensures exports are consistent. The script checks for existing exports to prevent duplicates.
Why Skip Existing Files?
Prevents accidental overwrites of customized code. Users can delete files manually if they want to regenerate.
Impact
Before: 15-20 minutes of manual file creation
After: 1 command, ~1 second
Time Savings: ~90% reduction in scaffolding time
Quality Improvements:
- ✅ Consistent boilerplate across all entities
- ✅ No copy-paste errors
- ✅ Correct import paths every time
- ✅ Follows architectural patterns exactly
Usage
# Generate a "customer" entity
pnpm run gen:entity customer
# Generate an "order" entity
pnpm run gen:entity order
# Generate a "product" entity
pnpm run gen:entity product
Notes
- Entity names are converted to PascalCase for classes and camelCase for files
- Existing files are never overwritten (script will skip them)
- The script creates directories as needed
- Use singular entity names (e.g.,
product, notproducts)
Conclusion
The scaffolding CLI transforms the most tedious part of development—creating boilerplate—into a one-second operation. This allows developers and AI agents to focus on what matters: implementing business logic, not wiring up files.
For AI agents in particular, this tool is invaluable. It ensures that every entity follows the exact same architectural patterns, making the codebase more predictable and easier to reason about.
See also:
- docs/SCAFFOLDING.md - Quick reference guide
- AI-Proof Monorepo Architecture - The 4-layer architecture this tool supports