Skip to content
academia.sh

Lesson 10 / 16

Organizing Code by Actor

Measuring the grouping of code by reason to change instead of by technical layer: how many actors' decisions are defined in a file, how many files each actor spans, and how many files a single accounting change touches, compared across two layouts of the same fee package.

Contents

Visibility decisions determine what a module gives to the outside. One question remains: what criterion split these modules apart? The fee calculation sits in one file, formatting in another; the split looks technical and reasonable.

What actually strains a codebase is different people touching the same file for different reasons. If a rounding change that accounting wants and a tier change that operations wants meet in the same file, the two decisions wait on each other; one breaks the other’s tests. This lesson counts how many separate actors a file serves.

What Is an Actor

An actor is the source of a change request: a role, a unit, a stakeholder. The fee library has three.

  • Operations decides how the shipping is done: weight tiers, the volumetric divisor, transfer count, delivery time.
  • Accounting decides how the money is counted: the tax rate, the rounding rule, the format of the invoice line.
  • Customer communication decides what is told to the customer: the label text, the delivery message.

The three change independently of each other. When the tax rate changes, there is no reason for the weight tiers to change. The criterion follows from this: a file should change at the request of a single actor.

Layout by Layer

The first layout separates files by technical function: calculation, flow, format. This split looks similar to the layer responsibilities established in the The Data Access Layer and Business Logic course, but there the split was determined by dependency direction; here it is only the kind of job.

mkdir -p layer actor
// layer/calc.mjs — operations' tier rules and accounting's tax rules together
export const VOLUMETRIC_DIVISOR = 3000;
export const WEIGHT_TIERS = [{ maxKg: 1, ratePerKg: 38 },
  { maxKg: 5, ratePerKg: 26 }, { maxKg: 30, ratePerKg: 17 }];
export const VAT_RATE = 0.2;

export function billableWeightKg(shipment) {
  const volumetric = (shipment.widthCm * shipment.lengthCm * shipment.heightCm) / VOLUMETRIC_DIVISOR;
  return Math.max(shipment.weightKg, volumetric);
}

export function selectTier(kg) {
  return WEIGHT_TIERS.find((t) => kg <= t.maxKg);
}

export function netFee(shipment) {
  const kg = billableWeightKg(shipment);
  return selectTier(kg).ratePerKg * kg;
}

export function round(amount) {
  return Math.round(amount * 100) / 100;
}

export function vat(net) {
  return round(net * VAT_RATE);
}
// layer/flow.mjs — operations' transfer rule and the customer's delivery message together
export function transferCount(shipment) {
  return shipment.zoneCode > 2 ? 2 : 1;
}

export function deliveryDays(shipment) {
  return 1 + transferCount(shipment);
}

export function deliveryMessage(shipment) {
  return `${shipment.code} your shipment will be delivered in ${deliveryDays(shipment)} day(s)`;
}
// layer/format.mjs — accounting's invoice format and the customer's label text together
import { netFee, vat, round, billableWeightKg } from "./calc.mjs";
import { transferCount } from "./flow.mjs";

export function invoiceLine(shipment) {
  const net = round(netFee(shipment));
  const tax = vat(netFee(shipment));
  return `${shipment.code} net=${net.toFixed(2)} vat=${tax.toFixed(2)} total=${round(net + tax).toFixed(2)}`;
}

export function labelText(shipment) {
  return `${shipment.code} ${billableWeightKg(shipment).toFixed(2)}kg ${transferCount(shipment)} transfer(s)`;
}

Each file is internally consistent. Yet calc.mjs carries both the tiers and the tax, format.mjs carries both the invoice and the label, and flow.mjs carries both the transfer and the customer message.

Layout by Actor

The second layout groups the same functions by their actor.

// actor/operations.mjs — tier, volume, and transfer decisions
export const VOLUMETRIC_DIVISOR = 3000;
export const WEIGHT_TIERS = [{ maxKg: 1, ratePerKg: 38 },
  { maxKg: 5, ratePerKg: 26 }, { maxKg: 30, ratePerKg: 17 }];

export function billableWeightKg(shipment) {
  const volumetric = (shipment.widthCm * shipment.lengthCm * shipment.heightCm) / VOLUMETRIC_DIVISOR;
  return Math.max(shipment.weightKg, volumetric);
}

export function selectTier(kg) {
  return WEIGHT_TIERS.find((t) => kg <= t.maxKg);
}

export function netFee(shipment) {
  const kg = billableWeightKg(shipment);
  return selectTier(kg).ratePerKg * kg;
}

export function transferCount(shipment) {
  return shipment.zoneCode > 2 ? 2 : 1;
}

export function deliveryDays(shipment) {
  return 1 + transferCount(shipment);
}
// actor/accounting.mjs — tax, rounding, and the invoice format
import { netFee } from "./operations.mjs";

