Skip to main content

Overview

Hooks provide a way to add custom logic at specific points in an entity’s lifecycle. They complement schema validation with business rules that can’t be expressed in a schema.
class User extends Aggregate<UserProps> {
  protected static hooks: EntityHooks<UserProps, User> = {
    onBeforeCreate: (props) => {
      console.log(`User (PROPS: Plain Object): ${props.name}`);
    },

    onCreate: (entity) => {
      console.log(`User (Entity) created: ${entity.name}`);
    },

    onBeforeUpdate: (entity, snapshot) => {
      // Block certain changes
      if (snapshot.email !== entity.email) {
        return false; // Reject email change
      }
      return true;
    },

    rules: (entity) => {
      // Custom business rules
      if (entity.name.toLowerCase() === "admin") {
        throwValidationError("name", 'Name cannot be "admin"');
      }
    },
  };
}

Available Hooks

onBeforeCreate

Usage

Called before the entity is created and validated:
protected static hooks: EntityHooks<UserProps, User> = {
  onBeforeCreate: (props) => {
    // Props is a plain object
    console.log(`User ${props.name}`);
  },
};
Timing: Runs before constructor completes, before any execution.

Use Cases

  • Change data before it is validated

onCreate

Called after the entity is successfully created and validated:
protected static hooks: EntityHooks<UserProps, User> = {
  onCreate: (entity) => {
    // Entity is fully constructed and valid
    console.log(`User ${entity.id.value} created`);

    // Initialize derived values
    entity.initializeDefaults();

    // Emit domain event
    entity.addDomainEvent(new UserCreatedEvent(entity.id));
  },
};
Timing: Runs after constructor completes, after schema validation. Use cases:
  • Logging
  • Initializing computed values
  • Adding domain events
  • Setting up internal state

onBeforeUpdate

Called before a property change is applied. Return false to reject the change:
protected static hooks: EntityHooks<UserProps, User> = {
  onBeforeUpdate: (entity, snapshot) => {
    // snapshot = original values before change
    // entity = current values (change already applied for validation)

    // Prevent email changes
    if (snapshot.email !== entity.email) {
      console.log("Email change blocked");
      return false; // Reject change
    }

    // Prevent status downgrade
    if (snapshot.status === "verified" && entity.status === "pending") {
      return false;
    }

    return true; // Allow change
  },
};
Timing: Runs before property change is finalized. Parameters:
  • entity: Current state (with proposed change)
  • snapshot: State before the change
Return value:
  • true or undefined: Allow the change
  • false: Reject the change (value reverts)

rules

Custom validation rules that run after schema validation:
protected static hooks: EntityHooks<UserProps, User> = {
  rules: (entity) => {
    // Reserved names
    const reserved = ["admin", "system", "root"];
    if (reserved.includes(entity.name.toLowerCase())) {
      throwValidationError("name", "This name is reserved");
    }

    // Cross-field validation
    if (entity.role === "admin" && !entity.email.endsWith("@company.com")) {
      throwValidationError("email", "Admin must use company email");
    }

    // Complex business rules
    if (entity.age < 18 && entity.accountType === "premium") {
      throwValidationError("accountType", "Premium requires age 18+");
    }
  },
};
Timing: Runs after schema validation, both on create and update. Use cases:
  • Cross-field validation
  • Business rule validation
  • Reserved value checks
  • Complex conditional rules

Throwing Validation Errors

Use throwValidationError helper in rules:
import { throwValidationError } from "@woltz/rich-domain";

rules: (entity) => {
  if (entity.price < entity.cost) {
    throwValidationError("price", "Price cannot be less than cost");
  }

  if (entity.endDate < entity.startDate) {
    throwValidationError("endDate", "End date must be after start date");
  }
};
The helper throws a ValidationError with proper structure:
// Equivalent to:
throw new ValidationError([
  { path: ["price"], message: "Price cannot be less than cost" },
]);

Combining Schema and Hooks

Schema validation runs first, then hooks:
class Product extends Aggregate<ProductProps> {
  // 1. Schema validates structure and types
  protected static validation: EntityValidation<ProductProps> = {
    schema: z.object({
      id: z.custom<Id>((val) => val instanceof Id),
      name: z.string().min(1),
      price: z.number().positive(),
      cost: z.number().positive(),
      sku: z.string().regex(/^[A-Z]{3}-\d{4}$/),
    }),
    config: { onCreate: true, onUpdate: true, throwOnError: true },
  };

  // 2. Hooks validate business rules
  protected static hooks: EntityHooks<ProductProps, Product> = {
    rules: (entity) => {
      // Cross-field validation not possible in schema
      if (entity.price < entity.cost * 1.1) {
        throwValidationError("price", "Price must be at least 10% above cost");
      }
    },

    onBeforeUpdate: (entity, snapshot) => {
      // Prevent SKU changes after creation
      if (!entity.isNew() && snapshot.sku !== entity.sku) {
        return false;
      }
      return true;
    },

    onCreate: (entity) => {
      console.log(`Product ${entity.sku} created at $${entity.price}`);
    },
  };
}

Execution Order

Constructor Called

onBeforeCreate Hook

Schema Validation (if onCreate: true)

rules() Hook

onCreate() Hook

Entity Ready

Property Change

onBeforeUpdate() Hook → false? → Reject Change

Schema Validation (if onUpdate: true)

rules() Hook

