Rulebearing Playground Coverage Schemas GitHub

Layers only import downward

A layered package works when each layer uses the ones below it and none above it: the web layer calls the services, the services use the domain, and the domain knows neither. import-linter writes this as a layers contract. In Rulebearing it is one forbidden rule per layer, with reachable: true so that a chain through other modules counts as well as a direct import, as it does in import-linter.

The rule

The lowest layer, shop/domain/, may reach neither layer above it; the middle layer, shop/services/, may not reach shop/web/. Nothing stops a higher layer importing a lower one, and modules inside one layer import each other freely.

rulebearing.yaml
$schema: https://benbahrenburg.github.io/rulebearing/schema/config-v1.json
languages:
  python: {}
rules:
  dependencies:
    forbidden:
      - name: domain-is-the-lowest-layer
        comment: "The domain is imported by the layers above it and imports neither of them, directly or through a chain. plan:0004-wave-4-reach"
        fix: "Move the code the domain needs out of the higher layer into shop/domain/, or have the higher layer pass it in as an argument."
        severity: error
        from: { path: "^shop/domain/" }
        to: { path: "^shop/(services|web)/", reachable: true }
        examples:
          forbidden:
            - "shop/domain/pricing.py -> shop/web/formatting.py"
            - "shop/domain/order.py -> shop/services/orders.py"
          allowed:
            - "shop/domain/order.py -> shop/domain/pricing.py"
            - "shop/services/orders.py -> shop/domain/order.py"
      - name: services-sit-below-web
        comment: "The services are called by the web layer and never call into it, directly or through a chain. plan:0004-wave-4-reach"
        fix: "Return data from the service and let shop/web/ format or render it."
        severity: error
        from: { path: "^shop/services/" }
        to: { path: "^shop/web/", reachable: true }
        examples:
          forbidden:
            - "shop/services/orders.py -> shop/web/formatting.py"
          allowed:
            - "shop/web/views.py -> shop/services/orders.py"
            - "shop/services/orders.py -> shop/domain/order.py"

The file is rulebearing.yaml. Its examples are the edges rulebearing test checks each rule against.

The same rule for dependency-cruiser

rulebearing config convert rulebearing.yaml --to dependency-cruiser writes the rules in dependency-cruiser's format and names what it dropped: the languages.python block, since dependency-cruiser reads TypeScript and JavaScript only, the $schema, and each rule's fix and examples.

.dependency-cruiser.json
{
  "forbidden": [
    {
      "name": "domain-is-the-lowest-layer",
      "comment": "The domain is imported by the layers above it and imports neither of them, directly or through a chain. plan:0004-wave-4-reach",
      "to": {
        "path": "^shop/(services|web)/",
        "reachable": true
      },
      "severity": "error",
      "from": {
        "path": "^shop/domain/"
      }
    },
    {
      "name": "services-sit-below-web",
      "comment": "The services are called by the web layer and never call into it, directly or through a chain. plan:0004-wave-4-reach",
      "to": {
        "path": "^shop/web/",
        "reachable": true
      },
      "severity": "error",
      "from": {
        "path": "^shop/services/"
      }
    }
  ]
}
dropped (stderr of config convert)
dropped 6 item(s) dependency-cruiser cannot hold:
  languages.python: dependency-cruiser reads TypeScript and JavaScript only
  $schema: points at the native schema
  forbidden[domain-is-the-lowest-layer].fix: a Rulebearing rule field
  forbidden[domain-is-the-lowest-layer].examples: a Rulebearing rule field
  forbidden[services-sit-below-web].fix: a Rulebearing rule field
  forbidden[services-sit-below-web].examples: a Rulebearing rule field

The files are converted/.dependency-cruiser.json and converted/dropped.txt.

The failing edge

In repo/, shop/domain/pricing.py imports shop.web.formatting to format a price. That one import makes three findings: the direct one, and two chains, because shop/domain/order.py and shop/services/orders.py reach the web layer through pricing.py. The Through column is the chain:

SeverityRuleFromToTypeThrough
errordomain-is-the-lowest-layershop/domain/order.pyshop/web/formatting.pyreachabilityshop/domain/pricing.py → shop/web/formatting.py
errordomain-is-the-lowest-layershop/domain/pricing.pyshop/web/formatting.pyreachabilityshop/web/formatting.py
errorservices-sit-below-webshop/services/orders.pyshop/web/formatting.pyreachabilityshop/domain/order.py → shop/domain/pricing.py → shop/web/formatting.py

The fix

Formatting belongs to the web layer. Delete label from shop/domain/pricing.py, and format where the page is built:

# shop/web/views.py
from shop.services import orders
from shop.web import formatting


def show_order(order_id: str) -> str:
    return formatting.money(orders.total(order_id))

