---
title: 'Project Structure'
source: 'https://academia.sh/en/courses/server-fundamentals/project-structure'
course: 'Server-Side Fundamentals'
language: en
updated: '2026-08-19T05:19:37+00:00'
license: 'CC BY-SA 4.0'
---

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

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.

```bash
#!/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.

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

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

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

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

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

```bash
#!/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.
