diff --git a/README.md b/README.md index 0bc2f13f..f7e00ccf 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ This meticulously crafted boilerplate serves as a solid foundation for building - Plugins: [@fastify/helmet](https://github.com/fastify/fastify-helmet) for security headers, [@fastify/swagger](https://github.com/fastify/fastify-swagger) for Swagger documentation, [@fastify/under-pressure](https://github.com/fastify/under-pressure) for automatic handling of "Service Unavailable", [@fastify/awilix](https://github.com/fastify/fastify-awilix) for dependency injection, [typebox](https://github.com/sinclairzx81/typebox) for JSON schema and TS generation and validation - DB: [Postgres](https://github.com/porsager/postgres) as client + [DBMate](https://github.com/amacneil/dbmate) for seeds and migrations - Graphql: [Mercurius](https://github.com/mercurius-js/mercurius) +- Code Generator: **[Hepha](script/HEPHA.md)** πŸ”¨ - CLI tool to generate feature modules (commands, queries, DTOs, domains, repositories) following the project's architecture - Format and Style: [Eslint 9](https://eslint.org/) + [Prettier](https://prettier.io/) - Dependencies validation: [depcruise](https://github.com/sverweij/dependency-cruiser) - Release flow: [Husky](https://github.com/typicode/husky) + [Commitlint](https://commitlint.js.org/) + [Semantic-release](https://github.com/semantic-release/semantic-release) @@ -51,6 +52,8 @@ yarn #Install dependencies. - `yarn db:migrate` - start db migrations. - `yarn db:create-seed` - creates a new db seed. - `yarn db:seed` - start db seeds. +- `yarn hepha [options]` - generate feature modules with commands, queries, DTOs, domains, and repositories. +- `yarn hepha:help` - show Hepha CLI help and options. ## 🧱 Principles @@ -211,6 +214,43 @@ npx openapi-typescript http://127.0.0.1:3000/api-docs/json -o ./client.schema.d. With a little effort you can add this process in the pipeline and have a package published with each version of the backend. Same concept apply for graphql schemas using [graphql-code-generator](https://the-guild.dev/graphql/codegen). +## Hepha - Feature Generator + +**Hepha** is a powerful CLI tool that automates the creation of feature modules following the project's architectural patterns. Named after Hephaestus, the Greek god of craftsmen and builders, Hepha forges well-structured, production-ready code scaffolding. + +### Quick Start + +```bash +# Generate a complete module with all components +yarn hepha product --all + +# Generate specific components using short aliases +yarn hepha order -c=create-order -q=find-orders -d -m -r + +# Add a query to an existing module +yarn hepha user -q=find-by-email +``` + +### What Hepha Generates + +- **Commands** (`-c`): State-changing operations (CREATE, UPDATE, DELETE) +- **Queries** (`-q`): Data-retrieval operations (GET, LIST) +- **DTOs** (`-d`): Request/response validation schemas +- **Domain** (`-m`): Business logic, entities, and domain errors +- **Repository** (`-r`): Database access layer with type-safe operations +- **Mapper**: Transformations between layers (domain ↔ persistence ↔ response) + +### Features + +βœ… Follows project's architectural patterns (DDD, Clean Architecture, CQRS) +βœ… Generates TypeScript with full type safety +βœ… Creates Fastify routes with schema validation +βœ… Integrates with command/query bus pattern +βœ… Supports incremental generation (add components to existing modules) +βœ… Smart file detection (never overwrites existing code) + +**[πŸ“– Full Documentation](script/HEPHA.md)** - Complete guide with examples, options, and workflows + ## Contributing Contributions are always welcome! If you have any ideas, suggestions, fixes, feel free to contribute. You can do that by going through the following steps: diff --git a/package.json b/package.json index 55a395e5..0640a924 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,8 @@ "db:create-seed": "dbmate -e DBMATE_DATABASE_URL -d ./db/seeds/ new", "db:seed": "dbmate -e DBMATE_DATABASE_URL -d ./db/seeds/ up", "db:seed:users": "artillery run ./tests/user/create-user/create-user.artillery.yaml", + "hepha": "node script/hepha.js", + "hepha:help": "node script/hepha.js --help", "generate:client-types": "npx openapi-typescript http://127.0.0.1:3000/api-docs/json -o ./client.schema.d.ts", "depcruise": "depcruise", "deps:validate": "depcruise src --config .dependency-cruiser.js --output-type err-long", diff --git a/script/HEPHA.md b/script/HEPHA.md new file mode 100644 index 00000000..ab3c2252 --- /dev/null +++ b/script/HEPHA.md @@ -0,0 +1,627 @@ +# Hepha - Feature Generator CLI + +> **Hepha** - Named after Hephaestus (Ἥφαιστος), the Greek god of blacksmiths, craftsmen, and builders who forged weapons and tools for the gods. Just as Hephaestus crafted with precision and artistry, Hepha forges well-structured feature modules for your application. πŸ”¨βš‘ + +A NestJS-inspired CLI tool to automate feature creation in the Fastify boilerplate project. This script generates commands, queries, DTOs, domains, and repositories following the vertical slice architecture and project structure principles. + +## Table of Contents + +- [Overview](#overview) +- [Installation](#installation) +- [Usage](#usage) +- [Options](#options) +- [Examples](#examples) +- [Generated Structure](#generated-structure) +- [Workflow](#workflow) +- [Advanced Usage](#advanced-usage) +- [Troubleshooting](#troubleshooting) + +## Overview + +The Feature Generator automates the creation of feature modules with all necessary components: + +- **Commands**: State-changing operations (CREATE, UPDATE, DELETE) +- **Queries**: Data-retrieval operations (GET, LIST) +- **DTOs**: Data Transfer Objects for request/response validation +- **Domain**: Business logic and entity definitions +- **Repository**: Database access layer with type-safe operations +- **Mapper**: Transformations between layers (domain, persistence, response) + +The generator intelligently: + +- βœ… Detects existing modules and components +- βœ… Skips files that already exist to avoid overwriting +- βœ… Creates only the requested components +- βœ… Follows the project's architectural patterns +- βœ… Generates type-safe TypeScript code + +## Installation + +No installation needed! The script is located in the `script/` directory and can be run directly with Node.js. + +Make the script executable (optional): + +```bash +chmod +x script/hepha.js +``` + +## Usage + +### Basic Syntax + +```bash +# Using Node.js directly +node script/hepha.js [options] + +# Or using the npm script +yarn hepha [options] +``` + +### Quick Start + +```bash +# Create a complete module with all components +yarn hepha product --all + +# Create a module with specific commands +yarn hepha product --command=create-product --command=update-product + +# Add a query to an existing module +yarn hepha user --query=find-by-email +``` + +## Options + +| Option | Alias | Description | Example | +| ------------------ | ----------- | ---------------------------------------------------- | ------------------- | +| `--command=` | `-c=` | Create a command handler, route, and schema | `-c=create-product` | +| `--query=` | `-q=` | Create a query handler, route, and schema | `-q=find-products` | +| `--dto` | `-d` | Generate DTO files (response and paginated response) | `-d` | +| `--domain` | `-m` | Generate domain files (types, service, errors) | `-m` (m = model) | +| `--repository` | `-r` | Generate repository files (port and implementation) | `-r` | +| `--all` | `-a` | Generate all components | `-a` | +| `--help` | `-h` | Show help message | `-h` | + +### Notes on Options + +- You can specify multiple `--command` and `--query` options (or their short forms `-c` and `-q`) +- `--all` (or `-a`) flag automatically includes `--dto`, `--domain`, `--repository`, and generates default command/query +- Components are generated only if they don't already exist +- The generator uses kebab-case naming conventions (e.g., `create-product`, `find-products`) +- Short aliases can be mixed with long form options (e.g., `yarn hepha product -c=create --domain -q=find-all`) + +### Short Aliases Quick Reference + +```bash +# These are equivalent: +yarn hepha product --command=create --query=find --dto --domain --repository +yarn hepha product -c=create -q=find -d -m -r + +# Multiple commands/queries: +yarn hepha order -c=create -c=update -q=find -q=find-by-id + +# Mix and match: +yarn hepha user --command=create-user -q=find-users --dto -m +``` + +## Examples + +### Example 1: Create a New Product Module (Complete) + +```bash +yarn hepha product --all +``` + +This generates: + +``` +src/modules/product/ +β”œβ”€β”€ commands/ +β”‚ └── create-product/ +β”‚ β”œβ”€β”€ create-product.handler.ts +β”‚ β”œβ”€β”€ create-product.route.ts +β”‚ └── create-product.schema.ts +β”œβ”€β”€ queries/ +β”‚ └── find-products/ +β”‚ β”œβ”€β”€ find-products.handler.ts +β”‚ β”œβ”€β”€ find-products.route.ts +β”‚ └── find-products.schema.ts +β”œβ”€β”€ database/ +β”‚ β”œβ”€β”€ product.repository.port.ts +β”‚ └── product.repository.ts +β”œβ”€β”€ domain/ +β”‚ β”œβ”€β”€ product.types.ts +β”‚ β”œβ”€β”€ product.domain.ts +β”‚ └── product.errors.ts +β”œβ”€β”€ dtos/ +β”‚ β”œβ”€β”€ product.response.dto.ts +β”‚ └── product.paginated.response.dto.ts +β”œβ”€β”€ product.mapper.ts +└── index.ts +``` + +### Example 2: Create Only Commands and Queries + +```bash +yarn hepha order \ + --command=create-order \ + --command=update-order \ + --command=cancel-order \ + --query=find-orders \ + --query=find-order-by-id +``` + +### Example 3: Add Components to Existing Module + +```bash +# Add a new query to the existing user module +yarn hepha user --query=find-users-by-country + +# Add domain and repository to an incomplete module +yarn hepha product --domain --repository +``` + +### Example 4: Create an Analytics Module (Read-Only) + +```bash +yarn hepha analytics \ + --query=get-user-stats \ + --query=get-revenue-report \ + --dto +``` + +### Example 5: Create CRUD Operations + +```bash +yarn hepha category \ + --command=create-category \ + --command=update-category \ + --command=delete-category \ + --query=find-categories \ + --query=find-category-by-id \ + --all +``` + +### Example 6: Using Short Aliases + +```bash +# Quick module creation with short aliases +yarn hepha payment -c=create-payment -c=cancel-payment -q=find-payments -d -m -r + +# Minimal command with all components +yarn hepha invoice -a + +# Mix long and short form +yarn hepha notification --command=send-notification -q=get-history -d +``` + +## Generated Structure + +### Command Structure + +Each command generates three files: + +**Handler (`.handler.ts`)** + +- Command action creator +- Event action creator +- Business logic orchestration +- Integration with command bus + +**Route (`.route.ts`)** + +- Fastify route definition +- HTTP method and URL +- Schema validation +- Response formatting + +**Schema (`.schema.ts`)** + +- TypeBox schema definitions +- Request DTO type +- Validation rules + +### Query Structure + +Similar to commands but for read operations: + +**Handler (`.handler.ts`)** + +- Query action creator +- Data retrieval logic +- Pagination support + +**Route (`.route.ts`)** + +- Fastify route definition +- Query parameter validation +- Response mapping + +**Schema (`.schema.ts`)** + +- Query parameter schema +- Extends pagination schema + +### Domain Structure + +**Types (`.types.ts`)** + +- Entity interfaces +- Creation property interfaces +- Domain-specific types + +**Domain Service (`.domain.ts`)** + +- Business logic functions +- Entity creation +- Domain rules + +**Errors (`.errors.ts`)** + +- Domain-specific exceptions +- Error codes +- Error messages + +### Repository Structure + +**Port (`.repository.port.ts`)** + +- Repository interface +- Custom method signatures +- Extends base repository + +**Implementation (`.repository.ts`)** + +- Database schema +- Repository implementation +- Custom queries + +### DTO Structure + +**Response DTO (`.response.dto.ts`)** + +- Response schema definition +- Single entity response + +**Paginated Response DTO (`.paginated.response.dto.ts`)** + +- Paginated response schema +- List response format + +### Mapper + +**Mapper (`.mapper.ts`)** + +- `toDomain()`: Database model β†’ Domain entity +- `toResponse()`: Domain entity β†’ Response DTO +- `toPersistence()`: Domain entity β†’ Database model +- Schema validation + +### Module Index + +**Index (`index.ts`)** + +- TypeScript global dependencies declaration +- Action creator factory +- Module exports + +## Workflow + +### 1. Generate the Module + +```bash +yarn hepha blog-post --all +``` + +### 2. Review Generated Files + +Check the `src/modules/blog-post/` directory and review all generated files. + +### 3. Update TODO Comments + +The generator adds TODO comments for required customizations: + +```typescript +// In domain/blog-post.types.ts +export interface CreateBlogPostProps { + // TODO: Add your properties here + name: string; +} +``` + +Update these with your actual business requirements. + +### 4. Create Database Migration + +```bash +yarn db:create-migration create_blog_posts_table +``` + +Edit the migration file in `db/migrations/`: + +```sql +-- migrate:up +CREATE TABLE blog_posts ( + id UUID PRIMARY KEY, + title VARCHAR(255) NOT NULL, + content TEXT NOT NULL, + author_id UUID NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +-- migrate:down +DROP TABLE blog_posts; +``` + +Run the migration: + +```bash +yarn db:migrate +``` + +### 5. Register Dependencies + +Update your DI container configuration to register the new module dependencies: + +```typescript +// In your container configuration +import blogPostMapper from '@/modules/blog-post/blog-post.mapper'; +import blogPostRepository from '@/modules/blog-post/database/blog-post.repository'; +import blogPostDomain from '@/modules/blog-post/domain/blog-post.domain'; + +// Register dependencies +container.register({ + blogPostDomain: asFunction(blogPostDomain).singleton(), + blogPostRepository: asFunction(blogPostRepository).singleton(), + blogPostMapper: asFunction(blogPostMapper).singleton(), +}); +``` + +### 6. Register Routes + +Register the generated routes in your Fastify server: + +```typescript +// Import routes +import createBlogPost from '@/modules/blog-post/commands/create-blog-post/create-blog-post.route'; +import findBlogPosts from '@/modules/blog-post/queries/find-blog-posts/find-blog-posts.route'; + +// Register routes +fastify.register(createBlogPost); +fastify.register(findBlogPosts); +``` + +### 7. Register Handlers + +Initialize command and query handlers: + +```typescript +import makeCreateBlogPost from '@/modules/blog-post/commands/create-blog-post/create-blog-post.handler'; +import makeFindBlogPosts from '@/modules/blog-post/queries/find-blog-posts/find-blog-posts.handler'; + +// Initialize handlers +const createBlogPostHandler = makeCreateBlogPost(container.cradle); +createBlogPostHandler.init(); + +const findBlogPostsHandler = makeFindBlogPosts(container.cradle); +findBlogPostsHandler.init(); +``` + +### 8. Test Your Feature + +```bash +# Start the development server +yarn start + +# Test the endpoints +curl -X POST http://localhost:3000/v1/blog-posts \ + -H "Content-Type: application/json" \ + -d '{"title": "My First Post", "content": "Hello World"}' + +curl http://localhost:3000/v1/blog-posts +``` + +## Advanced Usage + +### Naming Conventions + +The generator automatically handles naming conventions: + +- **kebab-case**: File names and command/query names (`create-blog-post`) +- **PascalCase**: Classes and types (`CreateBlogPost`, `BlogPostEntity`) +- **camelCase**: Variables and function names (`createBlogPost`, `blogPostDomain`) + +### Module Naming + +Choose clear, business-domain-aligned names: + +βœ… **Good**: + +- `product` +- `order` +- `blog-post` +- `user-profile` + +❌ **Avoid**: + +- `api` +- `service` +- `manager` +- `helper` + +### Command vs Query + +**Use Commands for**: + +- Creating resources (`create-user`) +- Updating resources (`update-product`) +- Deleting resources (`delete-order`) +- State changes (`publish-post`, `cancel-subscription`) + +**Use Queries for**: + +- Retrieving single resources (`find-user-by-id`) +- Listing resources (`find-products`) +- Analytics and reports (`get-sales-report`) +- Searching (`search-posts`) + +### Incremental Generation + +Build features incrementally: + +```bash +# Step 1: Start with domain +yarn hepha invoice --domain + +# Step 2: Add repository +yarn hepha invoice --repository --dto + +# Step 3: Add commands +yarn hepha invoice --command=create-invoice --command=send-invoice + +# Step 4: Add queries +yarn hepha invoice --query=find-invoices +``` + +## Troubleshooting + +### Common Issues + +**1. "Module already exists" warnings** + +This is expected behavior. The generator skips existing files to avoid overwriting your changes. + +**2. TypeScript errors after generation** + +Run type checking to see specific errors: + +```bash +yarn type-check +``` + +Common fixes: + +- Update TODO comments with actual types +- Import missing dependencies +- Register dependencies in DI container + +**3. Routes not working** + +Ensure you: + +- Registered routes in Fastify server +- Registered handlers in command/query bus +- Started the server after making changes + +**4. Database errors** + +Verify: + +- Migration files are created and run +- Table names match repository configuration +- Database connection is configured + +### Getting Help + +If you encounter issues: + +1. Check the generated TODO comments +2. Review the existing `user` module as a reference +3. Consult the [README.md](../README.md) for architecture principles +4. Check TypeScript errors: `yarn type-check` +5. Review Fastify logs for runtime errors + +## Best Practices + +### 1. Follow Domain-Driven Design + +Name your modules after business concepts, not technical patterns: + +- βœ… `order`, `payment`, `shipment` +- ❌ `order-service`, `payment-api` + +### 2. Keep Commands and Queries Focused + +Each command/query should have a single responsibility: + +- βœ… `create-order`, `cancel-order`, `update-order-status` +- ❌ `manage-order`, `handle-order-operations` + +### 3. Update Generated Code + +The generator provides templates. Always update: + +- Entity properties in `domain/*.types.ts` +- Business logic in `domain/*.domain.ts` +- Database queries in `database/*.repository.ts` +- Validation schemas in `commands/*/` and `queries/*/` + +### 4. Write Tests + +After generation, create tests: + +- Unit tests for domain logic (`domain/*.spec.ts`) +- Integration tests for repositories +- E2E tests for routes + +### 5. Version Control + +Commit generated files immediately with a clear message: + +```bash +git add src/modules/product +git commit -m "feat: generate product module scaffold" +``` + +Then customize and commit changes separately: + +```bash +git add src/modules/product +git commit -m "feat: implement product creation logic" +``` + +## Script Maintenance + +The Hepha generator script is located at: + +``` +script/hepha.js +``` + +To extend the generator: + +1. Add new template functions +2. Update the `generateFeature()` function +3. Add new command-line options +4. Test with a sample module + +### Why "Hepha"? + +Hepha is named after **Hephaestus** (Ἥφαιστος), the Greek god of: + +- **Blacksmithing and Metalworking** - Crafting with precision +- **Artisans and Craftsmen** - Creating with skill and care +- **Builders and Architects** - Constructing with purpose + +Just as Hephaestus forged legendary weapons and tools for the gods with his divine craftsmanship, Hepha forges well-architected feature modules for your application, following best practices and architectural patterns. + +## Related Documentation + +- [Project README](../README.md) - Architecture and principles +- [Domain-Driven Hexagon](https://github.com/Sairyss/domain-driven-hexagon) - Inspiration +- [Vertical Slice Architecture](https://www.jimmybogard.com/vertical-slice-architecture/) - Architecture pattern +- [Fastify Documentation](https://www.fastify.io/) - Framework docs + +## Contributing + +To improve the generator: + +1. Test your changes with various scenarios +2. Update this documentation +3. Follow the existing code style +4. Add comments for complex logic + +--- + +**Happy coding! πŸš€** diff --git a/script/hepha.js b/script/hepha.js new file mode 100644 index 00000000..fc15ed37 --- /dev/null +++ b/script/hepha.js @@ -0,0 +1,759 @@ +#!/usr/bin/env node + +/** + * Feature Generator CLI + * + * A NestJS-like CLI tool to automate feature creation in the Fastify boilerplate. + * Generates commands, queries, DTOs, domains, and repositories following the project structure. + * + * Usage: + * node script/generate-feature.js [options] + * + * Example: + * node script/generate-feature.js product --command=create --query=find-all --dto --domain --repository + */ + +const fs = require('fs'); +const path = require('path'); + +// Parse command line arguments +function parseArgs() { + const args = process.argv.slice(2); + + if (args.length === 0 || args[0] === '--help' || args[0] === '-h') { + showHelp(); + process.exit(0); + } + + const moduleName = args[0]; + const options = { + commands: [], + queries: [], + dto: false, + domain: false, + repository: false, + all: false, + }; + + for (let i = 1; i < args.length; i++) { + const arg = args[i]; + + if (arg === '--all' || arg === '-a') { + options.all = true; + } else if (arg.startsWith('--command=') || arg.startsWith('-c=')) { + const value = arg.split('=')[1]; + options.commands.push(value); + } else if (arg.startsWith('--query=') || arg.startsWith('-q=')) { + const value = arg.split('=')[1]; + options.queries.push(value); + } else if (arg === '--dto' || arg === '-d') { + options.dto = true; + } else if (arg === '--domain' || arg === '-m') { + options.domain = true; + } else if (arg === '--repository' || arg === '-r') { + options.repository = true; + } + } + + return { moduleName, options }; +} + +function showHelp() { + console.log(` +╔══════════════════════════════════════════════════════════════╗ +β•‘ Hepha - Feature Generator β•‘ +β•‘ Named after Hephaestus, the god of craftsmen πŸ”¨ β•‘ +β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β• + +Usage: + yarn hepha [options] + node script/hepha.js [options] + +Options: + --command=, -c= Create a command (e.g., -c=create-product) + --query=, -q= Create a query (e.g., -q=find-products) + --dto, -d Generate DTO files + --domain, -m Generate domain files (m = model) + --repository, -r Generate repository files + --all, -a Generate all components + --help, -h Show this help message + +Examples: + # Create a new module with a command + yarn hepha product -c=create-product + + # Create a module with everything + yarn hepha product --all + + # Create specific components (using short aliases) + yarn hepha product -c=create -c=update -q=find-all -d -m -r + + # Add a query to existing module + yarn hepha user -q=find-by-email + + # Mix long and short form + yarn hepha order --command=create-order -q=find-orders --dto + `); +} + +// Convert kebab-case to PascalCase +function toPascalCase(str) { + return str + .split('-') + .map(word => word.charAt(0).toUpperCase() + word.slice(1)) + .join(''); +} + +// Convert kebab-case to camelCase +function toCamelCase(str) { + const pascal = toPascalCase(str); + return pascal.charAt(0).toLowerCase() + pascal.slice(1); +} + +// Check if directory exists +function directoryExists(dirPath) { + return fs.existsSync(dirPath) && fs.statSync(dirPath).isDirectory(); +} + +// Check if file exists +function fileExists(filePath) { + return fs.existsSync(filePath) && fs.statSync(filePath).isFile(); +} + +// Create directory if it doesn't exist +function ensureDirectory(dirPath) { + if (!directoryExists(dirPath)) { + fs.mkdirSync(dirPath, { recursive: true }); + console.log(`βœ“ Created directory: ${dirPath}`); + return true; + } + console.log(`β„Ή Directory already exists: ${dirPath}`); + return false; +} + +// Write file if it doesn't exist +function writeFileIfNotExists(filePath, content) { + if (fileExists(filePath)) { + console.log(`⚠ File already exists, skipping: ${filePath}`); + return false; + } + fs.writeFileSync(filePath, content, 'utf8'); + console.log(`βœ“ Created file: ${filePath}`); + return true; +} + +// Template generators +function generateCommandHandler(moduleName, commandName) { + const pascalModule = toPascalCase(moduleName); + const pascalCommand = toPascalCase(commandName); + const camelModule = toCamelCase(moduleName); + + return `import { ${pascalCommand}RequestDto } from './${commandName}.schema'; +import { ${camelModule}ActionCreator } from '@/modules/${moduleName}'; +import { ConflictException } from '@/shared/exceptions'; + +export type ${pascalCommand}CommandResult = Promise; +export const ${toCamelCase(commandName)}Command = + ${camelModule}ActionCreator<${pascalCommand}RequestDto>('${commandName}'); +export const ${toCamelCase(commandName)}Event = + ${camelModule}ActionCreator<${pascalCommand}RequestDto>('${commandName}'); + +export default function make${pascalCommand}({ + ${camelModule}Repository, + ${camelModule}Domain, + commandBus, + eventBus, +}: Dependencies) { + return { + async handler({ + payload, + }: ReturnType): ${pascalCommand}CommandResult { + // TODO: Implement your business logic here + const ${camelModule} = ${camelModule}Domain.create${pascalModule}(payload); + + try { + await ${camelModule}Repository.insert(${camelModule}); + eventBus.emit(${toCamelCase(commandName)}Event(${camelModule})); + return ${camelModule}.id; + } catch (error: any) { + if (error instanceof ConflictException) { + throw error; + } + throw error; + } + }, + init() { + commandBus.register(${toCamelCase(commandName)}Command.type, this.handler); + }, + }; +} +`; +} + +function generateCommandRoute(moduleName, commandName) { + const pascalModule = toPascalCase(moduleName); + const pascalCommand = toPascalCase(commandName); + const camelCommand = toCamelCase(commandName); + + return `import { + ${camelCommand}Command, + ${pascalCommand}CommandResult, +} from '@/modules/${moduleName}/commands/${commandName}/${commandName}.handler'; +import { ${camelCommand}RequestDtoSchema } from '@/modules/${moduleName}/commands/${commandName}/${commandName}.schema'; +import { idDtoSchema } from '@/shared/api/id.response.dto'; +import { TypeBoxTypeProvider } from '@fastify/type-provider-typebox'; + +export default async function ${camelCommand}(fastify: FastifyRouteInstance) { + fastify.withTypeProvider().route({ + method: 'POST', + url: '/v1/${moduleName}', + schema: { + description: '${pascalCommand}', + body: ${camelCommand}RequestDtoSchema, + response: { + 201: idDtoSchema, + }, + tags: ['${moduleName}'], + }, + handler: async (req, res) => { + const id = await fastify.commandBus.execute<${pascalCommand}CommandResult>( + ${camelCommand}Command(req.body), + ); + return res.status(201).send({ id }); + }, + }); +} +`; +} + +function generateCommandSchema(commandName) { + const pascalCommand = toPascalCase(commandName); + + return `import { Static, Type } from '@sinclair/typebox'; + +export const ${toCamelCase(commandName)}RequestDtoSchema = Type.Object({ + // TODO: Add your properties here + name: Type.String({ + example: 'Example Name', + description: 'Name field', + maxLength: 255, + minLength: 1, + }), +}); + +export type ${pascalCommand}RequestDto = Static; +`; +} + +function generateQueryHandler(moduleName, queryName) { + const pascalModule = toPascalCase(moduleName); + const pascalQuery = toPascalCase(queryName); + const camelModule = toCamelCase(moduleName); + const camelQuery = toCamelCase(queryName); + + return `import { ${pascalModule}Entity } from '../../domain/${moduleName}.types'; +import { ${camelModule}ActionCreator } from '@/modules/${moduleName}'; +import { ${pascalModule}Model } from '@/modules/${moduleName}/database/${moduleName}.repository'; +import { Paginated, PaginatedQueryParams } from '@/shared/db/repository.port'; +import { paginatedQueryBase } from '@/shared/ddd/query.base'; + +export type ${pascalQuery}QueryResult = Promise>; +export const ${camelQuery}Query = ${camelModule}ActionCreator< + Partial +>('${queryName}'); + +export default function make${pascalQuery}Query({ + db, + queryBus, + ${camelModule}Mapper, +}: Dependencies) { + return { + async handler({ + payload, + }: ReturnType): ${pascalQuery}QueryResult { + const query = paginatedQueryBase(payload); + + // TODO: Implement your query logic here + const ${camelModule}s: { rows: ${pascalModule}Model[]; count: number }[] = await db\` + SELECT + (SELECT COUNT(*) FROM ${moduleName}s) as count, + (SELECT json_agg(t.*) FROM + (SELECT * FROM ${moduleName}s LIMIT \${query.limit} OFFSET \${query.offset}) + AS t) AS rows + \`; + + return { + data: ${camelModule}s[0].rows?.map((${camelModule}) => ${camelModule}Mapper.toDomain(${camelModule})) ?? [], + count: ${camelModule}s[0].count, + limit: query.limit, + page: query.page, + }; + }, + init() { + queryBus.register(${camelQuery}Query.type, this.handler); + }, + }; +} +`; +} + +function generateQueryRoute(moduleName, queryName) { + const pascalModule = toPascalCase(moduleName); + const pascalQuery = toPascalCase(queryName); + const camelQuery = toCamelCase(queryName); + + return `import { ${camelQuery}Query, ${pascalQuery}QueryResult } from './${queryName}.handler'; +import { ${camelQuery}RequestDtoSchema } from './${queryName}.schema'; +import { ${toCamelCase(moduleName)}PaginatedResponseSchema } from '@/modules/${moduleName}/dtos/${moduleName}.paginated.response.dto'; +import { TypeBoxTypeProvider } from '@fastify/type-provider-typebox'; + +export default async function ${camelQuery}(fastify: FastifyRouteInstance) { + fastify.withTypeProvider().route({ + method: 'GET', + url: '/v1/${moduleName}', + schema: { + description: '${pascalQuery}', + querystring: ${camelQuery}RequestDtoSchema, + response: { + 200: ${toCamelCase(moduleName)}PaginatedResponseSchema, + }, + tags: ['${moduleName}'], + }, + handler: async (req, res) => { + const result = await fastify.queryBus.execute<${pascalQuery}QueryResult>( + ${camelQuery}Query(req.query), + ); + const response = { + ...result, + data: result.data?.map( + fastify.diContainer.cradle.${toCamelCase(moduleName)}Mapper.toResponse, + ), + }; + return res.status(200).send(response); + }, + }); +} +`; +} + +function generateQuerySchema(queryName) { + const pascalQuery = toPascalCase(queryName); + + return `import { paginatedQueryRequestDtoSchema } from '@/shared/api/paginated-query.request.dto'; +import { Static, Type } from '@sinclair/typebox'; + +export const ${toCamelCase(queryName)}RequestDtoSchema = Type.Composite([ + paginatedQueryRequestDtoSchema, + Type.Object({ + // TODO: Add your query parameters here + }), +]); + +export type ${pascalQuery}RequestDto = Static; +`; +} + +function generateDomainTypes(moduleName) { + const pascalModule = toPascalCase(moduleName); + + return `// Properties that are needed for a ${moduleName} creation +export interface Create${pascalModule}Props { + // TODO: Add your properties here + name: string; +} + +export interface ${pascalModule}Entity { + id: string; + name: string; + createdAt: Date; + updatedAt: Date; +} +`; +} + +function generateDomainService(moduleName) { + const pascalModule = toPascalCase(moduleName); + const camelModule = toCamelCase(moduleName); + + return `import { + Create${pascalModule}Props, + ${pascalModule}Entity, +} from '@/modules/${moduleName}/domain/${moduleName}.types'; +import { randomUUID } from 'node:crypto'; + +export default function ${camelModule}Domain() { + return { + create${pascalModule}: (create: Create${pascalModule}Props): ${pascalModule}Entity => { + const now = new Date(); + + return { + id: randomUUID(), + createdAt: now, + updatedAt: now, + ...create, + }; + }, + }; +} +`; +} + +function generateDomainErrors(moduleName) { + const pascalModule = toPascalCase(moduleName); + + return `import { ConflictException, NotFoundException } from '@/shared/exceptions'; + +export class ${pascalModule}AlreadyExistsError extends ConflictException { + static readonly message = '${pascalModule} already exists'; + + constructor(cause?: Error, metadata?: unknown) { + super(${pascalModule}AlreadyExistsError.message, cause, metadata); + } +} + +export class ${pascalModule}NotFoundError extends NotFoundException { + constructor(message = '${pascalModule} not found') { + super(message); + } +} +`; +} + +function generateRepositoryPort(moduleName) { + const pascalModule = toPascalCase(moduleName); + + return `import { ${pascalModule}Entity } from '@/modules/${moduleName}/domain/${moduleName}.types'; +import { RepositoryPort } from '@/shared/db/repository.port'; + +export interface ${pascalModule}Repository + extends RepositoryPort<${pascalModule}Entity> { + // TODO: Add custom repository methods here + findOneByName(name: string): Promise<${pascalModule}Entity | undefined>; +} +`; +} + +function generateRepository(moduleName) { + const pascalModule = toPascalCase(moduleName); + const camelModule = toCamelCase(moduleName); + + return `import { ${pascalModule}Repository } from '@/modules/${moduleName}/database/${moduleName}.repository.port'; +import { ${pascalModule}Entity } from '@/modules/${moduleName}/domain/${moduleName}.types'; +import { Static, Type } from '@sinclair/typebox'; + +export const ${camelModule}Schema = Type.Object({ + id: Type.String({ format: 'uuid' }), + createdAt: Type.String({ format: 'date-time' }), + updatedAt: Type.String({ format: 'date-time' }), + name: Type.String({ minLength: 1, maxLength: 255 }), + // TODO: Add your schema properties here +}); +export type ${pascalModule}Model = Static; + +export default function ${camelModule}Repository({ + db, + ${camelModule}Mapper, + repositoryBase, +}: Dependencies): ${pascalModule}Repository { + const tableName = '${moduleName}s'; + return { + ...repositoryBase({ tableName, mapper: ${camelModule}Mapper }), + async findOneByName(name: string): Promise<${pascalModule}Entity | undefined> { + const [${camelModule}]: [${pascalModule}Model?] = + await db\`SELECT * FROM \${tableName} WHERE name = \${name} LIMIT 1\`; + return ${camelModule} ? ${camelModule}Mapper.toDomain(${camelModule}) : undefined; + }, + }; +} +`; +} + +function generateResponseDto(moduleName) { + const pascalModule = toPascalCase(moduleName); + const camelModule = toCamelCase(moduleName); + + return `import { baseResponseDtoSchema } from '@/shared/api/response.base'; +import { Static, Type } from '@sinclair/typebox'; + +export const ${camelModule}ResponseDtoSchema = Type.Composite([ + baseResponseDtoSchema, + Type.Object({ + name: Type.String({ + example: 'Example Name', + description: '${pascalModule} name', + }), + // TODO: Add your response properties here + }), +]); + +export type ${pascalModule}ResponseDto = Static; +`; +} + +function generatePaginatedResponseDto(moduleName) { + const pascalModule = toPascalCase(moduleName); + const camelModule = toCamelCase(moduleName); + + return `import { ${camelModule}ResponseDtoSchema } from '@/modules/${moduleName}/dtos/${moduleName}.response.dto'; +import { paginatedResponseBaseSchema } from '@/shared/api/paginated.response.base'; +import { Type } from '@sinclair/typebox'; + +export const ${camelModule}PaginatedResponseSchema = Type.Composite([ + paginatedResponseBaseSchema, + Type.Object({ + data: Type.Array(${camelModule}ResponseDtoSchema), + }), +]); +`; +} + +function generateMapper(moduleName) { + const pascalModule = toPascalCase(moduleName); + const camelModule = toCamelCase(moduleName); + + return `import { ${pascalModule}Model, ${camelModule}Schema } from '@/modules/${moduleName}/database/${moduleName}.repository'; +import { ${pascalModule}Entity } from '@/modules/${moduleName}/domain/${moduleName}.types'; +import { ${pascalModule}ResponseDto } from '@/modules/${moduleName}/dtos/${moduleName}.response.dto'; +import { Mapper } from '@/shared/ddd/mapper.interface'; +import { ArgumentInvalidException } from '@/shared/exceptions'; +import { ajv } from '@/shared/utils/validator.util'; + +export default function ${camelModule}Mapper(): Mapper< + ${pascalModule}Entity, + ${pascalModule}Model, + ${pascalModule}ResponseDto +> { + const persistenceValidator = ajv.compile(${camelModule}Schema); + return { + toDomain(record: ${pascalModule}Model): ${pascalModule}Entity { + return { + id: record.id, + createdAt: new Date(record.createdAt), + updatedAt: new Date(record.updatedAt), + name: record.name, + // TODO: Add your domain mapping here + }; + }, + toResponse(entity: ${pascalModule}Entity): ${pascalModule}ResponseDto { + return { + ...entity, + updatedAt: entity.updatedAt.toISOString(), + createdAt: entity.createdAt.toISOString(), + }; + }, + toPersistence(${camelModule}: ${pascalModule}Entity): ${pascalModule}Model { + const record: ${pascalModule}Model = { + id: ${camelModule}.id, + createdAt: ${camelModule}.createdAt.toISOString(), + updatedAt: ${camelModule}.updatedAt.toISOString(), + name: ${camelModule}.name, + // TODO: Add your persistence mapping here + }; + const validate = persistenceValidator(record); + if (!validate) { + throw new ArgumentInvalidException( + JSON.stringify(persistenceValidator.errors), + new Error('Mapper Validation error'), + record, + ); + } + return record; + }, + }; +} +`; +} + +function generateModuleIndex(moduleName) { + const pascalModule = toPascalCase(moduleName); + const camelModule = toCamelCase(moduleName); + + return `import { ${pascalModule}Model } from '@/modules/${moduleName}/database/${moduleName}.repository'; +import { ${pascalModule}Repository } from '@/modules/${moduleName}/database/${moduleName}.repository.port'; +import ${camelModule}Domain from '@/modules/${moduleName}/domain/${moduleName}.domain'; +import { ${pascalModule}Entity } from '@/modules/${moduleName}/domain/${moduleName}.types'; +import { ${pascalModule}ResponseDto } from '@/modules/${moduleName}/dtos/${moduleName}.response.dto'; +import { actionCreatorFactory } from '@/shared/cqrs/action-creator'; +import { Mapper } from '@/shared/ddd/mapper.interface'; + +declare global { + export interface Dependencies { + ${camelModule}Mapper: Mapper<${pascalModule}Entity, ${pascalModule}Model, ${pascalModule}ResponseDto>; + ${camelModule}Repository: ${pascalModule}Repository; + ${camelModule}Domain: ReturnType; + } +} + +export const ${camelModule}ActionCreator = actionCreatorFactory('${moduleName}'); +`; +} + +// Main generation function +function generateFeature({ moduleName, options }) { + const modulePath = path.join(process.cwd(), 'src', 'modules', moduleName); + const moduleExists = directoryExists(modulePath); + + console.log('\n╔══════════════════════════════════════════════════════════════╗'); + console.log(`β•‘ Generating feature: ${moduleName.padEnd(42)} β•‘`); + console.log('β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•\n'); + + if (moduleExists) { + console.log(`β„Ή Module "${moduleName}" already exists. Adding components...\n`); + } else { + console.log(`✨ Creating new module "${moduleName}"...\n`); + } + + // Create base module structure + ensureDirectory(modulePath); + + // Handle --all flag + if (options.all) { + options.dto = true; + options.domain = true; + options.repository = true; + if (options.commands.length === 0) { + options.commands.push(`create-${moduleName}`); + } + if (options.queries.length === 0) { + options.queries.push(`find-${moduleName}s`); + } + } + + // Generate commands + if (options.commands.length > 0) { + console.log('\nπŸ“ Generating Commands:'); + const commandsPath = path.join(modulePath, 'commands'); + ensureDirectory(commandsPath); + + options.commands.forEach(commandName => { + const commandPath = path.join(commandsPath, commandName); + ensureDirectory(commandPath); + + writeFileIfNotExists( + path.join(commandPath, `${commandName}.handler.ts`), + generateCommandHandler(moduleName, commandName) + ); + writeFileIfNotExists( + path.join(commandPath, `${commandName}.route.ts`), + generateCommandRoute(moduleName, commandName) + ); + writeFileIfNotExists( + path.join(commandPath, `${commandName}.schema.ts`), + generateCommandSchema(commandName) + ); + }); + } + + // Generate queries + if (options.queries.length > 0) { + console.log('\nπŸ” Generating Queries:'); + const queriesPath = path.join(modulePath, 'queries'); + ensureDirectory(queriesPath); + + options.queries.forEach(queryName => { + const queryPath = path.join(queriesPath, queryName); + ensureDirectory(queryPath); + + writeFileIfNotExists( + path.join(queryPath, `${queryName}.handler.ts`), + generateQueryHandler(moduleName, queryName) + ); + writeFileIfNotExists( + path.join(queryPath, `${queryName}.route.ts`), + generateQueryRoute(moduleName, queryName) + ); + writeFileIfNotExists( + path.join(queryPath, `${queryName}.schema.ts`), + generateQuerySchema(queryName) + ); + }); + } + + // Generate domain + if (options.domain) { + console.log('\nπŸ—οΈ Generating Domain:'); + const domainPath = path.join(modulePath, 'domain'); + ensureDirectory(domainPath); + + writeFileIfNotExists( + path.join(domainPath, `${moduleName}.types.ts`), + generateDomainTypes(moduleName) + ); + writeFileIfNotExists( + path.join(domainPath, `${moduleName}.domain.ts`), + generateDomainService(moduleName) + ); + writeFileIfNotExists( + path.join(domainPath, `${moduleName}.errors.ts`), + generateDomainErrors(moduleName) + ); + } + + // Generate repository + if (options.repository) { + console.log('\nπŸ’Ύ Generating Repository:'); + const databasePath = path.join(modulePath, 'database'); + ensureDirectory(databasePath); + + writeFileIfNotExists( + path.join(databasePath, `${moduleName}.repository.port.ts`), + generateRepositoryPort(moduleName) + ); + writeFileIfNotExists( + path.join(databasePath, `${moduleName}.repository.ts`), + generateRepository(moduleName) + ); + } + + // Generate DTOs + if (options.dto) { + console.log('\nπŸ“¦ Generating DTOs:'); + const dtosPath = path.join(modulePath, 'dtos'); + ensureDirectory(dtosPath); + + writeFileIfNotExists( + path.join(dtosPath, `${moduleName}.response.dto.ts`), + generateResponseDto(moduleName) + ); + writeFileIfNotExists( + path.join(dtosPath, `${moduleName}.paginated.response.dto.ts`), + generatePaginatedResponseDto(moduleName) + ); + } + + // Generate mapper + if (options.dto || options.repository || options.domain) { + console.log('\nπŸ—ΊοΈ Generating Mapper:'); + writeFileIfNotExists( + path.join(modulePath, `${moduleName}.mapper.ts`), + generateMapper(moduleName) + ); + } + + // Generate module index + console.log('\nπŸ“‹ Generating Module Index:'); + writeFileIfNotExists( + path.join(modulePath, 'index.ts'), + generateModuleIndex(moduleName) + ); + + console.log('\n╔══════════════════════════════════════════════════════════════╗'); + console.log('β•‘ ✨ Generation Complete! ✨ β•‘'); + console.log('β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•\n'); + + console.log('Next steps:'); + console.log(` 1. Review generated files in: src/modules/${moduleName}/`); + console.log(' 2. Update the TODO comments in generated files'); + console.log(' 3. Create database migrations for your entities'); + console.log(' 4. Register routes in your Fastify server'); + console.log(' 5. Register dependencies in your DI container\n'); +} + +// Main execution +try { + const { moduleName, options } = parseArgs(); + generateFeature({ moduleName, options }); +} catch (error) { + console.error('\n❌ Error:', error.message); + process.exit(1); +}