Skip to content

Domain Model Implementation

A rich domain model is at the heart of Domain-Driven Design. It encapsulates both data and behavior, ensuring that business rules are enforced consistently throughout the application.

Current State

The current domain model is anemic:

  • Domain entities are primarily data validation schemas
  • Business logic is scattered across services
  • Entities lack behavior and methods
  • No clear aggregates or value objects

Implementing Rich Domain Models

Entity Structure

A rich domain entity should:

  1. Encapsulate both state and behavior
  2. Enforce invariants
  3. Expose methods that represent business operations
  4. Hide implementation details

Example: Requisition Entity

Here's how we can transform the current anemic Requisition entity into a rich domain model:

JavaScript
// src/domain/requisitionManagement/entities/Requisition.js
const RequisitionStatus = require('../valueObjects/RequisitionStatus');
const RequisitionSubmittedEvent = require('../events/RequisitionSubmittedEvent');
const RequisitionApprovedEvent = require('../events/RequisitionApprovedEvent');
const RequisitionRejectedEvent = require('../events/RequisitionRejectedEvent');

class Requisition {
  constructor(props) {
    this.id = props.id;
    this.rsNumber = props.rsNumber;
    this.rsLetter = props.rsLetter;
    this.companyCode = props.companyCode;
    this.createdBy = props.createdBy;
    this.status = new RequisitionStatus(props.status || 'DRAFT');
    this.items = props.items || [];
    this.approvers = props.approvers || [];
    this.dateRequired = props.dateRequired;
    this.deliveryAddress = props.deliveryAddress;
    this.purpose = props.purpose;
    this.type = props.type;

    // Private properties
    this._validateState();
  }

  // Domain behavior
  addItem(item) {
    this.items.push(item);
  }

  removeItem(itemId) {
    this.items = this.items.filter(item => item.id !== itemId);
  }

  submit() {
    if (this.status.value !== 'DRAFT') {
      throw new Error('Only draft requisitions can be submitted');
    }

    if (this.items.length === 0) {
      throw new Error('Cannot submit requisition without items');
    }

    this.status = new RequisitionStatus('SUBMITTED');
    return new RequisitionSubmittedEvent(this);
  }

  approve(approverId) {
    if (this.status.value !== 'SUBMITTED' && this.status.value !== 'PARTIALLY_APPROVED') {
      throw new Error('Only submitted or partially approved requisitions can be approved');
    }

    const approver = this.approvers.find(a => a.id === approverId);
    if (!approver) {
      throw new Error('Approver not found');
    }

    if (approver.status === 'APPROVED') {
      throw new Error('Approver has already approved this requisition');
    }

    approver.status = 'APPROVED';

    // Check if all approvers have approved
    const allApproved = this.approvers.every(a => a.status === 'APPROVED');
    if (allApproved) {
      this.status = new RequisitionStatus('APPROVED');
      return new RequisitionApprovedEvent(this);
    } else {
      this.status = new RequisitionStatus('PARTIALLY_APPROVED');
    }

    return null;
  }

  reject(approverId, reason) {
    if (this.status.value !== 'SUBMITTED' && this.status.value !== 'PARTIALLY_APPROVED') {
      throw new Error('Only submitted or partially approved requisitions can be rejected');
    }

    const approver = this.approvers.find(a => a.id === approverId);
    if (!approver) {
      throw new Error('Approver not found');
    }

    approver.status = 'REJECTED';
    this.status = new RequisitionStatus('REJECTED');
    return new RequisitionRejectedEvent(this, reason);
  }

  // Private methods
  _validateState() {
    if (this.type !== 'ofm' && 
        this.type !== 'non-ofm' && 
        this.type !== 'ofm-tom' && 
        this.type !== 'non-ofm-tom' && 
        this.type !== 'transfer') {
      throw new Error('Invalid requisition type');
    }
  }

