Skip to content
academia.sh

Lesson 15 / 19

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.

Contents

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.

// 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.

mkdir -p decisions-up/application
// 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.

// 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.

mkdir -p decisions-in-domain/domain decisions-in-domain/application
// 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.

// 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();
}
// 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?

// 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"))}`);
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.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close