---
title: 'Use Cases'
source: 'https://academia.sh/en/courses/domain-driven-design/use-cases'
course: 'Domain-Driven Design'
language: en
updated: '2026-08-23T07:01:18+00:00'
license: 'CC BY-SA 4.0'
---

# Use Cases

Defining the application layer's responsibility through the use case: counting decision points across two versions of the same scenario — one that carries decisions in the application layer and one that pushes them down to the aggregate root — measuring how many places the domain expert's five sentences are written in the code, and testing the two versions' behavioral equality.

The Contexts topic established **where** a model holds: the boundary runs through the place a
name's meaning changes, contexts' relationships are named by direction and type, the outside
model's leakage is stopped with a layer, and the subdomain distinction says how much effort
goes to which part. The question left open sits **inside** a context: how does that context's
model fit inside an application? Where does a request enter, in what order does it descend to
which object, who draws the transaction's boundary, who does the result return to?

This topic sets up that arrangement, and its first lesson takes the **use case** as the
application layer's unit: a thin shell that calls the domain model, starts and ends the
transaction, but does not carry the business rule itself. Rules were called directly in every
example up to this point; now the question is what the caller is. This section's through-line
is the delivery operation context: `shipment` is the physical package with its route and
state timestamps, `route` is the sequence of transfer points, `delivery` is that package's
tracked state through the operation. The scenario at hand is a single sentence: record an
arrival at a transfer point.

## Step Order and Decision

A **use case** is a single operation the application exposes to the outside, and it can hold
two things at once: the **order** of the steps and the **decisions** inside them. Order is a
technical arrangement — calling the repository, opening the transaction boundary, writing the
result, producing the contract returned to the caller. A decision is the domain's own
sentence.

Defining the application boundary in one place was established in the Design Patterns course's
service layer pattern; the layers' responsibilities and dependency direction were established
in the Data Access Layer and Business Logic course. What is new here is not the layer's
definition but a single question: which file holds the decisions inside the scenario?

The domain expert's five sentences about recording an arrival: an arrival is not processed
against a closed delivery; an arrival is processed only against a point on the route; the same
point does not count as passed twice; an arrival must match the route's order; a shipment
reaching the route's last point goes out for delivery. How many places in the code these five
sentences are written to is this lesson's alignment measure.

Both versions use the same persistence arrangement.

```js
// repository.mjs — in-memory repository holding delivery records; both arrangements use it
const TABLE = new Map();

export const deliveryRepository = {
  find: (id) => structuredClone(TABLE.get(id) ?? null),
  write: (record) => { TABLE.set(record.id, structuredClone(record)); },
};

export function seed(id) {
  TABLE.set(id, { id, state: "accepted", route: ["34", "06", "35"], passed: [] });
}
```

## Decisions in the Application Layer

In the first version, the delivery record is a behaviorless pile of data; all five sentences
are written into the scenario's body.

```sh
mkdir -p decisions-up/application
```

```js
// decisions-up/application/record-arrival.mjs — the use case carries both order and decisions
import { deliveryRepository } from "../../repository.mjs";

export function recordArrival(id, point) {
  const t = deliveryRepository.find(id);
  if (t === null) throw new RangeError(`delivery not found: ${id}`);
  if (t.state === "delivered") throw new Error("cannot record arrival on a closed delivery");
  if (!t.route.includes(point)) throw new Error(`point not on route: ${point}`);
  if (t.passed.includes(point)) throw new Error(`point already passed: ${point}`);
  const order = t.route.indexOf(point);
  if (order !== t.passed.length) throw new Error("route order broken");
  t.passed.push(point);
  t.state = order === t.route.length - 1 ? "out-for-delivery" : "in-transfer";
  deliveryRepository.write(t);
  return { identity: t.id, state: t.state, passed: t.passed.length };
}
```

The application's second client batch-processes the arrival list the transfer center sends at
day's end. Because the decisions live in the scenario's body, the second client rewrites them.

