Skip to main content

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