  // Factory methods
  static create(props) {
    return new Requisition({
      ...props,
      status: 'DRAFT'
    });
  }

  static reconstitute(props) {
    return new Requisition(props);
  }
}

module.exports = Requisition;

Value Objects

Value objects represent concepts that are defined by their attributes rather than an identity:

JavaScript
// src/domain/sharedKernel/valueObjects/Money.js
class Money {
  constructor(amount, currency = 'PHP') {
    this._amount = amount;
    this._currency = currency;
    this._validate();
  }

  get amount() {
    return this._amount;
  }

  get currency() {
    return this._currency;
  }

  add(money) {
    if (this._currency !== money.currency) {
      throw new Error('Cannot add different currencies');
    }
    return new Money(this._amount + money.amount, this._currency);
  }

  subtract(money) {
    if (this._currency !== money.currency) {
      throw new Error('Cannot subtract different currencies');
    }
    return new Money(this._amount - money.amount, this._currency);
  }

  equals(money) {
    return this._amount === money.amount && this._currency === money.currency;
  }

  toString() {
    return `${this._currency} ${this._amount.toFixed(2)}`;
  }

  _validate() {
    if (isNaN(this._amount)) {
      throw new Error('Amount must be a number');
    }
    if (this._amount < 0) {
      throw new Error('Amount cannot be negative');
    }
  }
}

module.exports = Money;
JavaScript
// src/domain/requisitionManagement/valueObjects/RequisitionStatus.js
class RequisitionStatus {
  static DRAFT = 'DRAFT';
  static SUBMITTED = 'SUBMITTED';
  static PARTIALLY_APPROVED = 'PARTIALLY_APPROVED';
  static APPROVED = 'APPROVED';
  static REJECTED = 'REJECTED';
  static CANVASSING = 'CANVASSING';
  static ORDERED = 'ORDERED';
  static DELIVERED = 'DELIVERED';
  static COMPLETED = 'COMPLETED';
  static CANCELLED = 'CANCELLED';

  constructor(value) {
    const validStatuses = [
      RequisitionStatus.DRAFT,
      RequisitionStatus.SUBMITTED,
      RequisitionStatus.PARTIALLY_APPROVED,
      RequisitionStatus.APPROVED,
      RequisitionStatus.REJECTED,
      RequisitionStatus.CANVASSING,
      RequisitionStatus.ORDERED,
      RequisitionStatus.DELIVERED,
      RequisitionStatus.COMPLETED,
      RequisitionStatus.CANCELLED
    ];

    if (!validStatuses.includes(value)) {
      throw new Error(`Invalid requisition status: ${value}`);
    }

    this._value = value;
  }

  get value() {
    return this._value;
  }

  equals(status) {
    return this._value === status.value;
  }

  canTransitionTo(newStatus) {
    // Define valid state transitions
    const validTransitions = {
      [RequisitionStatus.DRAFT]: [RequisitionStatus.SUBMITTED],
      [RequisitionStatus.SUBMITTED]: [
        RequisitionStatus.PARTIALLY_APPROVED,
        RequisitionStatus.APPROVED,
        RequisitionStatus.REJECTED
      ],
      [RequisitionStatus.PARTIALLY_APPROVED]: [
        RequisitionStatus.APPROVED,
        RequisitionStatus.REJECTED
      ],
      [RequisitionStatus.APPROVED]: [RequisitionStatus.CANVASSING],
      [RequisitionStatus.CANVASSING]: [RequisitionStatus.ORDERED],
      [RequisitionStatus.ORDERED]: [RequisitionStatus.DELIVERED],
      [RequisitionStatus.DELIVERED]: [RequisitionStatus.COMPLETED],
      // Any status can be cancelled
    };

    if (newStatus.value === RequisitionStatus.CANCELLED) {
      return true;
    }

    return validTransitions[this._value]?.includes(newStatus.value) || false;
  }
}

module.exports = RequisitionStatus;