```js
// decisions-up/application/batch-arrival.mjs — the second client rewrites the same decisions
import { deliveryRepository } from "../../repository.mjs";

export function batchArrival(inputs) {
  return inputs.map(({ id, point }) => {
    try {
      const t = deliveryRepository.find(id);
      if (t === null) throw new RangeError(`delivery not found: ${id}`);
      if (t.state === "delivered") throw new Error("cannot record arrival on a closed delivery");
      if (!t.route.includes(point)) throw new Error(`point not on route: ${point}`);
      if (t.passed.includes(point)) throw new Error(`point already passed: ${point}`);
      const order = t.route.indexOf(point);
      if (order !== t.passed.length) throw new Error("route order broken");
      t.passed.push(point);
      t.state = order === t.route.length - 1 ? "out-for-delivery" : "in-transfer";
      deliveryRepository.write(t);
      return { identity: t.id, state: t.state, passed: t.passed.length };
    } catch (error) { return { identity: id, error: error.message }; }
  });
}
```

The batch script is the single scenario's body wrapped inside a map call. What repeats is not
a line, but the domain's five sentences.

## Decisions in the Domain Model

In the second version, the five sentences move into the **aggregate root**. Delivery is an
entity: it has an identity, its state changes over time, and the array of passed points
carries its invariant.

```sh
mkdir -p decisions-in-domain/domain decisions-in-domain/application
```

```js
// decisions-in-domain/domain/delivery.mjs — the same five decisions, inside the aggregate root
export class Delivery {
  #id; #route; #passed; #state;

  constructor(record) {
    this.#id = record.id; this.#route = [...record.route];
    this.#passed = [...record.passed]; this.#state = record.state;
  }

  recordArrival(point) {
    if (this.#state === "delivered") throw new Error("cannot record arrival on a closed delivery");
    if (!this.#route.includes(point)) throw new Error(`point not on route: ${point}`);
    if (this.#passed.includes(point)) throw new Error(`point already passed: ${point}`);
    const order = this.#route.indexOf(point);
    if (order !== this.#passed.length) throw new Error("route order broken");
    this.#passed.push(point);
    this.#state = order === this.#route.length - 1 ? "out-for-delivery" : "in-transfer";
  }

  toRecord() { return { id: this.#id, state: this.#state, route: [...this.#route], passed: [...this.#passed] }; }
  toSummary() { return { identity: this.#id, state: this.#state, passed: this.#passed.length }; }
}
```

Four steps remain in the scenario: read the record, tell the aggregate, write the result,
return the contract.

```js
// decisions-in-domain/application/record-arrival.mjs — the use case carries only order now
import { deliveryRepository } from "../../repository.mjs";
import { Delivery } from "../domain/delivery.mjs";

export function recordArrival(id, point) {
  const record = deliveryRepository.find(id);
  if (record === null) throw new RangeError(`delivery not found: ${id}`);
  const delivery = new Delivery(record);
  delivery.recordArrival(point);
  deliveryRepository.write(delivery.toRecord());
  return delivery.toSummary();
}
```

```js
// decisions-in-domain/application/batch-arrival.mjs — the second client only adds a loop
import { recordArrival } from "./record-arrival.mjs";

export function batchArrival(inputs) {
  return inputs.map(({ id, point }) => {
    try { return recordArrival(id, point); }
    catch (error) { return { identity: id, error: error.message }; }
  });
}
```

The second client's real difference from the single case is now visible: a loop and an error
shape. It knows nothing about the five sentences.

## Measurement

The measurement produces three numbers: decision points per file — the count of branch markers
(`if`, the ternary operator, `catch`); sites per rule — how many files each of the five
sentences appears in; and a run result — do the two versions give the same result on the same
operation sequence?

