Skip to content

Simple Domain-Driven Design Refactoring Approach for PRS Project

This document outlines a practical, minimalist approach to refactoring the PRS codebase to better align with Domain-Driven Design (DDD) principles while maintaining the existing architecture. This approach is specifically designed for teams with junior and mid-level developers, focusing on gradual improvements without overwhelming complexity.

Current Challenges

The PRS codebase currently faces several challenges that this refactoring aims to address:

  1. Business Logic Scattered: Business rules are spread across controllers and services
  2. Duplicate Logic: The same business rules are implemented in multiple places
  3. Difficult Maintenance: When business rules change, updates are needed in multiple files
  4. Testing Challenges: Business logic is tightly coupled with infrastructure code
  5. Onboarding Difficulties: New developers struggle to understand where business rules are implemented

Goals

  1. Improve Code Organization: Move business logic to domain entities
  2. Reduce Duplication: Centralize business rules in one place
  3. Enhance Maintainability: Make business rules easier to find and update
  4. Simplify Testing: Enable testing of business logic in isolation
  5. Minimize Disruption: Keep changes small and focused
  6. Support Junior Developers: Make the codebase more accessible to less experienced team members

Approach

Our approach focuses on making minimal changes to the existing architecture while still gaining the benefits of DDD principles. We'll keep the current project structure but enhance it with better organization of business logic.

1. Add Business Logic to Domain Entities

We're enhancing the existing domain entities by adding business logic methods while keeping the validation schemas. This allows us to centralize business rules without disrupting the current structure.

Before Refactoring: Business logic is scattered across controllers and services:

JavaScript
// In a controller or service
async submitRequisition(request, reply) {
  const requisition = await this.requisitionRepository.getById(requisitionId);

  // Business logic mixed with controller code
  if (requisition.status !== 'DRAFT') {
    throw new Error('Only draft requisitions can be submitted');
  }

  if (!requisition.requisitionItemLists || requisition.requisitionItemLists.length === 0) {
    throw new Error('Cannot submit requisition without items');
  }

  // More business logic...
}

After Refactoring: Business logic is centralized in domain entities:

JavaScript
// src/domain/entities/requisitionEntity.js

// Keep existing validation schemas
const createRequisitionSchema = z.object({
  // ... existing validation
});

// Add business logic methods
const requisitionBusinessLogic = {
  /**
   * Check if a requisition can be submitted
   * @param {Object} requisition - The requisition data
   * @returns {boolean} - Whether the requisition can be submitted
   */
  canBeSubmitted(requisition) {
    return requisition.status === 'DRAFT' &&
           Array.isArray(requisition.requisitionItemLists) &&
           requisition.requisitionItemLists.length > 0;
  },

  // ... other business methods
};

module.exports = {
  // Export existing schemas
  createRequisitionSchema,
  // ... other existing exports

  // Export business logic methods
  requisitionBusinessLogic
};

2. Refactor Controllers and Services to Use Domain Logic

Update controllers and services to use the business logic from domain entities. This separates the "what" (business rules) from the "how" (implementation details).

Before Refactoring: Controllers and services contain both business logic and implementation details:

JavaScript
// src/app/handlers/controllers/requisitionController.js
async submitRequisition(request, reply) {
  const { requisitionId } = request.body;

  const requisition = await this.requisitionRepository.getById(requisitionId);

  if (!requisition) {
    throw this.clientErrors.NOT_FOUND({ message: 'Requisition not found' });
  }

  // Business logic mixed with controller code
  if (requisition.status !== 'DRAFT') {
    throw this.clientErrors.BAD_REQUEST({
      message: 'Only draft requisitions can be submitted'
    });
  }

  if (!requisition.requisitionItemLists || requisition.requisitionItemLists.length === 0) {
    throw this.clientErrors.BAD_REQUEST({
      message: 'Cannot submit requisition without items'
    });
  }

  // ... rest of the method
}

After Refactoring: Controllers and services use domain logic from entities:

JavaScript
// src/app/handlers/controllers/requisitionController.js
async submitRequisition(request, reply) {
  const { requisitionId } = request.body;
  const { requisitionBusinessLogic } = this.requisitionEntity;

  // Get the requisition with its items
  const requisition = await this.requisitionRepository.getById(requisitionId, {
    include: ['requisitionItemLists']
  });

  if (!requisition) {
    throw this.clientErrors.NOT_FOUND({ message: 'Requisition not found' });
  }

  // Check if the requisition can be submitted using domain logic
  if (!requisitionBusinessLogic.canBeSubmitted(requisition)) {
    throw this.clientErrors.BAD_REQUEST({
      message: 'Requisition cannot be submitted. It must be in DRAFT status and have at least one item.'
    });
  }

  // ... rest of the method
}

Benefits for the PRS Project

1. Clear Business Rules

Business rules are clearly defined in one place, making them easier to understand and maintain. This is especially important for the PRS project where business rules around requisition approval, purchase orders, and payments are complex.

2. Improved Maintainability

When business rules change (which happens frequently in the PRS project), you only need to update them in one place. This reduces the risk of inconsistent implementations.

3. Better Testability

Business logic can be tested independently of controllers and services. This makes it easier to write unit tests for critical business rules like approval workflows and payment validations.

4. Reduced Duplication

Business rules are defined once and reused across the application. This is particularly valuable for rules that are applied in multiple contexts, such as validation rules for requisitions.

5. Minimal Disruption

This approach doesn't require major architectural changes, making it easier to implement incrementally. The team can continue working on features while gradually improving the codebase.

