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.
Files changed (235) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +620 -0
  4. data/db/migrations/000_create_schema_migrations.sql +4 -0
  5. data/db/migrations/001_create_sessions.sql +16 -0
  6. data/db/migrations/002_create_messages.sql +16 -0
  7. data/db/migrations/003_create_tasks.sql +17 -0
  8. data/db/migrations/004_create_task_dependencies.sql +8 -0
  9. data/db/migrations/005_create_memories.sql +44 -0
  10. data/db/migrations/006_create_cost_records.sql +16 -0
  11. data/db/migrations/007_create_hooks.sql +12 -0
  12. data/db/migrations/008_create_skills_cache.sql +8 -0
  13. data/db/migrations/009_create_teams.sql +27 -0
  14. data/db/migrations/010_create_instincts.sql +15 -0
  15. data/exe/rubyn-code +6 -0
  16. data/lib/rubyn_code/agent/conversation.rb +193 -0
  17. data/lib/rubyn_code/agent/loop.rb +517 -0
  18. data/lib/rubyn_code/agent/loop_detector.rb +78 -0
  19. data/lib/rubyn_code/auth/oauth.rb +174 -0
  20. data/lib/rubyn_code/auth/server.rb +126 -0
  21. data/lib/rubyn_code/auth/token_store.rb +153 -0
  22. data/lib/rubyn_code/autonomous/daemon.rb +233 -0
  23. data/lib/rubyn_code/autonomous/idle_poller.rb +111 -0
  24. data/lib/rubyn_code/autonomous/task_claimer.rb +100 -0
  25. data/lib/rubyn_code/background/job.rb +19 -0
  26. data/lib/rubyn_code/background/notifier.rb +44 -0
  27. data/lib/rubyn_code/background/worker.rb +146 -0
  28. data/lib/rubyn_code/cli/app.rb +118 -0
  29. data/lib/rubyn_code/cli/input_handler.rb +79 -0
  30. data/lib/rubyn_code/cli/renderer.rb +205 -0
  31. data/lib/rubyn_code/cli/repl.rb +519 -0
  32. data/lib/rubyn_code/cli/spinner.rb +100 -0
  33. data/lib/rubyn_code/cli/stream_formatter.rb +149 -0
  34. data/lib/rubyn_code/config/defaults.rb +43 -0
  35. data/lib/rubyn_code/config/project_config.rb +120 -0
  36. data/lib/rubyn_code/config/settings.rb +127 -0
  37. data/lib/rubyn_code/context/auto_compact.rb +81 -0
  38. data/lib/rubyn_code/context/compactor.rb +89 -0
  39. data/lib/rubyn_code/context/manager.rb +91 -0
  40. data/lib/rubyn_code/context/manual_compact.rb +87 -0
  41. data/lib/rubyn_code/context/micro_compact.rb +135 -0
  42. data/lib/rubyn_code/db/connection.rb +176 -0
  43. data/lib/rubyn_code/db/migrator.rb +146 -0
  44. data/lib/rubyn_code/db/schema.rb +106 -0
  45. data/lib/rubyn_code/hooks/built_in.rb +124 -0
  46. data/lib/rubyn_code/hooks/registry.rb +99 -0
  47. data/lib/rubyn_code/hooks/runner.rb +88 -0
  48. data/lib/rubyn_code/hooks/user_hooks.rb +90 -0
  49. data/lib/rubyn_code/learning/extractor.rb +191 -0
  50. data/lib/rubyn_code/learning/injector.rb +138 -0
  51. data/lib/rubyn_code/learning/instinct.rb +172 -0
  52. data/lib/rubyn_code/llm/client.rb +218 -0
  53. data/lib/rubyn_code/llm/message_builder.rb +116 -0
  54. data/lib/rubyn_code/llm/streaming.rb +203 -0
  55. data/lib/rubyn_code/mcp/client.rb +139 -0
  56. data/lib/rubyn_code/mcp/config.rb +83 -0
  57. data/lib/rubyn_code/mcp/sse_transport.rb +225 -0
  58. data/lib/rubyn_code/mcp/stdio_transport.rb +196 -0
  59. data/lib/rubyn_code/mcp/tool_bridge.rb +164 -0
  60. data/lib/rubyn_code/memory/models.rb +62 -0
  61. data/lib/rubyn_code/memory/search.rb +181 -0
  62. data/lib/rubyn_code/memory/session_persistence.rb +194 -0
  63. data/lib/rubyn_code/memory/store.rb +199 -0
  64. data/lib/rubyn_code/observability/budget_enforcer.rb +159 -0
  65. data/lib/rubyn_code/observability/cost_calculator.rb +61 -0
  66. data/lib/rubyn_code/observability/models.rb +29 -0
  67. data/lib/rubyn_code/observability/token_counter.rb +42 -0
  68. data/lib/rubyn_code/observability/usage_reporter.rb +140 -0
  69. data/lib/rubyn_code/output/diff_renderer.rb +212 -0
  70. data/lib/rubyn_code/output/formatter.rb +120 -0
  71. data/lib/rubyn_code/permissions/deny_list.rb +49 -0
  72. data/lib/rubyn_code/permissions/policy.rb +59 -0
  73. data/lib/rubyn_code/permissions/prompter.rb +80 -0
  74. data/lib/rubyn_code/permissions/tier.rb +22 -0
  75. data/lib/rubyn_code/protocols/interrupt_handler.rb +95 -0
  76. data/lib/rubyn_code/protocols/plan_approval.rb +67 -0
  77. data/lib/rubyn_code/protocols/shutdown_handshake.rb +109 -0
  78. data/lib/rubyn_code/skills/catalog.rb +70 -0
  79. data/lib/rubyn_code/skills/document.rb +80 -0
  80. data/lib/rubyn_code/skills/loader.rb +57 -0
  81. data/lib/rubyn_code/sub_agents/runner.rb +168 -0
  82. data/lib/rubyn_code/sub_agents/summarizer.rb +57 -0
  83. data/lib/rubyn_code/tasks/dag.rb +208 -0
  84. data/lib/rubyn_code/tasks/manager.rb +212 -0
  85. data/lib/rubyn_code/tasks/models.rb +31 -0
  86. data/lib/rubyn_code/teams/mailbox.rb +128 -0
  87. data/lib/rubyn_code/teams/manager.rb +175 -0
  88. data/lib/rubyn_code/teams/teammate.rb +38 -0
  89. data/lib/rubyn_code/tools/background_run.rb +41 -0
  90. data/lib/rubyn_code/tools/base.rb +84 -0
  91. data/lib/rubyn_code/tools/bash.rb +81 -0
  92. data/lib/rubyn_code/tools/bundle_add.rb +53 -0
  93. data/lib/rubyn_code/tools/bundle_install.rb +41 -0
  94. data/lib/rubyn_code/tools/compact.rb +57 -0
  95. data/lib/rubyn_code/tools/db_migrate.rb +52 -0
  96. data/lib/rubyn_code/tools/edit_file.rb +49 -0
  97. data/lib/rubyn_code/tools/executor.rb +62 -0
  98. data/lib/rubyn_code/tools/git_commit.rb +97 -0
  99. data/lib/rubyn_code/tools/git_diff.rb +61 -0
  100. data/lib/rubyn_code/tools/git_log.rb +59 -0
  101. data/lib/rubyn_code/tools/git_status.rb +59 -0
  102. data/lib/rubyn_code/tools/glob.rb +44 -0
  103. data/lib/rubyn_code/tools/grep.rb +81 -0
  104. data/lib/rubyn_code/tools/load_skill.rb +41 -0
  105. data/lib/rubyn_code/tools/memory_search.rb +77 -0
  106. data/lib/rubyn_code/tools/memory_write.rb +52 -0
  107. data/lib/rubyn_code/tools/rails_generate.rb +54 -0
  108. data/lib/rubyn_code/tools/read_file.rb +38 -0
  109. data/lib/rubyn_code/tools/read_inbox.rb +64 -0
  110. data/lib/rubyn_code/tools/registry.rb +48 -0
  111. data/lib/rubyn_code/tools/review_pr.rb +145 -0
  112. data/lib/rubyn_code/tools/run_specs.rb +75 -0
  113. data/lib/rubyn_code/tools/schema.rb +59 -0
  114. data/lib/rubyn_code/tools/send_message.rb +53 -0
  115. data/lib/rubyn_code/tools/spawn_agent.rb +154 -0
  116. data/lib/rubyn_code/tools/spawn_teammate.rb +168 -0
  117. data/lib/rubyn_code/tools/task.rb +148 -0
  118. data/lib/rubyn_code/tools/web_fetch.rb +108 -0
  119. data/lib/rubyn_code/tools/web_search.rb +196 -0
  120. data/lib/rubyn_code/tools/write_file.rb +30 -0
  121. data/lib/rubyn_code/version.rb +5 -0
  122. data/lib/rubyn_code.rb +203 -0
  123. data/skills/code_quality/fits_in_your_head.md +189 -0
  124. data/skills/code_quality/naming_conventions.md +213 -0
  125. data/skills/code_quality/null_object.md +205 -0
  126. data/skills/code_quality/technical_debt.md +135 -0
  127. data/skills/code_quality/value_objects.md +216 -0
  128. data/skills/code_quality/yagni.md +176 -0
  129. data/skills/design_patterns/adapter.md +191 -0
  130. data/skills/design_patterns/bridge_memento_visitor.md +254 -0
  131. data/skills/design_patterns/builder.md +158 -0
  132. data/skills/design_patterns/command.md +126 -0
  133. data/skills/design_patterns/composite.md +147 -0
  134. data/skills/design_patterns/decorator.md +204 -0
  135. data/skills/design_patterns/facade.md +133 -0
  136. data/skills/design_patterns/factory_method.md +169 -0
  137. data/skills/design_patterns/iterator.md +116 -0
  138. data/skills/design_patterns/mediator.md +133 -0
  139. data/skills/design_patterns/observer.md +177 -0
  140. data/skills/design_patterns/proxy.md +140 -0
  141. data/skills/design_patterns/singleton.md +124 -0
  142. data/skills/design_patterns/state.md +207 -0
  143. data/skills/design_patterns/strategy.md +127 -0
  144. data/skills/design_patterns/template_method.md +173 -0
  145. data/skills/gems/devise.md +365 -0
  146. data/skills/gems/dry_rb.md +186 -0
  147. data/skills/gems/factory_bot.md +268 -0
  148. data/skills/gems/faraday.md +263 -0
  149. data/skills/gems/graphql_ruby.md +514 -0
  150. data/skills/gems/pundit.md +446 -0
  151. data/skills/gems/redis.md +219 -0
  152. data/skills/gems/rubocop.md +257 -0
  153. data/skills/gems/sidekiq.md +360 -0
  154. data/skills/gems/stripe.md +224 -0
  155. data/skills/minitest/assertions.md +185 -0
  156. data/skills/minitest/fixtures.md +238 -0
  157. data/skills/minitest/integration_tests.md +210 -0
  158. data/skills/minitest/mailers_and_jobs.md +218 -0
  159. data/skills/minitest/mocking_stubbing.md +202 -0
  160. data/skills/minitest/service_tests_and_performance.md +246 -0
  161. data/skills/minitest/structure_and_conventions.md +169 -0
  162. data/skills/minitest/system_tests.md +237 -0
  163. data/skills/rails/action_cable.md +160 -0
  164. data/skills/rails/active_record_basics.md +174 -0
  165. data/skills/rails/active_storage.md +242 -0
  166. data/skills/rails/api_design.md +212 -0
  167. data/skills/rails/associations.md +182 -0
  168. data/skills/rails/background_jobs.md +212 -0
  169. data/skills/rails/caching.md +158 -0
  170. data/skills/rails/callbacks.md +135 -0
  171. data/skills/rails/concerns_controllers.md +218 -0
  172. data/skills/rails/concerns_models.md +280 -0
  173. data/skills/rails/controllers.md +190 -0
  174. data/skills/rails/engines.md +201 -0
  175. data/skills/rails/form_objects.md +168 -0
  176. data/skills/rails/hotwire.md +229 -0
  177. data/skills/rails/internationalization.md +192 -0
  178. data/skills/rails/logging.md +198 -0
  179. data/skills/rails/mailers.md +180 -0
  180. data/skills/rails/migrations.md +200 -0
  181. data/skills/rails/multitenancy.md +207 -0
  182. data/skills/rails/n_plus_one.md +151 -0
  183. data/skills/rails/presenters.md +244 -0
  184. data/skills/rails/query_objects.md +177 -0
  185. data/skills/rails/routing.md +194 -0
  186. data/skills/rails/scopes.md +187 -0
  187. data/skills/rails/security.md +233 -0
  188. data/skills/rails/serializers.md +243 -0
  189. data/skills/rails/service_objects.md +184 -0
  190. data/skills/rails/testing_strategy.md +258 -0
  191. data/skills/rails/validations.md +206 -0
  192. data/skills/refactoring/code_smells.md +251 -0
  193. data/skills/refactoring/command_query_separation.md +166 -0
  194. data/skills/refactoring/encapsulate_collection.md +125 -0
  195. data/skills/refactoring/extract_class.md +138 -0
  196. data/skills/refactoring/extract_method.md +185 -0
  197. data/skills/refactoring/replace_conditional.md +211 -0
  198. data/skills/refactoring/value_objects.md +246 -0
  199. data/skills/rspec/build_stubbed.md +199 -0
  200. data/skills/rspec/factory_design.md +206 -0
  201. data/skills/rspec/let_vs_let_bang.md +161 -0
  202. data/skills/rspec/mocking_stubbing.md +209 -0
  203. data/skills/rspec/request_specs.md +212 -0
  204. data/skills/rspec/service_specs.md +262 -0
  205. data/skills/rspec/shared_examples.md +244 -0
  206. data/skills/rspec/system_specs.md +286 -0
  207. data/skills/rspec/test_performance.md +215 -0
  208. data/skills/ruby/blocks_procs_lambdas.md +204 -0
  209. data/skills/ruby/classes.md +155 -0
  210. data/skills/ruby/concurrency.md +194 -0
  211. data/skills/ruby/data_struct_openstruct.md +158 -0
  212. data/skills/ruby/debugging_profiling.md +204 -0
  213. data/skills/ruby/enumerable_patterns.md +168 -0
  214. data/skills/ruby/exception_handling.md +199 -0
  215. data/skills/ruby/file_io.md +217 -0
  216. data/skills/ruby/hashes.md +195 -0
  217. data/skills/ruby/metaprogramming.md +170 -0
  218. data/skills/ruby/modules.md +210 -0
  219. data/skills/ruby/pattern_matching.md +177 -0
  220. data/skills/ruby/regular_expressions.md +166 -0
  221. data/skills/ruby/result_objects.md +200 -0
  222. data/skills/ruby/strings.md +177 -0
  223. data/skills/ruby_project/bundler_dependencies.md +181 -0
  224. data/skills/ruby_project/cli_tools.md +224 -0
  225. data/skills/ruby_project/rake_tasks.md +146 -0
  226. data/skills/ruby_project/structure.md +261 -0
  227. data/skills/sinatra/application_structure.md +241 -0
  228. data/skills/sinatra/middleware_and_deployment.md +221 -0
  229. data/skills/sinatra/testing.md +233 -0
  230. data/skills/solid/dependency_inversion.md +195 -0
  231. data/skills/solid/interface_segregation.md +237 -0
  232. data/skills/solid/liskov_substitution.md +263 -0
  233. data/skills/solid/open_closed.md +212 -0
  234. data/skills/solid/single_responsibility.md +183 -0
  235. 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.