```js
// decision-count.mjs — decision points, the number of files each rule is written in, and the two arrangements' behavior equality
import { readFileSync } from "node:fs";
import { deliveryRepository, seed } from "./repository.mjs";
import { batchArrival as aboveBatch } from "./decisions-up/application/batch-arrival.mjs";
import { batchArrival as domainBatch } from "./decisions-in-domain/application/batch-arrival.mjs";

const BRANCH = /\bif \(|\bcatch \(| \? /g;
const RULE = {
  "closed-delivery": /=== "delivered"/,
  "point-on-route": /route\.includes\(point\)/,
  "point-repeat": /passed\.includes\(point\)/,
  "route-order": /order !== /,
  "final-point": /"out-for-delivery"/,
};
const ABOVE = ["decisions-up/application/record-arrival.mjs", "decisions-up/application/batch-arrival.mjs"];
const IN_DOMAIN = ["decisions-in-domain/application/record-arrival.mjs", "decisions-in-domain/application/batch-arrival.mjs",
  "decisions-in-domain/domain/delivery.mjs"];

function scan(name, files) {
  console.log(name);
  const texts = files.map((f) => [f, readFileSync(f, "utf8")]);
  let applicationDecisions = 0, domainDecisions = 0;
  for (const [f, m] of texts) {
    const branches = (m.match(BRANCH) ?? []).length;
    if (f.includes("/application/")) applicationDecisions += branches; else domainDecisions += branches;
    console.log(`  ${f.padEnd(52)} decision points = ${branches}`);
  }
  console.log(`  application layer decisions = ${applicationDecisions}, domain module decisions = ${domainDecisions}`);
  let sites = 0;
  for (const [k, pattern] of Object.entries(RULE)) {
    const where = texts.filter(([, m]) => pattern.test(m)).map(([f]) => f.split("/").at(-1));
    sites += where.length;
    console.log(`  rule ${k.padEnd(16)} in ${where.length} place(s) (${where.join(", ")})`);
  }
  console.log(`  total sites for the five rules = ${sites}`);
}

scan("decisions in the application layer", ABOVE);
scan("decisions in the domain model", IN_DOMAIN);

const OPERATIONS = (id) => [
  { id, point: "34" }, { id, point: "34" }, { id, point: "35" }, { id, point: "99" },
  { id, point: "06" }, { id, point: "35" }, { id: "T-MISSING", point: "34" },
];
seed("T-A"); seed("T-B");
const a = aboveBatch(OPERATIONS("T-A"));
const b = domainBatch(OPERATIONS("T-B"));
const normalize = (r, id) => JSON.stringify(r).replaceAll(id, "T");
let diverged = 0;
for (let i = 0; i < a.length; i += 1) {
  const x = normalize(a[i], "T-A"), y = normalize(b[i], "T-B");
  if (x !== y) diverged += 1;
  console.log(`${String(i).padStart(2)} ${x}`);
}
console.log(`diverged result = ${diverged} / ${a.length}`);
console.log(`final record = ${JSON.stringify(deliveryRepository.find("T-B"))}`);
```

```sh
node decision-count.mjs
```

```
decisions in the application layer
  decisions-up/application/record-arrival.mjs          decision points = 6
  decisions-up/application/batch-arrival.mjs           decision points = 7
  application layer decisions = 13, domain module decisions = 0
  rule closed-delivery  in 2 place(s) (record-arrival.mjs, batch-arrival.mjs)
  rule point-on-route   in 2 place(s) (record-arrival.mjs, batch-arrival.mjs)
  rule point-repeat     in 2 place(s) (record-arrival.mjs, batch-arrival.mjs)
  rule route-order      in 2 place(s) (record-arrival.mjs, batch-arrival.mjs)
  rule final-point      in 2 place(s) (record-arrival.mjs, batch-arrival.mjs)
  total sites for the five rules = 10
decisions in the domain model
  decisions-in-domain/application/record-arrival.mjs   decision points = 1
  decisions-in-domain/application/batch-arrival.mjs    decision points = 1
  decisions-in-domain/domain/delivery.mjs              decision points = 5
  application layer decisions = 2, domain module decisions = 5
  rule closed-delivery  in 1 place(s) (delivery.mjs)
  rule point-on-route   in 1 place(s) (delivery.mjs)
  rule point-repeat     in 1 place(s) (delivery.mjs)
  rule route-order      in 1 place(s) (delivery.mjs)
  rule final-point      in 1 place(s) (delivery.mjs)
  total sites for the five rules = 5
 0 {"identity":"T","state":"in-transfer","passed":1}
 1 {"identity":"T","error":"point already passed: 34"}
 2 {"identity":"T","error":"route order broken"}
 3 {"identity":"T","error":"point not on route: 99"}
 4 {"identity":"T","state":"in-transfer","passed":2}
 5 {"identity":"T","state":"out-for-delivery","passed":3}
 6 {"identity":"T-MISSING","error":"delivery not found: T-MISSING"}
diverged result = 0 / 7
final record = {"id":"T-B","state":"out-for-delivery","route":["34","06","35"],"passed":["34","06","35"]}
```

