Skip to content
academia.sh

Lesson 01 / 18

Layered Architecture

Writing the layered style as a declared set of permitted edges: splitting the shipment fee library into four layers, measuring the layers a request touches and the boundaries it crosses, counting the layers that pass a request through unchanged, and separating a declared open layer from an undeclared leak by violation count.

Contents

The Domain-Driven Design course left one question open: every decision up to that point sat inside a single application. The context map named several bounded contexts, but all lived in the same running program. When contexts are split across separate programs, or deliberately kept in one, what gets measured changes: the units a request touches, the module boundaries it crosses, the parts that must ship together for a version to be deployed.

This course compares architectural arrangements by those measures, and every lesson measures the same library — a shipment fee and routing library — built under that arrangement. The first arrangement sorts units into ordered stacks by responsibility and bounds the direction of calls between them with a rule.

Layer as a Style

Layer responsibilities and the direction of dependency between them were established in the Layer Responsibilities lesson of the Data Access Layer and Business Logic course. The procedure for extracting layer edges from import lines and counting violations against a declared rule was written in the Dependency Graph Health lesson of the Design Principles course. Neither is retold here; both are used.

What is new is the style itself. Layered architecture says three things together: every unit belongs to exactly one layer; the layers are ordered; and, decisively, which layer may call which is a set of edges declared in advance. Without the third point, the first two are nothing more than a directory layout: if the rule is not written down, a violation is not defined either.

The shape of the edge set draws a distinction. If a layer is closed, the layer above it cannot skip over it; a request must pass through it. If it is open, skipping it is permitted. An open layer is not a violation but part of the rule: it is written into the set of permitted edges. Whether a call is permitted or a violation cannot be told from the code — only from the edge set.

The Library’s Layered Arrangement

The pricing context is split into four layers. Dependencies are wired by parameter, not by import; the only place that connects the units to one another is the composition root.

mkdir -p layered/infrastructure layered/domain layered/application layered/presentation
// layered/infrastructure/tariff-repository.mjs — infrastructure layer: tariff rows and postal code equivalent
const TIERS = [
  { capWeight: 1, fee: 4990 },
  { capWeight: 5, fee: 8490 },
  { capWeight: 15, fee: 14990 },
  { capWeight: 30, fee: 24990 },
];
const COEFFICIENT = { near: 1, mid: 1.35, far: 1.8 };
const POSTAL_ZONE = { 34: "near", "06": "mid", 35: "mid", 65: "far" };

export const tariffRepository = {
  tiers: () => TIERS.map((t) => ({ ...t })),
  coefficient: (postalCode) => COEFFICIENT[POSTAL_ZONE[postalCode.slice(0, 2)] ?? "far"],
};
// layered/domain/fee-rule.mjs — domain layer: fee rule, imports no other layer
const MINIMUM_FEE = 3990;
const ROUNDING_STEP = 50;

export const feeRule = {
  calculate: (shipment, tiers, coefficient) => {
    const tier = tiers.find((t) => shipment.weight <= t.capWeight);
    if (tier === undefined) throw new RangeError("no weight tier");
    const raw = tier.fee * coefficient;
    return Math.max(Math.round(raw / ROUNDING_STEP) * ROUNDING_STEP, MINIMUM_FEE);
  },
};
// layered/application/quote.mjs — application layer: the order of the scenario
export const applicationLayer = (domain, infrastructure) => ({
  quote: (shipment) => {
    const tiers = infrastructure.tiers();
    const coefficient = infrastructure.coefficient(shipment.postalCode);
    return { amount: domain.calculate(shipment, tiers, coefficient) };
  },
  listTiers: () => infrastructure.tiers(),
});
// layered/presentation/desk.mjs — presentation layer: body parsing and amount formatting
export const presentationLayer = (application) => ({
  quoteRequest: (body) => {
    const shipment = { weight: Number(body.weight), postalCode: String(body.postalCode) };
    const { amount } = application.quote(shipment);
    return { status: 200, amount: `${(amount / 100).toFixed(2)} TL` };
  },
  tiersRequest: () => application.listTiers(),
});

