Currency System - Domain-Driven Design Refactoring
Current Problems with Existing Architecture
- Business Logic Scattered: Currency rules spread across multiple service classes
- Tight Coupling: Application logic tightly coupled with persistence concerns
- Complex Conditionals: Complex if/else chains in services for business rules
- No Domain Model: Data structures without behavior, business logic in services
- Mixed Responsibilities: Services handle both orchestration and business rules
Proposed DDD Architecture
Domain Layer Structure
api/src/features/currency/domain/
├── entities/
│ ├── Currency.js
│ ├── TrainingSession.js
│ └── MemberCertification.js
├── value-objects/
│ ├── CurrencyPeriod.js
│ ├── CurrencyStatus.js
│ ├── CurrencyType.js
│ ├── TrainingCheckbox.js
│ └── ApprovalLevel.js
├── aggregates/
│ └── CurrencyAggregate.js
├── domain-services/
│ ├── CurrencyCalculationService.js
│ ├── CurrencyRenewalService.js
│ └── CheckboxValidationService.js
├── repositories/
│ └── CurrencyRepository.js (interface)
└── events/
├── CurrencyRenewed.js
├── CurrencyExpired.js
└── ApprovalLevelChanged.js
Domain Model Design
1. Value Objects
CurrencyPeriod - Immutable time period with business logic
// api/src/features/currency/domain/value-objects/CurrencyPeriod.js
export class CurrencyPeriod {
constructor(startDate, endDate, type = 'standard') {
this.startDate = new Date(startDate);
this.endDate = new Date(endDate);
this.type = type; // 'standard' | 'recurrent'
if (this.startDate >= this.endDate) {
throw new Error('Start date must be before end date');
}
}
isExpired(currentDate = new Date()) {
return currentDate > this.endDate;
}
isActive(currentDate = new Date()) {
return currentDate >= this.startDate && currentDate <= this.endDate;
}
daysRemaining(currentDate = new Date()) {
if (this.isExpired(currentDate)) return 0;
return Math.ceil((this.endDate - currentDate) / (1000 * 60 * 60 * 24));
}
isRecurrentPeriod() {
return this.type === 'recurrent';
}
static createStandardPeriod(trainingDate) {
// Standard 6-month periods aligned to Jan 16 / Jul 16
const date = new Date(trainingDate);
const month = date.getMonth();
const day = date.getDate();
let startDate, endDate;
if (month < 6 || (month === 6 && day <= 16)) {
// Current period: Jan 16 - Jul 16
startDate = new Date(date.getFullYear(), 0, 16);
endDate = new Date(date.getFullYear(), 6, 16);
} else {
// Current period: Jul 16 - Jan 16 (next year)
startDate = new Date(date.getFullYear(), 6, 16);
endDate = new Date(date.getFullYear() + 1, 0, 16);
}
return new CurrencyPeriod(startDate, endDate, 'standard');
}
static createRecurrentPeriod(recurrentDate) {
// 6 months from recurrent training date
const start = new Date(recurrentDate);
const end = new Date(start);
end.setMonth(end.getMonth() + 6);
return new CurrencyPeriod(start, end, 'recurrent');
}
}
CurrencyStatus - Current status with business rules
// api/src/features/currency/domain/value-objects/CurrencyStatus.js
export class CurrencyStatus {
constructor(status, expiryDate, isRecurrent = false) {
this.status = status; // 'current' | 'expired' | 'suspended'
this.expiryDate = new Date(expiryDate);
this.isRecurrent = isRecurrent;
}
isCurrent() {
return this.status === 'current' && !this.isExpired();
}
isExpired() {
return new Date() > this.expiryDate;
}
requiresRecurrency() {
return this.status === 'expired';
}
canMaintainCurrency() {
return this.status === 'current' && !this.isExpired();
}
}
TrainingCheckbox - Immutable checkbox data
// api/src/features/currency/domain/value-objects/TrainingCheckbox.js
export class TrainingCheckbox {
constructor(id, label, level, passed, approvalLevel, instructor = null, date = null) {
this.id = id;
this.label = label;
this.level = level;
this.passed = Boolean(passed);
this.approvalLevel = approvalLevel;
this.instructor = instructor;
this.date = date ? new Date(date) : null;
}
isPassed() {
return this.passed;
}
isLevelCheckbox() {
return this.level > 0;
}
getCompletedLevel() {
return this.isPassed() ? this.level : 0;
}
}
2. Entities
Currency - Rich domain entity with behavior
// api/src/features/currency/domain/entities/Currency.js
export class Currency {
constructor(id, memberId, type, status, period, approvalLevel, checkboxes = []) {
this.id = id;
this.memberId = memberId;
this.type = type; // CurrencyType value object
this.status = status; // CurrencyStatus value object
this.period = period; // CurrencyPeriod value object
this.approvalLevel = approvalLevel; // ApprovalLevel value object
this.checkboxes = checkboxes; // Array of TrainingCheckbox value objects
this.domainEvents = [];
}
canRenew() {
return this.status.requiresRecurrency() || this.status.canMaintainCurrency();
}
renewCurrency(trainingSession) {
if (!this.canRenew()) {
throw new Error('Currency cannot be renewed in current status');
}
const wasExpired = this.status.isExpired();
const newPeriod = wasExpired
? CurrencyPeriod.createRecurrentPeriod(trainingSession.completedDate)
: CurrencyPeriod.createStandardPeriod(trainingSession.completedDate);
const newApprovalLevel = this._calculateNewApprovalLevel(trainingSession.checkboxes);
this.period = newPeriod;
this.status = new CurrencyStatus('current', newPeriod.endDate, newPeriod.isRecurrentPeriod());
this.approvalLevel = newApprovalLevel;
this.checkboxes = trainingSession.checkboxes;
// Domain events
this.domainEvents.push(new CurrencyRenewed(this.memberId, this.type, newPeriod));
if (this.approvalLevel.hasChanged()) {
this.domainEvents.push(new ApprovalLevelChanged(this.memberId, this.type, newApprovalLevel));
}
}
expire() {
this.status = new CurrencyStatus('expired', this.period.endDate, this.period.isRecurrentPeriod());
this.domainEvents.push(new CurrencyExpired(this.memberId, this.type));
}
_calculateNewApprovalLevel(checkboxes) {
const passedLevelCheckboxes = checkboxes
.filter(cb => cb.isPassed() && cb.isLevelCheckbox())
.sort((a, b) => b.level - a.level);
const highestLevel = passedLevelCheckboxes.length > 0
? passedLevelCheckboxes[0].level
: 0;
return new ApprovalLevel(highestLevel, this.type);
}
getDomainEvents() {
return [...this.domainEvents];
}
clearDomainEvents() {
this.domainEvents = [];
}
}
TrainingSession - Training completion entity
// api/src/features/currency/domain/entities/TrainingSession.js
export class TrainingSession {
constructor(id, memberId, currencyType, checkboxes, completedDate, instructorId) {
this.id = id;
this.memberId = memberId;
this.currencyType = currencyType;
this.checkboxes = checkboxes; // Array of TrainingCheckbox
this.completedDate = new Date(completedDate);
this.instructorId = instructorId;
}
isCompleteTraining() {
return this.checkboxes.every(cb => cb.isPassed());
}
getHighestCompletedLevel() {
return this.checkboxes
.filter(cb => cb.isPassed() && cb.isLevelCheckbox())
.reduce((max, cb) => Math.max(max, cb.level), 0);
}
hasPassedRequiredCheckboxes(requiredCheckboxIds) {
return requiredCheckboxIds.every(id =>
this.checkboxes.some(cb => cb.id === id && cb.isPassed())
);
}
}
3. Domain Services
CurrencyCalculationService - Complex business logic
// api/src/features/currency/domain/domain-services/CurrencyCalculationService.js
export class CurrencyCalculationService {
calculateRenewalDate(lastTrainingDate, memberCurrency) {
const trainingDate = new Date(lastTrainingDate);
const isExpired = memberCurrency.status.isExpired();
if (isExpired) {
return this._calculateRecurrentRenewalDate(trainingDate);
} else {
return this._calculateStandardRenewalDate(trainingDate);
}
}
_calculateStandardRenewalDate(trainingDate) {
const period = CurrencyPeriod.createStandardPeriod(trainingDate);
// If training is in current period, extend to next period
if (period.isActive(trainingDate)) {
const nextPeriod = this._getNextStandardPeriod(period);
return nextPeriod.endDate;
}
return period.endDate;
}
_calculateRecurrentRenewalDate(recurrentDate) {
const recurrentPeriod = CurrencyPeriod.createRecurrentPeriod(recurrentDate);
// Check if recurrent period aligns with standard boundary
const nextStandardBoundary = this._getNextStandardBoundary(recurrentDate);
const daysToBoundary = (nextStandardBoundary - recurrentPeriod.endDate) / (1000 * 60 * 60 * 24);
// If within 30 days of boundary, align to it
if (daysToBoundary <= 30) {
return nextStandardBoundary;
}
return recurrentPeriod.endDate;
}
_getNextStandardPeriod(currentPeriod) {
// Implementation for next standard period calculation
// ...
}
_getNextStandardBoundary(date) {
// Implementation for next boundary calculation
// ...
}
}
CheckboxValidationService - Checkbox business rules
// api/src/features/currency/domain/domain-services/CheckboxValidationService.js
export class CheckboxValidationService {
validateCheckboxes(checkboxes, memberCertification, approverCertification) {
return checkboxes.map(checkbox =>
this._validateCheckbox(checkbox, memberCertification, approverCertification)
);
}
_validateCheckbox(checkbox, memberCert, approverCert) {
// Role-specific validation rules
switch (memberCert.role) {
case 'instructor':
return this._validateInstructorCheckbox(checkbox, memberCert, approverCert);
case 'trainer':
return this._validateTrainerCheckbox(checkbox, memberCert, approverCert);
case 'coach':
return this._validateCoachCheckbox(checkbox, memberCert, approverCert);
default:
return checkbox;
}
}
_validateInstructorCheckbox(checkbox, memberCert, approverCert) {
// Instructor-specific validation from existing logic
const level = checkbox.level;
if (level === 1 && !approverCert.hasSkill(171)) {
return { ...checkbox, disabled: true };
}
if (level === 3 && !approverCert.hasSkill(170)) {
return { ...checkbox, disabled: true };
}
if (level === 4 && (approverCert.trainerLevel < 3 || !approverCert.hasSkill(173))) {
return { ...checkbox, disabled: true };
}
return checkbox;
}
}
4. Application Services (Orchestration Layer)
CurrencyApplicationService - Coordinates domain operations
// api/src/features/currency/application/CurrencyApplicationService.js
export class CurrencyApplicationService {
constructor(currencyRepository, memberRepository, eventDispatcher, calculationService, validationService) {
this.currencyRepository = currencyRepository;
this.memberRepository = memberRepository;
this.eventDispatcher = eventDispatcher;
this.calculationService = calculationService;
this.validationService = validationService;
}
async completeCurrencyTraining(command) {
// Load aggregates
const member = await this.memberRepository.findById(command.memberId);
const currency = await this.currencyRepository.findByMemberAndType(command.memberId, command.currencyType);
const approver = await this.memberRepository.findById(command.approverId);
// Domain logic
const validatedCheckboxes = this.validationService.validateCheckboxes(
command.checkboxes,
member.getCertification(command.currencyType),
approver.getCertification(command.currencyType)
);
const trainingSession = new TrainingSession(
null,
command.memberId,
command.currencyType,
validatedCheckboxes,
command.completedDate,
command.approverId
);
currency.renewCurrency(trainingSession);
// Persistence
await this.currencyRepository.save(currency);
// Events
const events = currency.getDomainEvents();
for (const event of events) {
await this.eventDispatcher.dispatch(event);
}
currency.clearDomainEvents();
return {
currencyId: currency.id,
newExpiryDate: currency.period.endDate,
newApprovalLevel: currency.approvalLevel.value
};
}
async getCurrencyStatus(memberId, currencyType) {
const currency = await this.currencyRepository.findByMemberAndType(memberId, currencyType);
if (!currency) {
return { status: 'not_found' };
}
const nextRenewalDate = this.calculationService.calculateRenewalDate(
currency.period.startDate,
currency
);
return {
status: currency.status.status,
currentPeriod: currency.period,
nextRenewalDate,
approvalLevel: currency.approvalLevel.value,
isRecurrent: currency.period.isRecurrentPeriod(),
daysRemaining: currency.period.daysRemaining()
};
}
}
Migration Strategy
Phase 1: Domain Layer
- Create domain value objects and entities
- Extract business logic from existing services
- Create domain services for complex calculations
- Add unit tests for domain logic
Phase 2: Application Layer
- Create application services for orchestration
- Implement repository interfaces
- Add domain event handling
- Create command/query objects
Phase 3: Infrastructure Updates
- Update existing repositories to implement domain interfaces
- Add event dispatching infrastructure
- Update database models if needed
- Create adapters for existing data structures
Phase 4: API Layer Updates
- Update controllers to use application services
- Create DTOs for API responses
- Add validation at API boundary
- Update error handling
Benefits of DDD Refactoring
- Clear Separation of Concerns: Business logic isolated in domain layer
- Testable Business Logic: Domain objects can be unit tested in isolation
- Maintainable Code: Business rules centralized and explicit
- Extensible Design: Easy to add new currency types or rules
- Better Understanding: Domain model reflects business terminology
- Event-Driven Architecture: Domain events for side effects and integration
Example Usage
// Before (current approach)
const instructorService = new InstructorSafetyTrainingService(req, res);
const result = await instructorService.saveInstructorMember();
// After (DDD approach)
const command = new CompleteCurrencyTrainingCommand({
memberId: req.body.member_id,
currencyType: CurrencyType.INSTRUCTOR,
checkboxes: req.body.checkboxes.map(cb => new TrainingCheckbox(...)),
completedDate: new Date(),
approverId: req.user.member_id
});
const result = await currencyApplicationService.completeCurrencyTraining(command);
This refactoring creates a more maintainable, testable, and business-focused architecture while preserving all existing functionality.