Skip to content
academia.sh

Lesson 17 / 21

Layer Responsibilities

The separation of the presentation, application, domain and infrastructure layers: auditing the dependency direction rule through the import graph, counting the findings in the violating version, and inverting the dependency by moving the contract into the domain layer.

Contents

The Performance Problems topic measured how the data access layer behaves: query count, bytes transferred, statement count, query plan, pagination cost. None of it said what the layer is supposed to do. Where does the loan rule live, which layer runs validation, and is the record returned to the outside the same thing as the domain model?

This topic takes on those questions, and the first is the most basic one: which pieces does the code split into, and in which direction can those pieces call each other? Claiming the separation on paper is not enough; it has to be written down as a rule and audited through the import graph.

The Four Layers

The codebase splits into four responsibilities. The names vary, but the reasoning behind the split stays the same: each layer changes for a different reason.

Layer Responsibility Reason it changes
Presentation The shape of the outside world: route, body parsing, status code The interface or protocol changes
Application The order of the workflow: who to call, where to open the transaction, who gets the result The use case changes
Domain Business rules: what is valid, what is forbidden An organizational rule changes
Infrastructure Access to external resources: database, file, network The tool or schema changes

As important as the layer count is the dependency direction between them. The rule fits in one sentence: the domain layer cannot depend on any layer. Every other direction follows from this rule.

presentation  →  application  →  domain  ←  infrastructure

Infrastructure’s arrow points the other way. Database access does not call the domain layer; it satisfies a contract the domain layer defines. This reversed direction is the lesson’s actual subject.

The Violating Version

The four modules below serve a loan request. The directory names give the layer; the code is written first, in the order that comes to mind, with direct calls.

mkdir -p violating/presentation violating/application violating/domain violating/infrastructure
// violating/domain/loan-rules.mjs — business rules, but it directly calls two lower layers
import { SqliteLoanRepository } from "../infrastructure/sqlite-loan-repository.mjs";
import { response } from "../presentation/response.mjs";

export const MEMBER_LIMIT = 3;

export function issueLoan(db, memberId, bookId, date) {
  const repository = new SqliteLoanRepository(db);
  if (repository.booksOpenRecord(bookId) !== null) return response(409, "book is already on loan");
  if (repository.membersOpenRecords(memberId).length >= MEMBER_LIMIT) {
    return response(409, `member limit exceeded (${MEMBER_LIMIT})`);
  }
  return response(201, String(repository.add({ bookId, memberId, pickupDate: date })));
}
// violating/infrastructure/sqlite-loan-repository.mjs — SQL and the driver live here
import { DatabaseSync } from "node:sqlite";

export const connect = (file) => new DatabaseSync(file);

export class SqliteLoanRepository {
  constructor(db) { this.db = db; }
  booksOpenRecord(bookId) {
    return this.db.prepare(
      "SELECT loan_id FROM loan WHERE book_id = ? AND return_date IS NULL").get(bookId) ?? null;
  }
  membersOpenRecords(memberId) {
    return this.db.prepare(
      "SELECT loan_id FROM loan WHERE member_id = ? AND return_date IS NULL").all(memberId);
  }
  add(l) {
    return Number(this.db.prepare(
      "INSERT INTO loan (book_id, member_id, pickup_date, return_date) VALUES (?,?,?,NULL)")
      .run(l.bookId, l.memberId, l.pickupDate).lastInsertRowid);
  }
}
// violating/presentation/response.mjs — presentation helper that formats the HTTP response
export const response = (status, body) => ({ status, body });
// violating/application/loan-application.mjs — application service that handles the request
import { issueLoan } from "../domain/loan-rules.mjs";
import { connect } from "../infrastructure/sqlite-loan-repository.mjs";

export function loanRequest(request) {
  const db = connect("library.db");
  return issueLoan(db, request.memberId, request.bookId, request.date);
}
// violating/presentation/http-endpoint.mjs — decodes the body and calls the application service
import { loanRequest } from "../application/loan-application.mjs";

export const POST_loan = (body) => loanRequest(JSON.parse(body));

The code runs. The problem is not in the behavior but in the dependency direction: the business rule imports both the SQL class and the HTTP formatter.

Auditing the Rule

