Skip to Content
Business logicLogbookDDD refactor notes

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:

  1. Massive Service Class: 397 lines with mixed responsibilities (filtering, formatting, business rules)
  2. Complex Nested Logic: Deep conditional chains for skill progression rules (lines 247-396)
  3. Scattered Business Rules: Flyer progression logic, approval levels, and skill dependencies spread across methods
  4. Data Structure Manipulation: Heavy focus on data transformation rather than business behavior
  5. 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

  1. Create SkillLevel, ProgressionPath, ApprovalRequirement
  2. Add unit tests for business rules
  3. Replace primitive types in existing code

Phase 2: Create Domain Services

  1. Extract FlyerProgressionService from skill.service.js:247-396
  2. Create SkillEligibilityService from filtering logic
  3. Add domain service tests

Phase 3: Build Application Layer

  1. Create SkillApplicationService for orchestration
  2. Implement command/query objects
  3. Update controllers to use application services

Phase 4: Add Domain Events

  1. Implement skill completion events
  2. Add progression advancement events
  3. Create event handlers for side effects
Last updated on