More and more code is written by a model. Tests still tell you it works. RuboCop still tells you it’s tidy. Nothing tells you it still follows your architecture.
I released ArchSpec 1.0 today. It’s an architecture linter for Ruby and Rails. You declare your components and boundaries in one file, and every change gets checked, whether a person or an agent wrote it.
This is part of my push towards making Ruby one of the best languages to build with AI. RubyLLM is one piece. Schematist is another. Making the default Rails job queue fiber-based is another.
People and AIs take shortcuts
An agent or a person that’s in a hurry or doesn’t fully understand your architecture takes shortcuts.
The shortcuts work. They pass tests, implement features, and cross your boundaries. Months later, you realise your beautifully crafted architecture is now a patched mess.
Or, perhaps, you’re starting a new project and you want to ensure that the agent is using a good architecture, without having to police it at every step.
So you write it down in prompts and AGENTS.md. It helps, but it gets buried in its context window and something slips. Again, then again.
RuboCop reads your code and enforces a style. Herb does it for templates. Nothing did it for architecture. Until now.
ArchSpec: your architecture in one file
Declare your components and their rules in an Archspec.rb at the root of the project:
component :models, in: "app/models/**/*.rb"
component :controllers, in: "app/controllers/**/*.rb"
component :services, in: "app/services/**/*.rb"
models.cannot_use :controllers
services.cannot_call :render, :redirect_to, receiver: :none
controllers.can_only_use :models, :services
Then archspec check verifies every change. Models can’t reach into controllers. Domain code can’t touch adapters. Query objects can’t call save!. A pack exposes a public API and keeps everything else private. A directory has to stay empty, and says why.
If you’d rather not write rules at all, start from a preset:
architecture :vanilla_rails
That one line is the 37signals playbook: rich models, no service objects, no form objects, no policy objects, and app/services fails the build with a reason if anything shows up in it. There are presets for Rails, layered, hexagonal, clean architecture, modular monoliths, CQRS, and event-driven too.
Static Analysis, Not AI
I’m the author of RubyLLM so you’d think this uses AI.
Nope. ArchSpec doesn’t use AI.
Here’s how it works: Prism parses your Ruby, then ArchSpec extracts facts, references, inheritance, mixins, calls, definitions, and evaluates your rules against them. It’s deterministic, it’s offline, and it’s fast enough that you’ll leave it on: the full Discourse app, 1,899 files, was checked in 2.5 seconds, without booting the app.
It also won’t guess. ArchSpec doesn’t try to infer the “true” design pattern of arbitrary Ruby. You describe the architecture you want, and it tells you whether the code still matches. The AI is on the other side of the loop, writing the code that gets checked.
Failures an Agent Can Act On
When a rule breaks, you get this:
[error] models must not depend on controllers [dependencies.forbid]
app/models/user.rb:3:5
2 │ def admin_path
→ 3 │ UsersController.admin_path_for(self)
│ ^~~~~~~~~~~~~~~
4 │ end
note: User references UsersController
1 architecture violation found.
The format is a deliberate homage to clang and to Herb. Exact location, the offending span underlined, the evidence as a note, the rule id in brackets so you can suppress it narrowly.
A human reads it at a glance. An agent gets everything it needs to fix its own mistake without asking you: the file, the line, the rule, and why.
What It Caught in RubyLLM
I added ArchSpec checks to RubyLLM 2 months ago, in CI and as a pre-commit hook, and it has been instrumental in the big Protocol/Provider separation that’s coming in RubyLLM 2.0.
A protocol is a wire format, like Chat Completions, Responses, Anthropic’s Messages API, Gemini, or Bedrock Converse. A provider is an account you can talk to, like OpenAI, Azure, DeepSeek, or Ollama.
DeepSeek speaks Chat Completions. VertexAI speaks four: Gemini, Anthropic, Mistral, and Chat Completions. Writing each Protocol once is the reason the gem supports as many providers as it does.
That distinction is easy to state and easy to erode. The shortcut is to put a piece of wire format inside the provider that needs it, because right now that’s the only provider that needs it. Not on my watch:
providers.cannot_reference_constants 'RubyLLM::Protocol'
A provider can subclass a protocol family to change an endpoint or work around a quirk. Subclassing the bare Protocol means it’s inventing a wire format inside an adapter:
[error] providers must not reference RubyLLM::Protocol [constants.forbid]
lib/ruby_llm/providers/elevenlabs/audio.rb:9:21
8 │ # image endpoints, so those seams are left unimplemented.
→ 9 │ class Audio < Protocol
│ ^~~~~~~~
10 │ include ElevenLabs::Models
note: RubyLLM::Providers::ElevenLabs::Audio inherits from Protocol
1 architecture violation found.
This actually happened while I was developing RubyLLM 2.0. The ElevenLabs audio API and the AWS InvokeModel embedding family had become complete wire formats hiding inside provider adapters. Moving them out also deleted extra code that only existed to paper over the misplacement. Win-win.
Here are some more examples from RubyLLM:
Make contracts only static analysis can actually see
chat_protocol_families.must_implement :render_payload, :completion_url, :parse_completion_body
Every protocol family that speaks chat implements those three seams. The base class declares them abstract with define_method, so Ruby only raises at runtime and nothing catches it earlier. ArchSpec sees the definitions. A family that skips a seam fails the build instead of a request.
Keep your naming conventions
protocols.method_names.matching(/\A(serialize|deserialize|to_wire|from_wire)_/)
.forbidden(because: 'serialize with render_*, deserialize with parse_*')
In RubyLLM, serialization methods are called render_*, deserialization parse_*. Exactly the kind of convention an agent breaks, because serialize_payload is a perfectly reasonable name and your rule is 200 lines in AGENTS.md.
Stop slowly decaying API parity
chat.method_names.matching(/\Awith_(?<option>.+)/)
.requires('%<option>s', on: agent, scope: :class,
except: %i[with_temperature with_max_output_tokens])
Agent is a declarative wrapper over Chat, so every Chat#with_x setter needs a matching class-level macro on Agent. Adding the setter is the interesting half. Adding the macro is not. Add Chat#with_verbosity, forget Agent.verbosity, and the build tells you before your users do.
Make promises to your users
domain.cannot_reference_constants 'RubyLLM::ActiveRecord'
require "ruby_llm" works without Rails. That only stays true if the plain-Ruby objects never reach into the Rails integration. Without a check, you find out from a bug report.
Not every codebase needs a spec that long. Chat with Work runs its entire ruleset in one line, architecture :vanilla_rails, because the moat is the product and the code should stay plain and boring.
But Isn’t This Packwerk?
Packwerk is good, and if packs are what you need, use it. ArchSpec covers that case with architecture :modular_monolith, and then keeps going.
Packwerk checks constant references between packages. By design it ignores method calls, and it leans on Zeitwerk to resolve names, which means it’s shaped like a Rails app. ArchSpec checks constant references too, plus calls, inheritance, mixins, required methods, cycles, and naming conventions, in one Ruby file, on any Ruby codebase.
Different scopes for different folks.
Getting It Into Your Codebase
If your app is conventional, a preset is the whole file: architecture :rails, architecture :vanilla_rails, architecture :hexagonal.
If it isn’t, describe your architecture to a coding agent and have it draft the Archspec.rb. Agents are genuinely good at this. They can read the whole tree, they already know what your components are, and the DSL is small. Then read what it wrote, carefully, because the spec is the part you own, then add the parts the agents missed.
If one of our architecture presets is incomplete, or you’d like to add another one, send me an issue or a PR. I want to make this the best architecture linter around.
Existing codebases have existing violations. archspec check --update-todo records them in a todo file, so the build goes green on today’s code and fails on new drift. Work the list down whenever.
Then put it in a pre-commit hook and a CI step. Every provider gem scaffolded by RubyLLM 2.0’s new provider generator is born with its own Archspec.rb, an archspec step in the default rake task, and the hook. New code starts life with an architecture spec the same way it starts with tests.
Keep those pesky agents with --dangerously-skip-permissions accountable.
Where It Came From
This started at RubyConf Austria in May. The AI panel and the hallway conversations around it, mostly with Chad Fowler and José Valim, kept landing in the same place from different directions: if models write the code, the human job concentrates in the decisions above the code. I’ve argued before that engineering is not dead, because accountability isn’t. Vienna sharpened the follow-up. What do you actually use to hold that line?
Flying home, I kept thinking about the tools we already have. So I built one, showed an early version to José, who was encouraging, and released 0.1 quietly in June.
It’s been running on every commit in ArchSpec itself, in RubyLLM, in Chat with Work, and in everything else I’ve made in Ruby ever since.
Every release is torture-tested against pinned checkouts of Discourse, Mastodon, and Basecamp’s Fizzy, where the per-rule diagnostic counts have to match recorded snapshots before anything ships.
Use It
bundle add archspec
bundle exec archspec init
bundle exec archspec check
Docs at archspecrb.dev, source on GitHub. File issues for anything it gets wrong.
Agents can write the code. The architecture is still yours to keep.