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,185 @@
|
|
|
1
|
+
# Refactoring: Extract Method
|
|
2
|
+
|
|
3
|
+
## Pattern
|
|
4
|
+
|
|
5
|
+
When a method is too long or a code fragment needs a comment to explain what it does, extract that fragment into a method whose name explains the intent. The extracted method replaces the comment.
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
# BEFORE: Long method with inline comments explaining sections
|
|
9
|
+
class Orders::InvoiceGenerator
|
|
10
|
+
def generate(order)
|
|
11
|
+
# Calculate line item totals
|
|
12
|
+
line_totals = order.line_items.map do |item|
|
|
13
|
+
{
|
|
14
|
+
name: item.product.name,
|
|
15
|
+
quantity: item.quantity,
|
|
16
|
+
unit_price: item.unit_price,
|
|
17
|
+
total: item.quantity * item.unit_price
|
|
18
|
+
}
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Calculate subtotal
|
|
22
|
+
subtotal = line_totals.sum { |lt| lt[:total] }
|
|
23
|
+
|
|
24
|
+
# Apply discount if applicable
|
|
25
|
+
discount = 0
|
|
26
|
+
if order.discount_code.present?
|
|
27
|
+
discount_record = Discount.find_by(code: order.discount_code)
|
|
28
|
+
if discount_record&.active?
|
|
29
|
+
discount = case discount_record.discount_type
|
|
30
|
+
when "percentage" then subtotal * (discount_record.value / 100.0)
|
|
31
|
+
when "fixed" then discount_record.value
|
|
32
|
+
else 0
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Calculate tax
|
|
38
|
+
tax_rate = TaxRate.for(order.shipping_address.state)
|
|
39
|
+
tax = (subtotal - discount) * tax_rate
|
|
40
|
+
|
|
41
|
+
# Build invoice
|
|
42
|
+
{
|
|
43
|
+
order_reference: order.reference,
|
|
44
|
+
line_items: line_totals,
|
|
45
|
+
subtotal: subtotal,
|
|
46
|
+
discount: discount,
|
|
47
|
+
tax: tax,
|
|
48
|
+
total: subtotal - discount + tax,
|
|
49
|
+
generated_at: Time.current
|
|
50
|
+
}
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
```ruby
|
|
56
|
+
# AFTER: Each section extracted into a named method
|
|
57
|
+
class Orders::InvoiceGenerator
|
|
58
|
+
def generate(order)
|
|
59
|
+
line_totals = itemize(order.line_items)
|
|
60
|
+
subtotal = sum_totals(line_totals)
|
|
61
|
+
discount = calculate_discount(order.discount_code, subtotal)
|
|
62
|
+
tax = calculate_tax(order.shipping_address, subtotal - discount)
|
|
63
|
+
|
|
64
|
+
build_invoice(order, line_totals:, subtotal:, discount:, tax:)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def itemize(line_items)
|
|
70
|
+
line_items.map do |item|
|
|
71
|
+
{
|
|
72
|
+
name: item.product.name,
|
|
73
|
+
quantity: item.quantity,
|
|
74
|
+
unit_price: item.unit_price,
|
|
75
|
+
total: item.quantity * item.unit_price
|
|
76
|
+
}
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def sum_totals(line_totals)
|
|
81
|
+
line_totals.sum { |lt| lt[:total] }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def calculate_discount(code, subtotal)
|
|
85
|
+
return 0 if code.blank?
|
|
86
|
+
|
|
87
|
+
discount = Discount.active.find_by(code: code)
|
|
88
|
+
return 0 unless discount
|
|
89
|
+
|
|
90
|
+
case discount.discount_type
|
|
91
|
+
when "percentage" then subtotal * (discount.value / 100.0)
|
|
92
|
+
when "fixed" then discount.value
|
|
93
|
+
else 0
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def calculate_tax(address, taxable_amount)
|
|
98
|
+
rate = TaxRate.for(address.state)
|
|
99
|
+
taxable_amount * rate
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def build_invoice(order, line_totals:, subtotal:, discount:, tax:)
|
|
103
|
+
{
|
|
104
|
+
order_reference: order.reference,
|
|
105
|
+
line_items: line_totals,
|
|
106
|
+
subtotal: subtotal,
|
|
107
|
+
discount: discount,
|
|
108
|
+
tax: tax,
|
|
109
|
+
total: subtotal - discount + tax,
|
|
110
|
+
generated_at: Time.current
|
|
111
|
+
}
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## Why This Is Good
|
|
117
|
+
|
|
118
|
+
- **The public method reads like a summary.** `generate` is 5 lines that describe the algorithm at a high level: itemize, sum, discount, tax, build. You can understand the entire flow without reading implementation details.
|
|
119
|
+
- **Each private method has one purpose and a descriptive name.** `calculate_discount` replaces 8 lines and a comment. The method name IS the comment — and it can't go stale.
|
|
120
|
+
- **Independently testable.** You can test `calculate_discount` with various codes, types, and amounts without generating an entire invoice.
|
|
121
|
+
- **Reusable.** If another part of the app needs discount calculation, `calculate_discount` is available. Inline code in a long method is not.
|
|
122
|
+
- **Safe to refactor further.** `calculate_discount` is now isolated. Replacing the case statement with polymorphism is straightforward.
|
|
123
|
+
|
|
124
|
+
## Related Refactoring: Replace Temp with Query
|
|
125
|
+
|
|
126
|
+
When a temporary variable holds a computed value that could be a method call, replace the variable with a method. This makes the computation reusable and the code more readable.
|
|
127
|
+
|
|
128
|
+
```ruby
|
|
129
|
+
# BEFORE: Temporary variables
|
|
130
|
+
def price
|
|
131
|
+
base_price = quantity * unit_price
|
|
132
|
+
discount_factor = if base_price > 1000
|
|
133
|
+
0.95
|
|
134
|
+
elsif base_price > 500
|
|
135
|
+
0.98
|
|
136
|
+
else
|
|
137
|
+
1.0
|
|
138
|
+
end
|
|
139
|
+
base_price * discount_factor
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# AFTER: Replace temps with query methods
|
|
143
|
+
def price
|
|
144
|
+
base_price * discount_factor
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
private
|
|
148
|
+
|
|
149
|
+
def base_price
|
|
150
|
+
quantity * unit_price
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def discount_factor
|
|
154
|
+
if base_price > 1000
|
|
155
|
+
0.95
|
|
156
|
+
elsif base_price > 500
|
|
157
|
+
0.98
|
|
158
|
+
else
|
|
159
|
+
1.0
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
## When To Apply
|
|
165
|
+
|
|
166
|
+
- **A method is longer than 10 lines.** Extract until the public method is a readable summary.
|
|
167
|
+
- **You write a comment to explain a section.** The comment is the method name. Extract the section and delete the comment.
|
|
168
|
+
- **The same code fragment appears in multiple methods.** Extract once, call from both places.
|
|
169
|
+
- **A conditional body is more than 2-3 lines.** Extract the body into a method named for what it does, not how:
|
|
170
|
+
|
|
171
|
+
```ruby
|
|
172
|
+
# Before
|
|
173
|
+
if order.total > 500 && order.user.loyalty_tier == :gold && !order.used_promo?
|
|
174
|
+
# ... 5 lines applying VIP discount
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# After
|
|
178
|
+
apply_vip_discount(order) if eligible_for_vip_discount?(order)
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
## When NOT To Apply
|
|
182
|
+
|
|
183
|
+
- **The method is already 3-5 clear lines.** Don't extract a 2-line block into a method for purity. Extract for clarity, not for line count.
|
|
184
|
+
- **The extracted method would need 5+ parameters.** Too many parameters suggest the method needs an object, not an extraction. Consider an Introduce Parameter Object refactoring first.
|
|
185
|
+
- **The code is only used once and is already clear.** Extraction adds a level of indirection. If the inline code reads naturally, leave it.
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
# Refactoring: Replace Conditional with Polymorphism
|
|
2
|
+
|
|
3
|
+
## Pattern
|
|
4
|
+
|
|
5
|
+
When a `case/when` or `if/elsif` chain switches on a type to determine behavior, replace it with polymorphic objects. Each branch becomes a class that implements the same interface.
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
# BEFORE: case/when switches on type to determine pricing
|
|
9
|
+
class SubscriptionBiller
|
|
10
|
+
def monthly_charge(subscription)
|
|
11
|
+
case subscription.plan
|
|
12
|
+
when "free"
|
|
13
|
+
0
|
|
14
|
+
when "pro"
|
|
15
|
+
19_00
|
|
16
|
+
when "team"
|
|
17
|
+
base = 49_00
|
|
18
|
+
extra_seats = [subscription.seats - 5, 0].max
|
|
19
|
+
base + (extra_seats * 10_00)
|
|
20
|
+
when "enterprise"
|
|
21
|
+
custom_price = subscription.negotiated_price
|
|
22
|
+
custom_price || 199_00
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def usage_limit(subscription)
|
|
27
|
+
case subscription.plan
|
|
28
|
+
when "free" then 30
|
|
29
|
+
when "pro" then 1_000
|
|
30
|
+
when "team" then 5_000
|
|
31
|
+
when "enterprise" then Float::INFINITY
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def features(subscription)
|
|
36
|
+
case subscription.plan
|
|
37
|
+
when "free" then [:basic_ai]
|
|
38
|
+
when "pro" then [:basic_ai, :pro_mode, :export]
|
|
39
|
+
when "team" then [:basic_ai, :pro_mode, :export, :team_sharing, :admin_panel]
|
|
40
|
+
when "enterprise" then [:basic_ai, :pro_mode, :export, :team_sharing, :admin_panel, :sso, :audit_log]
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
```ruby
|
|
47
|
+
# AFTER: Each plan is a class with its own behavior
|
|
48
|
+
module Plans
|
|
49
|
+
class Free
|
|
50
|
+
def monthly_charge(_subscription) = 0
|
|
51
|
+
def usage_limit = 30
|
|
52
|
+
def features = [:basic_ai]
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
class Pro
|
|
56
|
+
def monthly_charge(_subscription) = 19_00
|
|
57
|
+
def usage_limit = 1_000
|
|
58
|
+
def features = [:basic_ai, :pro_mode, :export]
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
class Team
|
|
62
|
+
def monthly_charge(subscription)
|
|
63
|
+
base = 49_00
|
|
64
|
+
extra_seats = [subscription.seats - 5, 0].max
|
|
65
|
+
base + (extra_seats * 10_00)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def usage_limit = 5_000
|
|
69
|
+
def features = [:basic_ai, :pro_mode, :export, :team_sharing, :admin_panel]
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
class Enterprise
|
|
73
|
+
def monthly_charge(subscription)
|
|
74
|
+
subscription.negotiated_price || 199_00
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def usage_limit = Float::INFINITY
|
|
78
|
+
def features = [:basic_ai, :pro_mode, :export, :team_sharing, :admin_panel, :sso, :audit_log]
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
REGISTRY = {
|
|
82
|
+
"free" => Free.new,
|
|
83
|
+
"pro" => Pro.new,
|
|
84
|
+
"team" => Team.new,
|
|
85
|
+
"enterprise" => Enterprise.new
|
|
86
|
+
}.freeze
|
|
87
|
+
|
|
88
|
+
def self.for(plan_name)
|
|
89
|
+
REGISTRY.fetch(plan_name)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Subscription delegates to its plan
|
|
94
|
+
class Subscription < ApplicationRecord
|
|
95
|
+
def plan_object
|
|
96
|
+
Plans.for(plan)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def monthly_charge
|
|
100
|
+
plan_object.monthly_charge(self)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def usage_limit
|
|
104
|
+
plan_object.usage_limit
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def features
|
|
108
|
+
plan_object.features
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## Why This Is Good
|
|
114
|
+
|
|
115
|
+
- **Adding a new plan doesn't modify existing code.** A "Starter" plan means one new class. `Free`, `Pro`, `Team`, and `Enterprise` are untouched.
|
|
116
|
+
- **All behavior for one plan is in one place.** Open `Plans::Team` to see pricing, limits, and features together. No scanning across three `case` statements.
|
|
117
|
+
- **Each plan is independently testable.** `Plans::Team.new.monthly_charge(sub_with_10_seats)` — no branching, no other plans involved.
|
|
118
|
+
- **Eliminates the "parallel case statements" code smell.** Three methods all switching on `subscription.plan` is a sign that `plan` wants to be an object.
|
|
119
|
+
|
|
120
|
+
# Refactoring: Replace Nested Conditional with Guard Clauses
|
|
121
|
+
|
|
122
|
+
## Pattern
|
|
123
|
+
|
|
124
|
+
When a method has deep nesting or complex conditional logic, use guard clauses to handle edge cases and error conditions early, leaving the main logic un-nested.
|
|
125
|
+
|
|
126
|
+
```ruby
|
|
127
|
+
# BEFORE: Deep nesting
|
|
128
|
+
def process_payment(order)
|
|
129
|
+
if order.present?
|
|
130
|
+
if order.total > 0
|
|
131
|
+
if order.user.payment_method.present?
|
|
132
|
+
if order.user.payment_method.valid?
|
|
133
|
+
result = PaymentGateway.charge(order.user.payment_method, order.total)
|
|
134
|
+
if result.success?
|
|
135
|
+
order.update!(paid: true)
|
|
136
|
+
{ success: true, transaction_id: result.id }
|
|
137
|
+
else
|
|
138
|
+
{ success: false, error: result.error_message }
|
|
139
|
+
end
|
|
140
|
+
else
|
|
141
|
+
{ success: false, error: "Invalid payment method" }
|
|
142
|
+
end
|
|
143
|
+
else
|
|
144
|
+
{ success: false, error: "No payment method on file" }
|
|
145
|
+
end
|
|
146
|
+
else
|
|
147
|
+
{ success: false, error: "Order total must be positive" }
|
|
148
|
+
end
|
|
149
|
+
else
|
|
150
|
+
{ success: false, error: "Order not found" }
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
```ruby
|
|
156
|
+
# AFTER: Guard clauses handle edge cases first
|
|
157
|
+
def process_payment(order)
|
|
158
|
+
return { success: false, error: "Order not found" } unless order
|
|
159
|
+
return { success: false, error: "Order total must be positive" } unless order.total > 0
|
|
160
|
+
return { success: false, error: "No payment method on file" } unless order.user.payment_method
|
|
161
|
+
return { success: false, error: "Invalid payment method" } unless order.user.payment_method.valid?
|
|
162
|
+
|
|
163
|
+
result = PaymentGateway.charge(order.user.payment_method, order.total)
|
|
164
|
+
|
|
165
|
+
return { success: false, error: result.error_message } unless result.success?
|
|
166
|
+
|
|
167
|
+
order.update!(paid: true)
|
|
168
|
+
{ success: true, transaction_id: result.id }
|
|
169
|
+
end
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
## Why This Is Good
|
|
173
|
+
|
|
174
|
+
- **Linear reading.** Each guard clause handles one error and returns. After all guards pass, the happy path runs with no nesting. You read top to bottom, not inside-out.
|
|
175
|
+
- **The happy path is at the natural indentation level.** No 5-level-deep nesting to find the actual business logic. The important code stands out visually.
|
|
176
|
+
- **Each guard is independent.** Adding a new validation (e.g., "order not already paid") means adding one `return unless` line, not wrapping another `if` around everything.
|
|
177
|
+
- **Easier to test.** Each guard clause corresponds to one test case. The tests mirror the guard order.
|
|
178
|
+
|
|
179
|
+
## When To Apply
|
|
180
|
+
|
|
181
|
+
- **Nested conditionals deeper than 2 levels.** If you're at 3+ levels of `if`, guards will flatten it.
|
|
182
|
+
- **Multiple preconditions before the main logic.** Auth checks, validation, null checks — these are guards.
|
|
183
|
+
- **The "else" branches are error handling.** If every `else` returns an error, those are guard clauses waiting to be extracted.
|
|
184
|
+
- **Case statements that switch on a type.** 3+ branches with distinct behavior → polymorphism. 2 branches → maybe keep the conditional.
|
|
185
|
+
|
|
186
|
+
## When NOT To Apply
|
|
187
|
+
|
|
188
|
+
- **Simple if/else with balanced branches.** `if premium? then charge(19) else charge(0) end` — both branches are the "main logic," not guards.
|
|
189
|
+
- **Two types that will never grow.** Boolean branching (`if active?`) rarely benefits from polymorphism.
|
|
190
|
+
- **The conditional is already clear at one level of nesting.** Don't refactor for refactoring's sake.
|
|
191
|
+
|
|
192
|
+
## Edge Cases
|
|
193
|
+
|
|
194
|
+
**Guard clauses in Rails controllers:**
|
|
195
|
+
|
|
196
|
+
```ruby
|
|
197
|
+
def update
|
|
198
|
+
@order = current_user.orders.find_by(id: params[:id])
|
|
199
|
+
return head :not_found unless @order
|
|
200
|
+
return head :forbidden unless @order.editable?
|
|
201
|
+
|
|
202
|
+
if @order.update(order_params)
|
|
203
|
+
redirect_to @order
|
|
204
|
+
else
|
|
205
|
+
render :edit, status: :unprocessable_entity
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
**Combining both refactorings:**
|
|
211
|
+
First flatten with guard clauses, then extract polymorphism for the remaining branching logic. Guard clauses handle preconditions; polymorphism handles type-based behavior.
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
# Refactoring: Replace Primitive with Value Object
|
|
2
|
+
|
|
3
|
+
## Pattern
|
|
4
|
+
|
|
5
|
+
When primitives (strings, integers, floats) carry domain meaning, replace them with value objects that encapsulate the value, its validation, and its behavior. This eliminates scattered validation logic and gives you a natural place for formatting, comparison, and conversion methods.
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
# BEFORE: Money as cents integer, scattered formatting
|
|
9
|
+
class Order < ApplicationRecord
|
|
10
|
+
def formatted_total
|
|
11
|
+
"$#{format('%.2f', total / 100.0)}"
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def total_with_tax
|
|
15
|
+
total + (total * 0.08).round
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
class Invoice
|
|
20
|
+
def formatted_amount
|
|
21
|
+
"$#{format('%.2f', amount_cents / 100.0)}" # Same logic, different variable name
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# AFTER: Money value object
|
|
26
|
+
class Money
|
|
27
|
+
include Comparable
|
|
28
|
+
attr_reader :cents, :currency
|
|
29
|
+
|
|
30
|
+
def initialize(cents, currency = "USD")
|
|
31
|
+
@cents = Integer(cents)
|
|
32
|
+
@currency = currency.to_s.upcase.freeze
|
|
33
|
+
freeze
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.from_dollars(dollars, currency = "USD")
|
|
37
|
+
new((Float(dollars) * 100).round, currency)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def to_f
|
|
41
|
+
cents / 100.0
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def to_s
|
|
45
|
+
"$#{format('%.2f', to_f)}"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def +(other)
|
|
49
|
+
assert_same_currency!(other)
|
|
50
|
+
self.class.new(cents + other.cents, currency)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def -(other)
|
|
54
|
+
assert_same_currency!(other)
|
|
55
|
+
self.class.new(cents - other.cents, currency)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def *(multiplier)
|
|
59
|
+
self.class.new((cents * multiplier).round, currency)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def <=>(other)
|
|
63
|
+
return nil unless other.is_a?(Money) && currency == other.currency
|
|
64
|
+
cents <=> other.cents
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def zero?
|
|
68
|
+
cents.zero?
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def positive?
|
|
72
|
+
cents.positive?
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def assert_same_currency!(other)
|
|
78
|
+
raise ArgumentError, "Currency mismatch: #{currency} vs #{other.currency}" unless currency == other.currency
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Usage — clean, safe, reusable
|
|
83
|
+
price = Money.new(19_99)
|
|
84
|
+
tax = price * 0.08
|
|
85
|
+
total = price + tax
|
|
86
|
+
total.to_s # => "$21.59"
|
|
87
|
+
total > Money.new(20_00) # => true
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
```ruby
|
|
91
|
+
# BEFORE: Email as a string, validated in multiple places
|
|
92
|
+
class User < ApplicationRecord
|
|
93
|
+
validates :email, format: { with: URI::MailTo::EMAIL_REGEXP }
|
|
94
|
+
before_validation { self.email = email&.downcase&.strip }
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
class Invite < ApplicationRecord
|
|
98
|
+
validates :recipient_email, format: { with: URI::MailTo::EMAIL_REGEXP }
|
|
99
|
+
before_validation { self.recipient_email = recipient_email&.downcase&.strip }
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# AFTER: Email value object
|
|
103
|
+
class Email
|
|
104
|
+
REGEXP = URI::MailTo::EMAIL_REGEXP
|
|
105
|
+
|
|
106
|
+
attr_reader :address
|
|
107
|
+
|
|
108
|
+
def initialize(raw)
|
|
109
|
+
@address = raw.to_s.downcase.strip.freeze
|
|
110
|
+
raise ArgumentError, "Invalid email: #{raw}" unless valid?
|
|
111
|
+
freeze
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def valid?
|
|
115
|
+
REGEXP.match?(@address)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def domain
|
|
119
|
+
@address.split("@").last
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def to_s = @address
|
|
123
|
+
def ==(other) = other.is_a?(Email) && address == other.address
|
|
124
|
+
alias_method :eql?, :==
|
|
125
|
+
def hash = address.hash
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Usage
|
|
129
|
+
email = Email.new(" Alice@Example.COM ")
|
|
130
|
+
email.to_s # => "alice@example.com"
|
|
131
|
+
email.domain # => "example.com"
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
# Refactoring: Introduce Parameter Object
|
|
135
|
+
|
|
136
|
+
## Pattern
|
|
137
|
+
|
|
138
|
+
When the same group of parameters is passed together to multiple methods, bundle them into an object.
|
|
139
|
+
|
|
140
|
+
```ruby
|
|
141
|
+
# BEFORE: Same 3 params passed everywhere
|
|
142
|
+
def search_orders(start_date, end_date, status)
|
|
143
|
+
Order.where(created_at: start_date..end_date, status: status)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def export_orders(start_date, end_date, status, format)
|
|
147
|
+
orders = search_orders(start_date, end_date, status)
|
|
148
|
+
# ...
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def count_orders(start_date, end_date, status)
|
|
152
|
+
search_orders(start_date, end_date, status).count
|
|
153
|
+
end
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
```ruby
|
|
157
|
+
# AFTER: Parameter object bundles related params
|
|
158
|
+
class DateRange
|
|
159
|
+
attr_reader :start_date, :end_date
|
|
160
|
+
|
|
161
|
+
def initialize(start_date:, end_date:)
|
|
162
|
+
@start_date = start_date.to_date
|
|
163
|
+
@end_date = end_date.to_date
|
|
164
|
+
raise ArgumentError, "start must be before end" if @start_date > @end_date
|
|
165
|
+
freeze
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def to_range
|
|
169
|
+
start_date..end_date
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def days
|
|
173
|
+
(end_date - start_date).to_i
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def include?(date)
|
|
177
|
+
to_range.include?(date)
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
OrderFilter = Data.define(:date_range, :status) do
|
|
182
|
+
def to_scope(base = Order.all)
|
|
183
|
+
scope = base.where(created_at: date_range.to_range)
|
|
184
|
+
scope = scope.where(status: status) if status.present?
|
|
185
|
+
scope
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Usage — clean, validated, reusable
|
|
190
|
+
filter = OrderFilter.new(
|
|
191
|
+
date_range: DateRange.new(start_date: 30.days.ago, end_date: Date.today),
|
|
192
|
+
status: "pending"
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
orders = filter.to_scope
|
|
196
|
+
count = filter.to_scope.count
|
|
197
|
+
export = Orders::Exporter.call(filter.to_scope, format: :csv)
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
## Why This Is Good
|
|
201
|
+
|
|
202
|
+
- **Validation in one place.** `Money.new(-100)` is valid (a refund). `Email.new("not-valid")` raises immediately. No scattered regex checks.
|
|
203
|
+
- **Behavior on the object.** `money + other_money` handles currency matching. `email.domain` extracts the domain. Primitives have none of this.
|
|
204
|
+
- **Type safety through construction.** If a method accepts a `Money`, you know it's a valid integer of cents with a currency. If it accepts an `Integer`, it could be anything.
|
|
205
|
+
- **Eliminates duplicated formatting.** `money.to_s` always returns `"$19.99"`. No more `"$#{format('%.2f', cents / 100.0)}"` repeated in 12 views.
|
|
206
|
+
- **Comparable, hashable, freezable.** Value objects work as hash keys, in Sets, and in sorted collections. Primitives require manual comparison logic.
|
|
207
|
+
|
|
208
|
+
## When To Apply
|
|
209
|
+
|
|
210
|
+
- **The same primitive has validation logic in 2+ places.** Email format, money formatting, phone number parsing — extract once.
|
|
211
|
+
- **The same group of parameters travels together.** `start_date, end_date` → `DateRange`. `street, city, state, zip` → `Address`.
|
|
212
|
+
- **Arithmetic or comparison on the primitive.** If you add, subtract, or compare cents in 5 places, a Money object centralizes the logic.
|
|
213
|
+
- **A method has 4+ parameters.** Look for parameter groups to bundle.
|
|
214
|
+
|
|
215
|
+
## When NOT To Apply
|
|
216
|
+
|
|
217
|
+
- **A string that's just a string.** A user's `name` field doesn't need a `Name` value object unless you need parsing (first/last) or validation logic.
|
|
218
|
+
- **One-off usage.** If a date range is used in exactly one query, inlining `where(created_at: start..end)` is fine.
|
|
219
|
+
- **Don't create value objects for configuration.** `timeout: 30` doesn't need a `Timeout` value object.
|
|
220
|
+
|
|
221
|
+
## Edge Cases
|
|
222
|
+
|
|
223
|
+
**Value objects as ActiveRecord attributes:**
|
|
224
|
+
Use `composed_of` or custom attribute types:
|
|
225
|
+
|
|
226
|
+
```ruby
|
|
227
|
+
class Order < ApplicationRecord
|
|
228
|
+
composed_of :total_money,
|
|
229
|
+
class_name: "Money",
|
|
230
|
+
mapping: [%w[total_cents cents], %w[currency currency]]
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
order.total_money # => Money(1999, "USD")
|
|
234
|
+
order.total_money.to_s # => "$19.99"
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
**Ruby 3.2+ `Data` class for simple value objects:**
|
|
238
|
+
|
|
239
|
+
```ruby
|
|
240
|
+
Point = Data.define(:x, :y)
|
|
241
|
+
point = Point.new(x: 1, y: 2)
|
|
242
|
+
point.x # => 1
|
|
243
|
+
point.frozen? # => true
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
`Data.define` is perfect for simple value objects that don't need custom behavior beyond attribute access.
|