## Reading the Numbers

The application layer's decision points dropped from 13 to 2; the domain module gained 5. The
total falling from 13 to 7 is because duplication is gone, not decisions. The 2 remaining
decision points are the branch itself: the record not being found, in both scenarios.

The five sentences' total sites dropped from 10 to 5, the ratio from 2.0 to 1.0 — the lesson's
alignment measure: a sentence living in more than one place means the two places drifting
apart is a version problem, not a design problem. The growth shape is clear too: with `n`
clients each running the scenario alone, sites total `5n`; with decisions in the aggregate
root, a constant 5. A sixth sentence edits `n` files in the first arrangement, 1 in the second.

The last seven lines show behavior was preserved: the same operation sequence produced the
same result in both versions — three valid arrivals, three rule violations, one identity not
found. The diverged result is 0, and the final record confirms all three points were passed
and the state returned out for delivery.

## Decisions That Stay in the Application Layer

Reading the measure as "decision points should go to zero" would be wrong. The application
layer has decisions of its own, not the domain model's, and pushing them down makes the domain
model start knowing things it should not.

The record not being found is one of these: not a domain rule but a lookup result, given
before the delivery object exists. Where the transaction boundary opens and closes, caller
authorization, the order several aggregates are read in, which fields the outward contract
carries — these too are application layer decisions. None appear in the domain expert's
sentence: the expert says "an arrival is not processed against a closed delivery," not "open
the transaction here."

In practice the distinction is one question: does this decision come from the domain's
vocabulary, or from the application's implementation arrangement? The first case goes down to
the aggregate root; the second stays in the scenario.

## Summary

- A use case can hold step order and decision together; order belongs to the application, and
  decision belongs to the aggregate root once it belongs to the domain's vocabulary.
- While decisions lived in the scenario, the application layer's decision points were 13 and
  the domain module's were 0; once decisions moved to the aggregate root, the numbers became
  2 and 5.
- The domain expert's five sentences' total sites dropped from 10 to 5, and the sites-per-
  sentence ratio fell from 2.0 to 1.0; in the first arrangement this number grows with the
  client count as `5n`.
- All seven operations gave the same result in both versions; the change did not alter
  transport behavior, only which file the decision lives in.
- A lookup result, the transaction boundary, authorization, and the outward contract are the
  application layer's decisions; the test is whether they appear in the domain expert's
  sentence.

## Next Step

The decisions moved down to the aggregate root, but one of the scenario's four steps still
carries an outside-world name: `deliveryRepository`. This will not stay a single instance:
recording an arrival wants the carrier's own point code read, wants an arrival number
generated, wants a notice sent to the transfer center.

If each of these names enters the domain module as a direct link, the domain model starts
knowing the outside; its import closure grows and testing the model becomes tied to the
outside world being ready. The next lesson names every place the domain opens to the outside
as an explicit port, keeps adapters outside, and compares — across both arrangements — the
number of direct links from the domain module to the outside world and the number of files
an adapter change edits.
