Sitemap

Ruby Architecture Testing Compared: Why I’d Choose ArchUnitRuby

ArchUnitRuby, Packwerk, ArchSpec, and Rubyzen compared on dependency coverage, everyday testing workflow, and runtime.

Press enter or click to view image in full size
Black-and-white cover: Ruby architecture. Put it to the test. A practical comparison of ArchUnitRuby, Packwerk, ArchSpec, and Rubyzen on dependency coverage, testing workflow, and runtime.

“Please don’t call the storage layer from the API.”

A comment like that is an architecture rule. If it keeps appearing in code review, the team is still enforcing it by memory.

I want the next shortcut to fail a test instead.

But which Ruby library should enforce that rule? A tool that understands constant references may not follow explicit file loads. A source-code predicate may find a forbidden name without building the graph needed to detect a cycle. Runtime matters too: checks need to be practical to keep running.

I compared ArchUnitRuby 1.0.0, Packwerk 3.3.1, ArchSpec 1.1.0, and Rubyzen 0.3.0. For teams that want file imports, known project constant references, and cycle checks in their ordinary Ruby tests, ArchUnitRuby is my recommendation. Its expanded mode was the only tested configuration to catch all eleven seeded violations. On the largest timing workload, it also finished ahead of ArchSpec and Packwerk.

Rubyzen’s custom predicate was faster there. That result stays in the comparison; the question is what you want the library to do for you.

Disclosure: I created and maintain ArchUnitRuby. The pinned versions, configurations, results, and limitations below are the basis for this recommendation.

The Ruby libraries in this comparison

These four tools represent different approaches to architecture checks: graph-based test rules, package boundaries, semantic component rules, and custom source predicates.

  • ArchUnitRuby 1.0.0 — Architecture rules as ordinary Ruby tests. Combines file-load and opt-in constant-reference edges with reusable dependency, cycle, layer, and slice rules; also offers diagram contracts, reports, and metrics
  • Packwerk 3.3.1 — Declared package dependencies in Rails/Zeitwerk applications. Package-boundary enforcement and a workflow for managing an application’s package dependencies
  • ArchSpec 1.1.0 — Semantic component rules and source conventions through a CLI. Constant references, inheritance, mixins, component cycles, and richer method/convention rules
  • Rubyzen 0.3.0 — Queryable source facts and user-written rules in tests. Custom syntactic and call-site checks expressed as Ruby predicates

Packwerk, ArchSpec, and Rubyzen each have useful capabilities beyond the particular boundary checked here. The choice is about which combination best serves the team’s testing workflow.

Coverage: the boundary should survive a change in syntax

In the fixture, API code must not depend on retrieval internals. The shortcut can be an explicit file load:

require_relative '../retrieval/store'

Or a constant reference, with no explicit require:

Retrieval::Store.new

For that policy, both relationships matter. A file can load another file for its side effects without using its constants. An autoloaded application can reference a constant without explicitly loading its file.

The coverage comparison separates these cases rather than putting both shortcuts in one file and assuming every tool found the same dependency:

Press enter or click to view image in full size
Selected fixture matrix: ArchUnitRuby expanded catches the seven shown violation forms, covering imports, constants, inheritance, mixins and both cycle kinds. Other tested configurations cover subsets. Unimplemented cycle checks are labeled not checked. All five configurations pass the clean control.
Selected coverage probes; expanded constant analysis is opt-in. Rubyzen used custom predicates. Full suite: 15 cases across five configurations.

ArchUnitRuby’s expanded mode caught all eleven seeded violations, spanning explicit imports, known local-string imports, constant references, inheritance, mixins, and cycles built from imports or constants. All configurations passed the clean and comment/string controls.

That is the main reason for my recommendation: the same library-provided dependency graph supports both kinds of boundary evidence and both cycle cases in this suite. The team does not have to implement a second resolver or a cycle algorithm to connect those checks.

The distinctions in the other results matter:

  • ArchSpec caught the constant-reference, inheritance, mixin, and constant-cycle cases. It did not flag the file-load-only relationships in these configurations.
  • Packwerk caught the constant-boundary cases on a real, minimal Rails/Zeitwerk application. Its native dependency check was not scored as a file-cycle detector.
  • Rubyzen exposed source facts to custom predicates that caught literal require/load paths and explicitly qualified retrieval constants. Those predicates did not implement semantic constant resolution or graph-cycle detection. Additional custom code could extend them; that is different from getting those graph rules from the library.

Two configuration details are essential. Constant-reference analysis in ArchUnitRuby is opt-in, through dependency_analysis: :imports_and_constants. Known local-string imports are recognized in default import-only mode too. The require and load probes used explicit project-local load paths for the fixture's model roots.

Get Tristan Kruse’s stories in your inbox

Join Medium for free to get updates from this writer.

The eleven cases are a small, controlled suite — not an overall accuracy percentage. An environment-driven require path remained unresolved by all tested boundary rules. ArchUnitRuby, ArchSpec, and Packwerk also rejected the malformed-source fixture; our Rubyzen predicate passed that fixture.

Useful architecture rules belong in the normal test run

The coverage advantage is useful only if the team keeps running the checks.

For an RSpec or Minitest team, a test-native library puts architecture in the same feedback loop as the rest of the code. Developers run their tests during a refactor; CI reports a broken boundary alongside other failures. The policy is Ruby code, versioned and reviewed with the application.

Here is the boundary as an ArchUnitRuby test:

