Skip to content

Layer a backend

Markdown for agents

This guide writes the architecture of a small backend as tests. It uses two tables and one rule.

  • 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:

archangel.config.ts
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'],
},
}),
],
});
  1. Give a name to each layer in parts. Each file is in one part at most.

  2. Give a name to the database driver in externals. An externals set is a named set of outside packages. Here, driver holds pg.

  3. Write the table backend. It has one row for each layer:

    • routes may import services.
    • services may import domain and db.
    • db may import domain and the externals set driver.
    • domain may import only itself.

    A row always permits its own part. The table refuses each other import into the repository.

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. In src/domain/user.entity.ts, the capture {kind} takes entity.
  • files: 'domain' makes the table judge only the files of the part domain.
  • Because the table has files, each file in domain must have a row. A file such as src/domain/helpers.ts has no kind, so the rule model.every-file-has-a-kind refuses it.

You do not declare a kind. The pattern gives the names. Read Kinds for more patterns.

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.

  1. Run the check:

    Terminal
    $ npx archangel check
    error[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 problems
    For what a code means, run `archangel docs may-import`.

    The check finds two problems:

    • user.entity.ts imports an event. The row entity lists only value-object. To fix it, let the service make the event after it creates the user.
    • health.ts imports pg in a route. To fix it, move the query to a function in db, and call it through a service.
  2. Run the examples:

    Terminal
    $ npx archangel test
    pass no-cycles rejected #1 src/services/a.ts, src/services/b.ts
    1 examples, none failed; 0 rules without a rejected example
    7 table rows need no written example; `archangel rules` shows what each covers
  3. See what each row judged:

    Terminal
    $ npx archangel rules
    backend 4 rows archangel.config.ts:14
    because: A route calls a service. Only db talks to the database. The domain depends on nothing.
    backend.routes: routes may import only services, and itself
    2 files · 2 imports judged · 1 problem · 0 exceptions
    backend.services: services may import only domain, db, and itself
    1 file · 2 imports judged · 0 problems · 0 exceptions
    backend.db: db may import only domain, driver, and itself
    1 file · 2 imports judged · 0 problems · 0 exceptions
    backend.domain: domain may import only itself
    3 files · 3 imports judged · 0 problems · 0 exceptions
    backend.every-file-has-a-kind: a file in no row is not checked here; add `files` to check it
    7 files · 0 problems
    model 3 rows archangel.config.ts:23
    because: An entity does not know its events. A service publishes them.
    model.entity: entity may import only value-object, and itself
    1 file · 2 imports judged · 1 problem · 0 exceptions
    model.event: event may import only entity, value-object, and itself
    1 file · 1 import judged · 0 problems · 0 exceptions
    model.value-object: value-object may import only itself
    1 file · 0 imports judged · 0 problems · 0 exceptions
    model.every-file-has-a-kind: every file of model has a row
    3 files · 0 problems
    no-cycles archangel.config.ts:33
    because: Files that import each other cannot change one at a time.
    files in src/** may not import each other in a cycle
    7 files · 0 problems
    Not judged by any rule: 0 files, 0 imports between repository files.
    Outside packages that no rule names: none.

    The table backend has no files, so its rule backend.every-file-has-a-kind does not check files that have no row. The line says this. The table model has files, so each file of the domain must have a row.

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.