Skip to content

Architectures

Two questions, mixed on purpose

The five apps answer two different questions, so they are not five points on one scale. Folder names answer the first; import statements, the second. Organizing by business area can coexist with any direction of dependencies.

The two questions, by app
AppWhat splits the top level of src/Where dependencies point
LayeredTechnical role: http/, services/, repositories/Toward storage: services import concrete repositories.
Feature modulesBusiness area: modules/projects/, modules/tasks/Toward storage inside each a group of code around one business area, with its routes, rules and SQL; the rest of the code uses only its public entry point.; between modules, tasks → projects.
HexagonalInside and outside: core/, adapters/Toward the in Hexagonal, the application code that knows no technology: rules, model and ports, in core/.: the code that translates between a port and a specific technology, such as Express or node:sqlite. implement an interface the application declares in its own terms, for what it offers (the operations) or for what it needs from outside (storage, atomicity). that the core declares.
Cleanin Clean, each of the four levels of src/; a ring imports only from itself and from the inner rings.: entities/, use-cases/, interface-adapters/, frameworks/Inward, ring by ring, to the an object whose methods protect its own invariants, such as Project.archive in Clean..
Use-case-firstin Use-case-first, one folder per operation at the top of src/, with its route, its rules and its SQL, and no imports between actions.: create-project/, archive-project/, …Toward storage: each action runs its own SQL.

The three rules

  1. A project is archived only if none of its tasks is unfinished (todo or in-progress).
  2. An archived project accepts no new tasks.
  3. A task advances one step at a time (todo → in-progress → done); a task in done does not advance.

Terms

Layer
a group of code with a single technical role, such as receiving HTTP, applying rules or storing data, that depends only on the layers below it.
Module
a group of code around one business area, with its routes, rules and SQL; the rest of the code uses only its public entry point.
Port
an interface the application declares in its own terms, for what it offers (the operations) or for what it needs from outside (storage, atomicity).
Adapter
code that translates between a port and a specific technology, such as Express or node:sqlite.
Use case
one thing a user can do with the system; here, each of the six operations of the contract.
Entity
an object whose methods protect its own invariants, such as Project.archive in Clean.
Dependency rule
in Clean, source code dependencies point inward, toward the business rules, never toward frameworks or drivers.
Action
in Use-case-first, one folder per operation at the top of src/, with its route, its rules and its SQL, and no imports between actions.
Core
in Hexagonal, the application code that knows no technology: rules, model and ports, in core/.
Ring
in Clean, each of the four levels of src/; a ring imports only from itself and from the inner rings.

Layered

Technical a group of code with a single technical role, such as receiving HTTP, applying rules or storing data, that depends only on the layers below it. in one direction. Each request goes down from the controller to SQLite.

How it is organized
src/ is divided by technical role. A layer depends only on those below it, and main.ts wires the pieces together. Services use concrete repositories, without interfaces: there is only one storage implementation.
Where the rules live
Rule 1 (archive): ProjectService.archive. Rule 2 (create task): TaskService.create. Rule 3 (advance): TaskService.advance.
The path of archiving a project
  1. http/app.ts routes the request to the controller.
  2. http/project-controller.ts validates the id with requireId.
  3. services/project-service.ts opens the transaction from repositories/database.ts.
  4. repositories/project-repository.ts looks up the project; the service decides 404 or 409.
  5. repositories/task-repository.ts counts the unfinished tasks; if there are any, 409.
  6. updateStatus saves the change; http/errors.ts translates any error.
What it helps with
Finding a kind of code: all the SQL is in repositories/. Reading a rule without HTTP or SQL: ProjectService.archive is a short method.
What it costs
In this app, a feature change is spread across the three layers. The shared model couples them: a new field touches models.ts, the schema, the SQL and HTTP.
Where to start reading
  1. src/http/app.ts (the only place that lists the 6 operations)
  2. src/services/project-service.ts (rule 1 and the transaction in a single method)
  3. src/repositories/database.ts (connection, schema and transaction helper)
Differs from Feature modules in
that it cuts src/ by technical role, not by business area: the rule of a task and its SQL end up in different folders.

Feature modules

Code grouped by business area: projects and tasks, each with its routes, rules and SQL.

