0Pricing
Node.js Backend Development Bootcamp · レッスン

集約、コマンド、ドメインイベントモデリング

整合性の境界を守りながらコマンドを検証し、ドメインイベントを発行する集約を設計します。

「集約、コマンド、ドメインイベントモデリング」はCoddyKit上の無料Node.js Backend Development Bootcampレッスンです。 これはレッスン2/4です。 下記で完全なレッスンを無料で読むことができます。その後、ブラウザ内の組み込みコードエディタと24時間対応の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.

よくある質問

「集約、コマンド、ドメインイベントモデリング」レッスンは無料ですか?

はい。「集約、コマンド、ドメインイベントモデリング」の完全なテキストはこのウェブで無料で読めます。インタラクティブに演習し(組み込みコードエディタと24時間対応のAIチューター)、Node.js Backend Development Bootcampコースの残りをアンロックするには、CoddyKit PROにアップグレードしてください。 Node.js Backend Development Bootcampコースには全4レッスンが含まれています。

「集約、コマンド、ドメインイベントモデリング」で何を学びますか?

整合性の境界を守りながらコマンドを検証し、ドメインイベントを発行する集約を設計します。 ブラウザで直接実行するハンズオンコードでNode.js Backend Development Bootcampを演習し、24時間対応の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に戻る