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

# Validation Layers

Separating input validation from the domain rule: the different responses the same body produces in two layers, counting how many queries each layer's decision needs, the domain rule's dependence on data state, and the database constraint that gives the final guarantee.

In the previous lesson's incoming-direction example, the request body was accepted with
no check at all. Is `bookId` a number, is `pickupDate` a valid date, does the member
actually exist, is that member currently eligible to borrow?

All of these questions get filed under "validation", but they do not belong in the same
place. The first two are answered by looking at the body; the third and fourth cannot be
answered without looking at the database. The name for this split is **input
validation** versus **domain rule**, and this lesson measures the difference and shows
it directly.

The measurements below run against the following database. One member is suspended, one
member has reached three open records, and one book is on loan.

```sh
# setup.sh — sets up the lesson database from scratch
rm -f library.db
sqlite3 library.db <<'SQL'
CREATE TABLE member (member_id INTEGER PRIMARY KEY, name TEXT NOT NULL,
                  status TEXT NOT NULL CHECK (status IN ('active','suspended')));
CREATE TABLE book (book_id INTEGER PRIMARY KEY, title TEXT NOT NULL);
CREATE TABLE loan (loan_id INTEGER PRIMARY KEY,
                    book_id INTEGER NOT NULL REFERENCES book(book_id),
                    member_id INTEGER NOT NULL REFERENCES member(member_id),
                    pickup_date TEXT NOT NULL, return_date TEXT);
INSERT INTO member VALUES (1,'Alice Kane','active'),(2,'Ben Ortiz','suspended'),(3,'Clara Diaz','active');
INSERT INTO book VALUES (1,'Blindness'),(2,'The Disconnected'),(3,'The Book of Sand'),(4,'Puslu Kitalar'),
                         (5,'The Time Regulation Institute'),(6,'Cocukluk'),(7,'Aylak Adam');
INSERT INTO loan VALUES (1,1,1,'2025-06-01',NULL),(2,2,3,'2025-06-02',NULL),
                         (3,3,3,'2025-06-03',NULL),(4,4,3,'2025-06-04',NULL);
SQL
```

## Input Validation

The lesson's files sit in three directories.

```sh
mkdir -p presentation domain infrastructure
```

Input validation checks the shape of the body: is the field present, is the type
correct, does it match the pattern. It decides by looking only at the body. The
Validation Errors lesson in the Web API Design course defined this layer's output as a
list: every error carries a body path and a code.

```js
// presentation/input-validation.mjs — checks the shape of the body; never looks at data
const RULES = {
  required: (d) => (d === undefined || d === null || d === "" ? { code: "required" } : null),
  integer: (d) => (Number.isInteger(d) && d > 0 ? null : { code: "not_an_integer" }),
  date: (d) => (/^\d{4}-\d{2}-\d{2}$/.test(String(d)) ? null : { code: "not_a_date" }),
};

export const SCHEMA = {
  "/memberId": ["required", "integer"],
  "/bookId": ["required", "integer"],
  "/pickupDate": ["required", "date"],
};

// The returned list is shaped { path, code }, as in the Validation Errors lesson.
export function validateInput(schema, body) {
  const errors = [];
  for (const [path, rules] of Object.entries(schema)) {
    const value = body[path.slice(1)];
    for (const name of rules) {
      if (name !== "required" && (value === undefined || value === null || value === "")) break;
      const result = RULES[name](value);
      if (result) { errors.push({ path, ...result }); break; }   // one error per field
    }
  }
  return errors;
}
```

There is no database in the function's signature. This is the layer's definition: the
same body produces the same error list under every condition.

## The Domain Rule

The domain rule asks the organization's question: can this member borrow, is this book
available? The decision cannot be derived from the body.

```js
// domain/loan-rules.mjs — organizational rules; the decision needs data
export const MEMBER_LIMIT = 3;

export function canIssueLoan(repository, memberId, bookId) {
  const member = repository.findMember(memberId);
  if (member === null) return { result: "rejected", reason: "member_not_found" };
  if (member.status === "suspended") return { result: "rejected", reason: "member_suspended" };
  if (!repository.bookExists(bookId)) return { result: "rejected", reason: "book_not_found" };
  if (repository.booksOpenRecord(bookId) !== null) return { result: "rejected", reason: "book_on_loan" };
  if (repository.membersOpenCount(memberId) >= MEMBER_LIMIT) {
    return { result: "rejected", reason: "member_limit", limit: MEMBER_LIMIT };
  }
  return { result: "accepted" };
}
```

