---
title: 'Layer Responsibilities'
source: 'https://academia.sh/en/courses/data-access-layer/layer-responsibilities'
course: 'The Data Access Layer and Business Logic'
language: en
updated: '2026-08-19T05:19:34+00:00'
license: 'CC BY-SA 4.0'
---

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

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.

```sh
mkdir -p violating/presentation violating/application violating/domain violating/infrastructure
```

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

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

```js
// violating/presentation/response.mjs — presentation helper that formats the HTTP response
export const response = (status, body) => ({ status, body });
```

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

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

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

```sh
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.

```sh
mkdir -p corrected/presentation corrected/application corrected/domain corrected/infrastructure
```

```js
// 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;
}
```

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

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

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

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

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

```sh
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.

```sh
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.

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

```sh
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.
