The best guard for your architecture is probably already in your CI. You just use it for licenses.

Architecture rules erode, whether people or agents write the code. With agents it's faster: the rule sits in the docs, the docs fall out of the context window, and the next shortcut compiles just fine. Let's make it stop compiling.

# The shop

Picture a layered Rust workspace. The web handlers call the order service, the order service calls the store, the store talks to the database. Then someone needs one value fast, adds the store to the web crate's Cargo.toml, and calls it directly. Cargo builds it, the tests pass, and the order service got skipped.

Let's build exactly that, as a toy. The whole thing lives in a small repo, edu-cargo-deny-layers-rs, so you don't have to piece it together from the snippets:

cargo install cargo-deny   # the tool this post is about, more on it below
git clone https://github.com/sassman/edu-cargo-deny-layers-rs
cd edu-cargo-deny-layers-rs

Every step of this post is a tag in that repo, a name for one exact state of the code. When a block below starts with a line like $ git checkout -q 04-the-rule, that is you jumping to the state the step talks about. The output under it is what that state really prints, so you can run it and check every claim yourself. Some steps are broken on purpose, because the breakage is what they show. git checkout -q main brings you back to the finished, green version.

In Rust a program can be split into crates: packages with their own Cargo.toml, each listing which other crates it may use. Several crates in one repository form a workspace, and the workspace's root Cargo.toml is where we introduce all of them:

# Cargo.toml, at the root
[workspace]
resolver = "2"
members = [
    "crates/shop-app",      # starts the program and plugs the parts together
    "crates/shop-web",      # answers the HTTP requests from the browser
    "crates/shop-orders",   # the rules: is this your order? is it cancelled?
    "crates/shop-store",    # reads and writes the database
]

# every crate of the shop, introduced once, so the others can refer to it by name
[workspace.dependencies]
shop-web    = { path = "crates/shop-web" }
shop-orders = { path = "crates/shop-orders" }
shop-store  = { path = "crates/shop-store" }

members says which crates belong to the workspace. [workspace.dependencies] gives each of them a name and a place, once. From now on a crate that wants to use one of them only writes shop-orders.workspace = true, and cargo looks up the rest here.

Here is how they are meant to stack. An arrow means "uses":

shop-app
   ↓
shop-web
   ↓
shop-orders
   ↓
shop-store

The arrows only point down, one step at a time. shop-web asks shop-orders, and shop-orders asks shop-store. Parts stacked like this are called layers.

The rule we care about is the one that is not drawn: shop-web must never go to shop-store directly. The order rules sit in the middle on purpose. Skip them and you can read any order from the database without anyone checking whether it is yours.

Each crate's own Cargo.toml lists what it may use, and that list is not a suggestion. If a crate isn't listed, the compiler won't let you mention anything from it. So shop-web lists shop-orders, and nothing else:

# crates/shop-web/Cargo.toml
[dependencies]
shop-orders.workspace = true

shop-orders lists shop-store, the same way. And shop-app plugs everything together, so it is the one crate that knows all the others:

# crates/shop-app/Cargo.toml
[dependencies]
shop-web.workspace = true
shop-orders.workspace = true
shop-store.workspace = true

The code is as boring as it gets. Each layer just calls the one below:

// crates/shop-store/src/lib.rs
pub fn load(id: u32) -> String { format!("order {id}") }   // pretend this is a database query

// crates/shop-orders/src/lib.rs
pub fn show(id: u32) -> String { shop_store::load(id) }    // the real checks would go here

// crates/shop-web/src/lib.rs
pub fn get(id: u32) -> String { shop_orders::show(id) }    // pretend this is an HTTP handler

// crates/shop-app/src/main.rs
fn main() { println!("{}", shop_web::get(7)); }
$ git checkout -q 01-the-shop
$ cargo run -q
order 7

Nice. Now let's try to break it.

# What cargo already catches

First, let's make an arrow point up. The store suddenly wants something from the web part:

# crates/shop-store/Cargo.toml
[dependencies]
shop-web.workspace = true
$ git checkout -q 02-cycle
$ cargo check -q
error: cyclic package dependency: package `shop-orders v0.1.0` depends on itself. Cycle:
package `shop-orders v0.1.0`
    ... which satisfies path dependency `shop-orders` of package `shop-web v0.1.0`
    ... which satisfies path dependency `shop-web` of package `shop-store v0.1.0`
    ... which satisfies path dependency `shop-store` of package `shop-orders v0.1.0`

Cargo follows the arrows and ends up where it started: web, orders, store, web again. That's a circle, and cargo refuses circles on its own. Good, so arrows pointing up are covered for free.