The rule takes a repository and returns a single reason. Two differences show up here:
the decision's input is not the body but **state**, and its output is not a list but a
**single fact**.

To see what the decision costs, the repository counts the queries it runs.

```js
// infrastructure/counting-repository.mjs — satisfies the contract and counts the queries it runs
import { DatabaseSync } from "node:sqlite";

export class CountingRepository {
  constructor(file) { this.db = new DatabaseSync(file); this.queries = 0; }
  #one(sql, ...p) { this.queries++; return this.db.prepare(sql).get(...p) ?? null; }
  findMember(memberId) { return this.#one("SELECT member_id, status FROM member WHERE member_id = ?", memberId); }
  bookExists(bookId) {
    return this.#one("SELECT book_id FROM book WHERE book_id = ?", bookId) !== null;
  }
  booksOpenRecord(bookId) {
    return this.#one(
      "SELECT loan_id FROM loan WHERE book_id = ? AND return_date IS NULL", bookId);
  }
  membersOpenCount(memberId) {
    return this.#one(
      "SELECT count(*) AS n FROM loan WHERE member_id = ? AND return_date IS NULL", memberId).n;
  }
}
```

## The Same Input Through Two Layers

Five bodies pass through the two layers in order. The first is malformed; the remaining
four are well formed.

```js
// two-layers.mjs — the same bodies run through input validation, then the domain rule
import { validateInput, SCHEMA } from "./presentation/input-validation.mjs";
import { canIssueLoan } from "./domain/loan-rules.mjs";
import { CountingRepository } from "./infrastructure/counting-repository.mjs";

const BASE = "https://example.library/problems/";
const repository = new CountingRepository("library.db");

const BODIES = [
  { memberId: "two", bookId: 0, pickupDate: "15/06/2025" },
  { memberId: 2, bookId: 6, pickupDate: "2025-06-20" },
  { memberId: 1, bookId: 1, pickupDate: "2025-06-20" },
  { memberId: 3, bookId: 6, pickupDate: "2025-06-20" },
  { memberId: 1, bookId: 6, pickupDate: "2025-06-20" },
];

for (const body of BODIES) {
  console.log(`body: ${JSON.stringify(body)}`);
  const errors = validateInput(SCHEMA, body);
  console.log(`  input validation: ${errors.length} errors, queries = 0`);
  if (errors.length > 0) {
    console.log(`  response 422 ${JSON.stringify({ type: BASE + "validation", errors })}`);
    continue;
  }
  repository.queries = 0;
  const decision = canIssueLoan(repository, body.memberId, body.bookId);
  console.log(`  domain rule: ${decision.result}${decision.reason ? ` (${decision.reason})` : ""}, queries = ${repository.queries}`);
  console.log(decision.result === "accepted"
    ? "  response 201 " + JSON.stringify({ status: "issued" })
    : "  response 409 " + JSON.stringify({ type: BASE + decision.reason.replaceAll("_", "-") }));
}
```

```sh
sh setup.sh
node two-layers.mjs
```

```
body: {"memberId":"two","bookId":0,"pickupDate":"15/06/2025"}
  input validation: 3 errors, queries = 0
  response 422 {"type":"https://example.library/problems/validation","errors":[{"path":"/memberId","code":"not_an_integer"},{"path":"/bookId","code":"not_an_integer"},{"path":"/pickupDate","code":"not_a_date"}]}
body: {"memberId":2,"bookId":6,"pickupDate":"2025-06-20"}
  input validation: 0 errors, queries = 0
  domain rule: rejected (member_suspended), queries = 1
  response 409 {"type":"https://example.library/problems/member-suspended"}
body: {"memberId":1,"bookId":1,"pickupDate":"2025-06-20"}
  input validation: 0 errors, queries = 0
  domain rule: rejected (book_on_loan), queries = 3
  response 409 {"type":"https://example.library/problems/book-on-loan"}
body: {"memberId":3,"bookId":6,"pickupDate":"2025-06-20"}
  input validation: 0 errors, queries = 0
  domain rule: rejected (member_limit), queries = 4
  response 409 {"type":"https://example.library/problems/member-limit"}
body: {"memberId":1,"bookId":6,"pickupDate":"2025-06-20"}
  input validation: 0 errors, queries = 0
  domain rule: accepted, queries = 4
  response 201 {"status":"issued"}
```