6. Easier Onboarding

New developers can more easily understand the business rules by looking at the domain entities, rather than having to search through multiple controllers and services.

Implementation Strategy for PRS Project

1. Start with Core Entities

Begin with the most important entities in the PRS system:

  • Requisition: The central entity in the purchase requisition process
  • PurchaseOrder: Critical for vendor management and procurement
  • User: Important for approval workflows and permissions
  • Supplier: Essential for vendor management

2. Identify Business Rules

For each entity, identify the business rules that should be moved to the domain layer:

  • Validation rules beyond simple data validation (e.g., a requisition must have items before submission)
  • State transition rules (e.g., when a requisition can be approved, rejected, or closed)
  • Business calculations (e.g., calculating total amounts, taxes, or discounts)
  • Domain-specific constraints (e.g., approval hierarchies, budget limits)

3. Implement Incrementally

Refactor one entity at a time, and within each entity, refactor one method at a time:

  1. Start with the Requisition entity and its submission workflow
  2. Move to approval and rejection workflows
  3. Continue with PurchaseOrder creation and approval
  4. Implement Supplier and User business rules

4. Add Tests

Write tests for the business logic to ensure it works correctly:

JavaScript
// Example test for requisition business logic
describe('Requisition Business Logic', () => {
  describe('canBeSubmitted', () => {
    it('should return false for non-draft requisitions', () => {
      const requisition = { status: 'SUBMITTED', requisitionItemLists: [{}] };
      expect(requisitionBusinessLogic.canBeSubmitted(requisition)).toBe(false);
    });

    it('should return false for requisitions without items', () => {
      const requisition = { status: 'DRAFT', requisitionItemLists: [] };
      expect(requisitionBusinessLogic.canBeSubmitted(requisition)).toBe(false);
    });

    it('should return true for draft requisitions with items', () => {
      const requisition = { status: 'DRAFT', requisitionItemLists: [{}] };
      expect(requisitionBusinessLogic.canBeSubmitted(requisition)).toBe(true);
    });
  });
});

5. Update Documentation

Keep documentation up-to-date with the changes:

  • Update code comments to explain business rules
  • Maintain this refactoring guide
  • Create examples for the team to follow

Example: Requisition Entity in PRS

We've added the following business logic methods to the Requisition entity in the PRS project:

  • canBeSubmitted: Checks if a requisition can be submitted (must be in DRAFT status and have items)
  • canBeApprovedBy: Checks if a requisition can be approved by a specific user (based on approval workflow)
  • canBeRejectedBy: Checks if a requisition can be rejected by a specific user (based on approval workflow)
  • getNextStatusAfterApproval: Determines the next status after approval (PARTIALLY_APPROVED or APPROVED)
  • canBeClosed: Checks if a requisition can be closed (based on delivery status)
  • canBeAssigned: Checks if a requisition can be assigned to a user (based on permissions)
  • hasItemSufficientQuantity: Checks if an item has sufficient quantity (for inventory management)

These methods encapsulate critical business rules for the requisition process, which is the core workflow in the PRS system.

Guidelines for Junior and Mid-Level Developers

When to Add Business Logic to Domain Entities

Add business logic to domain entities when:

  • The logic represents a business rule (not just data manipulation)
  • The logic might be reused in multiple places
  • The logic is specific to a particular entity
  • The rule would otherwise be duplicated in multiple controllers or services

How to Use Business Logic in Controllers and Services

  1. Import the entity module:

    JavaScript
    const { requisitionBusinessLogic } = this.entities.requisition;
    

  2. Use the business logic methods:

    JavaScript
    1
    2
    3
    4
    5
    if (!requisitionBusinessLogic.canBeSubmitted(requisition)) {
      throw this.clientErrors.BAD_REQUEST({
        message: 'Requisition cannot be submitted. It must be in DRAFT status and have at least one item.'
      });
    }
    

  3. Keep controllers focused on HTTP concerns:

    JavaScript
    // Good: Controller handles HTTP, domain logic handles business rules
    async submitRequisition(request, reply) {
      // Get data from request
      const { requisitionId } = request.body;
    
      // Use repository to fetch data
      const requisition = await this.requisitionRepository.getById(requisitionId, {
        include: ['requisitionItemLists']
      });
    
      // Check business rules using domain logic
      if (!this.requisitionEntity.requisitionBusinessLogic.canBeSubmitted(requisition)) {
        throw this.clientErrors.BAD_REQUEST({ message: 'Cannot submit requisition' });
      }
    
      // Update data
      await this.requisitionRepository.update({ id: requisitionId }, { status: 'SUBMITTED' });
    
      // Return HTTP response
      return reply.status(200).send({ message: 'Requisition submitted successfully' });
    }
    

What Not to Put in Domain Entities

  • Database access code (use repositories instead)
  • HTTP request/response handling (keep in controllers)
  • File I/O (use services for this)
  • External API calls (use services for this)
  • Infrastructure concerns (logging, caching, etc.)

Conclusion

This simple approach to DDD refactoring allows us to improve the PRS codebase incrementally without major architectural changes. By moving business logic to domain entities, we make the code more maintainable, testable, and easier to understand.

The approach is particularly well-suited for the PRS project because:

  1. It maintains the existing architecture that developers are familiar with
  2. It addresses the specific challenges of scattered business logic
  3. It's accessible to junior and mid-level developers
  4. It can be implemented gradually alongside regular feature development
  5. It provides immediate benefits without requiring a complete rewrite

By following this approach, the PRS team can gradually improve the codebase while continuing to deliver value to users.