All three findings go with the one import, because the chains ran through it.

From an import-linter contract

The same question as import-linter's layers contract, in repo/.importlinter:

.importlinter
[importlinter]
root_package = shop

[importlinter:contract:layers]
name = The web layer sits on the services, which sit on the domain
type = layers
layers =
    shop.web
    shop.services
    shop.domain

rulebearing import import-linter translates it into one rule per lower-to-higher pair of layers, each with reachable: true, and a graph.chainsThrough that keeps the chains inside the root package as import-linter's graph does (ADR-0038):

rulebearing import import-linter
# Imported from .importlinter by `rulebearing import import-linter`.
# Each rule's comment names its import-linter contract; the contract's options are written above its first rule.
$schema: https://benbahrenburg.github.io/rulebearing/schema/config-v1.json
languages:
  python:
    roots: ["."]
rules:
  dependencies:
    forbidden:
      # import-linter contract `layers` (layers): The web layer sits on the services, which sit on the domain
      #   layers = shop.web, shop.services, shop.domain
      - name: layers:shop.services-to-shop.web
        comment: "import-linter contract: The web layer sits on the services, which sit on the domain"
        severity: error
        from:
          path: "^shop/services(/.*)?\\.py$"
        to:
          path: "^shop/web(/.*)?\\.py$"
          reachable: true
        graph:
          chainsThrough: "^shop(/.*)?\\.py$"
      - name: layers:shop.domain-to-shop.web
        comment: "import-linter contract: The web layer sits on the services, which sit on the domain"
        severity: error
        from:
          path: "^shop/domain(/.*)?\\.py$"
        to:
          path: "^shop/web(/.*)?\\.py$"
          reachable: true
        graph:
          chainsThrough: "^shop(/.*)?\\.py$"
      - name: layers:shop.domain-to-shop.services
        comment: "import-linter contract: The web layer sits on the services, which sit on the domain"
        severity: error
        from:
          path: "^shop/domain(/.*)?\\.py$"
        to:
          path: "^shop/services(/.*)?\\.py$"
          reachable: true
        graph:
          chainsThrough: "^shop(/.*)?\\.py$"

Run over the same repository, the imported rules find the same edges as the rules above:

SeverityRuleFromToTypeThrough
errorlayers:shop.domain-to-shop.webshop/domain/order.pyshop/web/formatting.pyreachabilityshop/domain/pricing.py → shop/web/formatting.py
errorlayers:shop.domain-to-shop.webshop/domain/pricing.pyshop/web/formatting.pyreachabilityshop/web/formatting.py
errorlayers:shop.services-to-shop.webshop/services/orders.pyshop/web/formatting.pyreachabilityshop/domain/order.py → shop/domain/pricing.py → shop/web/formatting.py

The files are imported/rulebearing.yaml and imported/expected.json.

Try it

The playground holds this page's repository and rule. Run cruises the files in this browser, as rulebearing cruise does in repo/; paste the imported rules into the configuration and run them, or pick another output type.

Everything runs in this browser, with the same library crates as the rulebearing binary: nothing you paste, choose or drop is uploaded or sent anywhere. The configuration is YAML or JSON. A JavaScript configuration is not accepted: the browser build has no JavaScript engine (ADR-0067). An extends of a file cannot be followed; the bundled presets resolve as on the command line. Errors are the module's own words, shown as they come.

  • A cruise result: paste the JSON that rulebearing cruise -T json (or dependency-cruiser) wrote, and Run evaluates the configuration's rules over it, as rulebearing cruise --graph graph.json -T <output type> does. This is the way in for .NET: assemblies are not accepted, since reading them needs a build a browser cannot run, so paste the cruise result the command line wrote for the solution.
  • Source files: choose a folder or files, or drop them, holding TypeScript and JavaScript or Python, and Run extracts and evaluates them, as rulebearing cruise -T <output type> does in that folder. Paths are taken from the chosen folder. The language is the one picked, or, left on Infer from the files, the one with more source files outside node_modules and virtual environments (a tie asks you to pick). Files that are not UTF-8 text, files over 1 MiB, anything past 32 MiB or 20,000 files in all, and version-control folders are skipped, and the note under the pickers names them. A configuration file at the folder's root (rulebearing.yaml, rulebearing.yml, rulebearing.json or .dependency-cruiser.json) is loaded into the configuration box. What needs the disk or a process is refused with the reason: the Node sidecar, Yarn Plug'n'Play, a ratchet's budget file, and a path outside the folder.
  • Explain prints what rulebearing explain <rule> --graph graph.json prints for the named rule over the current cruise result: the pasted one, or the json cruise of the chosen files.

Examples:

Loading the rulebearing module.