As long as the dependency direction stays a line in a document, it gets violated. To be auditable it has to be measurable. The script below reads every module in the directory tree, extracts the import statements, places each module into a layer by its directory, and reports every disallowed edge as a finding.

// layer-check.mjs — extracts the import graph and audits the dependency direction
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join, relative, dirname, resolve } from "node:path";

// Which layers each layer may import. Every edge outside this list is a violation.
const ALLOWED = {
  presentation: ["presentation", "application"],
  application: ["application", "domain"],
  infrastructure: ["infrastructure", "domain", "standard"],
  domain: ["domain", "standard"],
  root: ["root", "presentation", "application", "domain", "infrastructure", "standard"],
};

const layer = (path) => path.split("/")[0] in ALLOWED ? path.split("/")[0] : "root";
const externalLayer = (name) => (name === "node:sqlite" ? "infrastructure" : "standard");

function files(root, dir = root) {
  return readdirSync(dir).flatMap((name) => {
    const full = join(dir, name);
    if (statSync(full).isDirectory()) return files(root, full);
    return name.endsWith(".mjs") ? [relative(root, full)] : [];
  });
}

const IMPORT = /^\s*(?:import|export)[^;'"]*from\s+["']([^"']+)["']/gm;

function imports(root, file) {
  const text = readFileSync(join(root, file), "utf8");
  return [...text.matchAll(IMPORT)].map((m) => m[1]).map((h) =>
    h.startsWith(".") ? relative(root, resolve(root, dirname(file), h)) : h);
}

export function check(root) {
  const findings = [];
  for (const d of files(root)) {
    for (const h of imports(root, d)) {
      const source = layer(d);
      const target = h.startsWith("node:") ? externalLayer(h) : layer(h);
      if (!ALLOWED[source].includes(target)) findings.push({ d, h, source, target });
    }
  }
  return findings;
}

// The set of every module reachable starting from one module.
export function closure(root, entry) {
  const seen = new Set();
  const stack = [entry];
  while (stack.length > 0) {
    const next = stack.pop();
    if (seen.has(next) || next.startsWith("node:")) { seen.add(next); continue; }
    seen.add(next);
    for (const h of imports(root, next)) stack.push(h);
  }
  return seen;
}

const root = process.argv[2];
const findings = check(root);
for (const f of findings) console.log(`violation: ${f.d} -> ${f.h}   (${f.source} -> ${f.target})`);
console.log(`finding count = ${findings.length}`);

const c = closure(root, "domain/loan-rules.mjs");
console.log(`domain closure = ${c.size} modules, node:sqlite inside = ${c.has("node:sqlite") ? "yes" : "no"}`);

A driver import counts as a layer too: node:sqlite is infrastructure, and the remaining standard library modules are marked “standard” and importable from anywhere. Files at the root of the tree belong to no layer; these count as the composition root and may import every layer.

node layer-check.mjs violating
violation: application/loan-application.mjs -> infrastructure/sqlite-loan-repository.mjs   (application -> infrastructure)
violation: domain/loan-rules.mjs -> infrastructure/sqlite-loan-repository.mjs   (domain -> infrastructure)
violation: domain/loan-rules.mjs -> presentation/response.mjs   (domain -> presentation)
finding count = 3
domain closure = 4 modules, node:sqlite inside = yes

Three findings and one measurement. The last line shows what the violations add up to: touching the module that holds the business rule requires all four modules plus the database driver.

Inverting the Dependency

Two of the findings share the same cause: the domain layer is trying to build what it needs itself. The fix is for the need to be written as a contract in the domain layer, with the implementation staying in infrastructure. The repository interface built in the Repository Pattern lesson was one example of this; here the contract moves into its own module explicitly.

mkdir -p corrected/presentation corrected/application corrected/domain corrected/infrastructure
// corrected/domain/loan-repository.mjs — the repository contract the domain layer needs
export const REPOSITORY_CONTRACT = ["booksOpenRecord", "membersOpenRecords", "add"];

export function validateRepository(repository) {
  const missing = REPOSITORY_CONTRACT.filter((name) => typeof repository?.[name] !== "function");
  if (missing.length > 0) throw new TypeError(`repository contract is missing: ${missing.join(", ")}`);
  return repository;
}
// corrected/domain/loan-rules.mjs — business rules only; no HTTP, no SQL
import { validateRepository } from "./loan-repository.mjs";

export const MEMBER_LIMIT = 3;

export function issueLoan(repository, memberId, bookId, date) {
  validateRepository(repository);
  if (repository.booksOpenRecord(bookId) !== null) {
    return { result: "rejected", reason: "book_on_loan" };
  }
  if (repository.membersOpenRecords(memberId).length >= MEMBER_LIMIT) {
    return { result: "rejected", reason: "member_limit", limit: MEMBER_LIMIT };
  }
  return { result: "issued", loanId: repository.add({ bookId, memberId, pickupDate: date }) };
}

The return value changed too. The violating version produced an HTTP status code; this version speaks the domain’s language. Mapping the result to a status code is the presentation layer’s job.

// corrected/infrastructure/sqlite-loan-repository.mjs — the contract's SQL implementation
import { DatabaseSync } from "node:sqlite";
import { validateRepository } from "../domain/loan-repository.mjs";

export const connect = (file) => new DatabaseSync(file);

export class SqliteLoanRepository {
  constructor(db) { this.db = db; validateRepository(this); }
  booksOpenRecord(bookId) {
    return this.db.prepare(
      "SELECT loan_id FROM loan WHERE book_id = ? AND return_date IS NULL").get(bookId) ?? null;
  }
  membersOpenRecords(memberId) {
    return this.db.prepare(
      "SELECT loan_id FROM loan WHERE member_id = ? AND return_date IS NULL").all(memberId);
  }
  add(l) {
    return Number(this.db.prepare(
      "INSERT INTO loan (book_id, member_id, pickup_date, return_date) VALUES (?,?,?,NULL)")
      .run(l.bookId, l.memberId, l.pickupDate).lastInsertRowid);
  }
}

The infrastructure module now imports the domain layer. The arrow reversed, and this is the permitted direction.

// corrected/application/loan-application.mjs — takes the repository from outside, calls the rule
import { issueLoan } from "../domain/loan-rules.mjs";

export class LoanApplication {
  constructor(repository) { this.repository = repository; }
  issue(request) { return issueLoan(this.repository, request.memberId, request.bookId, request.date); }
}
// corrected/presentation/http-endpoint.mjs — turns the domain result into an HTTP response
const STATUS = { issued: 201, book_on_loan: 409, member_limit: 409 };

export function POST_loan(application, body) {
  const s = application.issue(JSON.parse(body));
  return s.result === "issued"
    ? { status: STATUS.issued, body: { loanId: s.loanId } }
    : { status: STATUS[s.reason], body: { reason: s.reason } };
}

No layer builds its own dependency. The one place that brings the connection, the repository and the application service together is the composition root.

// corrected/compose.mjs — composition root: only this file wires the layers together
import { connect, SqliteLoanRepository } from "./infrastructure/sqlite-loan-repository.mjs";
import { LoanApplication } from "./application/loan-application.mjs";
import { POST_loan } from "./presentation/http-endpoint.mjs";

const application = new LoanApplication(new SqliteLoanRepository(connect(process.argv[2])));
for (const body of ['{"memberId":6,"bookId":1,"date":"2025-07-15"}',
                     '{"memberId":6,"bookId":7,"date":"2025-07-15"}']) {
  console.log(body, "->", JSON.stringify(POST_loan(application, body)));
}

The audit runs again.

node layer-check.mjs corrected
finding count = 0
domain closure = 2 modules, node:sqlite inside = no

Zero findings. The second line says more than the first: the number of modules needed to reach the business rule dropped from four to two, and the database driver fell outside the closure.

This version also works with real data. The command below sets up the loan relation of the library schema from the SQL Fundamentals course and runs the composition root.

rm -f library.db
sqlite3 library.db <<'SQL'
CREATE TABLE loan (loan_id INTEGER PRIMARY KEY, book_id INTEGER NOT NULL,
                    member_id INTEGER NOT NULL, pickup_date TEXT NOT NULL, return_date TEXT);
INSERT INTO loan VALUES (1,1,1,'2025-01-10','2025-01-24'),(2,2,1,'2025-02-02','2025-02-20'),
  (3,1,2,'2025-02-11',NULL),(4,3,3,'2025-03-01','2025-03-15'),(5,4,3,'2025-03-18','2025-04-02'),
  (6,1,4,'2025-04-05','2025-04-19'),(7,5,4,'2025-04-21',NULL),(8,2,5,'2025-05-02','2025-05-30'),
  (9,7,1,'2025-05-14','2025-05-28'),(10,3,5,'2025-06-03',NULL),(11,6,2,'2025-06-11','2025-06-25'),
  (12,4,4,'2025-06-20','2025-07-04');
SQL
node corrected/compose.mjs library.db
{"memberId":6,"bookId":1,"date":"2025-07-15"} -> {"status":409,"body":{"reason":"book_on_loan"}}
{"memberId":6,"bookId":7,"date":"2025-07-15"} -> {"status":201,"body":{"loanId":13}}

The Practical Effect of the Closure

Module count looks like an abstract measure. Its effect is concrete: the smaller the closure, the more the business rule can be called without handing it anything else. The difference shows when both versions get a fake object that satisfies the contract and nothing more.

// try-with-fake.mjs — the same business rule, without a database, with a fake repository
import { issueLoan as violating } from "./violating/domain/loan-rules.mjs";
import { issueLoan as corrected } from "./corrected/domain/loan-rules.mjs";

const fakeRepository = {
  booksOpenRecord: () => null,
  membersOpenRecords: () => [],
  add: () => 42,
};

for (const [name, rule] of [["violating", violating], ["corrected", corrected]]) {
  try {
    console.log(`${name.padEnd(12)} ->`, JSON.stringify(rule(fakeRepository, 6, 7, "2025-07-15")));
  } catch (e) {
    console.log(`${name.padEnd(12)} -> ${e.constructor.name}: ${e.message}`);
  }
}
node try-with-fake.mjs
violating    -> TypeError: this.db.prepare is not a function
corrected    -> {"result":"issued","loanId":42}

The violating version rejects the fake repository, because it assumes the object it was given is a connection and tries to build its own SQL class. The corrected version looks at the contract, so it accepts the fake. This is how the dependency direction rule relates to testability: the rule is not a style preference, it is the precondition for the domain layer being able to run on its own.

The Boundary Between Application and Domain

Of the four layers, application and domain are the two most often confused. The split is made with one question: is the rule the organization’s rule, or is it this scenario’s flow?

“A book already on loan cannot be issued again” is the organization’s rule; the library applies it on the loan screen and in the bulk import alike. It belongs to the domain layer. “First find the member, then run the rule, then commit the transaction, then send the notification” is this scenario’s order; a different scenario changes the order. It belongs to the application layer.

The transaction boundary is also the application layer’s decision. The Transaction Boundaries lesson showed that the unit of work’s boundary belongs in the service layer; this lesson places that decision under a layer name. The domain layer never writes BEGIN, because it has no need to know whether a rule is being evaluated inside a transaction or outside one.

The boundary between presentation and application is easier. The presentation layer knows the shape of the protocol: which route maps to which function, how the body gets decoded, which status code the result maps to. Letting this knowledge leak into the application layer would block the same scenario from being called through a second entry point — a scheduled job, a shell command, a queue consumer.

Summary

  • The four layers split by their different reasons for changing; the core of the separation is not the layer count but the dependency direction between them.
  • The dependency direction can be audited from the import graph: the violating version produced three findings, and the finding count dropped to zero once the contract moved into the domain layer.
  • The domain layer’s import closure shrank from four modules to two, and the database driver fell outside the closure.
  • The business rule in the violating version could not be called with a fake repository; the corrected version worked with the same fake. The dependency direction rule is the precondition for testing the domain layer in isolation.
  • The business rule belongs to the domain layer, the scenario order and the transaction boundary to the application layer, the protocol shape to the presentation layer; the composition root is the only place that wires the dependencies together.

Next Step

In the corrected version, the domain layer returned an object shaped { result, reason }, and the presentation layer turned it into an HTTP body. For now this translation had three fields and was nearly one-to-one; the question sharpens once the record grows. Is the shape of the loan record returned to the outside the same thing as the domain model itself? When a new field is added to the domain model, should the body the client sees change? The next lesson produces two different external contracts from the same domain object, shows with a test that the external contract does not change when a field is added to the internal model, and counts how many internal fields returning the domain object directly leaks to the outside.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close