ArchUnitRuby is the Ruby member of the ArchUnitEverything family: one architecture-testing library per language, all recognisably the same product. Users write architecture rules as ordinary unit tests.
it "keeps the api away from the database" do
rule = project_files
.in_folder("app/api/**")
.should_not
.depend_on_files
.in_folder("app/db/**")
expect(rule).to pass
endThe siblings are ArchUnitTS and ArchUnitPython, which are shipped and in production, plus ArchUnitGo, ArchUnitNET and ArchUnitRust, which are being built alongside this one. Someone who knows any of them should be able to read this one without a map.
This is a guideline, not a specification. It exists so that code written by different people, in different languages, by different agents, comes out looking like it came from one library.
Follow it by default. Where Ruby makes a convention awkward, diverge — knowingly, and leave a line in the commit message or a comment saying why. Fighting the host language to match a sibling is the one failure mode worse than divergence. Idiomatic Ruby wins whenever the two genuinely conflict; most of the time they do not.
It is also not exhaustive, deliberately. If something is not covered here, pick whatever a reader of the other ArchUnit libraries would find least surprising, and get on with it.
ArchUnit turns a codebase into a directed dependency graph, lets the user describe a subset of that graph in a sentence, and reports every place the code disagrees with the sentence.
Everything in the library is one of four things: something that builds the graph, something that reshapes it into the vocabulary the user is talking about, something that judges the reshaped graph, or something that lets the user say the sentence. The fluent API is the product. The other three are its implementation.
A rule is a value, not an action. Building one does no work. Only the terminal check touches the
filesystem.
SOURCE -> EXTRACT -> PROJECT -> ASSERT -> REPORT
| Stage | Does what | Ruby-specific? |
|---|---|---|
| Source | The user's project, found from a project root or a build file. | Only in how the root is found. |
| Extract | Walk and parse the source, resolve every import to a target, emit Edge values. |
Yes, entirely. This is the only stage that knows Ruby. |
| Project | Relabel raw edges into what the rule is about: files, slices, layers. Drop the rest. | No. |
| Assert | Walk the projected structure, emit a Violation per disagreement. Pure, in-memory. |
No. |
| Report | Turn violations into a test failure, a message, or a rendered diagram. | Only the test-framework glue. |
The consequence worth internalising: almost all the Ruby-specific work is in Extract. If you find yourself rewriting projection or assertion logic in a language-specific way, something has gone wrong — go and look at how a sibling does it.
lib/archunit/common/ the kernel — everything shared
lib/archunit/ extraction/ the dependency-graph extractor <- the only Ruby-specific code
lib/archunit/ projection/ reshaping the graph, plus cycles/
lib/archunit/ assertion/ Violation, EmptyTestViolation
lib/archunit/ fluentapi/ Checkable, CheckOptions
lib/archunit/ error/ TechnicalError, UserError
lib/archunit/ util/ logging, pattern matching, path helpers
lib/archunit/files/ file-level dependency and naming rules
lib/archunit/layers/ named-layer policy
lib/archunit/slices/ component and diagram rules
lib/archunit/metrics/ numeric code-quality rules
lib/archunit/graph/ dependency-graph reports
lib/archunit/testing/ violation formatting and test-framework glue
lib/archunit.rb the public surface — re-exports, nothing else
Every domain module has the same internal shape. This is the single most useful convention in the whole file, because it means reading any one module teaches you how to navigate all of them.
files/
fluentapi/ the builder chain the user types <- the only public part
assertion/ pure: structure -> violations
projection/ module-specific reshaping (optional)
extraction/ module-specific gathering (optional)
calculation/ pure formulas (optional)
assertion, projection and calculation are pure — no filesystem, no clock, no globals. That
is what lets you test them against hand-built fixture graphs before the extractor works at all, and
you should.
Four dependency rules, which are just the library's own architecture rules:
commondepends on nothing but the standard library and the Ruby analysis toolchain.- Domain modules depend on
common. They must not depend on each other. testingdepends oncommonand on the domain modules' violation types. Nothing else.- The public surface depends on everything, and nothing depends on it.
Rule 2 is the one that decays first. files reaching into slices for "just one helper" is the
classic failure — the helper belongs in common/projection instead.
Six types carry everything. Keep the names; adapt the shapes to whatever Ruby wants.
Edge { source, target, external, importKinds }
Graph = list of Edge
MappedEdge { sourceLabel, targetLabel }
ProjectedEdge { sourceLabel, targetLabel, cumulatedEdges }
ProjectedNode { label, incoming, outgoing }
MapFunction = Edge -> MappedEdge | nothing ("nothing" means drop this edge)
Filter { regexp, target, matching, exclusions }
Violation the base type; every rule family adds its own
CheckOptions { allowEmptyTests, logging, clearCache, ...language knobs }
Checkable { check(options?) -> list of Violation }
Things that are easy to get wrong and expensive to fix later:
- Identifiers must be normalised and stable. Pick one convention — separators normalised, and either project-relative throughout or absolute throughout — and never mix. Patterns match against these strings, so inconsistency shows up as baffling rule failures.
- Every file gets a self-edge. That is how a file with no dependencies still appears as a node. Projections filter self-edges out by default; node projection depends on them.
- Parallel edges are merged, unioning their import kinds. Downstream code may assume
(source, target)is unique. - Globs are sugar, regex is the substrate. Every user pattern compiles to a regex at build time in one place. Nothing downstream ever sees a glob.
- Violations carry data, not prose. Message construction lives in
testing, so one place controls formatting, numbering and colour. - Zero matches is a violation, not a pass. A selector matching no files is almost always a typo,
and silently passing is the worst possible outcome.
EmptyTestViolationby default;allowEmptyTeststo opt out. This is the highest-value defensive thing in the library — every terminal needs it. ImportKindis expected to differ per language. Here it is:require,:require_relative,:autoload,:load. This is the one place a language-specific vocabulary is welcome in the shared model.
Checkable is the seam the whole library hangs from. Every terminal implements it, every consumer
programs against it and nothing else. Adding a rule should never require touching the testing layer.
This is the part users see and the part that most needs to feel the same in every language.
Every rule is an English sentence, read left to right:
ENTRY SCOPE MOOD PREDICATE OBJECT TERMINAL
project files in folder should not depend on in folder check
"**/api/**" files "**/db/**"
project files, in folder
**/api/**, should not depend on files in folder**/db/**.
The acceptance test for any new API method: read the whole chain aloud. If it is not a sentence an architect who does not write Ruby would understand, the name is wrong. Not the implementation — the name.
| Stage | How many | Returns |
|---|---|---|
| Entry | exactly 1 | a scope builder |
| Scope | 0..n, chainable, combined with AND | a scope builder |
| Mood | exactly 1 | a positive or negated predicate builder |
| Predicate | exactly 1 | a terminal, or an object builder if the predicate is relational |
| Object | 1..n, chainable | a terminal |
| Terminal | exactly 1 | violations, or a rendered artifact |
Word choice is fixed. Casing is yours. Methods and files are snake_case, classes and modules CamelCase. depend on files becomes depend_on_files.
Entry points are noun phrases — project files (alias files), project slices,
project layers (alias layers), project graph (alias dependency graph), metrics. Each takes
an optional project locator; omitted means auto-detect. Never make it required.
Scope verbs are prepositional, describing where or what — with name, in folder, in path,
in file, for classes matching, defined by, defined by regex, defined by folder.
Mood is exactly two words, should and should not. No synonyms, ever.
Predicates are bare infinitives, so that should + predicate reads as English — have no cycles, have name, be in folder, be in path, adhere to, depend on files, depend on external modules, adhere to diagram, contain dependency, may only depend on layers,
may not depend on layers.
Threshold predicates, metrics only, exactly these six — should be below, should be above,
should be, should be below or equal, should be above or equal, should satisfy. Do not add
should equal, should be at most, should be less than or any other synonym. Synonyms are how a
fluent API stops sounding like one language.
Modifiers are present participles, chainable, order-independent and always optional —
ignoring ..., including ..., focus on, reachable from, dependents of,
collapse to folder depth, collapse by pattern, titled, with check options.
Terminals — check(options?) returning violations is the universal one, plus to <format>,
export as <format>(path) and summary().
Two structural rules behind the grammar:
-
Mood is a boolean flag threaded into the assertion function, not a duplicated set of negative code paths. The positive and negated builders are two thin types over one shared assertion. This is why the negative API costs almost nothing to maintain, and why forking the logic would double the bug surface.
-
Builders are immutable and return new instances. A half-built rule can be stored and branched from:
base = project files, in folder "src/**" ruleA = base, should, have no cycles ruleB = base, should not, depend on files, in folder "**/legacy/**"
| Thing | Convention |
|---|---|
| Directories | Lowercase, singular, unabbreviated, no separators. fluentapi is one word. lowercase, exactly as written above |
| Files | One concept per file, named after the concept: extract_graph.rb, with the spec at spec/common/extraction/extract_graph_spec.rb |
extract <thing> |
gathers data from source |
project <thing> |
reshapes a graph |
per <thing> |
a MapFunction factory — per edge, per internal edge |
slice by <thing> |
a slicing MapFunction factory |
gather <thing> violations |
the pure assertion functions |
matches <thing> |
a boolean predicate |
<thing> matcher |
a Filter factory — filename matcher, folder matcher |
<Thing>Violation |
a violation subtype — always this order, never Violating<Thing> |
<Thing>Options |
an options bag |
<Thing>Factory |
a collection of constructors |
<Thing>Info |
extracted descriptive data — ClassInfo, FileInfo |
<Thing>Builder / <Thing>Condition |
a fluent stage / a fluent terminal |
Keep the file stems the same as the siblings. extract_graph, project_edges, tarjan_scc,
regex_factory. It looks like a small thing and it is worth a lot: a reviewer who knows one port
finds the corresponding file in another in one guess.
Options bags, always. No terminal takes more than one parameter beyond its required argument. Every optional knob goes in a named options type with defaults. That is what lets options be added later without breaking callers.
Two error types, and the distinction is about who is at fault. TechnicalError — the library or
the environment failed. UserError — the API was used wrongly. A failing architecture rule is
neither; it is a Violation in a returned list. Never raise for a rule failure. That is the
assert helper's job, at the very edge.
- Gem
archunit, code underlib/archunit/. Directories mirror the module tree:lib/archunit/common/extraction/extract_graph.rbdefinesArchUnit::Common::Extraction.fluentapistays one word, so the module isFluentApi. - Use Ruby's parentheses-free reading.
shouldandshould_notare argument-less methods, so the chain reads as a sentence with no punctuation noise. That is a real advantage over the siblings — take it. Mood is never a symbol argument. - Extraction uses Prism, the official Ruby parser. Locate the project by
Gemfileor*.gemspec, defaulting to the working directory. - Be honest about dynamic requires. Ruby resolves a lot at runtime. Record what can be resolved statically, and document plainly what cannot, rather than guessing.
- Options are keyword arguments with defaults, collected into a
CheckOptionsvalue object forcheck. - Errors are
ArchUnit::TechnicalErrorandArchUnit::UserError, both< StandardError. A failing rule is a returned array — never a raise. - Testing. RSpec is the target: a
passmatcher used asexpect(rule).to pass, registered on load whenRSpecis defined and silently skipped when it is not. Minitest getsassert_passes(rule). - Node vocabulary. Files stay primary. Ruby constants often span files; do not try to model the constant graph in the first pass.
# frozen_string_literal: trueeverywhere, no monkey-patching of core classes, RuboCop clean.
- Commit straight to
main. No branching ceremony, no required review, no pull requests. There are no users yet and one maintainer. Speed is the point. (This does not apply to ArchUnitTS and ArchUnitPython, which have real users and full process.) - Test the pure parts against fixture graphs, and add one integration test per rule through the fluent API against a sample project. The first is where the coverage comes from; the second proves the wiring.
- Dogfood. Once the files module works, the library should enforce its own architecture rules on itself in its own test suite. The siblings do this and it is the best documentation the project has.
- Issues in this repository cover a large chunk of the planned work. They are starting points, not specifications. If one conflicts with reality in Ruby, say so and we adjust it.
In order, and each step lands in a predictable place:
- Write the sentence first, and check it reads as English. If it does not, the rule or the verb is wrong. Do not proceed.
- Pick the module. New node vocabulary means a new module; otherwise it belongs in an existing one.
- Define the violation type in
<module>/assertion. Data only. - Write
gather <thing> violationsin<module>/assertion. Pure. Handle both moods via the flag. - Add a projection in
<module>/projection, or reuse one fromcommon. - Add the fluent stages in
<module>/fluentapi— the predicate on both builders, and the terminal implementingCheckable. - Wire the empty-test guard into the terminal.
- Teach the violation factory how to phrase it, in
testing. - Test at both levels.
If a step has no obvious home, the rule is probably in the wrong module.