0.0
The project is in a healthy, maintained state
why-classes is a linter and refactoring tool inspired by Dave Thomas's talk on the over-use of classes in Ruby. It scans a Ruby/Rails codebase (or a single file) for "class smells" -- stateless singleton-method classes, function buckets, invalid initial state, fat-base inheritance, and data buckets -- and reports each one with a concrete refactoring suggestion toward modules, composition, Struct or Data. The mechanically-safe refactorings (notably data bucket -> Struct/Data) can be applied automatically with --fix.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Runtime

>= 3.2
>= 1.0
 Project Readme

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
end

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 --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 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 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 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.