Node.js Backend Development Bootcamp · 课时

聚合、命令与领域事件建模

设计能够验证命令并发出领域事件的聚合,同时维护一致性边界。

第 2 / 4 课13 个步骤

聚合、命令与领域事件建模 是 CoddyKit 上的免费 Node.js Backend Development Bootcamp 课时。 这是第 2 节课,共 4 节。 你可以在下方免费阅读本课时的完整内容 — 然后在浏览器中使用内置代码编辑器和全天候 AI 导师进行实践。 这是 Node.js Backend Development Bootcamp 学习路径的一部分,你的进度在网页和 CoddyKit 应用中同步。 Node.js Backend Development Bootcamp 课程共包含 4 节课。

本课时的部分内容尚未翻译,以英文显示。

Why Aggregates Exist

In Event Sourcing, an aggregate is the unit that owns business rules and the source of truth for state changes. It is not a database table — it is a cluster of objects treated as one for the purpose of consistency.

  • An aggregate has a single root entity that the outside world talks to.
  • Every change goes through the root, so invariants can be enforced in one place.
  • The aggregate is the consistency boundary: everything inside is kept transactionally valid together.

In CQRS terms, the aggregate lives on the write side. It accepts commands, validates them, and emits domain events that describe what happened.

Commands vs Events

Two message types drive an event-sourced system, and confusing them is the most common modeling mistake.

  • A command is an intent: a request to do something that may be rejected. Named imperatively: OpenAccount, WithdrawFunds.
  • A domain event is a fact: something that already happened and cannot be rejected. Named in past tense: AccountOpened, FundsWithdrawn.

The aggregate is the bridge: command in → validate → events out. Events are the only thing that gets persisted; state is derived from them.

// Commands express intent (may be rejected)
const openAccount = { type: 'OpenAccount', accountId: 'a-1', owner: 'Ada' };
const withdraw = { type: 'WithdrawFunds', accountId: 'a-1', amount: 50 };

// Events express facts (already happened)
const accountOpened = { type: 'AccountOpened', accountId: 'a-1', owner: 'Ada' };
const fundsWithdrawn = { type: 'FundsWithdrawn', accountId: 'a-1', amount: 50 };

console.log('command:', withdraw.type);
console.log('event:', fundsWithdrawn.type);

Rebuilding State From Events

An event-sourced aggregate never stores its current state directly. Instead it folds over its past events to reconstruct state on load. This pure function is often called apply or the reducer.

  • apply(state, event) must be deterministic and side-effect free.
  • Replaying the same event stream always yields the same state.
  • This is how you load an aggregate before handling a new command.
function apply(state, event) {
  switch (event.type) {
    case 'AccountOpened':
      return { id: event.accountId, owner: event.owner, balance: 0 };
    case 'FundsDeposited':
      return { ...state, balance: state.balance + event.amount };
    case 'FundsWithdrawn':
      return { ...state, balance: state.balance - event.amount };
    default:
      return state;
  }
}

const history = [
  { type: 'AccountOpened', accountId: 'a-1', owner: 'Ada' },
  { type: 'FundsDeposited', amount: 100 },
  { type: 'FundsWithdrawn', amount: 30 },
];

const state = history.reduce(apply, null);
console.log(state); // { id: 'a-1', owner: 'Ada', balance: 70 }

The Command Handler Shape

Command handling has a consistent shape on the aggregate:

  • Decide: a pure function (state, command) => events[] that validates invariants and returns the events to emit (or throws/returns an error).
  • Evolve: the apply function from the previous scene, which folds events into state.

Keeping decide pure means no I/O, no clock, no randomness inside it — pass those in. This makes the core domain logic trivially unit-testable.

function decide(state, command) {
  switch (command.type) {
    case 'OpenAccount':
      if (state) throw new Error('Account already exists');
      return [{ type: 'AccountOpened', accountId: command.accountId, owner: command.owner }];
    case 'WithdrawFunds':
      if (!state) throw new Error('Account not found');
      if (command.amount <= 0) throw new Error('Amount must be positive');
      if (command.amount > state.balance) throw new Error('Insufficient funds');
      return [{ type: 'FundsWithdrawn', accountId: state.id, amount: command.amount }];
    default:
      throw new Error('Unknown command: ' + command.type);
  }
}

console.log(decide({ id: 'a-1', balance: 70 }, { type: 'WithdrawFunds', amount: 30 }));

Enforcing Invariants Inside the Boundary

An invariant is a rule that must always hold true for the aggregate. The classic example: "an account balance may never go negative."

  • Invariants are checked in decide before any event is emitted.
  • If a command would break an invariant, no event is produced and the command is rejected.
  • Because all state lives inside one consistency boundary, the check can be made against fully consistent data — no cross-aggregate reads needed.