Three differences were measured.

**The response shape is different.** Input validation collected three separate field
errors in one response; each is marked with a body path, so the client knows which input
box to highlight. The domain rule returned a single reason and gave no body path: "the
member is suspended" is not one field's error, it is the rejection of the operation
itself. The status code diverges for the same reason: one says the body is fixable, the
other says the request cannot be satisfied in its current state.

**The cost is different.** Input validation decided with zero queries. The domain rule's
decision ranged from one to four queries; when the rule rejected early, the query count
dropped. This is the justification for the layer order: a malformed body never reached
the domain layer, and four queries were not wasted.

**The source of information is different.** Input validation looked at the value in the
body; the domain rule looked at information that is not in the body. The request says
`memberId: 2`, it does not say the member is suspended. If this information could be
included in the body, it would already have been asked of the client, and the client
would have filled it in as it pleased.

## The Rule's Dependence on Data

The proof of the third difference is holding the body constant and changing the data.

```js
// data-dependent.mjs — same body, unchanged shape, changing data
import { DatabaseSync } from "node:sqlite";
import { validateInput, SCHEMA } from "./presentation/input-validation.mjs";
import { canIssueLoan } from "./domain/loan-rules.mjs";
import { CountingRepository } from "./infrastructure/counting-repository.mjs";

const BODY = { memberId: 2, bookId: 6, pickupDate: "2025-06-20" };
const writer = new DatabaseSync("library.db");
const repository = new CountingRepository("library.db");

for (const status of ["suspended", "active", "suspended"]) {
  writer.prepare("UPDATE member SET status = ? WHERE member_id = 2").run(status);
  const errors = validateInput(SCHEMA, BODY).length;
  const decision = canIssueLoan(repository, BODY.memberId, BODY.bookId);
  console.log(`member 2 status=${status.padEnd(9)} input errors=${errors}  ` +
              `domain decision=${decision.result}${decision.reason ? ` (${decision.reason})` : ""}`);
}
```

```sh
node data-dependent.mjs
```

```
member 2 status=suspended input errors=0  domain decision=rejected (member_suspended)
member 2 status=active    input errors=0  domain decision=accepted
member 2 status=suspended input errors=0  domain decision=rejected (member_suspended)
```

The body never changed. Input validation returned zero errors in all three rounds; the
domain rule produced two different decisions. The third round restores the original
status, so the script produces the same output when run again.

This is the formal justification for why the domain rule cannot be reduced to input
validation. Input validation is a function of the body; the domain rule is a joint
function of the body and the state. Input validation can be described by a fixed schema,
the domain rule cannot.

## The Third Layer

Once both layers have passed, a gap remains. The domain rule said "the book is not on
loan" and the write happened right after; if another request wrote the same book in
between, the rule was acting on stale information. This is the same check-then-write gap
as in the Optimistic and Pessimistic Locking lesson.

The script below sets up the order in which two requests both evaluate the rule before
writing, and runs the same order against two databases, one constrained and one
unconstrained.

