Practical DDD Refactoring Guide for PRS Backend
This guide provides a practical, step-by-step approach to refactoring the PRS Backend using Domain-Driven Design (DDD) principles. It focuses on concrete examples and folder structures to make the transition clear and manageable.
Current Folder Structure
| Text Only |
|---|
| src/
├── app/
│ ├── errors/
│ ├── handlers/
│ │ ├── controllers/
│ │ │ ├── requisitionController.js
│ │ │ ├── userController.js
│ │ │ └── ...
│ │ └── routes/
│ ├── middlewares/
│ ├── repositories/
│ │ ├── requisitionRepository.js
│ │ ├── userRepository.js
│ │ └── ...
│ ├── services/
│ │ ├── requisitionService.js
│ │ ├── userService.js
│ │ └── ...
│ └── utils/
├── domain/
│ ├── constants/
│ └── entities/
│ ├── requisitionEntity.js
│ ├── userEntity.js
│ └── ...
└── db/
├── models/
└── migrations/
|
Target DDD Folder Structure
| Text Only |
|---|
| src/
├── domain/ # Core business logic
│ ├── entities/ # Business entities with rules
│ │ ├── requisition/
│ │ │ ├── index.js
│ │ │ ├── RequisitionEntity.js
│ │ │ ├── RequisitionBusinessLogic.js
│ │ │ └── RequisitionErrors.js
│ │ └── user/
│ ├── valueObjects/ # Immutable objects
│ │ ├── RequisitionStatus.js
│ │ └── Money.js
│ ├── services/ # Domain services
│ │ └── RequisitionDomainService.js
│ └── constants/ # Domain constants
│ ├── statusConstants.js
│ └── errorMessages.js
├── application/ # Use case orchestration
│ ├── services/ # Application services
│ │ ├── requisition/
│ │ │ ├── RequisitionService.js
│ │ │ └── RequisitionQueryService.js
│ │ └── user/
│ └── dtos/ # Data Transfer Objects
│ ├── requisition/
│ │ ├── RequisitionDTO.js
│ │ └── RequisitionResponseDTO.js
│ └── common/
│ └── PaginatedResponseDTO.js
├── infrastructure/ # Technical concerns
│ ├── repositories/ # Data access
│ │ ├── requisition/
│ │ │ └── RequisitionRepository.js
│ │ └── user/
│ ├── database/ # Database configuration
│ │ ├── models/
│ │ └── migrations/
│ └── errors/ # Error handling
│ ├── DomainError.js
│ └── ApplicationError.js
└── interfaces/ # External communication
├── http/ # HTTP interface
│ ├── controllers/ # Request handlers
│ │ ├── requisition/
│ │ │ └── RequisitionController.js
│ │ └── user/
│ ├── routes/ # Route definitions
│ │ ├── requisitionRoutes.js
│ │ └── userRoutes.js
│ └── middleware/ # HTTP middleware
│ ├── authentication.js
│ └── validation.js
└── events/ # Event handlers
|
Practical Refactoring Steps
Step 1: Reorganize the Domain Layer
Current:
| JavaScript |
|---|
| // src/domain/entities/requisitionEntity.js
const { z } = require('zod');
const { sort, requisition, filter } = require('../constants');
// Validation schemas
const createRequisitionSchema = z.object({...});
const submitRequisition = z.object({...});
// Export everything
module.exports = {
createRequisitionSchema,
submitRequisition,
// ... other schemas
};
|
Refactored:
| JavaScript |
|---|
| // src/domain/entities/requisition/RequisitionEntity.js
const { RequisitionStatus } = require('../../valueObjects/RequisitionStatus');
class RequisitionEntity {
constructor(data) {
this.id = data.id;
this.status = data.status;
this.type = data.type;
this.items = data.requisitionItemLists || [];
// ... other properties
}
// Domain methods that enforce business rules
canBeSubmitted() {
return this.status === RequisitionStatus.DRAFT &&
this.items.length > 0;
}
canBeApprovedBy(userId) {
if (this.status !== RequisitionStatus.SUBMITTED &&
this.status !== RequisitionStatus.PARTIALLY_APPROVED) {
return false;
}
const approver = (this.requisitionApprovers || [])
.find(a => a.userId === userId || a.approverId === userId);
if (!approver) {
return false;
}
return approver.status !== 'APPROVED';
}
// ... other business methods
}
module.exports = RequisitionEntity;
|
| JavaScript |
|---|
| // src/domain/entities/requisition/RequisitionBusinessLogic.js
const { RequisitionStatus } = require('../../valueObjects/RequisitionStatus');
const requisitionBusinessLogic = {
canBeSubmitted(requisition) {
return requisition.status === RequisitionStatus.DRAFT &&
Array.isArray(requisition.requisitionItemLists) &&
requisition.requisitionItemLists.length > 0;
},
canBeApprovedBy(requisition, userId) {
// ... business logic
},
// ... other business methods
};
module.exports = requisitionBusinessLogic;
|
| JavaScript |
|---|
| // src/domain/entities/requisition/index.js
const RequisitionEntity = require('./RequisitionEntity');
const requisitionBusinessLogic = require('./RequisitionBusinessLogic');
const requisitionErrors = require('./RequisitionErrors');
module.exports = {
RequisitionEntity,
requisitionBusinessLogic,
requisitionErrors
};
|
Step 2: Create Value Objects
| JavaScript |
|---|
| // src/domain/valueObjects/RequisitionStatus.js
const RequisitionStatus = {
DRAFT: 'DRAFT',
SUBMITTED: 'SUBMITTED',
PARTIALLY_APPROVED: 'PARTIALLY_APPROVED',
APPROVED: 'APPROVED',
ASSIGNED: 'ASSIGNED',
CANVASSING: 'CANVASSING',
ORDERED: 'ORDERED',
PARTIALLY_DELIVERED: 'PARTIALLY_DELIVERED',
DELIVERED: 'DELIVERED',
CLOSED: 'CLOSED',
REJECTED: 'REJECTED',
CANCELLED: 'CANCELLED'
};
module.exports = { RequisitionStatus };
|
Step 3: Move Validation to Application Layer
| JavaScript |
|---|
| // src/application/dtos/requisition/RequisitionValidation.js
const { z } = require('zod');
const { RequisitionStatus } = require('../../../domain/valueObjects/RequisitionStatus');
const createRequisitionSchema = z.object({
isDraft: z.enum(['true']).transform(value => value === 'true'),
type: z.enum(['ofm', 'non-ofm', 'ofm-tom', 'non-ofm-tom', 'transfer']),
// ... other fields
});
const submitRequisitionSchema = z.object({
id: z.number().positive().or(z.string().transform(Number)),
isDraft: z.enum(['false']),
// ... other fields
});
module.exports = {
createRequisitionSchema,
submitRequisitionSchema
};
|
Step 4: Refactor Application Services
| JavaScript |
|---|
| // src/application/services/requisition/RequisitionService.js
const { RequisitionEntity, requisitionBusinessLogic } = require('../../../domain/entities/requisition');
const { DomainError } = require('../../../infrastructure/errors/DomainError');
class RequisitionService {
constructor(requisitionRepository, userRepository) {
this.requisitionRepository = requisitionRepository;
this.userRepository = userRepository;
}
async submitRequisition(requisitionId, userId) {
// Get the requisition
const requisitionData = await this.requisitionRepository.getById(requisitionId);
if (!requisitionData) {
throw new DomainError('Requisition not found');
}
// Check business rules
if (!requisitionBusinessLogic.canBeSubmitted(requisitionData)) {
throw new DomainError(
'Requisition cannot be submitted. It must be in DRAFT status and have at least one item.'
);
}
// Update status
await this.requisitionRepository.update(
{ id: requisitionId },
{ status: 'SUBMITTED' }
);
return { message: `Requisition with id ${requisitionId} submitted successfully` };
}
// ... other methods
}
module.exports = RequisitionService;
|
Step 5: Refactor Controllers
| JavaScript |
|---|
| // src/interfaces/http/controllers/requisition/RequisitionController.js
class RequisitionController {
constructor(requisitionService, requisitionValidation) {
this.requisitionService = requisitionService;
this.requisitionValidation = requisitionValidation;
}
async submitRequisition(request, reply) {
try {
// Validate input
const { requisitionId } = this.requisitionValidation.submitRequisitionSchema.parse(request.body);
// Call application service
const result = await this.requisitionService.submitRequisition(
requisitionId,
request.userFromToken.id
);
// Return response
return reply.status(200).send(result);
} catch (error) {
// Handle errors
if (error.name === 'DomainError') {
return reply.status(422).send({
message: error.message
});
}
if (error.name === 'ZodError') {
return reply.status(400).send({
message: 'Validation error',
errors: error.errors
});
}
return reply.status(500).send({
message: 'Internal server error'
});
}
}
// ... other methods
}
module.exports = RequisitionController;
|
Step 6: Update Routes
| JavaScript |
|---|
| // src/interfaces/http/routes/requisitionRoutes.js
const RequisitionController = require('../controllers/requisition/RequisitionController');
const authMiddleware = require('../middleware/authentication');
module.exports = function(fastify, opts, done) {
const requisitionController = new RequisitionController(
fastify.requisitionService,
fastify.requisitionValidation
);
fastify.post(
'/requisitions/:requisitionId/submit',
{ preHandler: authMiddleware },
requisitionController.submitRequisition.bind(requisitionController)
);
// ... other routes
done();
};
|
Practical Example: Refactoring the Requisition Submission Flow
Before Refactoring:
- Controller (
src/app/handlers/controllers/requisitionController.js):
- Contains validation logic
- Contains business rules
- Handles HTTP concerns
-
Calls repository directly
-
Entity (src/domain/entities/requisitionEntity.js):
- Only contains validation schemas
-
No business logic
-
Repository (src/app/repositories/requisitionRepository.js):
- Handles data access
After Refactoring:
- Controller (
src/interfaces/http/controllers/requisition/RequisitionController.js):
- Only handles HTTP concerns
- Calls application service
-
Handles errors
-
Application Service (src/application/services/requisition/RequisitionService.js):
- Orchestrates the use case
- Calls domain entities and repositories
-
Handles transactions
-
Domain Entity (src/domain/entities/requisition/RequisitionEntity.js):
- Contains business rules
-
Enforces invariants
-
Business Logic (src/domain/entities/requisition/RequisitionBusinessLogic.js):
- Contains pure business logic functions
-
Can be easily tested
-
Value Objects (src/domain/valueObjects/RequisitionStatus.js):
- Represents immutable concepts
-
Provides type safety
-
Validation (src/application/dtos/requisition/RequisitionValidation.js):
- Contains validation schemas
-
Separate from business logic
-
Repository (src/infrastructure/repositories/requisition/RequisitionRepository.js):
- Handles data access
- Returns domain entities
Benefits of This Approach
- Clear Separation of Concerns:
- Controllers handle HTTP
- Application services handle use cases
- Domain entities handle business rules
-
Repositories handle data access
-
Improved Testability:
- Business logic can be tested independently
- Controllers can be tested with mocked services
-
Services can be tested with mocked repositories
-
Better Error Handling:
- Domain errors are separate from technical errors
-
Consistent error responses
-
Enhanced Maintainability:
- Business rules are centralized
- Code is organized by domain concepts
- Changes are localized to specific components
Implementation Strategy
- Start Small:
- Begin with one domain entity (e.g., Requisition)
-
Refactor one use case at a time (e.g., submission flow)
-
Incremental Approach:
- Create the new structure alongside the existing code
- Gradually migrate functionality
-
Test thoroughly after each step
-
Focus on Business Logic First:
- Extract business rules to domain entities
-
Ensure they work correctly before refactoring controllers
-
Update Controllers Last:
- Once domain and application layers are in place
- Redirect controllers to use the new services
This practical guide provides a clear path to refactoring the PRS Backend using DDD principles, with concrete examples and a step-by-step approach that minimizes risk and maximizes value.