export const VAT_RATE = 0.2;

export function round(amount) {
  return Math.round(amount * 100) / 100;
}

export function vat(net) {
  return round(net * VAT_RATE);
}

export function invoiceLine(shipment) {
  const net = round(netFee(shipment));
  const tax = vat(netFee(shipment));
  return `${shipment.code} net=${net.toFixed(2)} vat=${tax.toFixed(2)} total=${round(net + tax).toFixed(2)}`;
}
// actor/customer.mjs — text visible to the customer
import { billableWeightKg, transferCount, deliveryDays } from "./operations.mjs";

export function labelText(shipment) {
  return `${shipment.code} ${billableWeightKg(shipment).toFixed(2)}kg ${transferCount(shipment)} transfer(s)`;
}

export function deliveryMessage(shipment) {
  return `${shipment.code} your shipment will be delivered in ${deliveryDays(shipment)} day(s)`;
}
// compare.mjs — verifies the two layouts produce the same output
import { invoiceLine as lInvoice, labelText as lLabel } from "./layer/format.mjs";
import { deliveryMessage as lMessage } from "./layer/flow.mjs";
import { invoiceLine as aInvoice } from "./actor/accounting.mjs";
import { labelText as aLabel, deliveryMessage as aMessage } from "./actor/customer.mjs";

const shipment = { code: "GN-4172", weightKg: 2.4, widthCm: 30, lengthCm: 24, heightCm: 18, zoneCode: 3 };
for (const [layer, actor] of [[lInvoice, aInvoice], [lLabel, aLabel], [lMessage, aMessage]]) {
  const a = layer(shipment), b = actor(shipment);
  console.log(`${a === b ? "same     " : "DIFFERENT"} ${a}`);
}
same      GN-4172 net=112.32 vat=22.46 total=134.78
same      GN-4172 4.32kg 2 transfer(s)
same      GN-4172 your shipment will be delivered in 3 day(s)

Measurement: How Many Actors in a File

The measurer takes each actor’s vocabulary and checks which actors’ decisions are defined in each file. An imported name is not that file’s decision; only definitions are counted.

// actor-measure.mjs — which actor's decisions are defined in each file, how many files each actor spans
import { readFileSync, readdirSync } from "node:fs";

const ACTOR = {
  operations: ["VOLUMETRIC_DIVISOR", "WEIGHT_TIERS", "billableWeightKg", "selectTier",
    "netFee", "transferCount", "deliveryDays"],
  accounting: ["VAT_RATE", "round", "vat", "invoiceLine"],
  customer: ["labelText", "deliveryMessage"],
};

// Only definitions are counted; a name imported from another file is not that file's decision.
const definitions = (m) => [...m.matchAll(/^export\s+(?:const|function|class)\s+(\w+)/gm)].map((e) => e[1]);

for (const dir of process.argv.slice(2)) {
  const files = readdirSync(dir).filter((a) => a.endsWith(".mjs")).sort();
  const spread = Object.fromEntries(Object.keys(ACTOR).map((a) => [a, 0]));
  let overlapping = 0;
  console.log(dir);
  for (const file of files) {
    const names = new Set(definitions(readFileSync(`${dir}/${file}`, "utf8")));
    const owners = Object.entries(ACTOR)
      .filter(([, words]) => words.some((w) => names.has(w))).map(([a]) => a);
    for (const a of owners) spread[a] += 1;
    if (owners.length > 1) overlapping += 1;
    console.log(`  ${file.padEnd(14)} actors=${owners.length}  [${owners.join(", ")}]`);
  }
  const tally = Object.entries(spread).map(([a, n]) => `${a}=${n}`).join("  ");
  console.log(`  files serving multiple actors=${overlapping}   files per actor: ${tally}`);
}
node actor-measure.mjs layer actor
layer
  calc.mjs       actors=2  [operations, accounting]
  flow.mjs       actors=2  [operations, customer]
  format.mjs     actors=2  [accounting, customer]
  files serving multiple actors=3   files per actor: operations=2  accounting=2  customer=2
actor
  accounting.mjs actors=1  [accounting]
  customer.mjs   actors=1  [customer]
  operations.mjs actors=1  [operations]
  files serving multiple actors=0   files per actor: operations=1  accounting=1  customer=1

In the layer layout, all three files serve multiple actors; each one can change at the request of two separate actors. In the actor layout, no file serves multiple actors. The second number says the same thing in reverse: in the layer layout, each actor’s decisions are spread across two files; in the actor layout, they are gathered into one.

A Real Change

The claim of the numbers is tested with a request. Accounting changes two things: the value-added tax rate drops from twenty percent to ten percent, and amounts are rounded to whole currency units instead of cents. The second decision also affects the format of the invoice line, because the cents digit is now meaningless.

