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.