// open layer: when the rule declares this edge, the read request skips the application layer
export const openTiersDesk = (infrastructure) => ({
  tiersRequest: () => infrastructure.tiers(),
});

The presentation module holds two desks. The first is the closed arrangement’s desk; it calls the application layer. The second is the open layer declaration’s counterpart and calls infrastructure directly, but only for the read request. Their sitting in the same file is not a coincidence: both fall under the same rule, and the difference is which edge the rule counts as permitted.

A Request’s Path

Because dependencies arrive as parameters, counters can be placed on the boundaries. The measurement script does the composition root’s job and passes every layer object through a wrapper that builds the call tree. Three numbers come out of the tree: the layers a request touches, the calls between layers, and the number of pass-through layers. If a node has a single child, the arguments it received match what it passed to the child, and it returns the child’s value unchanged, that layer is pass-through: it added nothing to the request.

// boundary-count.mjs — layers touched, boundaries crossed and pass-through layers for one request
import { tariffRepository } from "./layered/infrastructure/tariff-repository.mjs";
import { feeRule } from "./layered/domain/fee-rule.mjs";
import { applicationLayer } from "./layered/application/quote.mjs";
import { presentationLayer, openTiersDesk } from "./layered/presentation/desk.mjs";

const roots = [];
const stack = [];

function wrap(layer, object) {
  const wrapped = {};
  for (const [name, fn] of Object.entries(object)) {
    wrapped[name] = (...args) => {
      const node = { layer, name, args, child: [], value: null };
      (stack.at(-1)?.child ?? roots).push(node);
      stack.push(node);
      try { return (node.value = fn(...args)); } finally { stack.pop(); }
    };
  }
  return wrapped;
}

function traverse(node, visited = new Set(), crossings = [], passthrough = []) {
  visited.add(node.layer);
  for (const c of node.child) {
    crossings.push(`${node.layer}->${c.layer}`);
    traverse(c, visited, crossings, passthrough);
  }
  const single = node.child.length === 1 ? node.child[0] : null;
  if (single !== null && JSON.stringify(node.args) === JSON.stringify(single.args)
      && JSON.stringify(node.value) === JSON.stringify(single.value)) {
    passthrough.push(node.layer);
  }
  return { visited, crossings, passthrough };
}

const domain = wrap("domain", feeRule);
const infrastructure = wrap("infrastructure", tariffRepository);
const application = wrap("application", applicationLayer(domain, infrastructure));
const presentation = wrap("presentation", presentationLayer(application));
const openPresentation = wrap("presentation", openTiersDesk(infrastructure));

const REQUESTS = [
  ["quote / closed      ", () => presentation.quoteRequest({ weight: "3", postalCode: "06800" })],
  ["tiers / closed      ", () => presentation.tiersRequest()],
  ["tiers / open layer  ", () => openPresentation.tiersRequest()],
];

for (const [name, call] of REQUESTS) {
  roots.length = 0;
  const result = call();
  const { visited, crossings, passthrough } = traverse(roots[0]);
  const summary = Array.isArray(result) ? `${result.length} tiers` : JSON.stringify(result);
  console.log(`${name}  result = ${summary}`);
  console.log(`  layers touched = ${visited.size}, boundary crossings = ${crossings.length}, pass-through layers = ${passthrough.length}`);
  console.log(`  path = ${crossings.join(", ")}`);
  if (passthrough.length > 0) console.log(`  pass-through = ${passthrough.join(", ")}`);
}
node boundary-count.mjs
quote / closed        result = {"status":200,"amount":"114.50 TL"}
  layers touched = 4, boundary crossings = 4, pass-through layers = 0
  path = presentation->application, application->infrastructure, application->infrastructure, application->domain