Change Applied

Hooks for Value Objects

Value Objects support similar hooks:
import { ValueObject, VOHooks, throwValidationError } from "@woltz/rich-domain";

class Money extends ValueObject<{ amount: number; currency: string }> {
  protected static hooks: VOHooks<{ amount: number; currency: string }, Money> =
    {
      rules: (money) => {
        if (money.amount > 1000000) {
          throwValidationError("amount", "Amount cannot exceed 1,000,000");
        }

        const validCurrencies = ["USD", "EUR", "GBP", "BRL"];
        if (!validCurrencies.includes(money.currency)) {
          throwValidationError("currency", "Invalid currency code");
        }
      },

      onCreate: (money) => {
        console.log(`Money created: ${money.currency} ${money.amount}`);
      },
    };
}
Value Objects don’t have onBeforeUpdate because they’re immutable - you create new instances instead of modifying.

Real-World Examples

User Registration Rules

class User extends Aggregate<UserProps> {
  protected static hooks: EntityHooks<UserProps, User> = {
    rules: (user) => {
      // Username rules
      if (user.username.length < 3) {
        throwValidationError(
          "username",
          "Username must be at least 3 characters"
        );
      }
      if (!/^[a-zA-Z0-9_]+$/.test(user.username)) {
        throwValidationError(
          "username",
          "Username can only contain letters, numbers, and underscores"
        );
      }

      // Age verification
      if (user.birthDate) {
        const age = calculateAge(user.birthDate);
        if (age < 13) {
          throwValidationError("birthDate", "Must be at least 13 years old");
        }
      }

      // Email domain restrictions
      const blockedDomains = ["tempmail.com", "throwaway.com"];
      const domain = user.email.split("@")[1];
      if (blockedDomains.includes(domain)) {
        throwValidationError("email", "Temporary email addresses not allowed");
      }
    },

    onBeforeUpdate: (user, snapshot) => {
      // Prevent username changes after 30 days
      const daysSinceCreation = daysBetween(user.createdAt, new Date());
      if (daysSinceCreation > 30 && snapshot.username !== user.username) {
        return false;
      }

      // Prevent role downgrades
      const roleHierarchy = ["user", "moderator", "admin"];
      const oldRoleIndex = roleHierarchy.indexOf(snapshot.role);
      const newRoleIndex = roleHierarchy.indexOf(user.role);
      if (newRoleIndex < oldRoleIndex) {
        return false; // Can't downgrade
      }

      return true;
    },

    onCreate: (user) => {
      user.addDomainEvent(new UserRegisteredEvent(user.id, user.email));
    },
  };
}

Order Business Rules

class Order extends Aggregate<OrderProps> {
  protected static hooks: EntityHooks<OrderProps, Order> = {
    rules: (order) => {
      // Minimum order value
      if (order.total < 10) {
        throwValidationError("total", "Minimum order value is $10");
      }

      // Maximum items per order
      if (order.items.length > 50) {
        throwValidationError("items", "Maximum 50 items per order");
      }

      // Shipping address required for physical products
      const hasPhysical = order.items.some((i) => !i.isDigital);
      if (hasPhysical && !order.shippingAddress) {
        throwValidationError(
          "shippingAddress",
          "Shipping address required for physical products"
        );
      }

      // Validate item quantities
      for (const item of order.items) {
        if (item.quantity > item.maxPerOrder) {
          throwValidationError(
            "items",
            `${item.name}: maximum ${item.maxPerOrder} per order`
          );
        }
      }
    },

    onBeforeUpdate: (order, snapshot) => {
      // Can't modify confirmed orders
      if (snapshot.status !== "draft") {
        // Only allow status transitions
        if (JSON.stringify(snapshot.items) !== JSON.stringify(order.items)) {
          return false;
        }
        if (snapshot.total !== order.total) {
          return false;
        }
      }

      // Validate status transitions
      const validTransitions: Record<string, string[]> = {
        draft: ["confirmed", "cancelled"],
        confirmed: ["processing", "cancelled"],
        processing: ["shipped", "cancelled"],
        shipped: ["delivered"],
        delivered: [],
        cancelled: [],
      };

      if (snapshot.status !== order.status) {
        if (!validTransitions[snapshot.status].includes(order.status)) {
          return false;
        }
      }

      return true;
    },
  };
}

Inventory Rules

class InventoryItem extends Aggregate<InventoryProps> {
  protected static hooks: EntityHooks<InventoryProps, InventoryItem> = {
    rules: (item) => {
      // Stock cannot be negative
      if (item.quantity < 0) {
        throwValidationError("quantity", "Stock cannot be negative");
      }

      // Reorder point validation
      if (item.reorderPoint > item.maxStock) {
        throwValidationError(
          "reorderPoint",
          "Reorder point cannot exceed max stock"
        );
      }

      // Location format
      if (item.location && !/^[A-Z]\d{2}-\d{3}$/.test(item.location)) {
        throwValidationError("location", "Location must be in format A00-000");
      }
    },

    onBeforeUpdate: (item, snapshot) => {
      // Log significant quantity changes
      const change = item.quantity - snapshot.quantity;
      if (Math.abs(change) > 100) {
        console.warn(
          `Large inventory change: ${item.sku} changed by ${change}`
        );
      }

      // Prevent changes to archived items
      if (snapshot.status === "archived") {
        return false;
      }

      return true;
    },
  };
}