Let's undo that and try the other way of cheating. The web part skips the middle and goes straight to the store:

# crates/shop-web/Cargo.toml
[dependencies]
shop-orders.workspace = true
shop-store.workspace = true    # the shortcut
// crates/shop-web/src/lib.rs
pub fn get(id: u32) -> String { shop_orders::show(id) }
pub fn get_fast(id: u32) -> String { shop_store::load(id) }   // nobody checks the order now
$ git checkout -q 03-shortcut
$ cargo check -q
$

Hm, not a single word. No circle, so no complaint. As far as cargo is concerned, any crate may depend on any other crate, as long as you don't go round in a circle. Our layer rule only exists in our heads, and in the drawing above.

# Teaching the build our rule

cargo-deny is a tool that reads all those dependency lists and checks rules against them. People mostly use it to check licenses and known security problems, but it can also forbid crates. If you followed the setup above, it is already installed.

The rules go into a file called deny.toml, next to the root Cargo.toml:

# deny.toml
[bans]
# ignore that for now
multiple-versions = "allow"

# the store is reached through the order rules, or by the app that plugs it in
[[bans.deny]]
name = "shop-store"
wrappers = ["shop-orders", "shop-app"]

name is the crate we forbid. wrappers is a guest list of the crates that may use it anyway. cargo-deny calls them wrappers because they wrap the forbidden crate: everyone else has to go through one of them. So this reads: shop-store is forbidden, except for shop-orders and shop-app. (The multiple-versions line switches off an unrelated check, about the same crate showing up in two versions. It has nothing to do with layers.)

Cool, let's ask cargo-deny how it likes our shortcut:

$ git checkout -q 04-the-rule
$ cargo deny check bans
warning[unmatched-wrapper]: direct parent 'shop-web = 0.1.0' of banned crate 'shop-store = 0.1.0' was not marked as a wrapper
  ┌─ deny.toml:7:9
  │
7 │ name = "shop-store"
  │         ━━━━━━━━━━ banned here
  │
  ├ shop-store v0.1.0
    ├── shop-app v0.1.0
    ├── shop-orders v0.1.0
    │   ├── shop-app v0.1.0 (*)
    │   └── shop-web v0.1.0
    │       └── shop-app v0.1.0 (*)
    └── shop-web v0.1.0 (*)

error[banned]: crate 'shop-store = 0.1.0' is explicitly banned
  ┌─ deny.toml:7:9
  │
7 │ name = "shop-store"
  │         ━━━━━━━━━━ banned here

bans FAILED

There it is. The tree took me a moment to read, because it is upside down compared to our drawing. It starts with the forbidden crate and lists who uses it, then who uses those, and so on. A (*) means "I already showed you the rest of this one above".

Right under shop-store sit the crates that use it directly: shop-app, shop-orders and, in the last line, shop-web. The first two are on the guest list. The last one is our shortcut, and the warning names it: direct parent 'shop-web' ... was not marked as a wrapper. A parent, in cargo-deny's words, is a crate that depends on another one.

Now the detail that makes the whole thing work. shop-web appears twice in that tree. Once under shop-orders, which is the normal path through the middle, and once directly under shop-store. cargo-deny only checks the crates directly above the forbidden one. The path web -> orders -> store is fine, because the crate right above store is orders. The path web -> store is not. That is our layer rule, exactly: you may reach the bottom, but only through the layer in between.

Let's remove the shortcut again:

$ git checkout -q 05-rule-green
$ cargo deny check bans
bans ok

Notice what we did not write. There is no rule saying "shop-web must not use shop-store". We wrote who may, and everyone else is out.

That matters the day the shop gets a mobile app. The app needs its own way in, so somebody adds a fifth crate, shop-api, which answers the app's requests the way shop-web answers the browser's. It sits on the same layer as shop-web, right next to it:

        shop-app
      ┌────┴─────┐
  shop-web    shop-api
      └────┬─────┘
      shop-orders
           │
       shop-store

shop-app starts it, and it should ask shop-orders exactly like shop-web does. A rule written as "shop-web must not use shop-store" says nothing about shop-api, so shop-api could take the shortcut and nobody would notice. The guest list doesn't care what the new crate is called. shop-api isn't on it, so it can't use shop-store either, and nobody had to touch deny.toml.

# Not only our own crates

The same works for crates from crates.io, Rust's public package registry. Say only the store may ever talk to the database driver:

# the database driver lives behind the store, nobody else gets a connection
[[bans.deny]]
name = "postgres"
wrappers = ["shop-store"]
# crates/shop-store/Cargo.toml
[dependencies]
postgres = "0.19"

