How to Implement a New Feature Using Base Classes
This guide walks through the process of implementing a new feature in the PRS Backend using the BaseService, BaseRepository, and BaseController classes.
Overview
Implementing a new feature typically involves:
- Creating a database model
- Defining validation schemas
- Implementing a repository
- Implementing a service
- Implementing a controller
- Defining routes
We'll use a "Product" feature as an example.
Step 1: Create the Database Model
First, create a model in src/infra/database/models/productModel.js:
| JavaScript |
|---|
| module.exports = (sequelize, Sequelize) => {
const ProductModel = sequelize.define(
'products',
{
id: {
type: Sequelize.UUID,
defaultValue: Sequelize.UUIDV4,
primaryKey: true,
},
name: {
type: Sequelize.STRING(100),
allowNull: false,
},
description: {
type: Sequelize.TEXT,
allowNull: true,
},
price: {
type: Sequelize.DECIMAL(10, 2),
allowNull: false,
},
category: {
type: Sequelize.STRING(50),
allowNull: false,
},
isActive: {
type: Sequelize.BOOLEAN,
defaultValue: true,
},
createdBy: {
type: Sequelize.UUID,
allowNull: true,
},
updatedBy: {
type: Sequelize.UUID,
allowNull: true,
},
},
{
timestamps: true,
underscored: true,
paranoid: true, // Soft delete
}
);
ProductModel.associate = (models) => {
ProductModel.belongsTo(models.userModel, {
foreignKey: 'createdBy',
as: 'creator',
});
};
return ProductModel;
};
|
Step 2: Define Validation Schemas
Create validation schemas in src/domain/entities/productEntity.js:
| JavaScript |
|---|
| const { z } = require('zod');
// Schema for creating a new product
const createProductSchema = z.object({
name: z.string({
required_error: 'Name is required',
}).min(3, 'Name must be at least 3 characters'),
description: z.string().optional(),
price: z.number({
required_error: 'Price is required',
}).positive('Price must be positive'),
category: z.string({
required_error: 'Category is required',
}),
isActive: z.boolean().default(true),
});
// Schema for updating an existing product
const updateProductSchema = createProductSchema.partial();
// Schema for product ID parameter
const getProductParams = z.object({
id: z.string({
required_error: 'Product ID is required',
}),
});
module.exports = {
createProductSchema,
updateProductSchema,
getProductParams,
};
|
Add the entity to src/domain/entities/index.js:
| JavaScript |
|---|
| const product = require('./productEntity');
module.exports = {
// ... existing entities
product,
};
|
Step 3: Implement the Repository
Create a repository in src/infra/repositories/productRepository.js:
| JavaScript |
|---|
| const BaseRepository = require('./baseRepository');
class ProductRepository extends BaseRepository {
constructor({ db }) {
super(db.productModel);
this.db = db;
this.Sequelize = db.Sequelize;
}
// Add custom repository methods here
async findByCategory(category, options = {}) {
return await this.findAll({
...options,
where: {
...options.where,
category,
},
});
}
async findActiveProducts(options = {}) {
return await this.findAll({
...options,
where: {
...options.where,
isActive: true,
},
});
}
}
module.exports = ProductRepository;
|
Step 4: Implement the Service
Create a service in src/app/services/productService.js:
| JavaScript |
|---|
| const BaseService = require('./baseService');
class ProductService extends BaseService {
constructor(container) {
super(container);
const { productRepository, logger } = container;
this.productRepository = productRepository;
this.logger = logger;
}
// Create a new product
async createProduct(data, options = {}) {
this.logger.info('Creating new product', { productName: data.name });
return await this.create(this.productRepository, data, {
...options,
resource: 'Product',
});
}
// Update an existing product
async updateProduct(id, data, options = {}) {
this.logger.info('Updating product', { productId: id });
await this.update(this.productRepository, { id }, data, {
...options,
resource: 'Product',
});
return await this.getProductById(id, options);
}
// Delete a product
async deleteProduct(id, options = {}) {
this.logger.info('Deleting product', { productId: id });
return await this.delete(this.productRepository, { id }, {
...options,
resource: 'Product',
});
}
// Get a product by ID
async getProductById(id, options = {}) {
return await this.getById(this.productRepository, id, {
...options,
resource: 'Product',
});
}
// Get all products with pagination
async getAllProducts(query = {}) {
return await this.getAll(this.productRepository, query);
}
// Get products by category
async getProductsByCategory(category, query = {}) {
this.logger.info('Getting products by category', { category });
return await this.productRepository.findByCategory(category, query);
}
// Get active products
async getActiveProducts(query = {}) {
return await this.productRepository.findActiveProducts(query);
}
}
module.exports = ProductService;
|
Step 5: Implement the Controller
Create a controller in src/app/handlers/controllers/productController.js:
| JavaScript |
|---|
| const BaseController = require('./baseController');
class ProductController extends BaseController {
constructor(container) {
super(container);
const { productService, entities } = container;
this.productService = productService;
this.productEntity = entities.product;
}
// Create a new product
async createProduct(request, reply) {
return this.executeAction(async () => {
const { body, userFromToken } = request;
// Validate request body
const validatedData = this.validate(
this.productEntity.createProductSchema,
body
);
// Add user ID to data
const data = {
...validatedData,
createdBy: userFromToken.id,
};
// Create product
const product = await this.productService.createProduct(data);
// Return created response
return this.sendCreated(reply, product, 'Product created successfully');
}, request, reply);
}
// Update an existing product
async updateProduct(request, reply) {
return this.executeAction(async () => {
const { body, params, userFromToken } = request;
const { id } = params;
// Validate request body
const validatedData = this.validate(
this.productEntity.updateProductSchema,
body
);
// Add updated by user ID
const data = {
...validatedData,
updatedBy: userFromToken.id,
};
// Update product
const product = await this.productService.updateProduct(id, data);
// Return success response
return this.sendSuccess(reply, product, 200, 'Product updated successfully');
}, request, reply);
}
// Delete a product
async deleteProduct(request, reply) {
return this.executeAction(async () => {
const { params } = request;
const { id } = params;
// Delete product
await this.productService.deleteProduct(id);
// Return no content response
return this.sendNoContent(reply);
}, request, reply);
}
// Get a product by ID
async getProductById(request, reply) {
return this.executeAction(async () => {
const { params } = request;
const { id } = params;
// Get product
const product = await this.productService.getProductById(id);
// Return success response
return this.sendSuccess(reply, product);
}, request, reply);
}
// Get all products with pagination
async getAllProducts(request, reply) {
return this.executeAction(async () => {
const { query } = request;
// Get products
const products = await this.productService.getAllProducts(query);
// Return success response
return this.sendSuccess(reply, products);
}, request, reply);
}
// Get products by category
async getProductsByCategory(request, reply) {
return this.executeAction(async () => {
const { params, query } = request;
const { category } = params;
// Get products
const products = await this.productService.getProductsByCategory(category, query);
// Return success response
return this.sendSuccess(reply, products);
}, request, reply);
}
}
module.exports = ProductController;
|
Step 6: Define Routes
Create routes in src/app/handlers/routes/productRoutes.js:
| JavaScript |
|---|
| async function productRoutes(fastify, options) {
const { productController } = fastify.diScope.cradle;
const { authenticate } = fastify.diScope.resolve('middlewares');
// Create a new product
fastify.post(
'/products',
{
preHandler: [authenticate],
schema: {
tags: ['Products'],
summary: 'Create a new product',
description: 'Creates a new product with the provided data',
},
},
productController.createProduct.bind(productController)
);
// Update an existing product
fastify.put(
'/products/:id',
{
preHandler: [authenticate],
schema: {
tags: ['Products'],
summary: 'Update a product',
description: 'Updates an existing product with the provided data',
},
},
productController.updateProduct.bind(productController)
);
// Delete a product
fastify.delete(
'/products/:id',
{
preHandler: [authenticate],
schema: {
tags: ['Products'],
summary: 'Delete a product',
description: 'Deletes an existing product',
},
},
productController.deleteProduct.bind(productController)
);
// Get a product by ID
fastify.get(
'/products/:id',
{
schema: {
tags: ['Products'],
summary: 'Get a product by ID',
description: 'Returns a product by its ID',
},
},
productController.getProductById.bind(productController)
);
// Get all products with pagination
fastify.get(
'/products',
{
schema: {
tags: ['Products'],
summary: 'Get all products',
description: 'Returns all products with pagination',
},
},
productController.getAllProducts.bind(productController)
);
// Get products by category
fastify.get(
'/products/category/:category',
{
schema: {
tags: ['Products'],
summary: 'Get products by category',
description: 'Returns products filtered by category',
},
},
productController.getProductsByCategory.bind(productController)
);
}
module.exports = productRoutes;
|
Add the routes to src/app/handlers/routes/index.js:
| JavaScript |
|---|
| const productRoutes = require('./productRoutes');
module.exports = async function (fastify, options) {
// ... existing routes
fastify.register(productRoutes, { prefix: '/api/v1' });
};
|
Step 7: Register Dependencies
Update the container to register your new components:
| JavaScript |
|---|
| // Register repository
diContainer.register({
productRepository: asClass(ProductRepository, {
lifetime: Lifetime.SINGLETON,
}),
});
// Register service
diContainer.register({
productService: asClass(ProductService, {
lifetime: Lifetime.SINGLETON,
}),
});
// Register controller
diContainer.register({
productController: asClass(ProductController, {
lifetime: Lifetime.SINGLETON,
}),
});
|
Step 8: Test Your Implementation
Write tests for your implementation:
| JavaScript |
|---|
| // Test the repository
describe('ProductRepository', () => {
it('should find products by category', async () => {
// Test implementation
});
});
// Test the service
describe('ProductService', () => {
it('should create a product', async () => {
// Test implementation
});
});
// Test the controller
describe('ProductController', () => {
it('should handle product creation', async () => {
// Test implementation
});
});
|
Best Practices
- Follow the Pattern: Stick to the established pattern for consistency
- Use Base Classes: Extend the base classes to leverage common functionality
- Validate Input: Always validate input data using schemas
- Handle Errors: Use the error handling system for consistent error responses
- Add Logging: Include appropriate logging for monitoring and debugging
- Write Tests: Test all layers of your implementation
- Document APIs: Add schema documentation for API endpoints
Conclusion
By following this guide, you can implement new features in the PRS Backend using the BaseService, BaseRepository, and BaseController classes. This approach ensures consistency, maintainability, and adherence to the established architecture patterns.