cp -r layer layer-new && cp -r actor actor-new
// layer-new/calc.mjs — the tax rate and rounding rule changed
export const VOLUMETRIC_DIVISOR = 3000;
export const WEIGHT_TIERS = [{ maxKg: 1, ratePerKg: 38 },
  { maxKg: 5, ratePerKg: 26 }, { maxKg: 30, ratePerKg: 17 }];
export const VAT_RATE = 0.1;

export function billableWeightKg(shipment) {
  const volumetric = (shipment.widthCm * shipment.lengthCm * shipment.heightCm) / VOLUMETRIC_DIVISOR;
  return Math.max(shipment.weightKg, volumetric);
}

export function selectTier(kg) {
  return WEIGHT_TIERS.find((t) => kg <= t.maxKg);
}

export function netFee(shipment) {
  const kg = billableWeightKg(shipment);
  return selectTier(kg).ratePerKg * kg;
}

export function round(amount) {
  return Math.round(amount);
}

export function vat(net) {
  return round(net * VAT_RATE);
}
// layer-new/format.mjs — the cents digit dropped from the invoice line
import { netFee, vat, round, billableWeightKg } from "./calc.mjs";
import { transferCount } from "./flow.mjs";

export function invoiceLine(shipment) {
  const net = round(netFee(shipment));
  const tax = vat(netFee(shipment));
  return `${shipment.code} net=${net.toFixed(0)} vat=${tax.toFixed(0)} total=${round(net + tax).toFixed(0)}`;
}

export function labelText(shipment) {
  return `${shipment.code} ${billableWeightKg(shipment).toFixed(2)}kg ${transferCount(shipment)} transfer(s)`;
}
// actor-new/accounting.mjs — the same two decisions, in one file
import { netFee } from "./operations.mjs";

export const VAT_RATE = 0.1;

export function round(amount) {
  return Math.round(amount);
}

export function vat(net) {
  return round(net * VAT_RATE);
}

export function invoiceLine(shipment) {
  const net = round(netFee(shipment));
  const tax = vat(netFee(shipment));
  return `${shipment.code} net=${net.toFixed(0)} vat=${tax.toFixed(0)} total=${round(net + tax).toFixed(0)}`;
}
for d in layer actor; do
  n=0
  for f in "$d"/*.mjs; do
    diff -q <(tail -n +2 "$f") <(tail -n +2 "$d-new/$(basename "$f")") > /dev/null || n=$((n + 1))
  done
  echo "$d layout: the accounting change touched $n file(s)"
done
layer layout: the accounting change touched 2 file(s)
actor layout: the accounting change touched 1 file(s)

In the layer layout, format.mjs also changed, and that file also holds labelText, which belongs to customer communication. A change opened at accounting’s request ended up touching customer communication’s file. In the actor layout, customer.mjs was never opened.

The weight in the label text staying at two decimal places also shows this: the cents decision belonged to money, not to weight. Because the two formats sit in the same file in the layer layout, what makes the distinction is not the file but the attention of the person making the change.

The Limit of the Split

Grouping by actor does not replace technical splits; it stands at a right angle to them. An application has both a layer split (which determines dependency direction) and an actor split (which determines reason to change). When the two conflict, the choice is made by which split changes more often: if tax changes three times a year and the data source changes once a year, the top-level split goes by actor.

The second limit is that actors also change over time. If accounting and pricing are the same unit today, accounting.mjs carries both; the day they split, the file splits too. The measurement shows this early: when a file’s actor count rises to two, it is time to split.

The third limit is that the measure depends on the vocabulary. The actor vocabulary is written by hand, and if it is written wrong, the measurement comes out wrong too. What keeps the vocabulary correct is read not from the code but from where the change requests come from.

Summary

  • An actor is the source of a change request; the criterion is that a file changes at the request of a single actor.
  • In the layer layout, all three files carried two actors’ decisions; in the actor layout, the number of files serving multiple actors is zero.
  • The number of files per actor dropped from two to one; each actor’s decisions were gathered into a single place.
  • The tax-rate and rounding change touched two files in the layer layout and one in the actor layout; the second file touched in the layer layout also carried customer communication’s text.
  • An actor split does not replace a layer split; when the two conflict, the more frequently changing split stays on top, and when a file’s actor count rises to two, it is time to split.

Next Step

In the actor layout, invoiceLine moved into a single file, but the library still has places where the same job is done in more than one form: in one place an amount is written with toFixed, in another it is formatted by hand; in one place a not-found case throws an error, in another it returns an empty result. None of this is wrong, only different. That difference also has a cost, and it can be counted. The next lesson writes a scan that finds how many separate forms the same job takes, and repeats the measurement after unification.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close