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.
| App | What splits the top level of src/ | Where dependencies point |
|---|---|---|
| Layered | Technical role: http/, services/, repositories/ | Toward storage: services import concrete repositories. |
| Feature modules | Business 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. |
| Hexagonal | Inside 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. |
| Clean | in 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-first | 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.: create-project/, archive-project/, … | Toward storage: each action runs its own SQL. |
The three rules
- A project is archived only if none of its tasks is unfinished (
todoorin-progress). - An archived project accepts no new tasks.
- A task advances one step at a time (
todo → in-progress → done); a task indonedoes 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.archivein 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, andmain.tswires 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
http/app.tsroutes the request to the controller.http/project-controller.tsvalidates the id withrequireId.services/project-service.tsopens the transaction fromrepositories/database.ts.repositories/project-repository.tslooks up the project; the service decides404or409.repositories/task-repository.tscounts the unfinished tasks; if there are any,409.updateStatussaves the change;http/errors.tstranslates 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.archiveis 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
src/http/app.ts(the only place that lists the 6 operations)src/services/project-service.ts(rule 1 and the transaction in a single method)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. - Code
apps/layered
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 itsindex.tsis imported.tasksmay importprojects; not the other way around. - Where the rules live
- Rule 1 (archive):
archiveinmodules/projects/projects.ts. Rule 2 (create task) and rule 3 (advance):createandadvanceinmodules/tasks/tasks.ts. - The path of archiving a project
modules/projects/project-routes.ts:requireIdrejects ids that are not UUIDs (400).modules/projects/projects.ts:archiveopens the transaction (shared/database.ts).modules/projects/project-store.ts:findProjectByIdreads;archivedecides404or409.main.ts: the injected functionhasUnfinishedTasksqueries thetasksmodule.modules/tasks/task-store.ts: counts the unfinished tasks inside the same transaction.modules/projects/projects.ts:archivedecides 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,
projectsreceives a function fromtasks, wired inmain.ts. Both modules depend onshared/, which according to its README could become a hidden layer. TheDatabaseconnection is part of each module's public API. - Where to start reading
src/main.ts(creates the two modules and wires them together)src/modules/projects/projects.ts(rule 1 and why it does not importtasks)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:sqliteand an in-memory version.- Where the rules live
- Rules 1, 2 and 3:
ProjectBoard.archiveProject,createTaskandadvanceTask, incore/project-board.ts. HTTP only validates the shape of the request. - The path of archiving a project
adapters/http/app.ts: validates the id and callsarchiveProject.core/project-board.ts: puts the checks and the write inside theAtomicport.adapters/sqlite/atomic.ts: opensBEGIN IMMEDIATEbefore reading.core/project-board.ts: rejects if the project does not exist or is already archived.adapters/sqlite/task-store.ts: counts the unfinished tasks; the core decides rule 1.adapters/sqlite/project-store.ts: saves;SqliteAtomicissuesCOMMIT.
- What it helps with
- The rules are tested without a database, on the in-memory adapter. A single call in
main.tschooses 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
src/core/project-board.ts(the operations and the rules)src/core/ports.ts(what the core asks of storage)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.
- External reference
- Hexagonal Architecture — Alistair Cockburn
- Code
apps/hexagonal
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.archiveandProject.acceptTaskinentities/project.ts. Rule 3 (advance):Task.advanceinentities/task.ts. - The path of archiving a project
frameworks/http/app.ts:route()builds a plain request, without Express, for the controller.interface-adapters/project-controller.ts: validates the id and calls the use case.use-cases/archive-project.ts:ArchiveProject.executeopens the transaction withTransactionRunner.frameworks/sqlite/: the repositories return the project and all its tasks as entities.entities/project.ts:Project.archiverejects if the project is already archived or if unfinished tasks remain.- The use case saves with
update;interface-adapters/error-translation.tstranslates the rejections to404or409.
- 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.tsbuilds 12 objects before starting. Rule 1 loads all the tasks as entities, where aCOUNTwould be enough. - Where to start reading
src/entities/project.ts(rules 1 and 2 as methods of the entity)src/use-cases/archive-project.ts(a complete operation inside a transaction)src/use-cases/persistence.ts(the contracts thatframeworks/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
ProjectBoardand shows interchangeable adapters. - External reference
- The Clean Architecture — Robert C. Martin
- Code
apps/clean
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
- The route in
archive-project/archive-project.tsvalidates the id withrequireId(shared/http.ts). archiveProjectopens the transaction fromshared/database.ts.findProject: if the project does not exist,404; if it is already archived,409.countUnfinishedTasksqueriestasksdirectly; if any are unfinished,409.updateProjectStatussavesarchived, and the transaction runsCOMMIT.
- The route in
- 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 onlydonetasks depends on three actions at once. - Where to start reading
src/main.ts(registers the six actions)src/archive-project/archive-project.ts(a whole action, top to bottom)src/shared/database.ts(the shared schema and transaction)
- Differs from Feature modules in
- that it groups by verb, not by noun:
archive-projectqueriestaskswithout going through another module. - External reference
- Screaming Architecture — Robert C. Martin
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.
| Measure | Layered | Feature modules | Hexagonal | Clean | Use-case-first |
|---|---|---|---|---|---|
| Files that run when archiving (success / with error) | 7 / 9 | 9 / 10 | 6 / 8 | 11 / 14 | 3 / 4 |
| How rule 1 counts unfinished tasks | COUNT … 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. decides | COUNT … IN ('todo', 'in-progress') |
Files that import node:sqlite | 3, in repositories/ | 3: shared/database.ts and 2 stores | 5, in adapters/sqlite/ | 4, in frameworks/sqlite/ | 1: shared/database.ts; the 6 actions write their own SQL |
Files that import express | 4, in http/ | 4: app.ts, 2 route files, shared/http.ts | 2, in adapters/http/ | 2, in frameworks/http/ | 7: the 6 actions and shared/http.ts |
| What the lint checks | Direction 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 tasks | The core imports only its own files and uses no Node globals; independent adapters | Each ring imports only from itself and from inner rings; no Node globals inside | Actions 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 helpers | 14 / 609 | 14 / 688 | 15 / 787 | 25 / 1001 | 11 / 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
93f7d9eand change when the apps change.