DEV Community

joonquixote
joonquixote

Posted on Originally published at blog.joonquixote.com

ArchUnitTS is not another ESLint

Originally published at https://blog.joonquixote.com/en/posts/not-another-eslint/.

ArchUnitTS is built on architecture tests, and it does what it promises: it takes the architecture rules that erode most easily and lets you enforce them in test code.
But the feedback only lands when the suite runs, which is reason enough to look again at how you are actually using it.
This post is about where that line falls: which rules to keep in ArchUnitTS, and which to hand to ESLint or to the structure of the project.
It also covers what to do about rules that live only in a markdown file, now that AI agents write a growing share of the code.

Agreeing on a rule is not the same as keeping it

Most teams have at least one architecture rule they have agreed on.
Something like "the presentation layer does not depend on the database layer."
The trouble is that rules like this are usually held up by nothing but a document and code review.
The bigger the project gets and the more people touch it, the more the rule drifts, and the harder that drift is to undo.

I have watched it happen. A project I worked on had a perfectly clear layering rule and still ended up with a circular dependency, assembled one missed import at a time in review.
A cycle is just two files importing each other, either directly or through a chain of other files, and once one exists it is genuinely hard to work out where to cut.
That is what pushed me toward enforcing rules in code rather than trusting memory and review to catch them.

What ArchUnitTS gives you

The tool I settled on was ArchUnitTS.
It lets you write architecture rules as test code.
The rules are ordinary test cases, so they need no runner of their own.
They run with everything else: when you run npm test locally, in a pre-commit hook, and in CI.
A rule that used to live in a document becomes part of the test suite, which is to say it becomes code that runs.

Here is a test that checks for cycles and for a layering rule, written for Jest.

import { projectFiles } from 'archunit';

it('has no cycles inside src', async () => {
  const rule = projectFiles().inFolder('src/**').should().haveNoCycles();

  await expect(rule).toPassAsync();
});

it('keeps the presentation layer off the database layer', async () => {
  const rule = projectFiles()
    .inFolder('src/presentation/**')
    .shouldNot()
    .dependOnFiles()
    .inFolder('src/database/**');

  await expect(rule).toPassAsync();
});
Enter fullscreen mode Exit fullscreen mode

Written as a test, a violation surfaces as a failing test. The rule is held by verification rather than by agreement.

What is ArchUnit?

ArchUnit is the Java library for checking architecture rules with unit tests.
It lets you write rules like "this package must not depend on that one" as JUnit tests.
ArchUnitTS carries the idea over to TypeScript, and is not officially affiliated with ArchUnit.

Can't we just do that with ESLint?

When I introduced ArchUnitTS to the team, that was the first question back.
"Can't we just do that with ESLint?"
I said no at the time. Turning it over afterwards, it was half right.
The two tools part company at the moment they tell you about a violation.

  • ESLint flags the violation as you save the file, and for some rules it will fix it for you. Offending code is caught on the author's screen, before it is ever committed.
  • An ArchUnitTS rule only reports once the tests run. If you did not run them locally, you find out when CI goes red, not when you wrote the line.

Why the feedback arrives later

To check relationships between files, the way cycle and layering rules do, ArchUnitTS parses the AST of the whole project and builds a dependency graph out of it.
A lint rule can decide from the import statements in one file; these rules cannot be answered without the whole graph.
That is exactly what lets it express rules lint cannot, and it is also why its feedback lands later than an editor running ESLint on save.

That gap is what made me question whether simple dependency rules belong in ArchUnitTS at all.

Most of our rules turned out to be simple ones

Looking back over the rules we had really written with ArchUnitTS, nearly all of them were some variation of "folder A must not depend on folder B."
For the rules that genuinely need the whole graph, like cycle detection and code metrics, it earned its keep. For plain dependency bans it started to feel like over-engineering.

Empty test protection was the part I valued most.
It fails a rule that ends up checking zero files.
A typo in a folder path would otherwise leave the rule matching nothing and passing in silence; ArchUnitTS turns that into a failure instead, which is what stops you believing in a rule that is not running.