tiers / closed        result = 4 tiers
  layers touched = 3, boundary crossings = 2, pass-through layers = 2
  path = presentation->application, application->infrastructure
  pass-through = application, presentation
tiers / open layer    result = 4 tiers
  layers touched = 2, boundary crossings = 1, pass-through layers = 1
  path = presentation->infrastructure
  pass-through = presentation

The Pass-Through Layer

The first request shows the style doing its job. The quote request touched four layers, crossed four boundaries, and the pass-through count came out at 0: presentation parsed the body and formatted the amount, application built the call order, infrastructure supplied the two tables, domain computed the rule. Every layer added something.

The second request shows the same style’s cost. The tier list request touched three layers, but two came out pass-through: the application layer passed the empty argument list straight down and handed the returned list back up unchanged; the presentation layer did the same. Two of the three layers were nothing but a relay point. Calling this symptom layer skipping is wrong: nothing here is being skipped — the opposite is true, there are two stops that do nothing.

The third run shows how far the measure drops. The open layer declaration produced the same result while touching two layers and crossing one boundary: layers touched went from 3 to 2, boundary crossings from 2 to 1, pass-through layers from 2 to 1. The gain is not free; the difference between the two desks is that the same read request can now be served from two separate places, and the day a business rule gets added to that read, the rule is either written in two places or the open edge is withdrawn.

The Leak Showing Up in the Import Graph

An open layer and a leak look alike at run time: in both, a layer gets skipped. The difference is whether it was declared, and that difference is measured in the import graph. The block below takes a copy of the closed arrangement and changes two files.

cp -r layered leaky
cat > leaky/presentation/desk.mjs <<'EOF'
// leaky/presentation/desk.mjs — read request skips the application layer and calls infrastructure directly
import { tariffRepository } from "../infrastructure/tariff-repository.mjs";

export const presentationLayer = (application) => ({
  quoteRequest: (body) => {
    const shipment = { weight: Number(body.weight), postalCode: String(body.postalCode) };
    const { amount } = application.quote(shipment);
    return { status: 200, amount: `${(amount / 100).toFixed(2)} TL` };
  },
  tiersRequest: () => tariffRepository.tiers(),
});
EOF
cat > leaky/domain/zone-resolver.mjs <<'EOF'
// leaky/domain/zone-resolver.mjs — domain layer helper reaching into the infrastructure table
import { tariffRepository } from "../infrastructure/tariff-repository.mjs";

export const mostExpensiveTier = () =>
  tariffRepository.tiers().reduce((a, b) => (a.fee >= b.fee ? a : b));
EOF
find leaky -name '*.mjs' | sort
leaky/application/quote.mjs
leaky/domain/fee-rule.mjs
leaky/domain/zone-resolver.mjs
leaky/infrastructure/tariff-repository.mjs
leaky/presentation/desk.mjs

Each change looks innocent on its own. The presentation desk sees no reason to place a layer in between for a list request; the helper in the domain layer just wants the most expensive tier. The audit tool reads each file’s layer from its directory, reduces import lines to layer edges, and counts them against two edge sets.

// layer-violation.mjs — reduces the import graph to layer edges, counts violations against a declared rule
import { readdirSync, readFileSync } from "node:fs";
import { basename, dirname, join, normalize } from "node:path";

const CLOSED = new Set(["presentation->application", "application->domain", "application->infrastructure"]);
const OPEN = new Set([...CLOSED, "presentation->infrastructure"]);

function layerEdges(root) {
  const edge = new Set();
  for (const layer of readdirSync(root).sort()) {
    for (const file of readdirSync(join(root, layer)).sort()) {
      const text = readFileSync(join(root, layer, file), "utf8");
      for (const m of text.matchAll(/^import\s.*?from\s+"(\.[^"]+)"/gm)) {
        const target = basename(dirname(normalize(join(root, layer, m[1]))));
        if (target !== layer) edge.add(`${layer}->${target}`);
      }
    }
  }
  return [...edge];
}