```js
// third-layer.mjs — the domain rule passed, another request came in before the write: the constraint has the last word
import { DatabaseSync } from "node:sqlite";
import { rmSync } from "node:fs";
import { canIssueLoan } from "./domain/loan-rules.mjs";

function setup(file, constrained) {
  rmSync(file, { force: true });
  const db = new DatabaseSync(file);
  db.exec(`CREATE TABLE member (member_id INTEGER PRIMARY KEY, name TEXT NOT NULL, status TEXT NOT NULL);
           CREATE TABLE book (book_id INTEGER PRIMARY KEY, title TEXT NOT NULL);
           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 member VALUES (1,'Alice Kane','active'),(2,'Ben Ortiz','active');
           INSERT INTO book VALUES (6,'Cocukluk');`);
  if (constrained) {
    db.exec("CREATE UNIQUE INDEX open_loan ON loan(book_id) WHERE return_date IS NULL");
  }
  return db;
}

const buildRepository = (db) => ({
  findMember: (id) => db.prepare("SELECT member_id, status FROM member WHERE member_id = ?").get(id) ?? null,
  bookExists: (id) => db.prepare("SELECT book_id FROM book WHERE book_id = ?").get(id) !== undefined,
  booksOpenRecord: (id) => db.prepare(
    "SELECT loan_id FROM loan WHERE book_id = ? AND return_date IS NULL").get(id) ?? null,
  membersOpenCount: (id) => db.prepare(
    "SELECT count(*) AS n FROM loan WHERE member_id = ? AND return_date IS NULL").get(id).n,
});

for (const constrained of [false, true]) {
  const db = setup(constrained ? "constrained.db" : "unconstrained.db", constrained);
  const repository = buildRepository(db);
  const write = (memberId) => db.prepare(
    "INSERT INTO loan (book_id, member_id, pickup_date, return_date) VALUES (6,?,'2025-06-20',NULL)")
    .run(memberId);

  // Two requests want the same book; both evaluate the rule before writing.
  const decision1 = canIssueLoan(repository, 1, 6).result;
  const decision2 = canIssueLoan(repository, 2, 6).result;
  write(1);
  let second;
  try { write(2); second = "written"; }
  catch (e) { second = `rejected (${e.message})`; }

  const open = db.prepare(
    "SELECT count(*) AS n FROM loan WHERE book_id = 6 AND return_date IS NULL").get().n;
  console.log(`constraint ${constrained ? "on" : "off"}: decision1=${decision1} decision2=${decision2}  ` +
              `second write=${second}  book 6 open records=${open}`);
}
```

```sh
node third-layer.mjs
```

```
constraint off: decision1=accepted decision2=accepted  second write=written  book 6 open records=2
constraint on: decision1=accepted decision2=accepted  second write=rejected (UNIQUE constraint failed: loan.book_id)  book 6 open records=1
```

In both cases the domain rule accepted both requests; the rule was not wrong, its timing
was unfortunate. Without the constraint the book ended up with two open records, a
direct violation of the business rule. With the constraint the second write was
rejected, and the open record count stayed at one.

This is how the layers' roles split apart. The domain rule produces **the reason for the
decision**: it is the only way the user can be told "this book is on loan"; a constraint
error cannot say that sentence. The constraint guarantees **the invariant** and has the
final word when the rule is bypassed or raced. Neither substitutes for the other: an
application that relies only on the constraint cannot give the user a meaningful error,
and one that relies only on the rule is exposed to corrupt data.

## Drawing the Boundary

Which layer a new rule belongs to is settled by one question: **can the rule be answered
by looking at the body?**

"The return date cannot be in the past" is answered by looking at the body; it is a date
comparison and belongs to input validation. "A member with an overdue record cannot be
issued a new loan" cannot be answered by looking at the body; it is a domain rule. "The
ISBN must be thirteen digits" is shape. "This ISBN must exist in the catalog" is data.

There is a middle case: cross-field validation. "The pickup date must be before the due
date" compares two fields, needs no data, and stays in input validation. The schema in
the Validation Errors lesson could already express this kind of rule through the field
path; the criterion does not change.

The order also follows from this split: shape first, then rule, then constraint. The
reverse order produces two costs. Running a query against a malformed body is wasted
work; showing the user a constraint error instead of the rule is a response that does
not explain what the problem is.

## Summary

- Input validation is a function of the body and decided with zero queries; the domain
  rule is a joint function of the body and the state, and its decision ranged from one
  to four queries.
- The two layers' responses differ in shape: one is an error list marked with body
  paths, the other produced a single rejection reason.
- With the body held constant and the member's status changed, input validation
  returned zero errors in all three rounds while the domain rule produced two different
  decisions.
- The domain rule is exposed to the check-then-write gap: in the unconstrained database
  the same book ended up with two open records, in the constrained database the second
  write was rejected.
- The rule gives the reason, the constraint gives the invariant; one produces a
  meaningful error for the user, the other guarantees the data.

## Next Step

The fifth body passed through both layers, and it was time to write the loan record.
Other work needs to start along with the write: the member should be notified, a daily
statistic should be updated, an entry should be written to the log for future reports.
Writing all of this into the body of the loan-issuing function means changing that
function on every new side effect. The next lesson publishes the fact the loan decision
produces as a **domain event**, attaches two listeners, and measures the event's
relationship to the transaction boundary: when a transaction is rolled back, the wrong
notifications the listener produced are counted and brought down to zero with the outbox
pattern.