This is the heart of why aggregate boundaries matter: they define exactly which data must be strongly consistent together.

Designing the Consistency Boundary

How big should an aggregate be? The rule of thumb: make it as small as possible while still being able to enforce its invariants in a single transaction.

  • Data that must change together transactionally belongs in the same aggregate.
  • Data that can be eventually consistent belongs in separate aggregates.
  • One command should modify exactly one aggregate instance per transaction.

Example: an Order and its line items share an invariant (total = sum of lines), so they are one aggregate. A Customer and their Orders do not, so they are separate aggregates linked only by ID.

A Self-Contained Aggregate Module

Putting decide and apply together gives a complete, framework-free aggregate. Notice it depends on nothing external — it just transforms data.

  • load rebuilds state from history.
  • handle loads, decides, and returns new events for the infrastructure to persist.
function apply(state, e) {
  switch (e.type) {
    case 'OrderCreated': return { id: e.orderId, lines: [], placed: false };
    case 'LineAdded': return { ...state, lines: [...state.lines, e.line] };
    case 'OrderPlaced': return { ...state, placed: true };
    default: return state;
  }
}

function decide(state, cmd) {
  switch (cmd.type) {
    case 'CreateOrder':
      if (state) throw new Error('exists');
      return [{ type: 'OrderCreated', orderId: cmd.orderId }];
    case 'AddLine':
      if (!state || state.placed) throw new Error('cannot add line');
      return [{ type: 'LineAdded', line: cmd.line }];
    case 'PlaceOrder':
      if (!state || state.lines.length === 0) throw new Error('empty order');
      return [{ type: 'OrderPlaced', orderId: state.id }];
    default: throw new Error('unknown');
  }
}

const load = (history) => history.reduce(apply, null);
const handle = (history, cmd) => decide(load(history), cmd);

let stream = handle([], { type: 'CreateOrder', orderId: 'o-1' });
stream = stream.concat(handle(stream, { type: 'AddLine', line: { sku: 'X', qty: 2 } }));
console.log(handle(stream, { type: 'PlaceOrder' }));

Optimistic Concurrency With Versions

Two commands can race against the same aggregate. Event Sourcing solves this with an expected version check at append time.

  • Each aggregate stream has a version = number of events appended so far.
  • When loading, you capture the current version.
  • When appending new events, you tell the store "only succeed if the stream is still at that version."

If another writer got there first, the append fails and you reload-and-retry. This enforces the consistency boundary without locking.

// In-memory event store demonstrating optimistic concurrency
class EventStore {
  constructor() { this.streams = new Map(); }
  load(id) { return this.streams.get(id) || []; }
  append(id, expectedVersion, newEvents) {
    const current = this.load(id);
    if (current.length !== expectedVersion) {
      throw new Error(`Concurrency conflict: expected ${expectedVersion}, got ${current.length}`);
    }
    this.streams.set(id, current.concat(newEvents));
  }
}

const store = new EventStore();
store.append('a-1', 0, [{ type: 'AccountOpened' }]);
try {
  store.append('a-1', 0, [{ type: 'FundsDeposited', amount: 10 }]); // stale version
} catch (err) {
  console.log(err.message);
}
store.append('a-1', 1, [{ type: 'FundsDeposited', amount: 10 }]); // correct version
console.log('events:', store.load('a-1').length);

Idempotency and Command Deduplication

Clients retry. A network blip can cause the same command to arrive twice, and you do not want two FundsWithdrawn events from one withdrawal.

  • Attach a command id (idempotency key) to each command.
  • The aggregate (or a dedup layer) records which command ids it has already processed.
  • A duplicate command yields zero new events instead of repeating the effect.

Combined with version checks, this gives you exactly-once effect even on an at-least-once delivery channel.

function decide(state, cmd) {
  // state.processed tracks handled command ids
  if (state && state.processed.includes(cmd.commandId)) {
    return []; // already handled -> no new events
  }
  if (!state) {
    return [{ type: 'Opened', commandId: cmd.commandId }];
  }
  return [{ type: 'Deposited', amount: cmd.amount, commandId: cmd.commandId }];
}

function apply(state, e) {
  if (!state) return { balance: 0, processed: [e.commandId] };
  return { balance: state.balance + (e.amount || 0), processed: [...state.processed, e.commandId] };
}

let history = [];
history = history.concat(decide(history.reduce(apply, null), { type: 'Open', commandId: 'c1' }));
const retry = decide(history.reduce(apply, null), { type: 'Deposit', amount: 5, commandId: 'c1' });
console.log('duplicate command emitted events:', retry.length); // 0