for (const root of ["layered", "leaky"]) {
  const edges = layerEdges(root);
  console.log(`${root}/  layer edges = ${edges.length}  (${edges.join(", ") || "none"})`);
  for (const [name, rule] of [["closed rule", CLOSED], ["open layer rule", OPEN]]) {
    const violation = edges.filter((k) => rule.has(k) === false);
    console.log(`  ${name.padEnd(19)} allowed edges = ${rule.size}, violation = ${violation.length}${violation.length ? ` (${violation.join(", ")})` : ""}`);
  }
}
node layer-violation.mjs
layered/  layer edges = 0  (none)
  closed rule         allowed edges = 3, violation = 0
  open layer rule     allowed edges = 4, violation = 0
leaky/  layer edges = 2  (domain->infrastructure, presentation->infrastructure)
  closed rule         allowed edges = 3, violation = 2 (domain->infrastructure, presentation->infrastructure)
  open layer rule     allowed edges = 4, violation = 1 (domain->infrastructure)

The closed arrangement came out with zero layer edges. That is not a gap but a consequence of the composition-root arrangement: no layer module imports another layer, the wiring is built in a single file. In an arrangement like this, the layer rule is audited not in the import graph but in that one file — and a leak becomes visible in the import graph the moment that file gets bypassed.

The leaky arrangement’s two edges are of two separate kinds. The presentation->infrastructure edge is permitted under the open layer rule and a violation under the closed rule: the same line gets two different verdicts. The domain->infrastructure edge, by contrast, is a violation under both rules, because no edge set declares an outgoing edge from the domain layer. That is the difference between the two kinds of leak: the first is a place the rule may deserve revisiting, the second breaks the style’s load-bearing rule. The domain layer reaching into a table module also ends that layer’s ability to be imported on its own; testing the rule now requires loading the table too.

The Trade-off’s Numbers

The style improves one quality attribute and worsens another. On maintainability, the gain was measured: the fee rule sits in a single file, its outgoing edge count is 0, and the closed arrangement’s layer violation count is 0. When the rule changes, exactly one file is touched, and the tables’ format never enters the rule at all.

The cost is written in the same measures. The same style touches 3 layers for a read request that needs no business rule, and 2 of those 3 add nothing to it. The ratio grows with the layer count: every new layer means one more pass-through stop on requests that carry no rule. The style itself is the trade-off between these two numbers — the tighter the declared edge set, the more certain the rule’s location and the higher the pass-through stop count.

Summary

  • The layered style says three things together: every unit belongs to one layer, the layers are ordered, and permitted call edges are declared in advance; without the third, a violation is not defined.
  • The quote request touched 4 layers, crossed 4 boundaries, and the pass-through count came out at 0; the tier list request touched 3 layers, and 2 of those 3 added nothing to it.
  • The open layer declaration brought the same read request down to 2 layers and 1 boundary; its cost is that the request can now be served from two separate places.
  • The closed arrangement’s layer edge count came out at 0, because the wiring is built in the composition root; a leak becomes visible the moment that root is bypassed.
  • Of the leaky arrangement’s two edges, presentation->infrastructure is permitted under the open rule and a violation under the closed rule; domain->infrastructure is a violation under both and ends the domain layer’s ability to load on its own.

Next Step

One edge in the closed arrangement’s permitted edge set stands out: application->infrastructure. The rule lets the layer that builds the workflow reach the table directly — in the ordering, that makes infrastructure just another downward layer alongside domain. Built this way, what sits at the library’s center is not the domain model but the path a request takes. The next lesson takes up two arrangements that reverse the ordering: ring layouts where every dependency looks inward, toward the domain. Outside the hexagonal architecture sits an unordered ring of adapters; in the onion architecture the rings are ordered. The two arrangements are compared on the same three change scenarios, by the number of files edited and added.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close