Then someone adds the same line to shop-orders, "just for one quick query":

$ git checkout -q 06-third-party
$ cargo deny check bans
warning[unmatched-wrapper]: direct parent 'shop-orders = 0.1.0' of banned crate 'postgres = 0.19.14' was not marked as a wrapper
   ┌─ deny.toml:12:9
   │
12 │ name = "postgres"
   │         ━━━━━━━━ banned here
   │
   ├ postgres v0.19.14
     ├── shop-orders v0.1.0
     │   ├── shop-app v0.1.0
     │   └── shop-web v0.1.0
     │       └── shop-app v0.1.0 (*)
     └── shop-store v0.1.0
         ├── shop-app v0.1.0 (*)
         └── shop-orders v0.1.0 (*)

error[banned]: crate 'postgres = 0.19.14' is explicitly banned

bans FAILED

Same rule, same answer. For cargo-deny there is no difference between a crate in our workspace and one we downloaded.

# Oooops, the tests

Let's say shop-web wants the real store in its tests. Cargo has a separate list for that, [dev-dependencies]: crates that are only used when running tests, and never end up in the finished program. So that must be fine, right?

# crates/shop-web/Cargo.toml
[dev-dependencies]
shop-store.workspace = true
$ git checkout -q 07-dev-dependency
$ cargo deny check bans
warning[unmatched-wrapper]: direct parent 'shop-web = 0.1.0' of banned crate 'shop-store = 0.1.0' was not marked as a wrapper
  ├ shop-store v0.1.0
    ├── shop-app v0.1.0
    ├── shop-orders v0.1.0
    │   ...
    └── (dev) shop-web v0.1.0 (*)
error[banned]: crate 'shop-store = 0.1.0' is explicitly banned

bans FAILED

Note the (dev) in the last line of the tree. cargo-deny checks test-only dependencies too. My first reaction was annoyance, and it took me a while to come around. Test code is code, and a test that goes straight to the store is testing the shortcut.

You could put shop-web on the store's guest list, with a comment saying it's only for the tests. cargo-deny would be happy. But I wouldn't do it. It's a smell. The guest list can't tell a test-only use from a real one, so from then on shop-web may use the store in its normal code too. The next shortcut from web to store would pass the check before anyone has even written it. One small exception for the tests, and the rule from the top of this post is gone.

What I'd do instead: give the tests their own store. A crate shop-store-in-mem that answers like the real one, from memory instead of a database, and exists only for tests. A stand-in like that is called a test double. It joins the workspace like the others, a new line under members and [workspace.dependencies]:

# Cargo.toml, at the root
members = [
    # ...
    "crates/shop-store-in-mem", # a fake store for tests, no database
]

[workspace.dependencies]
# ...
shop-store-in-mem = { path = "crates/shop-store-in-mem" }
// crates/shop-store-in-mem/src/lib.rs
// same answers as shop-store, from memory instead of a database
pub fn load(id: u32) -> String { format!("order {id}") }

shop-web takes it as a test-only dependency, instead of the real store:

# crates/shop-web/Cargo.toml
[dev-dependencies]
shop-store-in-mem.workspace = true

And the exception goes on that crate, with its own rule, while the real store's guest list stays exactly as it was:

# the store is reached through the order rules, or by the app that plugs it in
[[bans.deny]]
name = "shop-store"
wrappers = ["shop-orders", "shop-app"]

# the in-memory store is a test double: shop-web takes it as a dev-dependency only,
# so it never ends up in the program shop-app builds
[[bans.deny]]
name = "shop-store-in-mem"
wrappers = ["shop-web"]
$ git checkout -q 08-test-double
$ cargo deny check bans
bans ok

Now the exception is harmless. The worst shop-web can do with it is talk to a store that has no database behind it. And it stays out of the real program, which you can check. cargo tree shows what a crate pulls in, -e normal leaves out the test-only dependencies, and -i asks the question the other way round: who pulls this crate in?

$ cargo tree -p shop-app -e normal -i shop-store-in-mem
error: package ID specification `shop-store-in-mem` did not match any packages

Nobody. Seen from the program shop-app builds, the in-memory store doesn't exist.

That's the nice thing about a guest list. An exception can't sneak in as one more line in a Cargo.toml nobody reads twice. It has to show up in deny.toml, next to the rule, with its reason, and whoever reviews the change sees the boundary move. And if the reason reads like a smell, the review is the moment to find a better one.

# Keeping the guest list true

Lists rot. Let's say we once planned for shop-web to talk to Postgres, put it on the list, and then never did:

