{"slug": "archspec-executable-architecture-specification-for-ruby-s-agentic-coding-era", "title": "ArchSpec: Executable Architecture Specification for Ruby's Agentic Coding Era", "summary": "ArchSpec 1.0, an architecture linter for Ruby and Rails, was released today by developer Enrique Comba Riepenhausen, who also created RubyLLM. The tool allows developers to declare components and boundaries in a single file and checks every code change against those rules, whether written by a person or an AI agent. It uses static analysis via Prism, not AI, and can check the full Discourse app (1,899 files) in 2.5 seconds without booting the app.", "body_md": "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.\n\nI released [ArchSpec](https://archspecrb.dev) 1.0 today. It’s an architecture linter for [Ruby](https://www.ruby-lang.org/en/) and [Rails](https://rubyonrails.org). You declare your components and boundaries in one file, and every change gets checked, whether a person or an agent wrote it.\n\n*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.*\n\n## People and AIs take shortcuts\n\nAn agent or a person that’s in a hurry or doesn’t fully understand your architecture takes shortcuts.\n\nThe shortcuts work. They pass tests, implement features, and *cross your boundaries*. Months later, you realise your beautifully crafted architecture is now a patched mess.\n\nOr, 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.\n\nSo you write it down in prompts and `AGENTS.md`\n\n. It helps, but it gets buried in its context window and something slips. Again, then again.\n\nRuboCop reads your code and enforces a style. [Herb](https://herb-tools.dev) does it for templates. Nothing did it for architecture. Until now.\n\n## ArchSpec: your architecture in one file\n\nDeclare your components and their rules in an `Archspec.rb`\n\nat the root of the project:\n\n```\ncomponent :models, in: \"app/models/**/*.rb\"\ncomponent :controllers, in: \"app/controllers/**/*.rb\"\ncomponent :services, in: \"app/services/**/*.rb\"\n\nmodels.cannot_use :controllers\nservices.cannot_call :render, :redirect_to, receiver: :none\ncontrollers.can_only_use :models, :services\n```\n\nThen `archspec check`\n\nverifies every change. Models can’t reach into controllers. Domain code can’t touch adapters. Query objects can’t call `save!`\n\n. A pack exposes a public API and keeps everything else private. A directory has to stay empty, and says why.\n\nIf you’d rather not write rules at all, start from a preset:\n\n```\narchitecture :vanilla_rails\n```\n\nThat one line is the 37signals playbook: rich models, no service objects, no form objects, no policy objects, and `app/services`\n\nfails 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.\n\n## Static Analysis, Not AI\n\nI’m the author of [RubyLLM](https://rubyllm.com) so you’d think this uses AI.\n\nNope. *ArchSpec doesn’t use AI*.\n\nHere’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.\n\nPrism is its only runtime dependency. No Rails, no ActiveSupport, nothing else, so it works on any Ruby codebase. RubyLLM is a plain gem and it’s been the main proving ground since June.\n\nIt 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.\n\n## Failures an Agent Can Act On\n\nWhen a rule breaks, you get this:\n\n```\n[error] models must not depend on controllers [dependencies.forbid]\n\napp/models/user.rb:3:5\n\n    2 │   def admin_path\n  → 3 │     UsersController.admin_path_for(self)\n      │     ^~~~~~~~~~~~~~~\n    4 │   end\n\n  note: User references UsersController\n\n1 architecture violation found.\n```\n\nThe 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.\n\nA 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.\n\n## What It Caught in RubyLLM\n\nI added ArchSpec checks to [RubyLLM](https://rubyllm.com) [2 months ago](https://github.com/crmne/ruby_llm/commit/f1cf3b0e92e3e9244a94a2c1b5c7d4f2716d2aae), 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.\n\nA 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.\n\nDeepSeek 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.\n\nThat 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:\n\n```\nproviders.cannot_reference_constants 'RubyLLM::Protocol'\n```\n\nA provider can subclass a protocol family to change an endpoint or work around a quirk. Subclassing the bare `Protocol`\n\nmeans it’s inventing a wire format inside an adapter:\n\n```\n[error] providers must not reference RubyLLM::Protocol [constants.forbid]\n\nlib/ruby_llm/providers/elevenlabs/audio.rb:9:21\n\n     8 │       # image endpoints, so those seams are left unimplemented.\n  →  9 │       class Audio < Protocol\n       │                     ^~~~~~~~\n    10 │         include ElevenLabs::Models\n\n  note: RubyLLM::Providers::ElevenLabs::Audio inherits from Protocol\n\n1 architecture violation found.\n```\n\nThis 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.\n\nHere are some more examples from RubyLLM:\n\n### Make contracts only static analysis can actually see\n\n```\nchat_protocol_families.must_implement :render_payload, :completion_url, :parse_completion_body\n```\n\nEvery protocol family that speaks chat implements those three seams. The base class declares them abstract with `define_method`\n\n, 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.\n\n### Keep your naming conventions\n\n```\nprotocols.method_names.matching(/\\A(serialize|deserialize|to_wire|from_wire)_/)\n         .forbidden(because: 'serialize with render_*, deserialize with parse_*')\n```\n\nIn RubyLLM, serialization methods are called `render_*`\n\n, deserialization `parse_*`\n\n. Exactly the kind of convention an agent breaks, because `serialize_payload`\n\nis a perfectly reasonable name and your rule is 200 lines in `AGENTS.md`\n\n.\n\n### Stop slowly decaying API parity\n\n```\nchat.method_names.matching(/\\Awith_(?<option>.+)/)\n    .requires('%<option>s', on: agent, scope: :class,\n              except: %i[with_temperature with_max_output_tokens])\n```\n\n`Agent`\n\nis a declarative wrapper over `Chat`\n\n, so every `Chat#with_x`\n\nsetter needs a matching class-level macro on `Agent`\n\n. Adding the setter is the interesting half. Adding the macro is not. Add `Chat#with_verbosity`\n\n, forget `Agent.verbosity`\n\n, and the build tells you before your users do.\n\n### Make promises to your users\n\n```\ndomain.cannot_reference_constants 'RubyLLM::ActiveRecord'\n```\n\n`require \"ruby_llm\"`\n\nworks 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.\n\nNot every codebase needs a spec that long. [Chat with Work](https://chatwithwork.com) runs its entire ruleset in one line, `architecture :vanilla_rails`\n\n, because the moat is the product and the code should stay plain and boring.\n\n## But Isn’t This Packwerk?\n\n[Packwerk](https://github.com/Shopify/packwerk) is good, and if packs are what you need, use it. ArchSpec covers that case with `architecture :modular_monolith`\n\n, and then keeps going.\n\nPackwerk 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.\n\nDifferent scopes for different folks.\n\n## Getting It Into Your Codebase\n\nIf your app is conventional, a preset is the whole file: `architecture :rails`\n\n, `architecture :vanilla_rails`\n\n, `architecture :hexagonal`\n\n.\n\nIf it isn’t, describe your architecture to a coding agent and have it draft the `Archspec.rb`\n\n. 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.\n\nIf one of our `architecture`\n\npresets is incomplete, or you’d like to add another one, send me an [issue](https://github.com/crmne/archspec/issues) or a [PR](https://github.com/crmne/archspec/pulls). I want to make this the best architecture linter around.\n\nExisting codebases have existing violations. `archspec check --update-todo`\n\nrecords them in a todo file, so the build goes green on today’s code and fails on new drift. Work the list down whenever.\n\nThen 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`\n\n, 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.\n\nKeep those pesky agents with `--dangerously-skip-permissions`\n\naccountable.\n\n## Where It Came From\n\nThis started at RubyConf Austria in May. The [AI panel](https://radan.dev/news/ruby-conf-at) 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](/engineering-is-not-dead/). Vienna sharpened the follow-up. What do you actually use to hold that line?\n\nFlying 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.\n\nIt’s been running on every commit in ArchSpec itself, in RubyLLM, in [Chat with Work](https://chatwithwork.com), and in everything else I’ve made in Ruby ever since.\n\nEvery 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.\n\n## Use It\n\n```\nbundle add archspec\nbundle exec archspec init\nbundle exec archspec check\n```\n\nDocs at [archspecrb.dev](https://archspecrb.dev), source on [GitHub](https://github.com/crmne/archspec). File issues for anything it gets wrong.\n\nAgents can write the code. The architecture is still yours to keep.", "url": "https://wpnews.pro/news/archspec-executable-architecture-specification-for-ruby-s-agentic-coding-era", "canonical_source": "https://paolino.me/archspec/", "published_at": "2026-08-21 10:23:29+00:00", "updated_at": "2026-08-21 10:44:09.075423+00:00", "lang": "en", "topics": ["developer-tools", "ai-agents"], "entities": ["ArchSpec", "Ruby", "Rails", "RubyLLM", "Prism", "Discourse", "Enrique Comba Riepenhausen"], "alternates": {"html": "https://wpnews.pro/news/archspec-executable-architecture-specification-for-ruby-s-agentic-coding-era", "markdown": "https://wpnews.pro/news/archspec-executable-architecture-specification-for-ruby-s-agentic-coding-era.md", "text": "https://wpnews.pro/news/archspec-executable-architecture-specification-for-ruby-s-agentic-coding-era.txt", "jsonld": "https://wpnews.pro/news/archspec-executable-architecture-specification-for-ruby-s-agentic-coding-era.jsonld"}}