Layer a backend
Markdown for agentsThis guide writes the architecture of a small backend as tests. It uses two tables and one rule.
Before you start
Section titled “Before you start”- Read Getting started.
- You need a backend whose code is in folders by layer.
The example is a users API. A route calls a service. A service uses the domain and the database
code. Only the db folder talks to PostgreSQL through the package pg.
- package.json
Directorysrc/
Directoryroutes/
- health.ts
- users.ts
Directoryservices/
- sign-up.ts
Directorydb/
- users.ts
Directorydomain/
- email.value-object.ts
- user-created.event.ts
- user.entity.ts
This is the config. The steps below tell you about each part of it:
import { defineConfig } from '@enhance-eng/archangel';
export default defineConfig({ parts: { routes: 'src/routes/**', services: 'src/services/**', db: 'src/db/**', domain: 'src/domain/**', }, externals: { driver: ['pg'], }, rules: ({ mayImport, rule }) => [ mayImport('backend', { routes: ['services'], services: ['domain', 'db'], db: ['domain', 'driver'], domain: [], }, { because: 'A route calls a service. Only db talks to the database. The domain depends on nothing.', }),
mayImport('model', { entity: ['value-object'], event: ['entity', 'value-object'], 'value-object': [], }, { files: 'domain', kinds: 'src/domain/*.{kind}.ts', because: 'An entity does not know its events. A service publishes them.', }),
rule('no-cycles', { files: 'src/**', noCycles: true, because: 'Files that import each other cannot change one at a time.', examples: { rejected: ['src/services/a.ts imports src/services/b.ts imports src/services/a.ts'], }, }), ],});Write the layers
Section titled “Write the layers”-
Give a name to each layer in
parts. Each file is in one part at most. -
Give a name to the database driver in
externals. An externals set is a named set of outside packages. Here,driverholdspg. -
Write the table
backend. It has one row for each layer:routesmay importservices.servicesmay importdomainanddb.dbmay importdomainand the externals setdriver.domainmay import only itself.
A row always permits its own part. The table refuses each other import into the repository.
Give the domain files kinds
Section titled “Give the domain files kinds”The domain has three kinds of file: entities, value objects and events. The file name gives the
kind, for example user.entity.ts. A second table, model, uses the kinds as its rows:
kinds: 'src/domain/*.{kind}.ts'is the pattern that gives a file its kind. Insrc/domain/user.entity.ts, the capture{kind}takesentity.files: 'domain'makes the table judge only the files of the partdomain.- Because the table has
files, each file indomainmust have a row. A file such assrc/domain/helpers.tshas no kind, so the rulemodel.every-file-has-a-kindrefuses it.
You do not declare a kind. The pattern gives the names. Read Kinds for more patterns.
Stop import cycles
Section titled “Stop import cycles”The rule no-cycles refuses files that import each other. It counts only runtime imports by
default, because the compiler removes an import type.
In the example, user.entity.ts imports user-created.event.ts, and the event imports the type
User from the entity. This is a cycle of one runtime import and one type import. It cannot break
when the code runs, so no-cycles does not refuse it. To refuse type cycles too, add
imports: 'all' to the rule.
The rule has a rejected example. archangel test makes the two files of the example in memory and
checks that the rule refuses them.
Check the backend
Section titled “Check the backend”-
Run the check:
Terminal $ npx archangel checkerror[may-import]: entity may not import event, through "./user-created.event": entity may import only value-object, and itself--> src/domain/user.entity.ts:2:29|2 | import { userCreated } from './user-created.event';| ^^^^^^^^^^^^^^^^^^^^^^ entity may import only value-object, and itself|= help: remove the import, or move the code to a file whose row permits the import= note: rule: model.entity= note: because: An entity does not know its events. A service publishes them.error[may-import]: routes may not import the package `pg`, through "pg": routes may import only services, and itself--> src/routes/health.ts:1:22|1 | import { Pool } from 'pg';| ^^^^ routes may import only services, and itself|= help: remove the import, or move the code to a file whose row permits the import= note: rule: backend.routes= note: because: A route calls a service. Only db talks to the database. The domain depends on nothing.checked 8 files and 10 imports: 2 problemsFor what a code means, run `archangel docs may-import`.The check finds two problems:
user.entity.tsimports an event. The rowentitylists onlyvalue-object. To fix it, let the service make the event after it creates the user.health.tsimportspgin a route. To fix it, move the query to a function indb, and call it through a service.
-
Run the examples:
Terminal $ npx archangel testpass no-cycles rejected #1 src/services/a.ts, src/services/b.ts1 examples, none failed; 0 rules without a rejected example7 table rows need no written example; `archangel rules` shows what each covers -
See what each row judged:
Terminal $ npx archangel rulesbackend 4 rows archangel.config.ts:14because: A route calls a service. Only db talks to the database. The domain depends on nothing.backend.routes: routes may import only services, and itself2 files · 2 imports judged · 1 problem · 0 exceptionsbackend.services: services may import only domain, db, and itself1 file · 2 imports judged · 0 problems · 0 exceptionsbackend.db: db may import only domain, driver, and itself1 file · 2 imports judged · 0 problems · 0 exceptionsbackend.domain: domain may import only itself3 files · 3 imports judged · 0 problems · 0 exceptionsbackend.every-file-has-a-kind: a file in no row is not checked here; add `files` to check it7 files · 0 problemsmodel 3 rows archangel.config.ts:23because: An entity does not know its events. A service publishes them.model.entity: entity may import only value-object, and itself1 file · 2 imports judged · 1 problem · 0 exceptionsmodel.event: event may import only entity, value-object, and itself1 file · 1 import judged · 0 problems · 0 exceptionsmodel.value-object: value-object may import only itself1 file · 0 imports judged · 0 problems · 0 exceptionsmodel.every-file-has-a-kind: every file of model has a row3 files · 0 problemsno-cycles archangel.config.ts:33because: Files that import each other cannot change one at a time.files in src/** may not import each other in a cycle7 files · 0 problemsNot judged by any rule: 0 files, 0 imports between repository files.Outside packages that no rule names: none.The table
backendhas nofiles, so its rulebackend.every-file-has-a-kinddoes not check files that have no row. The line says this. The tablemodelhasfiles, so each file of the domain must have a row.
A table or layers
Section titled “A table or layers”The shorthand layers(['routes', 'services', 'db']) also writes layers. It says only that a layer
may not import a layer before it in the list. So a route may import db directly. A table says
what each layer may import, so it refuses that import. Use a table when you want to say what each
layer may use, not only what it may not use.
Next steps
Section titled “Next steps”- Tables, rows and cells explains each cell type.
- Exceptions and baselines tells you what to do with code that breaks a rule on purpose.
- Monorepos and workspace tables.