Skip to main content

Currency System - Domain-Driven Design Refactoring

Current Problems with Existing Architecture

  1. Business Logic Scattered: Currency rules spread across multiple service classes
  2. Tight Coupling: Application logic tightly coupled with persistence concerns
  3. Complex Conditionals: Complex if/else chains in services for business rules
  4. No Domain Model: Data structures without behavior, business logic in services
  5. 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

  1. Create domain value objects and entities
  2. Extract business logic from existing services
  3. Create domain services for complex calculations
  4. Add unit tests for domain logic

Phase 2: Application Layer

  1. Create application services for orchestration
  2. Implement repository interfaces
  3. Add domain event handling
  4. Create command/query objects

Phase 3: Infrastructure Updates

  1. Update existing repositories to implement domain interfaces
  2. Add event dispatching infrastructure
  3. Update database models if needed
  4. Create adapters for existing data structures

Phase 4: API Layer Updates

  1. Update controllers to use application services
  2. Create DTOs for API responses
  3. Add validation at API boundary
  4. Update error handling

Benefits of DDD Refactoring

  1. Clear Separation of Concerns: Business logic isolated in domain layer
  2. Testable Business Logic: Domain objects can be unit tested in isolation
  3. Maintainable Code: Business rules centralized and explicit
  4. Extensible Design: Easy to add new currency types or rules
  5. Better Understanding: Domain model reflects business terminology
  6. 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.