Skip to content

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:

  1. Creating a database model
  2. Defining validation schemas
  3. Implementing a repository
  4. Implementing a service
  5. Implementing a controller
  6. 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
1
2
3
4
5
6
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
1
2
3
4
5
6
7
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

  1. Follow the Pattern: Stick to the established pattern for consistency
  2. Use Base Classes: Extend the base classes to leverage common functionality
  3. Validate Input: Always validate input data using schemas
  4. Handle Errors: Use the error handling system for consistent error responses
  5. Add Logging: Include appropriate logging for monitoring and debugging
  6. Write Tests: Test all layers of your implementation
  7. 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.