It does not cover every rule, though.
Checking it myself, cycle detection is the exception: hand haveNoCycles a folder with a typo in it and the rule passes quietly, having examined nothing.
The library documents this - cycle checks test the unfiltered set of files for emptiness rather than the filtered set they actually analyse.
Cycle detection is the single strongest reason I had for keeping ArchUnitTS at all.
The protection is missing exactly where I lean on it hardest, which makes it the one exception worth remembering.

Even so, I was not convinced a simple dependency ban is best checked by a test.

There was one more question worth putting to myself.
"Do I really need architecture rules complicated enough to justify this?"
Sometimes the answer is yes. But when it is, I have also wondered whether the real fix is to simplify the structure rather than add another rule.

Faster, safer ways to hold a simple dependency rule

1. Catch it on save, with ESLint

Move a simple dependency ban into ESLint and the feedback shows up in the editor.
eslint-plugin-import has no-restricted-paths, which expresses a layering rule in a few lines of config.

// eslint.config.js
import importPlugin from 'eslint-plugin-import';

export default [
  {
    plugins: { import: importPlugin },
    rules: {
      'import/no-restricted-paths': [
        'error',
        {
          zones: [
            { target: './src/presentation', from: './src/database' },
            { target: './src/business', from: './src/database' },
          ],
        },
      ],
    },
  },
];
Enter fullscreen mode Exit fullscreen mode

With that in place, importing the database layer from presentation puts an error on the screen as you type it.
The moment the violation is created and the moment it is found become the same moment.

2. Carve the rule into the structure with package boundaries

In a monorepo you can go a step further and put the rule in the structure itself rather than in configuration.
Split the layers into packages, and let each package.json declare only the dependencies it is allowed.

{
  "name": "@app/presentation",
  "dependencies": {
    "@app/business": "workspace:*"
  }
}
Enter fullscreen mode Exit fullscreen mode

@app/presentation has no @app/database in its dependencies, so importing the database layer from presentation fails at module resolution.
There is no rule left to check, because the violation is not expressible.

Before adding a new library, it is worth asking whether you can pull the feedback earlier or push the rule into the structure.

In the age of agents, a markdown file guarantees nothing

People are no longer the only ones who can break a rule.
AI coding agents write a fast-growing share of the code.
So teams have started keeping markdown files, CLAUDE.md or AGENTS.md, to hand the project's rules and context to the agent.
You write down "the presentation layer does not depend on the database layer" and hope the agent honours it.

But what that file really is, is a blob of context.
An agent consults context; it does not promise to follow it.
A rule that human review already failed to hold is not going to be held by a document you cannot even confirm was read.

So a rule you put in a document needs an executable check alongside it.
When the agent breaks the rule, a lint error or a failing test is an unambiguous signal, and that signal feeds straight back into the loop where the agent fixes its own code.
The distinction drawn above, about when a rule gets checked, applies to agents exactly as it does to people.
A simple dependency ban is caught by lint inside the agent's own loop, and a package boundary makes the violation impossible to begin with.

What changes is where ArchUnitTS sits.
Among the rules people write out in prose in a markdown file are sentences like "there are no cycles anywhere in src" and "the overall structure follows this diagram."
Lint, which only ever sees one file's imports, cannot check those; ArchUnitTS's cycle detection and its diagram-conformance check map onto them almost one to one.
That makes a division of labour possible, where the document keeps the intent and the test carries the verification.

What matters is when the check runs, not which tool runs it

ArchUnitTS is genuinely useful where a lint rule cannot reach: cycle detection, code metrics such as cohesion, which measures how tightly a class's methods and fields actually hang together, and checking real code against a UML diagram.
Use it only for simple dependency bans, though, and it turns into another ESLint, and a slower one at that.

So when someone asks whether ESLint can just do that, this is my answer now.
For a simple dependency rule, yes, it can.
For a rule about the structure as a whole, no, it cannot.
Three questions make the choice easier before you add a rule.
Can this only be checked by running the tests, can it be checked the moment the file is saved, or can the structure rule it out entirely?
In the age of agents there is a fourth.
Does this rule exist only in a document, or does it come with a check that runs?

In the end, what matters is the attitude rather than the tool.
Rather than settling on one library as the answer, keep suspecting there is something that fits the situation better, and go looking for it.
Doubting a tool I had adopted as a matter of course is how I learned a sturdier way to hold an architecture together.

Top comments (0)