why-classes
Not everything needs to be a class.
why-classes is a linter and refactoring tool for Ruby, inspired by Dave Thomas's
talk on the over-use of classes. Ruby gives you modules, composition, Struct, and
Data — but reflex and framework habits push us toward classes even when a simpler
construct would be clearer. This gem finds those "class smells" in a codebase (or a
single file), explains each one, and can rewrite the mechanically-safe ones for you.
It targets Rails codebases (with Rails-aware suppression and advice) but works on plain Ruby with no dependency on Rails or RuboCop.
Under the hood it parses with Prism (via
Prism::Translation::Parser) and rewrites with the parser gem's TreeRewriter, so
detection and autocorrection share one coordinate system.
The rules
| Rule | Smell | Suggests | Autocorrect |
|---|---|---|---|
DataBucket |
attr_* + a field-assigning initialize, no behaviour |
Struct.new / Data.define |
✅ --fix |
StatelessSingletonMethods |
only self. methods, no state |
module + extend self |
advice |
FunctionBucket |
a single call/perform/run, no state |
module_function |
advice |
InvalidInitialState |
initialize leaves a field nil, needs a setter first |
pass it in / drop the object | advice |
InheritanceForMixins |
inheriting a fat base (< ActiveRecord::Base) for behaviour |
include only the mixins you use | advice |
SingleAttributeReader |
a method that only reads one field | a freestanding function | advice (off by default) |
attr_reader-only buckets become immutable Data.define; attr_accessor/attr_writer
buckets (mutation expected) become Struct.new.
Install
gem install why-classes
# or in a Gemfile:
gem "why-classes", group: :development
Usage
why-classes # scan the current directory, report + suggest
why-classes app/models/point.rb # scan one file
why-classes --diff app/ # preview the rewrites as a unified diff
why-classes --fix app/ # apply the safe (DataBucket) rewrites
why-classes --only DataBucket app # run a single rule
why-classes --format json app # machine-readable output
why-classes --list-rules # list every rule
Example:
app/models/point.rb:1:1: [DataBucket] [correctable] Point is a data bucket: it only stores fields and has no behaviour.
Replace the class with:
Point = Data.define(:x, :y)
Data objects are immutable and compare by value; `.new` keeps the same fields.
Options
| Flag | Meaning | |||
|---|---|---|---|---|
--diff, --dry-run |
show a diff of proposed rewrites; write nothing | |||
-a, --fix |
apply suggested (safe-tier) corrections and write files | |||
--fix-unsafe |
also apply unsafe-tier corrections (reserved for future rules) | |||
--only A,B / --except A,B |
select rules | |||
| `--format progress\ | clang\ | json\ | diff` | output format (default progress) |
--config PATH |
use a specific .why-classes.yml |
|||
--[no-]rails |
toggle Rails awareness | |||
| `--fail-level none\ | convention\ | warning` | CI exit-code threshold (default convention) |
Exit codes: 0 clean (or below the fail level), 1 offenses found, 2 usage error.
Configuration
Drop a .why-classes.yml at your project root; it is deep-merged over the defaults.
AllRules:
Include: ["**/*.rb"]
Exclude: ["db/**/*", "vendor/**/*"]
Rails: true
Rules:
DataBucket:
Enabled: true
Autocorrect: true
PreferData: false # true => always emit Data.define, even for accessors
FunctionBucket:
MethodNames: [call, perform, run, execute]
SingleAttributeReader:
Enabled: true # opt in to the noisy one
Inline suppression
# why-classes:disable DataBucket
class LegacyThing # not flagged anywhere in this file
end
class CreateUser # why-classes:disable-line FunctionBucket
def call = :ok
end
Safety
Autocorrection is opt-in and reviewable. --fix only touches DataBucket, the most
mechanical transform, and even then it bails to advice-only when a superclass,
default arguments, extra initialize logic, real behaviour, or metaprogramming is
present. Every rewrite is re-parsed with Prism and discarded if it would produce
invalid Ruby. Converting a class changes its public shape (Data is immutable, ==
becomes value equality) — always review the diff.
Development
bin/setup # or: bundle install
bundle exec rspec
bundle exec exe/why-classes lib # dogfood
License
MIT.