require 'rspec'
require 'archunit'
RSpec.describe 'architecture' do
it 'keeps API code away from retrieval internals' do
options = ArchUnit::CheckOptions.new(
dependency_analysis: :imports_and_constants
)
rule = ArchUnit.project_files
.in_path('app/api/**')
.should_not.depend_on_files
.in_path('app/retrieval/**')
ArchUnit.assert_passes(rule, options)
end
end

Run it with the normal suite. A supported forbidden import or resolved constant reference fails the same architecture rule. There is no separate check to remember when using that test run, and no application boot is needed to inspect those dependencies.

Rubyzen also integrates with tests, and CLI tools integrate well with CI. ArchUnitRuby’s advantage is the combination: ordinary tests plus reusable graph-based rules, rather than test integration alone.

As the requirements grow, the library also provides layer and slice policies, checked-in PlantUML contracts, reports, and structural metrics through the same API. Those are documented capabilities, not extra checks included in the timing benchmark. Some alternatives cover adjacent needs differently, including the broader Packwerk ecosystem.

Performance at 100, 1,000, and 5,000 files

I measured one common policy on generated projects containing 100, 1,000, and 5,000 Ruby files, split between API and retrieval packages. The timed policy forbids API-to-retrieval constant dependencies.

Before timing each size, every tool had to accept the clean project and reject exactly one planted cross-boundary reference. Both test-native tools ran one RSpec example. ArchSpec used its CLI check; Packwerk used its native check including a minimal Rails boot. ArchUnitRuby used expanded analysis, not the narrower import-only mode.

On the 5,000-file project, ArchUnitRuby recorded a 6.83-second median, compared with 17.40 seconds for ArchSpec and 21.38 seconds for Packwerk. Rubyzen’s custom source predicate was faster at 4.80 seconds. For this workload, ArchUnitRuby combines the file-load and constant-reference coverage demonstrated above with a shorter end-to-end run than the two CLI-based setups. This is not an overall product ranking.

Press enter or click to view image in full size
5,000-file workload median seconds: Rubyzen custom source predicate 4.80, ArchUnitRuby expanded 6.83, ArchSpec 17.40, Packwerk 21.38. Lower is better. RSpec is included for both test-native tools; Packwerk includes Rails boot.
5,000 Ruby files; median of ten fresh-process runs. Packwerk includes Rails boot; Rubyzen uses a custom source predicate.
Press enter or click to view image in full size
Median seconds at 100, 1,000 and 5,000 Ruby files: Rubyzen custom predicate 1.13, 1.64, 4.80; ArchUnitRuby expanded 1.08, 1.73, 6.83; ArchSpec 0.76, 1.85, 17.40; Packwerk 10.64, 12.63, 21.38. Ten fresh-process runs per size; Packwerk includes Rails boot.
Median end-to-end seconds at all three sizes. Measured 27 October 2026; lower is better.

Rubyzen’s faster predicate answered a narrower question: did this source contain a forbidden path or an explicitly named retrieval constant? That was sufficient for the planted violation in this timing fixture. A team wanting resolved file edges and reusable cycle checks is asking for more than our predicate implemented; that is where I would choose the library-provided graph rules.

For ArchUnitRuby, the useful result is that expanded dependency analysis remained practical while covering the broader set of dependency forms in the separate fixtures. It was not fastest everywhere: ArchSpec had the lowest median at 100 files, and Rubyzen’s predicate was fastest at 5,000. The recommendation rests on the combination of coverage, ready-made rules, workflow, and runtime.

Measurement details

Each number is the median of ten fresh-process runs after a warm-up, with randomized order. Measurements were taken on 27 October 2026 using Windows 11 and Ruby 4.0.6. Packwerk’s persistent cache was disabled and its execution was serial. Operating-system file caches were not flushed; some initial validation passes took substantially longer. These are warmed-filesystem results, not new-CI-checkout timings.

The raw captures retain the commands, outputs, activated dependency versions, samples, and ranges. This is one synthetic workload, not an isolated parser contest, a complete architecture-suite benchmark, or a prediction for a production Rails application. The coverage probes are separate from the timing corpus.

Why ArchUnitRuby is my recommended choice

For a team that wants file-load and constant-reference boundaries, reusable cycle checks, and rules in ordinary Ruby tests, ArchUnitRuby is the best fit among these four options.

It brings together three things that matter for that use case:

  1. The tested coverage. Expanded mode was the only configuration in this comparison to catch all eleven seeded violations, including the known local-string import and both kinds of cycle.
  2. Less rule machinery for the team to own. Dependency resolution and graph rules come from the library. The team writes its policy and can use the same API for layers, slices, diagrams, reports, and metrics as needed.
  3. Competitive measured runtime. On the 5,000-file policy, it finished ahead of the ArchSpec and Packwerk setups, with expanded analysis, graph construction, and the RSpec run included.

There are still reasons to choose another tool. A team already invested in Packwerk’s package workflow may prefer to keep it. ArchSpec may be a better match when method or source-convention rules are central. Rubyzen is attractive when the requirement is a bespoke syntactic check rather than a ready-made graph policy.

ArchUnitRuby’s constant lookup also has documented limits: ambiguous or reopened definitions, alias traversal, inherited constant members, and dynamic definitions can remain unresolved. Dynamic require paths are not evaluated. In a long-lived watch process, use the documented cache-clearing option after source edits. Those limits matter when deciding whether this model fits an application.

For the dependency-and-cycle testing workflow compared here, though, I would choose ArchUnitRuby. It puts the architectural decision in a test and covers the different ways this suite expresses that dependency — without asking the team to build the analyzer first.

Install archunit, enable expanded analysis when constant references matter, and protect one boundary your team is tired of defending in code review.

Related: The Ruby Code Worked. The Architecture Didn’t.