How it is organized
Inside, each a group of code around one business area, with its routes, rules and SQL; the rest of the code uses only its public entry point. is divided by role: *-routes.ts, logic and *-store.ts. From outside, only its index.ts is imported. tasks may import projects; not the other way around.
Where the rules live
Rule 1 (archive): archive in modules/projects/projects.ts. Rule 2 (create task) and rule 3 (advance): create and advance in modules/tasks/tasks.ts.
The path of archiving a project
  1. modules/projects/project-routes.ts: requireId rejects ids that are not UUIDs (400).
  2. modules/projects/projects.ts: archive opens the transaction (shared/database.ts).
  3. modules/projects/project-store.ts: findProjectById reads; archive decides 404 or 409.
  4. main.ts: the injected function hasUnfinishedTasks queries the tasks module.
  5. modules/tasks/task-store.ts: counts the unfinished tasks inside the same transaction.
  6. modules/projects/projects.ts: archive decides rule 1 (409) or saves.
What it helps with
Each table lives in a single module, with its rules and its routes. The rule tests sit next to the module they protect.
What it costs
Because of rule 1, projects receives a function from tasks, wired in main.ts. Both modules depend on shared/, which according to its README could become a hidden layer. The Database connection is part of each module's public API.
Where to start reading
  1. src/main.ts (creates the two modules and wires them together)
  2. src/modules/projects/projects.ts (rule 1 and why it does not import tasks)
  3. src/modules/tasks/tasks.ts (the only import between modules; rules 2 and 3)
Differs from Layered in
that src/ is cut first by business area, not by technical role.

Hexagonal

A core with no technology, and around it everything that talks to HTTP or SQLite.

How it is organized
core/ holds the rules and the an interface the application declares in its own terms, for what it offers (the operations) or for what it needs from outside (storage, atomicity). it declares. adapters/ implements those ports with Express, node:sqlite and an in-memory version.
Where the rules live
Rules 1, 2 and 3: ProjectBoard.archiveProject, createTask and advanceTask, in core/project-board.ts. HTTP only validates the shape of the request.
The path of archiving a project
  1. adapters/http/app.ts: validates the id and calls archiveProject.
  2. core/project-board.ts: puts the checks and the write inside the Atomic port.
  3. adapters/sqlite/atomic.ts: opens BEGIN IMMEDIATE before reading.
  4. core/project-board.ts: rejects if the project does not exist or is already archived.
  5. adapters/sqlite/task-store.ts: counts the unfinished tasks; the core decides rule 1.
  6. adapters/sqlite/project-store.ts: saves; SqliteAtomic issues COMMIT.
What it helps with
The rules are tested without a database, on the in-memory adapter. A single call in main.ts chooses the storage.
What it costs
Three ports and two storage adapters for six operations. The in-memory adapter exists for the tests and has to imitate SQLite.
Where to start reading
  1. src/core/project-board.ts (the operations and the rules)
  2. src/core/ports.ts (what the core asks of storage)
  3. src/main.ts (where the core and the adapters are wired together)
Differs from Clean in
the focus: the boundary between inside and outside, and the replaceable adapter. Clean puts the rules in entities.

Clean

Four rings: the rules at the center and all dependencies pointing inward.

How it is organized
Entities protect their invariants. Each operation is a one thing a user can do with the system; here, each of the six operations of the contract. class. The dependency rule forbids a ring from importing from an outer one, and the lint enforces it.
Where the rules live
Rule 1 (archive) and rule 2 (create task): Project.archive and Project.acceptTask in entities/project.ts. Rule 3 (advance): Task.advance in entities/task.ts.
The path of archiving a project
  1. frameworks/http/app.ts: route() builds a plain request, without Express, for the controller.
  2. interface-adapters/project-controller.ts: validates the id and calls the use case.
  3. use-cases/archive-project.ts: ArchiveProject.execute opens the transaction with TransactionRunner.
  4. frameworks/sqlite/: the repositories return the project and all its tasks as entities.
  5. entities/project.ts: Project.archive rejects if the project is already archived or if unfinished tasks remain.
  6. The use case saves with update; interface-adapters/error-translation.ts translates the rejections to 404 or 409.
What it helps with
Rules 1 to 3 are tested with entities alone, without a database. The controllers translate HTTP without Express: they receive and return plain data.
What it costs
More files for small flows: main.ts builds 12 objects before starting. Rule 1 loads all the tasks as entities, where a COUNT would be enough.
Where to start reading
  1. src/entities/project.ts (rules 1 and 2 as methods of the entity)
  2. src/use-cases/archive-project.ts (a complete operation inside a transaction)
  3. src/use-cases/persistence.ts (the contracts that frameworks/sqlite/ implements)