Modeling Events for the Long Term

Events are stored forever, so their shape is a long-lived contract. Model them carefully.

  • Name events as business facts in past tense, not CRUD verbs (OrderShipped, not OrderUpdated).
  • Capture intent and meaning, not just the resulting state diff — "why" matters for projections later.
  • Include a version/schema marker so you can upcast old events when the shape evolves.
  • Keep events lean: only domain data, never transient infrastructure details.
// A well-modeled domain event with metadata + schema version
function priceReduced({ productId, oldPrice, newPrice, reason }) {
  return {
    type: 'ProductPriceReduced',
    schemaVersion: 1,
    occurredAt: '2026-06-10T10:00:00Z', // injected, not Date.now()
    data: { productId, oldPrice, newPrice, reason },
  };
}

console.log(priceReduced({
  productId: 'p-9', oldPrice: 100, newPrice: 80, reason: 'clearance',
}));

Wiring the Aggregate Into a Handler

Infrastructure orchestrates the pure core. A typical write-side flow in a Node.js backend:

  • Load the event stream for the target aggregate id.
  • Fold it with apply to get current state and capture the version.
  • Run decide(state, command) to get new events.
  • Append events with the expected version (optimistic concurrency).
  • Publish the events so projections and other bounded contexts react.

The domain logic stays pure; only this thin handler touches the store. This separation is what makes CQRS aggregates testable and resilient.

async function handleCommand(store, bus, id, command) {
  const history = await store.load(id);
  const state = history.reduce(apply, null);
  const version = history.length;

  const newEvents = decide(state, command); // pure domain decision
  if (newEvents.length === 0) return state; // idempotent no-op

  await store.append(id, version, newEvents); // optimistic concurrency
  for (const e of newEvents) await bus.publish(e);
  return newEvents.reduce(apply, state);
}

Quick Check

Test your understanding of aggregate design and consistency boundaries.

Recap

You learned how to model the write side of an event-sourced, CQRS system:

  • Aggregates are consistency boundaries with a single root that owns business rules.
  • Commands express intent and may be rejected; events are immutable past-tense facts and are the only persisted state.
  • Core logic splits into a pure decide(state, command) => events and a pure apply(state, event) => state; state is rebuilt by folding events.
  • Invariants are checked in decide before emitting events, all within one boundary.
  • Size aggregates to be as small as possible while still enforcing their invariants in one transaction — one command touches one aggregate.
  • Optimistic concurrency (expected version) and idempotency keys give safe, exactly-once writes without locks.
  • Model events as durable, well-named, versioned contracts for the long term.
免费开始

用 AI 导师学习 JavaScript — 免费

在浏览器中编写并运行真实代码,获得全天候 AI 导师的即时帮助,并在网页或应用中继续学习。

课程
22
课程
92

常见问题解答

「聚合、命令与领域事件建模」课时是免费的吗?

是的 — 「聚合、命令与领域事件建模」的完整文本可在网页上免费阅读。要进行交互式练习(内置代码编辑器和全天候 AI 导师)并解锁 Node.js Backend Development Bootcamp 课程的其余内容,请升级到 CoddyKit PRO。 Node.js Backend Development Bootcamp 课程共包含 4 节课。

「聚合、命令与领域事件建模」这节课中我会学到什么?

设计能够验证命令并发出领域事件的聚合,同时维护一致性边界。 你通过在浏览器中直接运行的动手代码来练习 Node.js Backend Development Bootcamp,全天候 AI 导师会在你学习这节课的过程中回答你的问题。

学习 Node.js Backend Development Bootcamp 需要有经验吗?

无需任何先前经验。CoddyKit 上的 Node.js Backend Development Bootcamp 课程适合初学者到高级学习者,你可以从这里开始或从头开始,按照自己的节奏学习。 这是第 2 节课,共 4 节。

「聚合、命令与领域事件建模」课时需要多长时间?

大多数 CoddyKit 课程大约需要 5–10 分钟。每节课都很精短且互动,所以你能稳步进步,并在网页和应用中从离开的地方继续。

我能在这节 Node.js Backend Development Bootcamp 课中编写并运行代码吗?

能。每节 Node.js Backend Development Bootcamp 课都包含内置代码编辑器,你可以在浏览器中直接编写并运行真实代码,并获得即时 AI 反馈 — 无需本地设置。

此课程中的所有课时

  1. 事件作为事实来源与仅追加日志
  2. 聚合、命令与领域事件建模
  3. 构建读取模型与投影
  4. 快照、版本控制与事件模式演进
← 返回 Node.js Backend Development Bootcamp