rubyn-code 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +620 -0
- data/db/migrations/000_create_schema_migrations.sql +4 -0
- data/db/migrations/001_create_sessions.sql +16 -0
- data/db/migrations/002_create_messages.sql +16 -0
- data/db/migrations/003_create_tasks.sql +17 -0
- data/db/migrations/004_create_task_dependencies.sql +8 -0
- data/db/migrations/005_create_memories.sql +44 -0
- data/db/migrations/006_create_cost_records.sql +16 -0
- data/db/migrations/007_create_hooks.sql +12 -0
- data/db/migrations/008_create_skills_cache.sql +8 -0
- data/db/migrations/009_create_teams.sql +27 -0
- data/db/migrations/010_create_instincts.sql +15 -0
- data/exe/rubyn-code +6 -0
- data/lib/rubyn_code/agent/conversation.rb +193 -0
- data/lib/rubyn_code/agent/loop.rb +517 -0
- data/lib/rubyn_code/agent/loop_detector.rb +78 -0
- data/lib/rubyn_code/auth/oauth.rb +174 -0
- data/lib/rubyn_code/auth/server.rb +126 -0
- data/lib/rubyn_code/auth/token_store.rb +153 -0
- data/lib/rubyn_code/autonomous/daemon.rb +233 -0
- data/lib/rubyn_code/autonomous/idle_poller.rb +111 -0
- data/lib/rubyn_code/autonomous/task_claimer.rb +100 -0
- data/lib/rubyn_code/background/job.rb +19 -0
- data/lib/rubyn_code/background/notifier.rb +44 -0
- data/lib/rubyn_code/background/worker.rb +146 -0
- data/lib/rubyn_code/cli/app.rb +118 -0
- data/lib/rubyn_code/cli/input_handler.rb +79 -0
- data/lib/rubyn_code/cli/renderer.rb +205 -0
- data/lib/rubyn_code/cli/repl.rb +519 -0
- data/lib/rubyn_code/cli/spinner.rb +100 -0
- data/lib/rubyn_code/cli/stream_formatter.rb +149 -0
- data/lib/rubyn_code/config/defaults.rb +43 -0
- data/lib/rubyn_code/config/project_config.rb +120 -0
- data/lib/rubyn_code/config/settings.rb +127 -0
- data/lib/rubyn_code/context/auto_compact.rb +81 -0
- data/lib/rubyn_code/context/compactor.rb +89 -0
- data/lib/rubyn_code/context/manager.rb +91 -0
- data/lib/rubyn_code/context/manual_compact.rb +87 -0
- data/lib/rubyn_code/context/micro_compact.rb +135 -0
- data/lib/rubyn_code/db/connection.rb +176 -0
- data/lib/rubyn_code/db/migrator.rb +146 -0
- data/lib/rubyn_code/db/schema.rb +106 -0
- data/lib/rubyn_code/hooks/built_in.rb +124 -0
- data/lib/rubyn_code/hooks/registry.rb +99 -0
- data/lib/rubyn_code/hooks/runner.rb +88 -0
- data/lib/rubyn_code/hooks/user_hooks.rb +90 -0
- data/lib/rubyn_code/learning/extractor.rb +191 -0
- data/lib/rubyn_code/learning/injector.rb +138 -0
- data/lib/rubyn_code/learning/instinct.rb +172 -0
- data/lib/rubyn_code/llm/client.rb +218 -0
- data/lib/rubyn_code/llm/message_builder.rb +116 -0
- data/lib/rubyn_code/llm/streaming.rb +203 -0
- data/lib/rubyn_code/mcp/client.rb +139 -0
- data/lib/rubyn_code/mcp/config.rb +83 -0
- data/lib/rubyn_code/mcp/sse_transport.rb +225 -0
- data/lib/rubyn_code/mcp/stdio_transport.rb +196 -0
- data/lib/rubyn_code/mcp/tool_bridge.rb +164 -0
- data/lib/rubyn_code/memory/models.rb +62 -0
- data/lib/rubyn_code/memory/search.rb +181 -0
- data/lib/rubyn_code/memory/session_persistence.rb +194 -0
- data/lib/rubyn_code/memory/store.rb +199 -0
- data/lib/rubyn_code/observability/budget_enforcer.rb +159 -0
- data/lib/rubyn_code/observability/cost_calculator.rb +61 -0
- data/lib/rubyn_code/observability/models.rb +29 -0
- data/lib/rubyn_code/observability/token_counter.rb +42 -0
- data/lib/rubyn_code/observability/usage_reporter.rb +140 -0
- data/lib/rubyn_code/output/diff_renderer.rb +212 -0
- data/lib/rubyn_code/output/formatter.rb +120 -0
- data/lib/rubyn_code/permissions/deny_list.rb +49 -0
- data/lib/rubyn_code/permissions/policy.rb +59 -0
- data/lib/rubyn_code/permissions/prompter.rb +80 -0
- data/lib/rubyn_code/permissions/tier.rb +22 -0
- data/lib/rubyn_code/protocols/interrupt_handler.rb +95 -0
- data/lib/rubyn_code/protocols/plan_approval.rb +67 -0
- data/lib/rubyn_code/protocols/shutdown_handshake.rb +109 -0
- data/lib/rubyn_code/skills/catalog.rb +70 -0
- data/lib/rubyn_code/skills/document.rb +80 -0
- data/lib/rubyn_code/skills/loader.rb +57 -0
- data/lib/rubyn_code/sub_agents/runner.rb +168 -0
- data/lib/rubyn_code/sub_agents/summarizer.rb +57 -0
- data/lib/rubyn_code/tasks/dag.rb +208 -0
- data/lib/rubyn_code/tasks/manager.rb +212 -0
- data/lib/rubyn_code/tasks/models.rb +31 -0
- data/lib/rubyn_code/teams/mailbox.rb +128 -0
- data/lib/rubyn_code/teams/manager.rb +175 -0
- data/lib/rubyn_code/teams/teammate.rb +38 -0
- data/lib/rubyn_code/tools/background_run.rb +41 -0
- data/lib/rubyn_code/tools/base.rb +84 -0
- data/lib/rubyn_code/tools/bash.rb +81 -0
- data/lib/rubyn_code/tools/bundle_add.rb +53 -0
- data/lib/rubyn_code/tools/bundle_install.rb +41 -0
- data/lib/rubyn_code/tools/compact.rb +57 -0
- data/lib/rubyn_code/tools/db_migrate.rb +52 -0
- data/lib/rubyn_code/tools/edit_file.rb +49 -0
- data/lib/rubyn_code/tools/executor.rb +62 -0
- data/lib/rubyn_code/tools/git_commit.rb +97 -0
- data/lib/rubyn_code/tools/git_diff.rb +61 -0
- data/lib/rubyn_code/tools/git_log.rb +59 -0
- data/lib/rubyn_code/tools/git_status.rb +59 -0
- data/lib/rubyn_code/tools/glob.rb +44 -0
- data/lib/rubyn_code/tools/grep.rb +81 -0
- data/lib/rubyn_code/tools/load_skill.rb +41 -0
- data/lib/rubyn_code/tools/memory_search.rb +77 -0
- data/lib/rubyn_code/tools/memory_write.rb +52 -0
- data/lib/rubyn_code/tools/rails_generate.rb +54 -0
- data/lib/rubyn_code/tools/read_file.rb +38 -0
- data/lib/rubyn_code/tools/read_inbox.rb +64 -0
- data/lib/rubyn_code/tools/registry.rb +48 -0
- data/lib/rubyn_code/tools/review_pr.rb +145 -0
- data/lib/rubyn_code/tools/run_specs.rb +75 -0
- data/lib/rubyn_code/tools/schema.rb +59 -0
- data/lib/rubyn_code/tools/send_message.rb +53 -0
- data/lib/rubyn_code/tools/spawn_agent.rb +154 -0
- data/lib/rubyn_code/tools/spawn_teammate.rb +168 -0
- data/lib/rubyn_code/tools/task.rb +148 -0
- data/lib/rubyn_code/tools/web_fetch.rb +108 -0
- data/lib/rubyn_code/tools/web_search.rb +196 -0
- data/lib/rubyn_code/tools/write_file.rb +30 -0
- data/lib/rubyn_code/version.rb +5 -0
- data/lib/rubyn_code.rb +203 -0
- data/skills/code_quality/fits_in_your_head.md +189 -0
- data/skills/code_quality/naming_conventions.md +213 -0
- data/skills/code_quality/null_object.md +205 -0
- data/skills/code_quality/technical_debt.md +135 -0
- data/skills/code_quality/value_objects.md +216 -0
- data/skills/code_quality/yagni.md +176 -0
- data/skills/design_patterns/adapter.md +191 -0
- data/skills/design_patterns/bridge_memento_visitor.md +254 -0
- data/skills/design_patterns/builder.md +158 -0
- data/skills/design_patterns/command.md +126 -0
- data/skills/design_patterns/composite.md +147 -0
- data/skills/design_patterns/decorator.md +204 -0
- data/skills/design_patterns/facade.md +133 -0
- data/skills/design_patterns/factory_method.md +169 -0
- data/skills/design_patterns/iterator.md +116 -0
- data/skills/design_patterns/mediator.md +133 -0
- data/skills/design_patterns/observer.md +177 -0
- data/skills/design_patterns/proxy.md +140 -0
- data/skills/design_patterns/singleton.md +124 -0
- data/skills/design_patterns/state.md +207 -0
- data/skills/design_patterns/strategy.md +127 -0
- data/skills/design_patterns/template_method.md +173 -0
- data/skills/gems/devise.md +365 -0
- data/skills/gems/dry_rb.md +186 -0
- data/skills/gems/factory_bot.md +268 -0
- data/skills/gems/faraday.md +263 -0
- data/skills/gems/graphql_ruby.md +514 -0
- data/skills/gems/pundit.md +446 -0
- data/skills/gems/redis.md +219 -0
- data/skills/gems/rubocop.md +257 -0
- data/skills/gems/sidekiq.md +360 -0
- data/skills/gems/stripe.md +224 -0
- data/skills/minitest/assertions.md +185 -0
- data/skills/minitest/fixtures.md +238 -0
- data/skills/minitest/integration_tests.md +210 -0
- data/skills/minitest/mailers_and_jobs.md +218 -0
- data/skills/minitest/mocking_stubbing.md +202 -0
- data/skills/minitest/service_tests_and_performance.md +246 -0
- data/skills/minitest/structure_and_conventions.md +169 -0
- data/skills/minitest/system_tests.md +237 -0
- data/skills/rails/action_cable.md +160 -0
- data/skills/rails/active_record_basics.md +174 -0
- data/skills/rails/active_storage.md +242 -0
- data/skills/rails/api_design.md +212 -0
- data/skills/rails/associations.md +182 -0
- data/skills/rails/background_jobs.md +212 -0
- data/skills/rails/caching.md +158 -0
- data/skills/rails/callbacks.md +135 -0
- data/skills/rails/concerns_controllers.md +218 -0
- data/skills/rails/concerns_models.md +280 -0
- data/skills/rails/controllers.md +190 -0
- data/skills/rails/engines.md +201 -0
- data/skills/rails/form_objects.md +168 -0
- data/skills/rails/hotwire.md +229 -0
- data/skills/rails/internationalization.md +192 -0
- data/skills/rails/logging.md +198 -0
- data/skills/rails/mailers.md +180 -0
- data/skills/rails/migrations.md +200 -0
- data/skills/rails/multitenancy.md +207 -0
- data/skills/rails/n_plus_one.md +151 -0
- data/skills/rails/presenters.md +244 -0
- data/skills/rails/query_objects.md +177 -0
- data/skills/rails/routing.md +194 -0
- data/skills/rails/scopes.md +187 -0
- data/skills/rails/security.md +233 -0
- data/skills/rails/serializers.md +243 -0
- data/skills/rails/service_objects.md +184 -0
- data/skills/rails/testing_strategy.md +258 -0
- data/skills/rails/validations.md +206 -0
- data/skills/refactoring/code_smells.md +251 -0
- data/skills/refactoring/command_query_separation.md +166 -0
- data/skills/refactoring/encapsulate_collection.md +125 -0
- data/skills/refactoring/extract_class.md +138 -0
- data/skills/refactoring/extract_method.md +185 -0
- data/skills/refactoring/replace_conditional.md +211 -0
- data/skills/refactoring/value_objects.md +246 -0
- data/skills/rspec/build_stubbed.md +199 -0
- data/skills/rspec/factory_design.md +206 -0
- data/skills/rspec/let_vs_let_bang.md +161 -0
- data/skills/rspec/mocking_stubbing.md +209 -0
- data/skills/rspec/request_specs.md +212 -0
- data/skills/rspec/service_specs.md +262 -0
- data/skills/rspec/shared_examples.md +244 -0
- data/skills/rspec/system_specs.md +286 -0
- data/skills/rspec/test_performance.md +215 -0
- data/skills/ruby/blocks_procs_lambdas.md +204 -0
- data/skills/ruby/classes.md +155 -0
- data/skills/ruby/concurrency.md +194 -0
- data/skills/ruby/data_struct_openstruct.md +158 -0
- data/skills/ruby/debugging_profiling.md +204 -0
- data/skills/ruby/enumerable_patterns.md +168 -0
- data/skills/ruby/exception_handling.md +199 -0
- data/skills/ruby/file_io.md +217 -0
- data/skills/ruby/hashes.md +195 -0
- data/skills/ruby/metaprogramming.md +170 -0
- data/skills/ruby/modules.md +210 -0
- data/skills/ruby/pattern_matching.md +177 -0
- data/skills/ruby/regular_expressions.md +166 -0
- data/skills/ruby/result_objects.md +200 -0
- data/skills/ruby/strings.md +177 -0
- data/skills/ruby_project/bundler_dependencies.md +181 -0
- data/skills/ruby_project/cli_tools.md +224 -0
- data/skills/ruby_project/rake_tasks.md +146 -0
- data/skills/ruby_project/structure.md +261 -0
- data/skills/sinatra/application_structure.md +241 -0
- data/skills/sinatra/middleware_and_deployment.md +221 -0
- data/skills/sinatra/testing.md +233 -0
- data/skills/solid/dependency_inversion.md +195 -0
- data/skills/solid/interface_segregation.md +237 -0
- data/skills/solid/liskov_substitution.md +263 -0
- data/skills/solid/open_closed.md +212 -0
- data/skills/solid/single_responsibility.md +183 -0
- metadata +397 -0
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
# Code Smells: Recognition and Remedies
|
|
2
|
+
|
|
3
|
+
## What Are Code Smells?
|
|
4
|
+
|
|
5
|
+
Code smells are surface indicators that usually correspond to deeper design problems. They're not bugs — the code works — but they signal that the code will be increasingly painful to maintain, extend, and test. Recognizing smells is the first step; the refactoring that fixes them is the second.
|
|
6
|
+
|
|
7
|
+
## Bloaters
|
|
8
|
+
|
|
9
|
+
Smells where code has grown too large to work with effectively.
|
|
10
|
+
|
|
11
|
+
### Long Method
|
|
12
|
+
|
|
13
|
+
**Smell:** A method longer than ~10 lines, especially if it has comments explaining sections.
|
|
14
|
+
|
|
15
|
+
```ruby
|
|
16
|
+
# SMELL: 30+ lines doing multiple things
|
|
17
|
+
def process_order(params)
|
|
18
|
+
# Validate
|
|
19
|
+
return error("Missing address") if params[:address].blank?
|
|
20
|
+
return error("No items") if params[:items].empty?
|
|
21
|
+
|
|
22
|
+
# Create order
|
|
23
|
+
order = Order.new(address: params[:address], user: current_user)
|
|
24
|
+
params[:items].each do |item|
|
|
25
|
+
product = Product.find(item[:id])
|
|
26
|
+
order.line_items.build(product: product, quantity: item[:qty], price: product.price)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Calculate totals
|
|
30
|
+
order.subtotal = order.line_items.sum { |li| li.quantity * li.price }
|
|
31
|
+
order.tax = order.subtotal * 0.08
|
|
32
|
+
order.total = order.subtotal + order.tax
|
|
33
|
+
|
|
34
|
+
# Save and notify
|
|
35
|
+
order.save!
|
|
36
|
+
OrderMailer.confirmation(order).deliver_later
|
|
37
|
+
WarehouseService.notify(order)
|
|
38
|
+
order
|
|
39
|
+
end
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
**Fix:** Extract Method. Each comment-delimited section becomes a named method. Or better — each section becomes a service object.
|
|
43
|
+
|
|
44
|
+
### Large Class
|
|
45
|
+
|
|
46
|
+
**Smell:** A class with 200+ lines, 15+ methods, or 7+ instance variables. In Rails, models that include 5+ concerns.
|
|
47
|
+
|
|
48
|
+
**Fix:** Extract Class. Identify clusters of methods that work together and move them into collaborator objects (service objects, value objects, query objects).
|
|
49
|
+
|
|
50
|
+
### Long Parameter List
|
|
51
|
+
|
|
52
|
+
**Smell:** A method with 4+ parameters, especially positional ones.
|
|
53
|
+
|
|
54
|
+
```ruby
|
|
55
|
+
# SMELL
|
|
56
|
+
def create_user(email, name, role, company_name, plan, referral_code, notify)
|
|
57
|
+
|
|
58
|
+
# FIX: Introduce Parameter Object or use keyword arguments
|
|
59
|
+
def create_user(email:, name:, role:, company_name:, plan:, referral_code: nil, notify: true)
|
|
60
|
+
|
|
61
|
+
# BETTER FIX: If parameters are always passed together, create a value object
|
|
62
|
+
RegistrationParams = Data.define(:email, :name, :role, :company_name, :plan, :referral_code)
|
|
63
|
+
def create_user(params, notify: true)
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### Primitive Obsession
|
|
67
|
+
|
|
68
|
+
**Smell:** Using strings, integers, or hashes where a domain object would be clearer.
|
|
69
|
+
|
|
70
|
+
```ruby
|
|
71
|
+
# SMELL: Money as a float, address as a hash
|
|
72
|
+
order.total = 19.99
|
|
73
|
+
order.address = { street: "123 Main", city: "Austin", state: "TX", zip: "78701" }
|
|
74
|
+
|
|
75
|
+
# FIX: Replace Data Value with Object
|
|
76
|
+
order.total = Money.new(19_99, "USD")
|
|
77
|
+
order.address = Address.new(street: "123 Main", city: "Austin", state: "TX", zip: "78701")
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Value objects have behavior — `money.to_s`, `address.full`, `money + other_money` — that primitives don't.
|
|
81
|
+
|
|
82
|
+
## Couplers
|
|
83
|
+
|
|
84
|
+
Smells where classes are too intertwined.
|
|
85
|
+
|
|
86
|
+
### Feature Envy
|
|
87
|
+
|
|
88
|
+
**Smell:** A method that uses more data from another object than from its own.
|
|
89
|
+
|
|
90
|
+
```ruby
|
|
91
|
+
# SMELL: This method on OrderPresenter mostly accesses user attributes
|
|
92
|
+
class OrderPresenter
|
|
93
|
+
def shipping_label(order)
|
|
94
|
+
"#{order.user.name}\n#{order.user.address.street}\n#{order.user.address.city}, #{order.user.address.state} #{order.user.address.zip}"
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# FIX: Move Method — the method belongs on Address or User
|
|
99
|
+
class Address
|
|
100
|
+
def to_label(name)
|
|
101
|
+
"#{name}\n#{street}\n#{city}, #{state} #{zip}"
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Usage
|
|
106
|
+
order.user.address.to_label(order.user.name)
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### Message Chains (Law of Demeter Violation)
|
|
110
|
+
|
|
111
|
+
**Smell:** `order.user.company.billing_address.country.tax_rate` — a long chain of navigating object relationships.
|
|
112
|
+
|
|
113
|
+
```ruby
|
|
114
|
+
# SMELL: Caller knows the entire object graph
|
|
115
|
+
tax_rate = order.user.company.billing_address.country.tax_rate
|
|
116
|
+
|
|
117
|
+
# FIX: Hide Delegate — each object only talks to its immediate neighbors
|
|
118
|
+
class Order
|
|
119
|
+
delegate :tax_rate, to: :user, prefix: false
|
|
120
|
+
|
|
121
|
+
# Or a dedicated method
|
|
122
|
+
def applicable_tax_rate
|
|
123
|
+
user.billing_tax_rate
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
class User
|
|
128
|
+
def billing_tax_rate
|
|
129
|
+
company.billing_tax_rate
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
class Company
|
|
134
|
+
def billing_tax_rate
|
|
135
|
+
billing_address.country_tax_rate
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Usage
|
|
140
|
+
order.applicable_tax_rate
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
### Inappropriate Intimacy
|
|
144
|
+
|
|
145
|
+
**Smell:** Two classes that access each other's internal details excessively.
|
|
146
|
+
|
|
147
|
+
```ruby
|
|
148
|
+
# SMELL: Service reaches into order's internals
|
|
149
|
+
class ShippingCalculator
|
|
150
|
+
def calculate(order)
|
|
151
|
+
weight = order.instance_variable_get(:@total_weight) # Accessing internals!
|
|
152
|
+
order.line_items.each { |li| li.instance_variable_set(:@shipping_cost, weight * 0.5) }
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# FIX: Use public interfaces
|
|
157
|
+
class ShippingCalculator
|
|
158
|
+
def calculate(order)
|
|
159
|
+
weight = order.total_weight # Public method
|
|
160
|
+
weight * shipping_rate_per_kg
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
## Dispensables
|
|
166
|
+
|
|
167
|
+
Smells where something isn't needed.
|
|
168
|
+
|
|
169
|
+
### Dead Code
|
|
170
|
+
|
|
171
|
+
**Smell:** Methods, variables, classes, or branches that are never executed.
|
|
172
|
+
|
|
173
|
+
```ruby
|
|
174
|
+
# SMELL: Method hasn't been called since 2023
|
|
175
|
+
def legacy_import(csv_path)
|
|
176
|
+
# 40 lines of import logic
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# SMELL: Unreachable branch
|
|
180
|
+
def status_label
|
|
181
|
+
case status
|
|
182
|
+
when "active" then "Active"
|
|
183
|
+
when "inactive" then "Inactive"
|
|
184
|
+
when "deleted" then "Deleted" # status is never "deleted" — soft delete uses discarded_at
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
**Fix:** Delete it. Version control has the history if you ever need it. Dead code creates confusion ("is this still used?"), false grep results, and maintenance burden.
|
|
190
|
+
|
|
191
|
+
### Speculative Generality
|
|
192
|
+
|
|
193
|
+
**Smell:** Abstractions, hooks, parameters, or classes that exist "in case we need them later" but have no current use.
|
|
194
|
+
|
|
195
|
+
```ruby
|
|
196
|
+
# SMELL: AbstractNotificationFactory that only has one subclass
|
|
197
|
+
class AbstractNotificationFactory
|
|
198
|
+
def build(type, **opts)
|
|
199
|
+
raise NotImplementedError
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
class EmailNotificationFactory < AbstractNotificationFactory
|
|
204
|
+
def build(type, **opts)
|
|
205
|
+
# ... this is the only implementation
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
**Fix:** Delete the abstraction. When you actually need a second factory, extract the interface then. YAGNI (You Ain't Gonna Need It).
|
|
211
|
+
|
|
212
|
+
### Duplicate Code
|
|
213
|
+
|
|
214
|
+
**Smell:** The same code structure in two or more places.
|
|
215
|
+
|
|
216
|
+
```ruby
|
|
217
|
+
# SMELL: Same pattern in two controllers
|
|
218
|
+
class OrdersController < ApplicationController
|
|
219
|
+
def index
|
|
220
|
+
@orders = current_user.orders
|
|
221
|
+
@orders = @orders.where(status: params[:status]) if params[:status].present?
|
|
222
|
+
@orders = @orders.where("created_at >= ?", params[:from]) if params[:from].present?
|
|
223
|
+
@orders = @orders.page(params[:page])
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
class InvoicesController < ApplicationController
|
|
228
|
+
def index
|
|
229
|
+
@invoices = current_user.invoices
|
|
230
|
+
@invoices = @invoices.where(status: params[:status]) if params[:status].present?
|
|
231
|
+
@invoices = @invoices.where("created_at >= ?", params[:from]) if params[:from].present?
|
|
232
|
+
@invoices = @invoices.page(params[:page])
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
**Fix:** Extract the filtering logic into a query object or a concern that both controllers use:
|
|
238
|
+
|
|
239
|
+
```ruby
|
|
240
|
+
class FilteredQuery
|
|
241
|
+
def self.call(scope, params)
|
|
242
|
+
scope = scope.where(status: params[:status]) if params[:status].present?
|
|
243
|
+
scope = scope.where("created_at >= ?", params[:from]) if params[:from].present?
|
|
244
|
+
scope.page(params[:page])
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
## How Rubyn Uses This
|
|
250
|
+
|
|
251
|
+
When analyzing code, Rubyn identifies these smells and suggests the specific refactoring to fix them. The recommendation always includes the smell name, why it matters, and the concrete transformation — not just "this method is too long."
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# Refactoring: Separate Query from Modifier (CQS)
|
|
2
|
+
|
|
3
|
+
## Pattern
|
|
4
|
+
|
|
5
|
+
A method should either return a value (query) or change state (command), but not both. When a method does both — returns data AND has side effects — split it into two methods. This is the Command-Query Separation (CQS) principle.
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
# BEFORE: Method both modifies state AND returns a value
|
|
9
|
+
class ShoppingCart
|
|
10
|
+
def add_item(product, quantity: 1)
|
|
11
|
+
item = @items.find { |i| i.product == product }
|
|
12
|
+
if item
|
|
13
|
+
item.quantity += quantity
|
|
14
|
+
else
|
|
15
|
+
@items << CartItem.new(product: product, quantity: quantity)
|
|
16
|
+
end
|
|
17
|
+
calculate_total # Returns the new total — side effect + return value mixed
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def remove_expired_items
|
|
21
|
+
expired = @items.select { |item| item.product.expired? }
|
|
22
|
+
@items -= expired
|
|
23
|
+
expired # Returns removed items AND modifies the cart
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Usage is confusing — does this return something? Change something? Both?
|
|
28
|
+
total = cart.add_item(widget)
|
|
29
|
+
removed = cart.remove_expired_items
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
```ruby
|
|
33
|
+
# AFTER: Commands modify state. Queries return data. They don't overlap.
|
|
34
|
+
class ShoppingCart
|
|
35
|
+
# COMMANDS: modify state, return nothing meaningful (or self for chaining)
|
|
36
|
+
def add_item(product, quantity: 1)
|
|
37
|
+
item = @items.find { |i| i.product == product }
|
|
38
|
+
if item
|
|
39
|
+
item.quantity += quantity
|
|
40
|
+
else
|
|
41
|
+
@items << CartItem.new(product: product, quantity: quantity)
|
|
42
|
+
end
|
|
43
|
+
nil # Or `self` for chaining
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def remove_expired_items
|
|
47
|
+
@items.reject! { |item| item.product.expired? }
|
|
48
|
+
nil
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# QUERIES: return data, never modify state
|
|
52
|
+
def total
|
|
53
|
+
@items.sum { |item| item.quantity * item.product.price }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def expired_items
|
|
57
|
+
@items.select { |item| item.product.expired? }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def item_count
|
|
61
|
+
@items.sum(&:quantity)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def empty?
|
|
65
|
+
@items.empty?
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Usage is clear — commands do, queries ask
|
|
70
|
+
cart.add_item(widget, quantity: 2)
|
|
71
|
+
cart.remove_expired_items
|
|
72
|
+
puts cart.total
|
|
73
|
+
puts cart.expired_items.map(&:product_name)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### CQS in Rails
|
|
77
|
+
|
|
78
|
+
```ruby
|
|
79
|
+
# BEFORE: Scope that modifies data (violates CQS)
|
|
80
|
+
class Order < ApplicationRecord
|
|
81
|
+
scope :archive_old, -> {
|
|
82
|
+
where(created_at: ...90.days.ago).update_all(archived: true)
|
|
83
|
+
# This is a command disguised as a scope — scopes should be queries
|
|
84
|
+
}
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# AFTER: Scope queries, service commands
|
|
88
|
+
class Order < ApplicationRecord
|
|
89
|
+
scope :archivable, -> { where(created_at: ...90.days.ago, archived: false) }
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
class Orders::ArchiveService
|
|
93
|
+
def self.call
|
|
94
|
+
count = Order.archivable.update_all(archived: true, archived_at: Time.current)
|
|
95
|
+
Result.new(success: true, count: count)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Usage
|
|
100
|
+
puts "#{Order.archivable.count} orders to archive" # Query
|
|
101
|
+
Orders::ArchiveService.call # Command
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
```ruby
|
|
105
|
+
# BEFORE: Method that checks permission AND logs the attempt
|
|
106
|
+
class Authorization
|
|
107
|
+
def authorized?(user, action)
|
|
108
|
+
allowed = user.permissions.include?(action)
|
|
109
|
+
AuditLog.create!(user: user, action: action, allowed: allowed) # Side effect!
|
|
110
|
+
allowed
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Calling code doesn't expect a query to write to the database
|
|
115
|
+
if auth.authorized?(user, :delete_order) # Surprise! This created an audit record
|
|
116
|
+
order.destroy!
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# AFTER: Separated
|
|
120
|
+
class Authorization
|
|
121
|
+
def authorized?(user, action)
|
|
122
|
+
user.permissions.include?(action)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def check_and_log(user, action)
|
|
126
|
+
allowed = authorized?(user, action)
|
|
127
|
+
AuditLog.create!(user: user, action: action, allowed: allowed)
|
|
128
|
+
allowed
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
## Why This Is Good
|
|
134
|
+
|
|
135
|
+
- **Queries are safe to call anywhere.** If `total` only reads data, calling it in a view, a test, or a debug session never changes state. No surprises.
|
|
136
|
+
- **Commands are explicit about mutation.** When you see `cart.add_item(widget)`, you know state is changing. When you see `cart.total`, you know it's read-only.
|
|
137
|
+
- **Easier to test.** Queries are tested with simple assertions on return values. Commands are tested by checking state before and after. When they're mixed, you have to assert both.
|
|
138
|
+
- **Easier to reason about.** In concurrent systems, queries are safe to parallelize. Commands need synchronization. Knowing which is which matters.
|
|
139
|
+
- **Caching is safe for queries.** You can cache `cart.total` because calling it doesn't change anything. If `total` also triggered a recalculation and saved to the database, caching it would be dangerous.
|
|
140
|
+
|
|
141
|
+
## When To Apply
|
|
142
|
+
|
|
143
|
+
- **Methods that both return a value AND modify state.** These are CQS violations. Split them.
|
|
144
|
+
- **ActiveRecord scopes that modify data.** Scopes should query. Services should command.
|
|
145
|
+
- **Methods named like queries that have side effects.** `user.authorized?` shouldn't write to an audit log. `user.full_name` shouldn't trigger a name parsing service.
|
|
146
|
+
- **APIs where calling a "getter" triggers unexpected behavior.** If reading a property sends an HTTP request, logs to a database, or increments a counter — separate the read from the write.
|
|
147
|
+
|
|
148
|
+
## When NOT To Apply
|
|
149
|
+
|
|
150
|
+
- **`save` and `update` return a boolean.** Rails' `order.save` both modifies state and returns true/false. This is a pragmatic CQS violation that Rails developers expect. Don't fight it.
|
|
151
|
+
- **`pop` and `shift` on arrays.** These both modify the array and return the removed element. They're standard Ruby and universally understood.
|
|
152
|
+
- **Idempotent cache operations.** `Rails.cache.fetch(key) { compute }` both reads and writes, but it's idempotent and universally expected. Don't split it.
|
|
153
|
+
- **The split would make code significantly harder to use.** CQS is a guideline for clarity. If separating a method makes the API confusing, keep them together and document the behavior.
|
|
154
|
+
|
|
155
|
+
## Edge Cases
|
|
156
|
+
|
|
157
|
+
**`find_or_create_by` is a deliberate CQS violation:**
|
|
158
|
+
```ruby
|
|
159
|
+
user = User.find_or_create_by(email: "alice@example.com") do |u|
|
|
160
|
+
u.name = "Alice"
|
|
161
|
+
end
|
|
162
|
+
```
|
|
163
|
+
This queries and potentially creates. It's a Rails convention that everyone understands. Don't wrap it in a service object for CQS purity.
|
|
164
|
+
|
|
165
|
+
**The "Tell, Don't Ask" tension:**
|
|
166
|
+
CQS says "separate queries from commands." Tell Don't Ask says "don't query an object then act on the result — tell the object to act." These can conflict. In practice, CQS applies to individual methods, and Tell Don't Ask applies to object interactions. Both are guidelines, not laws.
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# Refactoring: Encapsulate Collection
|
|
2
|
+
|
|
3
|
+
## Pattern
|
|
4
|
+
|
|
5
|
+
When a class exposes a raw collection (array, hash) through a getter, external code can modify it without the owning class knowing. Encapsulate the collection by providing specific methods for adding, removing, and querying — never exposing the raw collection.
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
# BEFORE: Exposed collection — anyone can mutate it
|
|
9
|
+
class Order
|
|
10
|
+
attr_accessor :line_items
|
|
11
|
+
|
|
12
|
+
def initialize
|
|
13
|
+
@line_items = []
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def total
|
|
17
|
+
@line_items.sum { |item| item.quantity * item.unit_price }
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
order = Order.new
|
|
22
|
+
order.line_items << LineItem.new(quantity: 2, unit_price: 10_00)
|
|
23
|
+
order.line_items.delete_at(0) # External code mutates the collection
|
|
24
|
+
order.line_items = [] # External code replaces the entire collection
|
|
25
|
+
order.line_items.clear # External code empties it
|
|
26
|
+
# The Order has no control over its own state
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
```ruby
|
|
30
|
+
# AFTER: Encapsulated — Order controls all access
|
|
31
|
+
class Order
|
|
32
|
+
def initialize
|
|
33
|
+
@line_items = []
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def add_item(product:, quantity:)
|
|
37
|
+
raise ArgumentError, "Quantity must be positive" unless quantity > 0
|
|
38
|
+
|
|
39
|
+
existing = @line_items.find { |li| li.product == product }
|
|
40
|
+
if existing
|
|
41
|
+
existing.quantity += quantity
|
|
42
|
+
else
|
|
43
|
+
@line_items << LineItem.new(product: product, quantity: quantity, unit_price: product.price)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def remove_item(product)
|
|
48
|
+
@line_items.reject! { |li| li.product == product }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def line_items
|
|
52
|
+
@line_items.dup.freeze # Return a frozen copy — mutations don't affect the original
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def item_count
|
|
56
|
+
@line_items.sum(&:quantity)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def empty?
|
|
60
|
+
@line_items.empty?
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def total
|
|
64
|
+
@line_items.sum { |li| li.quantity * li.unit_price }
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
order = Order.new
|
|
69
|
+
order.add_item(product: widget, quantity: 2) # Controlled: validates, merges duplicates
|
|
70
|
+
order.remove_item(widget) # Controlled: uses Order's own method
|
|
71
|
+
order.line_items # Returns frozen copy — can read but not mutate
|
|
72
|
+
order.line_items << something # FrozenError — can't modify the copy
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Why This Is Good
|
|
76
|
+
|
|
77
|
+
- **Invariants are enforced.** `add_item` validates quantity, merges duplicates, and sets unit price from the product. Raw `<<` skips all of this.
|
|
78
|
+
- **Change notification is possible.** If `add_item` needs to recalculate totals, trigger events, or update caches, it can. Raw mutation bypasses all hooks.
|
|
79
|
+
- **The collection can't be replaced.** No `order.line_items = []` wiping the data. The only way to modify is through the Order's intentional interface.
|
|
80
|
+
- **Frozen copies enable safe reads.** Callers can iterate, map, and filter the returned collection without accidentally modifying the Order's state.
|
|
81
|
+
|
|
82
|
+
## When To Apply
|
|
83
|
+
|
|
84
|
+
- **Any class that owns a collection.** If a class has an `attr_accessor` or `attr_reader` for an Array or Hash, encapsulate it.
|
|
85
|
+
- **When the collection has rules.** No duplicates, maximum size, items must be valid, items must belong to the parent — these rules belong in the owning class, not scattered across callers.
|
|
86
|
+
- **Domain objects and value objects.** `Cart`, `Order`, `Playlist`, `Team` — anything with a "contains items" relationship.
|
|
87
|
+
|
|
88
|
+
## When NOT To Apply
|
|
89
|
+
|
|
90
|
+
- **ActiveRecord associations.** `has_many :line_items` is already encapsulated by Rails with callbacks, validations, and scoping. Don't wrap it in another layer.
|
|
91
|
+
- **Simple data transfer objects.** A Struct or Data class that just carries data doesn't need encapsulation — it's intentionally transparent.
|
|
92
|
+
- **Internal implementation details.** If the collection is only used inside the class and never exposed, encapsulation isn't needed.
|
|
93
|
+
|
|
94
|
+
## Edge Cases
|
|
95
|
+
|
|
96
|
+
**Exposing an iterator instead of the collection:**
|
|
97
|
+
|
|
98
|
+
```ruby
|
|
99
|
+
def each_item(&block)
|
|
100
|
+
@line_items.each(&block)
|
|
101
|
+
end
|
|
102
|
+
include Enumerable # Now Order is iterable but the array isn't exposed
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
**Hash encapsulation:**
|
|
106
|
+
|
|
107
|
+
```ruby
|
|
108
|
+
class Configuration
|
|
109
|
+
def initialize
|
|
110
|
+
@settings = {}
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def set(key, value)
|
|
114
|
+
@settings[key.to_sym] = value
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def get(key, default: nil)
|
|
118
|
+
@settings.fetch(key.to_sym, default)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def to_h
|
|
122
|
+
@settings.dup.freeze
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
```
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# Refactoring: Extract Class
|
|
2
|
+
|
|
3
|
+
## Pattern
|
|
4
|
+
|
|
5
|
+
When a class has too many responsibilities — groups of data and methods that logically belong together — extract them into a new class. The original class delegates to the extracted class.
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
# BEFORE: User model handles profile, settings, AND billing
|
|
9
|
+
class User < ApplicationRecord
|
|
10
|
+
# Profile concern
|
|
11
|
+
def full_name = "#{first_name} #{last_name}"
|
|
12
|
+
def initials = "#{first_name[0]}#{last_name[0]}".upcase
|
|
13
|
+
def display_name = nickname.presence || full_name
|
|
14
|
+
def avatar_url = avatar.attached? ? avatar.url : gravatar_url
|
|
15
|
+
def gravatar_url = "https://gravatar.com/avatar/#{Digest::MD5.hexdigest(email)}"
|
|
16
|
+
|
|
17
|
+
# Billing concern
|
|
18
|
+
def active_subscription = subscriptions.active.last
|
|
19
|
+
def plan_name = active_subscription&.plan || "free"
|
|
20
|
+
def credit_balance = credit_ledger_entries.sum(:amount)
|
|
21
|
+
def can_afford?(credits) = credit_balance >= credits
|
|
22
|
+
def deduct_credits!(amount)
|
|
23
|
+
credit_ledger_entries.create!(amount: -amount, description: "Usage")
|
|
24
|
+
end
|
|
25
|
+
def billing_email = billing_email_override.presence || email
|
|
26
|
+
def billing_address = addresses.find_by(type: "billing")
|
|
27
|
+
|
|
28
|
+
# Settings concern
|
|
29
|
+
def notification_preferences = settings.dig("notifications") || {}
|
|
30
|
+
def email_notifications? = notification_preferences.fetch("email", true)
|
|
31
|
+
def theme = settings.dig("appearance", "theme") || "system"
|
|
32
|
+
def timezone = settings.dig("timezone") || "UTC"
|
|
33
|
+
def locale = settings.dig("locale") || "en"
|
|
34
|
+
end
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
```ruby
|
|
38
|
+
# AFTER: Extracted into focused collaborators
|
|
39
|
+
|
|
40
|
+
class User < ApplicationRecord
|
|
41
|
+
has_one :profile, dependent: :destroy
|
|
42
|
+
has_one :billing_account, dependent: :destroy
|
|
43
|
+
has_one :user_settings, dependent: :destroy
|
|
44
|
+
|
|
45
|
+
delegate :full_name, :initials, :display_name, :avatar_url, to: :profile
|
|
46
|
+
delegate :credit_balance, :can_afford?, :deduct_credits!, :plan_name, to: :billing_account
|
|
47
|
+
delegate :email_notifications?, :theme, :timezone, :locale, to: :user_settings
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
class Profile < ApplicationRecord
|
|
51
|
+
belongs_to :user
|
|
52
|
+
|
|
53
|
+
def full_name = "#{user.first_name} #{user.last_name}"
|
|
54
|
+
def initials = "#{user.first_name[0]}#{user.last_name[0]}".upcase
|
|
55
|
+
def display_name = nickname.presence || full_name
|
|
56
|
+
def avatar_url = avatar.attached? ? avatar.url : gravatar_url
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def gravatar_url = "https://gravatar.com/avatar/#{Digest::MD5.hexdigest(user.email)}"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
class BillingAccount < ApplicationRecord
|
|
64
|
+
belongs_to :user
|
|
65
|
+
has_many :credit_ledger_entries
|
|
66
|
+
has_many :subscriptions
|
|
67
|
+
|
|
68
|
+
def active_subscription = subscriptions.active.last
|
|
69
|
+
def plan_name = active_subscription&.plan || "free"
|
|
70
|
+
def credit_balance = credit_ledger_entries.sum(:amount)
|
|
71
|
+
def can_afford?(credits) = credit_balance >= credits
|
|
72
|
+
|
|
73
|
+
def deduct_credits!(amount)
|
|
74
|
+
credit_ledger_entries.create!(amount: -amount, description: "Usage")
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
class UserSettings < ApplicationRecord
|
|
79
|
+
belongs_to :user
|
|
80
|
+
|
|
81
|
+
def email_notifications? = preferences.dig("notifications", "email") != false
|
|
82
|
+
def theme = preferences.dig("appearance", "theme") || "system"
|
|
83
|
+
def timezone = preferences.dig("timezone") || "UTC"
|
|
84
|
+
def locale = preferences.dig("locale") || "en"
|
|
85
|
+
end
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## Why This Is Good
|
|
89
|
+
|
|
90
|
+
- **Each class has one reason to change.** Billing rule changes touch `BillingAccount`. Display changes touch `Profile`. Notification settings touch `UserSettings`. The `User` model stays stable.
|
|
91
|
+
- **Smaller classes are easier to understand.** `BillingAccount` has 5 methods about billing. Reading it, you grasp the entire billing interface in 30 seconds.
|
|
92
|
+
- **Better testing.** Test `BillingAccount#deduct_credits!` without loading profile logic, settings, or 20 other user methods.
|
|
93
|
+
- **`delegate` maintains the interface.** Callers still call `user.credit_balance`. The extraction is invisible to external code.
|
|
94
|
+
|
|
95
|
+
# Refactoring: Move Method
|
|
96
|
+
|
|
97
|
+
## Pattern
|
|
98
|
+
|
|
99
|
+
When a method uses more features of another class than the class it's defined on, move it to where the data lives.
|
|
100
|
+
|
|
101
|
+
```ruby
|
|
102
|
+
# BEFORE: Method on Order that mostly accesses User data
|
|
103
|
+
class Order < ApplicationRecord
|
|
104
|
+
def customer_summary
|
|
105
|
+
"#{user.name} (#{user.email}) — #{user.plan_name} plan, #{user.orders.count} orders, " \
|
|
106
|
+
"member since #{user.created_at.year}"
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# AFTER: Method moved to User where the data lives
|
|
111
|
+
class User < ApplicationRecord
|
|
112
|
+
def customer_summary
|
|
113
|
+
"#{name} (#{email}) — #{plan_name} plan, #{orders.count} orders, member since #{created_at.year}"
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Order delegates or the caller accesses directly
|
|
118
|
+
order.user.customer_summary
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
## When To Apply Extract Class
|
|
122
|
+
|
|
123
|
+
- **A class has 200+ lines.** Look for clusters of related methods to extract.
|
|
124
|
+
- **You can describe the class with "and."** "User handles authentication AND billing AND settings" → extract billing and settings.
|
|
125
|
+
- **Multiple developers frequently edit the same file.** Different teams own different responsibilities → different classes.
|
|
126
|
+
- **A group of methods share the same instance variables.** Methods that all use `@subscription` and `@credit_entries` are a billing class waiting to be extracted.
|
|
127
|
+
|
|
128
|
+
## When To Apply Move Method
|
|
129
|
+
|
|
130
|
+
- **Feature Envy.** A method references another object 3+ times and its own object 0-1 times.
|
|
131
|
+
- **After Extract Class.** Once you identify a cluster, move the methods to the new class.
|
|
132
|
+
- **When adding `delegate` chains.** If `User` delegates 5 methods to `BillingAccount` and then adds `billing_` prefix methods, maybe those callers should reference `BillingAccount` directly.
|
|
133
|
+
|
|
134
|
+
## When NOT To Apply
|
|
135
|
+
|
|
136
|
+
- **Don't extract prematurely.** A User model with 80 lines and 8 methods is fine. Extract when it grows past 150-200 lines or when the clusters become obvious.
|
|
137
|
+
- **Don't create single-method classes.** A `UserGreeter` with just `def greet` is over-extraction. The method can live on User.
|
|
138
|
+
- **Delegate is fine for 3-5 methods.** If User delegates 15 methods to a single collaborator, callers should reference the collaborator directly.
|