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 (safe) |
StatelessSingletonMethods |
only self. methods, no state |
module + extend self
|
⚠️ --fix-unsafe
|
FunctionBucket |
a single call/perform/run, no state |
module_function |
⚠️ --fix-unsafe
|
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 | ⚠️ --fix-unsafe (off by default) |
Autocorrect tiers. --fix applies only the safe tier (DataBucket): a
self-contained, within-file transform. The unsafe tier (StatelessSingletonMethods,
FunctionBucket, SingleAttributeReader) is mechanically correct in the file but can
break call sites in files this tool never saw — Foo.new, subclassing, Foo.new.call,
or person.age becoming age(person) — so it is opt-in via --fix-unsafe (which
implies --fix). Always review the diff. Run --list-rules to see each rule's tier.
SingleAttributeReader extracts an instance method that only reads one field into a
freestanding function (Thomas's "decoupled polymorphism"). Two ParameterStyles:
# before # entity (default) # attribute
class Person def age_in_seconds(entity) def age_in_seconds(date_of_birth)
def age_in_seconds => Time.now - Time.now - date_of_birth
Time.now - @date_of_birth entity.date_of_birth end
end end
endattr_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: :developmentUsage
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 --fix-unsafe app/ # also rewrite stateless classes -> modules
why-classes --only DataBucket app # run a single rule
why-classes --cache app/ # cache results for faster re-scans
why-classes --format json app # machine-readable output
why-classes --list-rules # list every ruleExample:
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 safe-tier corrections (DataBucket) and write files |
--fix-unsafe |
also apply unsafe-tier corrections (module / module_function); implies --fix
|
--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) |
--cache / --cache-dir PATH
|
cache results on disk for faster re-scans (report modes only) |
--list-rules |
list every rule with its tier ([--fix] / [--fix-unsafe] / [advice]) |
-v, --version
|
print the version and exit |
-h, --help
|
print usage and exit |
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 oneInline suppression
# why-classes:disable DataBucket
class LegacyThing # not flagged anywhere in this file
end
class CreateUser # why-classes:disable-line FunctionBucket
def call = :ok
endSafety
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 # dogfoodLicense
MIT.