[[bans.deny]]
name = "postgres"
wrappers = ["shop-store", "shop-web"]
$ git checkout -q 09-unused-wrapper
$ cargo deny check bans
warning[unused-wrapper]: wrapper for banned crate was not encountered
   ┌─ deny.toml:19:28
   │
19 │ wrappers = ["shop-store", "shop-web"]
   │                            ━━━━━━━━ unmatched wrapper

bans ok

Hm, a warning, but still bans ok. A guest who never shows up is a door left open for the next person, so I want this to fail. -D warnings (the D is for deny) turns every warning into an error:

$ cargo deny check -D warnings bans
error[unused-wrapper]: wrapper for banned crate was not encountered
   │
19 │ wrappers = ["shop-store", "shop-web"]
   │                            ━━━━━━━━ unmatched wrapper

bans FAILED

Now the file can only describe the dependencies the code really has, not the ones somebody once planned.

# Why I care about this so much

A lot of my code these days is written together with coding agents. An agent takes the shortest path to code that compiles, and adding shop-store to shop-web is the shortest path. It doesn't read the architecture docs first. To be fair, neither do I on a Friday afternoon.

But both of us read the error when a check fails. And this error points at a line in deny.toml with the reason right above it, at exactly the moment someone is about to take the shortcut. The rule and its explanation are the same few lines, so they can't drift apart the way a wiki page and the code do.

# Where it stops

Two things cargo-deny can't see, and you should know them before you trust a green check.

Cargo has features: optional parts of a crate that are only built when you switch them on in Cargo.toml. cargo-deny sees that one crate depends on another, but not which feature pulled it in. "The release build never contains an HTTP client" can be true, and still impossible to write as a guest list.

And it only sees dependencies between crates. If shop-web and shop-store both talk to the same database, shop-web can read the orders table with plain SQL, and no arrow shows up anywhere.

If a rule can't be written as a guest list, say so in a comment in deny.toml. Otherwise the green check suggests it covers something it never looked at.

# Bonus Track: make it run on every change

Here's how I learned this last part. I use all of the above in a bigger project: a Rust rewrite of MaNGOS (Massive Network Game Object Server), an open source server for an old online game. It has about forty crates and two dozen rules like ours. While writing this post I ran the check there:

error[banned]: crate 'firewall = 0.1.0' is explicitly banned

bans FAILED

The login code had started using a type from the firewall crate, which the rules reserve for the outermost part of the server. The check was failing for four days. Fifteen commits landed on top of it, eleven of them new features, and nobody noticed. Me included.

The tool worked fine. Nobody ran it. The project has a command that runs all the checks, but only when somebody remembers to type it. A check that depends on remembering is still a habit.

So the last step is making it run by itself on every change. First, a recipe for running it locally. I use just for that, a small task runner like make. The recipe fails when cargo-deny is missing, because a check that silently skips looks exactly like a check that passed:

arch:
    #!/usr/bin/env bash
    set -euo pipefail
    if ! command -v cargo-deny >/dev/null; then
        echo "cargo-deny is not installed: cargo install cargo-deny"
        exit 1
    fi
    cargo deny check -D warnings bans

Then a GitHub workflow, which runs on every push and every pull request, so nobody has to remember:

# .github/workflows/architecture.yml
name: architecture
on: [push, pull_request]
jobs:
  bans:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: EmbarkStudios/cargo-deny-action@v2
        with:
          command: check bans
          command-arguments: -D warnings   # options of `check`, after the command

Careful with that last line, it got me. The action also has an input called arguments, but those go before check, where -D isn't a valid option. cargo-deny then stops with a usage error, so the job fails on every push, whether a rule is broken or not.

Locally, that looks like this on the last stage, which is also what main holds:

$ git checkout -q 10-ci
$ just arch
bans ok

Now the shortcut fails on the pull request, before it ever reaches main.

# Wrap up

  • Each crate lists what it may use, so the dependency lists of a workspace are the arrows of your architecture drawing.
  • Cargo only refuses circles. A shortcut from the top layer to the bottom one builds fine.
  • [[bans.deny]] with wrappers is a guest list of the crates allowed directly above a crate. That turns "only through the layer in between" into something a tool checks.
  • Test-only dependencies count too. Give the tests their own crate, like shop-store-in-mem, instead of widening a guest list for them.
  • -D warnings keeps the guest list from going stale.
  • None of it matters until CI runs it.

Thanks for reading and don't miss out to give feedback :) I'm curious how you keep your layers apart, and if you found a way to cover the things cargo-deny can't see.

Versions used:

rustc 1.99.1
cargo-deny 0.20.2