Lesson 07 / 16
Project Structure
Two layouts for files, layer-oriented and feature-oriented, are compared by measurement; domain, data, transport, and composition responsibilities are separated, and the dependency direction rule is wired to an audit program that reports a violation through its exit code.
Contents
The previous topic broke a request’s server-side path into its components. The parts that came out of it — chain links, handlers, business rules, data access — still sit side by side in a single file. As a file grows, changing it gets harder; the real problem is not the line count but the fact that parts that should not know about each other can still reach one another.
This lesson sets up a directory layout that could carry a library lending service, and it looks for the answer to one question: which module can call which? The answer will be written not as a document but as an audit program that reports a violation through its exit code.
Two Layouts: Layer-Oriented and Feature-Oriented
There are two common ways to split an application’s files. A layer-oriented layout opens its top-level directories by technical concern: every business rule in one directory, every piece of data access in another. A feature-oriented layout opens its top-level directories by business area: everything belonging to the loan operation in one directory, everything belonging to the catalog operation in another.
The same nine modules can be placed into either layout. The script below builds both and counts how many directories two different changes touch.
#!/usr/bin/env bash # Places the same nine modules in two different layouts and counts how many directories a change touches. mkdir -p layouts for feature in loan catalog member; do for concern in domain data http; do mkdir -p "layouts/layer-oriented/src/$concern" "layouts/feature-oriented/src/$feature" printf '// %s feature, %s concern\n' "$feature" "$concern" \ > "layouts/layer-oriented/src/$concern/$feature.mjs" printf '// %s feature, %s concern\n' "$feature" "$concern" \ > "layouts/feature-oriented/src/$feature/$concern.mjs" done done echo "--- layer-oriented tree ---" find layouts/layer-oriented -name '*.mjs' | sort echo "--- feature-oriented tree ---" find layouts/feature-oriented -name '*.mjs' | sort directoryCount() { # $1 = tree root, $2 = search marker grep -rl "$2" "$1" | xargs -n1 dirname | sort -u | wc -l | tr -d ' ' } echo "--- how many directories a change touches ---" printf '%-16s %-22s %s\n' "layout" "loan feature" "data concern" for layout in layer-oriented feature-oriented; do printf '%-16s %-22s %s\n' "$layout" \ "$(directoryCount "layouts/$layout" 'loan feature') dirs" \ "$(directoryCount "layouts/$layout" 'data concern') dirs" done
--- layer-oriented tree --- layouts/layer-oriented/src/data/catalog.mjs layouts/layer-oriented/src/data/loan.mjs layouts/layer-oriented/src/data/member.mjs layouts/layer-oriented/src/domain/catalog.mjs layouts/layer-oriented/src/domain/loan.mjs layouts/layer-oriented/src/domain/member.mjs layouts/layer-oriented/src/http/catalog.mjs layouts/layer-oriented/src/http/loan.mjs layouts/layer-oriented/src/http/member.mjs --- feature-oriented tree --- layouts/feature-oriented/src/catalog/data.mjs layouts/feature-oriented/src/catalog/domain.mjs layouts/feature-oriented/src/catalog/http.mjs layouts/feature-oriented/src/loan/data.mjs layouts/feature-oriented/src/loan/domain.mjs layouts/feature-oriented/src/loan/http.mjs layouts/feature-oriented/src/member/data.mjs layouts/feature-oriented/src/member/domain.mjs layouts/feature-oriented/src/member/http.mjs --- how many directories a change touches --- layout loan feature data concern layer-oriented 3 dirs 1 dirs feature-oriented 1 dirs 3 dirs
The table is symmetric, and that symmetry is the point of the choice: neither layout is better than the other, each just makes a different kind of change cheaper. Adding a new condition to the loan rule touches a single directory in the feature-oriented layout; changing the shape of data access touches a single directory in the layer-oriented layout.
The question that decides between them is which kind of change happens more often in this application. With a small number of business areas and technical concerns that change often, a layer-oriented layout moves fewer files around; with many independent business areas, a feature-oriented layout does the same job. The rest of this lesson uses a layer-oriented layout, because the rule it sets up — dependency direction — can be expressed directly through directory names there.
Four Responsibilities
A layer-oriented layout has four directories, and each one is defined by something it does not know.
Domain (src/domain) carries the business rules and calls no other layer. It does not
know where the database lives, that the request arrived over HTTP, or that the response is
JSON.
// src/domain/loan.mjs — loan rule; calls no other layer export const LOAN_DAYS = 14; export const MEMBER_LOAN_LIMIT = 5; export const canLoan = (book, openLoanCount) => { if (!book) return { allowed: false, code: "book_not_found" }; if (book.shelfCount < 1) return { allowed: false, code: "shelf_empty" }; if (openLoanCount >= MEMBER_LOAN_LIMIT) return { allowed: false, code: "limit_exceeded" }; return { allowed: true }; }; export const dueDate = (start) => new Date(start.getTime() + LOAN_DAYS * 86400000);
None of these functions is asynchronous, and none of them touches the outside world. They compute a decision from the inputs they are given, which is why testing them needs neither a server nor a database.
Data (src/data) takes on persistence and knows nothing about business rules. It is never
asked how many loans the limit allows; it is only ever asked for a number.
// src/data/book-store.mjs — owns persistence; knows nothing about business rules const BOOKS = new Map([ ["978-0262033848", { title: "Introduction to Algorithms", shelfCount: 1 }], ["978-0201835953", { title: "The Mythical Man-Month", shelfCount: 0 }], ]); const LOANS = []; export const findBook = async (isbn) => BOOKS.get(isbn) ?? null; export const openLoanCount = async (member) => LOANS.filter((l) => l.member === member && !l.returned).length; export const recordLoan = async (record) => { BOOKS.get(record.isbn).shelfCount -= 1; LOANS.push(record); return record; };
The store is kept in memory here. Under the rule set up in the previous topic, this is a temporary application that does not work across more than one process; once it moves to a persistent store, this is the only directory that has to change, and the domain layer is unaffected by that change.
Transport (src/http) translates the protocol into the domain’s language and carries no
business rule. Its job is to gather input, leave the decision to the domain layer, and map the
result onto a status code.
// src/http/loan-endpoint.mjs — translates the HTTP request into domain language; no business rule import { canLoan, dueDate } from "../domain/loan.mjs"; import { findBook, openLoanCount, recordLoan } from "../data/book-store.mjs"; export const loanBook = async ({ isbn, member }, today = new Date()) => { const book = await findBook(isbn); const decision = canLoan(book, await openLoanCount(member)); if (!decision.allowed) return { status: 409, body: { error: decision.code } }; const record = await recordLoan({ isbn, member, dueDate: dueDate(today), returned: false }); return { status: 201, body: { isbn, member, dueDate: record.dueDate.toISOString() } }; };
Composition (src/setup) is the single place that brings the parts together. It is where
an application wires its dependencies, reads its configuration, and brings the server up.
// src/setup/app.mjs — the single place that wires the parts together import { loanBook } from "../http/loan-endpoint.mjs"; const today = new Date("2026-03-01T00:00:00Z"); for (const request of [ { isbn: "978-0262033848", member: "U-4711" }, // only one copy on the shelf { isbn: "978-0262033848", member: "U-4712" }, // that same copy is now on loan { isbn: "978-0000000000", member: "U-4713" }, // not in the catalog ]) { console.log(JSON.stringify(await loanBook(request, today))); }
Dependency Direction
The existence of the directories alone guarantees nothing. What makes the separation meaningful is that the imports between them run in one direction only: an outer layer may call an inner one, an inner layer may never call an outer one.
| Layer | May import |
|---|---|
domain |
— |
data |
domain |
http |
domain, data |
setup |
domain, data, http |
The reasoning behind the rule is concrete. If the domain layer called the data layer, testing a business rule would require a store; when the store changes, the rule would change with it. A function that knows the loan limit is five would also have to know where the record is kept — a module that changes for two unrelated reasons.
The rule holds because the domain layer receives the data it needs as a parameter. The
canLoan function does not query the member’s open loan count itself; it takes the count from
its caller. The querying is left to an outer layer, here the transport layer.
Tying the Rule to an Audit
A rule written in a document does not warn anyone when it is broken. The program below reads import lines, works out each file’s layer from its directory name, and reports any line that does not match the permission table. If there is a violation, it produces a nonzero exit code — the one thing an audit needs in order to be wired into a continuous integration step.
// audit/dependency.mjs — checks the dependency direction between layers; exits with 1 on violation import { readdir, readFile } from "node:fs/promises"; import { join, relative, sep } from "node:path"; const ALLOWED = { // layers each layer may import domain: [], data: ["domain"], http: ["domain", "data"], setup: ["domain", "data", "http"], }; const IMPORT_STATEMENT = /^\s*import[^"']*["']([^"']+)["']/gm; const entries = await readdir("src", { withFileTypes: true, recursive: true }); const files = entries.filter((e) => e.isFile() && e.name.endsWith(".mjs")) .map((e) => join(e.parentPath, e.name)).sort(); let violations = 0; for (const path of files) { const sourceLayer = relative("src", path).split(sep)[0]; const text = await readFile(path, "utf8"); for (const [, specifier] of text.matchAll(IMPORT_STATEMENT)) { if (!specifier.startsWith(".")) continue; // skip packages and built-in modules const target = specifier.replace(/^(\.\.\/|\.\/)+/, "").split("/")[0]; if (!(target in ALLOWED) || target === sourceLayer) continue; if (!ALLOWED[sourceLayer].includes(target)) { console.log(`VIOLATION ${path}: ${sourceLayer} -> ${target}`); violations++; } } } console.log(violations === 0 ? `${files.length} files scanned, dependency direction follows the rule` : `${files.length} files scanned, ${violations} violations found`); process.exit(violations === 0 ? 0 : 1);
To see the audit actually work, the rule has to be broken. The script below first runs the
application, then passes the audit, then adds an import from the data layer into the domain
layer and runs the audit again, and finally restores the tree to its original state. The
script runs in the directory holding the four source files shown above and
audit/dependency.mjs.
#!/usr/bin/env bash # Runs the scaffold, passes the audit, then deliberately breaks the rule and shows the audit catching it. echo "--- application ---" node src/setup/app.mjs echo "--- audit: tree that follows the rule ---" node audit/dependency.mjs; echo "exit code=$?" echo "--- adding a data-layer import to the domain layer ---" cp src/domain/loan.mjs loan.bak printf '%s\n' 'import { findBook } from "../data/book-store.mjs";' > loan.violating cat loan.bak >> loan.violating cp loan.violating src/domain/loan.mjs echo "--- audit: tree with the broken rule ---" node audit/dependency.mjs; echo "exit code=$?" cp loan.bak src/domain/loan.mjs # tree restored to its original state rm -f loan.bak loan.violating
--- application ---
{"status":201,"body":{"isbn":"978-0262033848","member":"U-4711","dueDate":"2026-03-15T00:00:00.000Z"}}
{"status":409,"body":{"error":"shelf_empty"}}
{"status":409,"body":{"error":"book_not_found"}}
--- audit: tree that follows the rule ---
4 files scanned, dependency direction follows the rule
exit code=0
--- adding a data-layer import to the domain layer ---
--- audit: tree with the broken rule ---
VIOLATION src/domain/loan.mjs: domain -> data
4 files scanned, 1 violations found
exit code=1
The application’s output also confirms the layers’ division of labor: the book with a single
copy on the shelf is loaned to the first member (201), the second member requesting the same
book gets a shelf_empty response, and a request not in the catalog gets book_not_found. All
three decisions were computed in the domain layer; their translation into a status code
happened in the transport layer.
The only difference between the audit’s two runs is a single import line. The moment the rule
breaks, the violation is reported by file and by direction, and the exit code flips from 0
to 1. When a build step sees that code, it stops; the rule is no longer a suggestion, it is a
constraint.
Summary
- A layer-oriented layout opens its top-level directories by technical concern, a feature-oriented one by business area; in the measurement, a change touching the loan feature touched three directories in one and a single directory in the other, and the ratio reversed for a change touching data access.
- A layer-oriented layout separates four responsibilities: domain carries the business rule and calls no layer, data owns persistence, transport translates the protocol into the domain’s language, and composition wires the parts together in one place.
- The dependency direction runs one way: an outer layer calls an inner one, never the other way around. The data the domain layer needs is handed to it as a parameter.
- Once the rule is wired to an audit program, a violation is reported by file name and
direction and the exit code becomes
1; because that code can stop a build step, the rule becomes a constraint. - The in-memory store is only valid for a single-process application; moving to a persistent store changes only the data directory and leaves the domain layer untouched.
Next Step
The scaffold is standing, but every value is still written into the code: the port, the loan period, the member loan limit, the store’s location. The same application has to run with different values locally, in a test environment, and in production, and those values cannot be read out of the code itself. The next lesson takes up reading configuration from the environment: which value is variable and which is a constant, and what should the application do with a missing or wrong-typed variable? The measurement will show which message and which exit code an application started with a missing variable fails at, on startup.
To keep your progress and take notes, Log in
My notes
Log in to take notes.