Logbook System - Domain-Driven Design Refactoring
Current Problems Analysis
After analyzing the logbook system, particularly api/src/features/account/logbook/service/skill.service.js, several issues emerge:
- Massive Service Class: 397 lines with mixed responsibilities (filtering, formatting, business rules)
- Complex Nested Logic: Deep conditional chains for skill progression rules (lines 247-396)
- Scattered Business Rules: Flyer progression logic, approval levels, and skill dependencies spread across methods
- Data Structure Manipulation: Heavy focus on data transformation rather than business behavior
- Tight Coupling: Service directly manipulates data structures and handles presentation concerns
Domain Concepts Identified
Core Entities
- Skill: Training skill with prerequisites, approval levels, and categories
- LogbookEntry: Member's attempt/completion of a skill
- SkillProgression: Member's progression path through skill levels
- TrainingCategory: Grouping of related skills (flyer, instructor, trainer, coach)
Value Objects
- SkillLevel: Level with validation rules
- ApprovalRequirement: Required approval level for skill completion
- ProgressionPath: Ordered sequence of skills
- SkillStatus: Current status (open, suspended, closed, not_current)
Business Rules
- Flyer Progression: Complex level-based progression (Level 1 → Level 2 paths → Level 3+)
- Prerequisite Validation: Skills requiring other skills to be completed first
- Approval Level Enforcement: Different roles can approve different skill levels
- Currency Dependencies: Some skills require active currency status
Proposed DDD Architecture
Domain Layer Structure
api/src/features/logbook/domain/
├── entities/
│ ├── Skill.js
│ ├── LogbookEntry.js
│ ├── SkillProgression.js
│ └── TrainingCategory.js
├── value-objects/
│ ├── SkillLevel.js
│ ├── ApprovalRequirement.js
│ ├── ProgressionPath.js
│ ├── SkillStatus.js
│ └── SkillPrerequisite.js
├── domain-services/
│ ├── SkillProgressionService.js
│ ├── SkillEligibilityService.js
│ ├── FlyerProgressionService.js
│ └── ApprovalValidationService.js
├── repositories/
│ ├── SkillRepository.js (interface)
│ └── LogbookRepository.js (interface)
└── events/
├── SkillCompleted.js
├── ProgressionAdvanced.js
└── SkillSuspended.js
Domain Model Design
1. Value Objects
SkillLevel - Encapsulates level logic and validation
// api/src/features/logbook/domain/value-objects/SkillLevel.js
export class SkillLevel {
constructor(level, category) {
this.level = level;
this.category = category; // 'flyer', 'instructor', 'trainer', 'coach'
if (level < 0 || level > 5) {
throw new Error('Invalid skill level');
}
}
isLevel1() {
return this.level === 1;
}
isHigherThan(otherLevel) {
return this.level > otherLevel.level;
}
requiresPrerequisites() {
return this.level > 1;
}
getNextLevels() {
switch (this.category) {
case 'flyer':
return this._getFlyerNextLevels();
case 'instructor':
return this._getInstructorNextLevels();
default:
return [];
}
}
_getFlyerNextLevels() {
const progressionMap = {
1: [{ level: 2, types: ['dynamic', 'formation'] }],
2: [{ level: 3, types: ['static', 'dynamic', 'formation'] }],
3: [{ level: 4, types: ['static', 'dynamic'] }],
4: [{ level: 5, types: ['static', 'dynamic'] }]
};
return progressionMap[this.level] || [];
}
}
ProgressionPath - Defines valid skill sequences
// api/src/features/logbook/domain/value-objects/ProgressionPath.js
export class ProgressionPath {
constructor(pathType, requiredSkills = [], nextSkills = []) {
this.pathType = pathType; // 'linear', 'branching', 'parallel'
this.requiredSkills = requiredSkills;
this.nextSkills = nextSkills;
}
canProgress(completedSkills) {
return this.requiredSkills.every(skillId =>
completedSkills.includes(skillId)
);
}
getAvailableNextSkills(completedSkills) {
if (!this.canProgress(completedSkills)) {
return [];
}
return this.nextSkills;
}
static createFlyerProgression() {
return new Map([
['level1', new ProgressionPath('linear', [], [
'flyer_dynamic_level2',
'flyer_formation_level2'
])],
['level2_dynamic', new ProgressionPath('branching',
['flyer_level1'],
['flyer_static_level3', 'flyer_dynamic_level3']
)],
['level2_formation', new ProgressionPath('parallel',
['flyer_level1'],
['flyer_formation_level3', 'flyer_formation_level4']
)]
]);
}
}
ApprovalRequirement - Encapsulates approval rules
// api/src/features/logbook/domain/value-objects/ApprovalRequirement.js
export class ApprovalRequirement {
constructor(minimumLevel, requiredRole, dependentSkill = null) {
this.minimumLevel = minimumLevel;
this.requiredRole = requiredRole; // 'instructor', 'trainer', 'coach'
this.dependentSkill = dependentSkill;
}
canApprove(approver) {
const hasRequiredLevel = this._hasRequiredApprovalLevel(approver);
const hasDependentSkill = this._hasDependentSkill(approver);
return hasRequiredLevel && hasDependentSkill;
}
_hasRequiredApprovalLevel(approver) {
const approverLevel = approver.getApprovalLevel(this.requiredRole);
return approverLevel >= this.minimumLevel;
}
_hasDependentSkill(approver) {
if (!this.dependentSkill) return true;
return approver.hasCompletedSkill(this.dependentSkill);
}
}
2. Entities
Skill - Rich skill entity with business behavior
// api/src/features/logbook/domain/entities/Skill.js
export class Skill {
constructor(id, title, level, category, approvalRequirement, prerequisites = []) {
this.id = id;
this.title = title;
this.level = level; // SkillLevel value object
this.category = category; // TrainingCategory
this.approvalRequirement = approvalRequirement; // ApprovalRequirement
this.prerequisites = prerequisites; // Array of skill IDs
}
canBeAttemptedBy(member, completedSkills) {
return this._hasPrerequisites(completedSkills) &&
this._meetsCurrencyRequirements(member) &&
this._meetsProgressionRequirements(member, completedSkills);
}
canBeApprovedBy(approver) {
return this.approvalRequirement.canApprove(approver);
}
_hasPrerequisites(completedSkills) {
return this.prerequisites.every(prereqId =>
completedSkills.includes(prereqId)
);
}
_meetsCurrencyRequirements(member) {
// For flyer skills, check flyer currency
if (this.category.isFlyer()) {
return this._checkFlyerCurrency(member);
}
return true;
}
_checkFlyerCurrency(member) {
const { flyer_currency_checkbox = [] } = member;
if (!flyer_currency_checkbox.length ||
flyer_currency_checkbox[0]?.parent_status === 'open') {
return true;
}
const foundCheckbox = flyer_currency_checkbox.find(
checkbox => checkbox.level === this.level.level
);
return !foundCheckbox || foundCheckbox.passed;
}
_meetsProgressionRequirements(member, completedSkills) {
if (this.category.isFlyer()) {
return this._checkFlyerProgression(member, completedSkills);
}
return true;
}
_checkFlyerProgression(member, completedSkills) {
// Complex flyer progression logic extracted from original service
// This encapsulates the business rules from lines 247-396 of skill.service.js
const hasLevel1 = completedSkills.includes('flyer_level1');
const hasDynamicLevel2 = completedSkills.includes('flyer_dynamic_level2');
const hasFormationLevel2 = completedSkills.includes('flyer_formation_level2');
if (!hasLevel1 && this.level.level === 1) {
return true;
}
if (this.level.level === 2) {
return hasLevel1;
}
if (this.level.level >= 3) {
return hasLevel1 && (hasDynamicLevel2 || hasFormationLevel2);
}
return false;
}
}
SkillProgression - Member's progression through skills
// api/src/features/logbook/domain/entities/SkillProgression.js
export class SkillProgression {
constructor(memberId, category) {
this.memberId = memberId;
this.category = category;
this.completedSkills = new Map(); // skillId -> LogbookEntry
this.currentLevel = new SkillLevel(0, category);
this.domainEvents = [];
}
completeSkill(skill, logbookEntry) {
if (!skill.canBeAttemptedBy(this.member, Array.from(this.completedSkills.keys()))) {
throw new Error(`Skill ${skill.title} cannot be attempted at this time`);
}
this.completedSkills.set(skill.id, logbookEntry);
if (skill.level.isHigherThan(this.currentLevel)) {
const oldLevel = this.currentLevel;
this.currentLevel = skill.level;
this.domainEvents.push(
new ProgressionAdvanced(this.memberId, this.category, oldLevel, this.currentLevel)
);
}
this.domainEvents.push(
new SkillCompleted(this.memberId, skill.id, logbookEntry.id)
);
}
getAvailableSkills(allSkills) {
const completedSkillIds = Array.from(this.completedSkills.keys());
return allSkills.filter(skill =>
!completedSkillIds.includes(skill.id) &&
skill.canBeAttemptedBy(this.member, completedSkillIds)
);
}
getCurrentLevel() {
return this.currentLevel;
}
hasCompletedSkill(skillId) {
return this.completedSkills.has(skillId);
}
}
3. Domain Services
FlyerProgressionService - Complex flyer progression logic
// api/src/features/logbook/domain/domain-services/FlyerProgressionService.js
export class FlyerProgressionService {
determineAvailableSkills(memberProgression, allFlyerSkills) {
const completedSkills = Array.from(memberProgression.completedSkills.keys());
const memberLevels = this._analyzeMemberLevels(completedSkills);
return allFlyerSkills.filter(skill =>
this._isSkillAvailable(skill, memberLevels, completedSkills)
);
}
_analyzeMemberLevels(completedSkills) {
return {
hasLevel1: completedSkills.includes('flyer_level1'),
hasDynamicLevel2: completedSkills.includes('flyer_dynamic_level2'),
hasFormationLevel2: completedSkills.includes('flyer_formation_level2'),
hasHighSpeedControl: completedSkills.includes('high_speed_control')
};
}
_isSkillAvailable(skill, memberLevels, completedSkills) {
// Extract the complex conditional logic from original lines 299-336
if (!memberLevels.hasLevel1 && skill.level.level === 1) {
return true;
}
if (memberLevels.hasLevel1) {
return this._checkLevel2AndAboveAvailability(skill, memberLevels);
}
return false;
}
_checkLevel2AndAboveAvailability(skill, memberLevels) {
const skillType = skill.getType(); // 'dynamic', 'formation', 'static'
switch (skill.level.level) {
case 2:
return this._checkLevel2Availability(skill, skillType, memberLevels);
case 3:
case 4:
case 5:
return this._checkAdvancedLevelAvailability(skill, skillType, memberLevels);
default:
return false;
}
}
_checkLevel2Availability(skill, skillType, memberLevels) {
if (skillType === 'dynamic' && !memberLevels.hasDynamicLevel2) {
return true;
}
if (skillType === 'formation' && !memberLevels.hasFormationLevel2) {
return true;
}
return false;
}
_checkAdvancedLevelAvailability(skill, skillType, memberLevels) {
if (skillType === 'formation' && memberLevels.hasFormationLevel2) {
return true;
}
if (memberLevels.hasDynamicLevel2) {
if (skillType === 'static') {
return this._checkHighSpeedControlRequirement(skill, memberLevels);
}
if (skillType === 'dynamic') {
return true;
}
}
return false;
}
_checkHighSpeedControlRequirement(skill, memberLevels) {
const isHighSpeedControlSkill = skill.id === 'high_speed_control';
const hasOpenHighSpeedControl = memberLevels.hasHighSpeedControl;
if (isHighSpeedControlSkill && !hasOpenHighSpeedControl) {
return true;
}
if (!isHighSpeedControlSkill && hasOpenHighSpeedControl) {
return true;
}
return false;
}
}
SkillEligibilityService - Determines skill eligibility
// api/src/features/logbook/domain/domain-services/SkillEligibilityService.js
export class SkillEligibilityService {
constructor(flyerProgressionService, approvalValidationService) {
this.flyerProgressionService = flyerProgressionService;
this.approvalValidationService = approvalValidationService;
}
getEligibleSkills(member, category, allSkills) {
const progression = member.getProgression(category);
let eligibleSkills = [];
switch (category) {
case 'flyer':
eligibleSkills = this.flyerProgressionService.determineAvailableSkills(
progression,
allSkills
);
break;
case 'coach':
eligibleSkills = this._filterCoachSkills(member, allSkills);
break;
default:
eligibleSkills = this._filterStandardSkills(progression, allSkills);
}
return this._filterByApprovalRequirements(eligibleSkills, member);
}
_filterCoachSkills(member, allSkills) {
const { isConfirmedCoach, allow_coach_spotter } = member;
if (!isConfirmedCoach || !allow_coach_spotter) {
return allSkills.filter(skill => skill.id !== 'coach_spotter');
}
return allSkills;
}
_filterStandardSkills(progression, allSkills) {
return progression.getAvailableSkills(allSkills);
}
_filterByApprovalRequirements(skills, member) {
return skills.filter(skill =>
this._meetsApprovalDependencies(skill, member)
);
}
_meetsApprovalDependencies(skill, member) {
if (!skill.approvalRequirement.dependentSkill) {
return true;
}
return member.hasCompletedSkill(skill.approvalRequirement.dependentSkill);
}
}
4. Application Services
SkillApplicationService - Orchestrates domain operations
// api/src/features/logbook/application/SkillApplicationService.js
export class SkillApplicationService {
constructor(
skillRepository,
logbookRepository,
eligibilityService,
eventDispatcher
) {
this.skillRepository = skillRepository;
this.logbookRepository = logbookRepository;
this.eligibilityService = eligibilityService;
this.eventDispatcher = eventDispatcher;
}
async getMemberEligibleSkills(query) {
const member = await this.memberRepository.findById(query.memberId);
const allSkills = await this.skillRepository.findByCategory(query.category);
const eligibleSkills = this.eligibilityService.getEligibleSkills(
member,
query.category,
allSkills
);
return {
skills: eligibleSkills.map(skill => this._toSkillDTO(skill)),
memberLevel: member.getProgression(query.category).getCurrentLevel(),
totalSkills: allSkills.length
};
}
async createLogbookEntry(command) {
const member = await this.memberRepository.findById(command.memberId);
const skill = await this.skillRepository.findById(command.skillId);
const instructor = await this.memberRepository.findById(command.instructorId);
// Domain validation
if (!skill.canBeAttemptedBy(member, member.getCompletedSkillIds(skill.category))) {
throw new Error('Skill cannot be attempted at this time');
}
if (!skill.canBeApprovedBy(instructor)) {
throw new Error('Instructor cannot approve this skill level');
}
// Create logbook entry
const logbookEntry = new LogbookEntry(
null,
command.memberId,
command.skillId,
command.entryDate,
'requested',
command.instructorId,
command.tunnel,
command.time,
command.comment
);
// Update progression
const progression = member.getProgression(skill.category);
if (command.status === 'open') {
progression.completeSkill(skill, logbookEntry);
}
// Persistence
const savedEntry = await this.logbookRepository.save(logbookEntry);
await this.memberRepository.save(member);
// Events
const events = progression.getDomainEvents();
for (const event of events) {
await this.eventDispatcher.dispatch(event);
}
progression.clearDomainEvents();
return {
entryId: savedEntry.id,
status: savedEntry.status,
newLevel: progression.getCurrentLevel()
};
}
_toSkillDTO(skill) {
return {
id: skill.id,
title: skill.title,
level: skill.level.level,
category: skill.category.name,
prerequisites: skill.prerequisites,
approvalLevel: skill.approvalRequirement.minimumLevel
};
}
}
Migration Benefits
1. Clear Business Logic
Current scattered rules across 400 lines become focused domain objects:
- Flyer progression rules (lines 247-396) →
FlyerProgressionService - Approval validation (scattered) →
ApprovalValidationService - Skill dependencies (lines 210-220) →
SkillEligibilityService
2. Testable Design
// Before: Hard to test due to database dependencies
const skillService = new SkillService(req, res);
await skillService.getFilterSkills(); // Hits database
// After: Pure domain logic testing
const flyerService = new FlyerProgressionService();
const memberLevels = { hasLevel1: true, hasDynamicLevel2: false };
const available = flyerService.determineAvailableSkills(memberLevels, skills);
3. Reduced Complexity
- Single Responsibility: Each class has one clear purpose
- Extracted Methods: Complex conditionals become readable methods
- Type Safety: Value objects prevent invalid states
4. Maintainable Extensions
Adding new skill categories or progression rules becomes straightforward:
// New military progression service
class MilitaryProgressionService {
determineAvailableSkills(memberProgression, allSkills) {
// Military-specific logic
}
}
Example Usage Comparison
// Before (current approach)
const skillService = new SkillService(req, res);
const skills = await skillService.getFilterSkills();
// After (DDD approach)
const query = new GetEligibleSkillsQuery(memberId, 'flyer');
const result = await skillApplicationService.getMemberEligibleSkills(query);
This refactoring transforms a complex, hard-to-maintain service into a clear domain model that reflects the business reality of skill progression and logbook management.
Implementation Strategy
Phase 1: Extract Value Objects
- Create
SkillLevel,ProgressionPath,ApprovalRequirement - Add unit tests for business rules
- Replace primitive types in existing code
Phase 2: Create Domain Services
- Extract
FlyerProgressionServicefrom skill.service.js:247-396 - Create
SkillEligibilityServicefrom filtering logic - Add domain service tests
Phase 3: Build Application Layer
- Create
SkillApplicationServicefor orchestration - Implement command/query objects
- Update controllers to use application services
Phase 4: Add Domain Events
- Implement skill completion events
- Add progression advancement events
- Create event handlers for side effects