What you’ll learn

  • How actions differ from auto-setters
  • When to use guard clauses inside actions
  • How to make multi-field changes atomic
  • Why actions should protect their own preconditions

Anatomy of an action

actions: {
  verifyEmail(state) {
    if (state.emailVerified) throw new Error('Already verified');
    state.emailVerified = true;
  },
}
  • state is a mutable draft of the entity’s props. Mutate it directly.
  • After the action returns, state is frozen and invariants (if any) are checked.
  • Throw an Error to reject the mutation — state is rolled back.

Guard clauses — make illegal states unrepresentable

The core job of an action is to reject mutations that would put the entity in an invalid state:

actions: {
  addRole(state, role: string) {
    if (!VALID_ROLES.includes(role)) throw new Error(`Unknown role: ${role}`);
    if (state.roles.includes(role)) throw new Error(`Already has role: ${role}`);
    state.roles.push(role);
  },
  removeRole(state, role: string) {
    if (role === 'admin' && state.roles.length === 1) {
      throw new Error('Cannot remove last admin');
    }
    state.roles = state.roles.filter(r => r !== role);
  },
}

Validating outside the entity:

// In the controller:
if (!user.props.emailVerified) {
  throw new BadRequestException('Verify email first');
}
user.actions.deactivate();

The guard is email verified? belongs in deactivate(), not in the controller. When you validate outside, every caller must remember the precondition. When you validate inside, the entity enforces its own rules.

Multi-field atomic changes

When two fields must change together or not at all:

actions: {
  transferOwnership(state, newOwnerId: string) {
    state.previousOwnerId = state.ownerId;
    state.ownerId = newOwnerId;
    state.transferredAt = new Date();
  },
}

Splitting atomic changes across calls:

user.actions.setOwnerId(newId);
user.actions.setTransferredAt(new Date());
// Between these calls, ownerId is new but transferredAt is stale

If the two mutations represent one business operation, make it one action. Callers can call auto-setters in any order — and leave intermediate states no one intended.

Action that rejects on invalid state transition

actions: {
  ship(state) {
    if (state.status !== 'confirmed') {
      throw new Error('Only confirmed orders can be shipped');
    }
    state.status = 'shipped';
    return { event: { aggregateId: state.id, timestamp: new Date() } };
  },
}

The guard enforces the state machine: pending → confirmed → shipped. No caller can accidentally ship an unconfirmed order.

Trusting the caller to know the state:

actions: {
  ship(state) {
    state.status = 'shipped'; // hopes caller checked first
  },
}

An entity that can be put in an invalid state by calling its own method has leaked its invariants. Every action should protect its own preconditions.

Domain events from actions

Actions in an Aggregate can return { event }:

actions: {
  confirm(state) {
    state.status = 'confirmed';
    return { event: { aggregateId: state.id, timestamp: new Date() } };
  },
}

Events accumulate in a queue. Pull them after your use case:

order.actions.confirm();
const events = order.getPendingEvents();
// dispatch to event bus, email service, analytics...

Events are pull-based by design. No hidden side effects. If you don’t return them, they don’t exist. If you don’t pull them, nothing happens.

Decision tree: action or auto-setter?

Does the mutation have business meaning beyond "change this value"?
  ├── NO  → auto-setter (setField)
  └── YES → action
       ├── Guard clause needed?          → action
       ├── Multiple fields together?     → action
       ├── State machine transition?     → action
       ├── Domain event returned?        → action (Aggregate)
       └── Validation beyond Zod schema? → action

What you’ve learned

Actions carry business meaning. Auto-setters handle raw field assignment. Actions earn their place through guard clauses, multi-field atomicity, or state machine transitions.

Guard clauses belong inside actions. Don’t validate preconditions in the controller — every caller must then duplicate the check. Put the guard in the action and the entity enforces its own rules.

Domain events are pull-based. Return { event } from actions in an Aggregate. Events accumulate in a queue. You decide when to dispatch — no hidden side effects.

Use the decision tree. Single assignment → auto-setter. Guard clause, multi-field change, state transition, or event → action.

What’s next?

Computed properties derive values from entity state without mutation. They’re properties, not function calls — recalculated on every access. Computed Properties covers what they are, what they’re for, and what they must never do.

Computed Properties