Differs from Hexagonal in
the emphasis: here the entities hold the invariants and each operation is a class. Hexagonal gathers the rules in ProjectBoard and shows interchangeable adapters.

Use-case-first

One folder per user in Use-case-first, one folder per operation at the top of src/, with its route, its rules and its SQL, and no imports between actions.; each file has its route, its rules and its SQL.

How it is organized
No action imports another; they share only shared/ (connection, errors and HTTP). It is an interpretation of Screaming Architecture, in which the tree shows what the system does; it is not a formal architecture.
Where the rules live
Rule 1 (archive): archiveProject. Rule 2 (create task): createTask. Rule 3 (advance): advanceTask. Each one is in the file of its action.
The path of archiving a project
  1. The route in archive-project/archive-project.ts validates the id with requireId (shared/http.ts).
  2. archiveProject opens the transaction from shared/database.ts.
  3. findProject: if the project does not exist, 404; if it is already archived, 409.
  4. countUnfinishedTasks queries tasks directly; if any are unfinished, 409.
  5. updateProjectStatus saves archived, and the transaction runs COMMIT.
What it helps with
Reading a whole action, from route to SQL, in a single file. Adding an action: a new folder, plus its import and its registration in main.ts.
What it costs
Deliberate duplication: the input rule and "load the project or 404" are repeated. A column change is searched for by text in every action. Whether an archived project has only done tasks depends on three actions at once.
Where to start reading
  1. src/main.ts (registers the six actions)
  2. src/archive-project/archive-project.ts (a whole action, top to bottom)
  3. src/shared/database.ts (the shared schema and transaction)
Differs from Feature modules in
that it groups by verb, not by noun: archive-project queries tasks without going through another module.

The five, side by side

The first two rows measure the same request: archiving a project (try it in the playground). Rule 1 prevents archiving a project with unfinished tasks.

Comparison by app
MeasureLayeredFeature modulesHexagonalCleanUse-case-first
Files that run when archiving (success / with error)7 / 99 / 106 / 811 / 143 / 4
How rule 1 counts unfinished tasksCOUNT … status <> 'done'COUNT … status <> 'done'COUNT … status IN (?, ?)Loads all tasks; the an object whose methods protect its own invariants, such as Project.archive in Clean. decidesCOUNT … IN ('todo', 'in-progress')
Files that import node:sqlite3, in repositories/3: shared/database.ts and 2 stores5, in adapters/sqlite/4, in frameworks/sqlite/1: shared/database.ts; the 6 actions write their own SQL
Files that import express4, in http/4: app.ts, 2 route files, shared/http.ts2, in adapters/http/2, in frameworks/http/7: the 6 actions and shared/http.ts
What the lint checksDirection of the a group of code with a single technical role, such as receiving HTTP, applying rules or storing data, that depends only on the layers below it.; node:sqlite only in repositories/Public entry points; projects never imports tasksThe core imports only its own files and uses no Node globals; independent adaptersEach ring imports only from itself and from inner rings; no Node globals insideActions do not import each other; node:sqlite only in shared/database.ts
Where the rule tests run (files / tests)Real SQLite, no substitute (2 / 5)SQLite; projects alone, with a fake hasUnfinishedTasks (3 / 7)In-memory code that translates between a port and a specific technology, such as Express or node:sqlite.; rule 1 on both adapters (3 / 19)Entities only, no storage (3 / 9)SQLite, test data in plain SQL (5 / 7)
Files / lines, excluding tests and test helpers14 / 60914 / 68815 / 78725 / 100111 / 636

How to read these numbers

  • They show where the code is and how much of it a request goes through. They do not measure quality.
  • The first row counts the files whose code runs while the request is handled. It does not include route registration at startup or type-only imports.
  • Size favors small structures. With 6 operations and 3 rules, the apps with less structure look cheaper; many rules that cross operations would change the balance.
  • Tests are not compared by count. The 19 in Hexagonal include tests that run once per adapter. Compare how they are set up, not the totals.
  • The lint checks imports, and in Hexagonal and Clean also globals. It never checks where the logic is written.
  • The counts come from the code at commit 93f7d9e and change when the apps change.