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,158 @@
1
+ # Rails: Caching
2
+
3
+ ## Pattern
4
+
5
+ Cache at the right layer for the right duration. Rails provides fragment caching (views), low-level caching (arbitrary data), Russian doll caching (nested fragments), and HTTP caching (ETags). Use the cheapest cache that satisfies the freshness requirement.
6
+
7
+ ### Low-Level Caching (Most Versatile)
8
+
9
+ ```ruby
10
+ # Cache expensive queries or computations
11
+ class DashboardService
12
+ def call(user)
13
+ {
14
+ order_count: cached_order_count(user),
15
+ revenue: cached_revenue(user),
16
+ top_products: cached_top_products(user)
17
+ }
18
+ end
19
+
20
+ private
21
+
22
+ def cached_order_count(user)
23
+ Rails.cache.fetch("dashboard:#{user.id}:order_count", expires_in: 15.minutes) do
24
+ user.orders.count
25
+ end
26
+ end
27
+
28
+ def cached_revenue(user)
29
+ Rails.cache.fetch("dashboard:#{user.id}:revenue", expires_in: 15.minutes) do
30
+ user.orders.shipped.sum(:total)
31
+ end
32
+ end
33
+
34
+ def cached_top_products(user)
35
+ Rails.cache.fetch("dashboard:#{user.id}:top_products", expires_in: 1.hour) do
36
+ user.orders
37
+ .joins(line_items: :product)
38
+ .group("products.name")
39
+ .order("count_all DESC")
40
+ .limit(5)
41
+ .count
42
+ end
43
+ end
44
+ end
45
+ ```
46
+
47
+ ### Cache Key Design
48
+
49
+ ```ruby
50
+ # Key-based expiration — cache auto-expires when the record changes
51
+ class Order < ApplicationRecord
52
+ def cache_key_with_version
53
+ "orders/#{id}-#{updated_at.to_i}"
54
+ end
55
+ end
56
+
57
+ # Collection cache keys
58
+ Rails.cache.fetch(["v1/orders", current_user.orders.cache_key_with_version]) do
59
+ current_user.orders.includes(:line_items).map(&:as_json)
60
+ end
61
+
62
+ # Manual invalidation when needed
63
+ def invalidate_dashboard_cache(user)
64
+ Rails.cache.delete_matched("dashboard:#{user.id}:*")
65
+ end
66
+ ```
67
+
68
+ ### Fragment Caching (Views)
69
+
70
+ ```erb
71
+ <%# Russian doll caching — outer cache wraps inner caches %>
72
+ <% cache @order do %>
73
+ <h2><%= @order.reference %></h2>
74
+ <p>Total: <%= number_to_currency(@order.total / 100.0) %></p>
75
+
76
+ <% @order.line_items.each do |item| %>
77
+ <%# Inner cache — only re-renders if item changes %>
78
+ <% cache item do %>
79
+ <div class="line-item">
80
+ <%= item.product.name %> x <%= item.quantity %>
81
+ </div>
82
+ <% end %>
83
+ <% end %>
84
+ <% end %>
85
+ ```
86
+
87
+ ### HTTP Caching
88
+
89
+ ```ruby
90
+ class Api::V1::ProductsController < Api::V1::BaseController
91
+ # ETag-based — returns 304 Not Modified if content hasn't changed
92
+ def show
93
+ product = Product.find(params[:id])
94
+
95
+ if stale?(product)
96
+ render json: ProductSerializer.new(product).as_json
97
+ end
98
+ end
99
+
100
+ # Time-based — client caches for the specified duration
101
+ def index
102
+ expires_in 5.minutes, public: true
103
+
104
+ products = Product.active.order(:name)
105
+ render json: products.map { |p| ProductSerializer.new(p).as_json }
106
+ end
107
+ end
108
+ ```
109
+
110
+ ### Counter Caches (Database-Level Caching)
111
+
112
+ ```ruby
113
+ # Migration
114
+ add_column :users, :orders_count, :integer, default: 0, null: false
115
+
116
+ # Model
117
+ class Order < ApplicationRecord
118
+ belongs_to :user, counter_cache: true
119
+ end
120
+
121
+ # Now user.orders_count is a column read, not a COUNT(*) query
122
+ # Updated automatically on Order create/destroy
123
+ ```
124
+
125
+ ## Why This Is Good
126
+
127
+ - **`Rails.cache.fetch` is atomic.** If the cache misses, the block runs, and the result is stored. No race conditions between check and set.
128
+ - **Key-based expiration is self-managing.** `"orders/#{id}-#{updated_at.to_i}"` automatically expires when the record is updated. No manual invalidation needed.
129
+ - **Russian doll caching is granular.** When one line item changes, only that item's fragment re-renders. The order fragment and other items serve from cache.
130
+ - **HTTP caching offloads the server.** ETags and `expires_in` let browsers and CDNs serve cached responses without hitting your app at all.
131
+ - **Counter caches eliminate N+1 counts.** Displaying `user.orders_count` for 50 users is 0 queries instead of 50.
132
+
133
+ ## Anti-Pattern
134
+
135
+ Caching without expiration or invalidation:
136
+
137
+ ```ruby
138
+ # BAD: Cache forever with no expiration
139
+ Rails.cache.write("all_products", Product.all.to_a) # Never expires, grows stale
140
+
141
+ # BAD: Over-caching mutable data
142
+ Rails.cache.fetch("user:#{user.id}", expires_in: 24.hours) do
143
+ user.attributes # User could change their email, name, plan in 24 hours
144
+ end
145
+ ```
146
+
147
+ ## When To Apply
148
+
149
+ - **Expensive queries displayed on every page load.** Dashboard counts, leaderboards, aggregate stats.
150
+ - **Rarely-changing reference data.** Product catalogs, category trees, configuration.
151
+ - **API responses that many clients request.** HTTP caching with CDNs.
152
+ - **View fragments with complex rendering.** Partial renders that involve multiple queries or helpers.
153
+
154
+ ## When NOT To Apply
155
+
156
+ - **Data that must be real-time.** Account balances, stock levels, live order status. Stale caches here cause user-visible bugs.
157
+ - **Simple queries that are already fast.** Caching a `find_by(id:)` that takes 1ms adds complexity without meaningful speedup.
158
+ - **In development.** Enable caching in development only when actively debugging cache behavior: `rails dev:cache`.
@@ -0,0 +1,135 @@
1
+ # Rails: ActiveRecord Callbacks
2
+
3
+ ## Pattern
4
+
5
+ Use callbacks only for concerns that are intrinsic to data integrity — things that must always happen whenever the record changes, regardless of context. Everything else belongs in service objects.
6
+
7
+ Safe callback use cases:
8
+
9
+ ```ruby
10
+ class Order < ApplicationRecord
11
+ # GOOD: Normalizing data before save — this should always happen
12
+ before_validation :normalize_email
13
+ before_validation :generate_reference, on: :create
14
+
15
+ # GOOD: Maintaining data integrity
16
+ before_save :calculate_total, if: :line_items_changed?
17
+
18
+ # GOOD: Cleaning up owned resources
19
+ after_destroy :purge_attached_files
20
+
21
+ private
22
+
23
+ def normalize_email
24
+ self.email = email&.downcase&.strip
25
+ end
26
+
27
+ def generate_reference
28
+ self.reference ||= "ORD-#{SecureRandom.hex(6).upcase}"
29
+ end
30
+
31
+ def calculate_total
32
+ self.total = line_items.sum { |item| item.quantity * item.unit_price }
33
+ end
34
+
35
+ def purge_attached_files
36
+ receipt.purge_later if receipt.attached?
37
+ end
38
+ end
39
+ ```
40
+
41
+ ## Why This Is Good
42
+
43
+ - **Predictable.** Callbacks for data normalization and integrity are expected behavior. Every developer knows `before_validation` might downcase an email. Nobody expects `after_create` to charge a credit card.
44
+ - **Context-independent.** Normalizing an email should happen whether the record is created via web form, API, console, seed file, or test factory. That's intrinsic to the data.
45
+ - **No surprises in tests.** When a test creates an Order, it gets a reference number and a calculated total. It does NOT send emails, charge cards, or hit external APIs.
46
+ - **Safe to call from anywhere.** `Order.create!(params)` works correctly from a controller, a Sidekiq job, a rake task, or the Rails console — because the callbacks only handle data integrity.
47
+
48
+ ## Anti-Pattern
49
+
50
+ Using callbacks for business logic, side effects, and cross-model operations:
51
+
52
+ ```ruby
53
+ class Order < ApplicationRecord
54
+ after_create :send_confirmation_email
55
+ after_create :notify_warehouse
56
+ after_create :update_product_inventory
57
+ after_create :award_loyalty_points
58
+ after_create :track_analytics_event
59
+
60
+ after_update :send_status_change_email, if: :saved_change_to_status?
61
+ after_update :refund_if_cancelled, if: -> { saved_change_to_status?(to: "cancelled") }
62
+
63
+ after_destroy :restore_inventory
64
+ after_destroy :send_cancellation_email
65
+
66
+ private
67
+
68
+ def send_confirmation_email
69
+ OrderMailer.confirmation(self).deliver_later
70
+ end
71
+
72
+ def notify_warehouse
73
+ WarehouseApi.new.notify(order_id: id, items: line_items.map(&:sku))
74
+ end
75
+
76
+ def update_product_inventory
77
+ line_items.each do |item|
78
+ item.product.decrement!(:stock, item.quantity)
79
+ end
80
+ end
81
+
82
+ def award_loyalty_points
83
+ user.increment!(:loyalty_points, (total / 10).floor)
84
+ end
85
+
86
+ def track_analytics_event
87
+ Analytics.track("order_created", order_id: id, total: total)
88
+ end
89
+ end
90
+ ```
91
+
92
+ ## Why This Is Bad
93
+
94
+ - **Hidden side effects.** A developer running `Order.create!(params)` in the console to fix a data issue accidentally sends a confirmation email, notifies a warehouse, decrements inventory, awards loyalty points, and fires an analytics event. None of this is visible from the call site.
95
+ - **Tests become slow and fragile.** Every test that creates an order triggers the full callback chain. You need to stub mailers, mock external APIs, and create associated products with sufficient stock. Factory creation becomes a minefield.
96
+ - **Ordering problems.** Callbacks run in declaration order. If `notify_warehouse` depends on `update_product_inventory` having run first, reordering the declarations breaks the app silently.
97
+ - **Impossible to skip selectively.** You can't create an order without sending an email unless you add flags (`skip_email: true`) that pollute the model with callback control logic.
98
+ - **Transaction danger.** `after_create` runs inside the transaction. If `notify_warehouse` raises an HTTP error, the entire order creation rolls back — even though the order itself was valid.
99
+ - **Circular dependencies.** Callback A on Order updates Product stock. A callback on Product recalculates availability. That triggers a callback that touches Order again. Infinite loops are hard to debug.
100
+
101
+ ## When To Apply
102
+
103
+ Use callbacks ONLY for these purposes:
104
+
105
+ - **Data normalization** — downcasing emails, stripping whitespace, formatting phone numbers, generating slugs/tokens
106
+ - **Default values** — setting a reference number, a UUID, a default status on creation
107
+ - **Derived calculations** — computing a total from line items, a full name from first + last
108
+ - **Cleanup of owned resources** — purging Active Storage attachments, removing associated cache entries
109
+ - **Counter maintenance** — only when `counter_cache` on the association isn't sufficient
110
+
111
+ The test: "If I create this record from the Rails console with no other context, should this behavior still happen?" If yes → callback. If no → service object.
112
+
113
+ ## When NOT To Apply
114
+
115
+ Do NOT use callbacks for:
116
+
117
+ - **Sending emails or notifications.** These are side effects that depend on context. An order created by an admin backfill should not trigger a customer email.
118
+ - **Calling external APIs.** Webhooks, warehouse notifications, payment charges. These fail independently and should not roll back the record.
119
+ - **Modifying other models.** Updating inventory, awarding points, creating audit records. These are business logic, not data integrity.
120
+ - **Enqueuing background jobs.** Use service objects that explicitly enqueue after the primary operation succeeds.
121
+ - **Anything with `if:` conditions based on business context.** If a callback needs `if: :registering?` or `if: :from_api?`, it's not intrinsic to the data — it's business logic wearing a callback costume.
122
+
123
+ ## Edge Cases
124
+
125
+ **The team already has callbacks everywhere:**
126
+ Don't rip them all out at once. When modifying a model, extract the business-logic callbacks into a service object one at a time. Leave the data-integrity callbacks in place.
127
+
128
+ **`after_commit` vs `after_create`:**
129
+ If you must trigger a side effect from a model (not recommended, but sometimes pragmatic), use `after_commit` instead of `after_create`. It runs after the transaction commits, so a failure won't roll back the record. But this is still a code smell — prefer service objects.
130
+
131
+ **Gems that require callbacks (like `acts_as_paranoid`, `paper_trail`):**
132
+ These are fine. They manage data integrity (soft deletes, audit trails) which is a legitimate callback concern. The gem handles the complexity.
133
+
134
+ **Touch callbacks (`belongs_to :order, touch: true`):**
135
+ These are fine — they maintain cache integrity and are intrinsic to the data relationship.
@@ -0,0 +1,218 @@
1
+ # Rails: Controller Concerns
2
+
3
+ ## Pattern
4
+
5
+ Use controller concerns for cross-cutting HTTP behavior shared across multiple controllers — authentication helpers, pagination, error handling, and response formatting. Keep concerns focused on one capability.
6
+
7
+ ```ruby
8
+ # app/controllers/concerns/authenticatable.rb
9
+ module Authenticatable
10
+ extend ActiveSupport::Concern
11
+
12
+ included do
13
+ before_action :authenticate_user!
14
+ helper_method :current_user
15
+ end
16
+
17
+ private
18
+
19
+ def current_user
20
+ @current_user ||= User.find_by(id: session[:user_id])
21
+ end
22
+
23
+ def authenticate_user!
24
+ redirect_to login_path, alert: "Please log in" unless current_user
25
+ end
26
+ end
27
+ ```
28
+
29
+ ```ruby
30
+ # app/controllers/concerns/paginatable.rb
31
+ module Paginatable
32
+ extend ActiveSupport::Concern
33
+
34
+ private
35
+
36
+ def paginate(scope, per_page: 25)
37
+ scope.page(params[:page]).per(per_page)
38
+ end
39
+
40
+ def pagination_meta(collection)
41
+ {
42
+ current_page: collection.current_page,
43
+ total_pages: collection.total_pages,
44
+ total_count: collection.total_count,
45
+ per_page: collection.limit_value
46
+ }
47
+ end
48
+ end
49
+ ```
50
+
51
+ ```ruby
52
+ # app/controllers/concerns/api_error_handling.rb
53
+ module ApiErrorHandling
54
+ extend ActiveSupport::Concern
55
+
56
+ included do
57
+ rescue_from ActiveRecord::RecordNotFound, with: :not_found
58
+ rescue_from ActiveRecord::RecordInvalid, with: :unprocessable
59
+ rescue_from ActionController::ParameterMissing, with: :bad_request
60
+ end
61
+
62
+ private
63
+
64
+ def not_found(exception)
65
+ render json: { error: "Not found", detail: exception.message }, status: :not_found
66
+ end
67
+
68
+ def unprocessable(exception)
69
+ render json: { error: "Validation failed", details: exception.record.errors.full_messages }, status: :unprocessable_entity
70
+ end
71
+
72
+ def bad_request(exception)
73
+ render json: { error: "Bad request", detail: exception.message }, status: :bad_request
74
+ end
75
+ end
76
+ ```
77
+
78
+ Usage — compose focused concerns:
79
+
80
+ ```ruby
81
+ class Api::V1::BaseController < ActionController::API
82
+ include Authenticatable
83
+ include Paginatable
84
+ include ApiErrorHandling
85
+ end
86
+
87
+ class Api::V1::OrdersController < Api::V1::BaseController
88
+ def index
89
+ orders = paginate(current_user.orders.recent)
90
+ render json: { orders: orders, meta: pagination_meta(orders) }
91
+ end
92
+ end
93
+ ```
94
+
95
+ ## Why This Is Good
96
+
97
+ - **Single responsibility per concern.** `Authenticatable` handles auth. `Paginatable` handles pagination. `ApiErrorHandling` handles errors. Each is independently understandable and testable.
98
+ - **Composable.** A controller includes the concerns it needs. An API controller includes `ApiErrorHandling`. A web controller includes `WebErrorHandling` instead. No monolithic base class.
99
+ - **DRY across controllers.** Pagination logic is identical across every index action. Writing it once in a concern prevents copy-paste and ensures consistency.
100
+ - **`rescue_from` in a concern centralizes error handling.** Every API controller inheriting from `BaseController` gets consistent error responses for common exceptions without any per-controller code.
101
+
102
+ ## Anti-Pattern
103
+
104
+ Concerns with business logic, controller-specific behavior, or too many responsibilities:
105
+
106
+ ```ruby
107
+ # BAD: Business logic in a controller concern
108
+ module OrderProcessing
109
+ extend ActiveSupport::Concern
110
+
111
+ private
112
+
113
+ def process_order(order)
114
+ validate_inventory(order)
115
+ calculate_total(order)
116
+ apply_discount(order)
117
+ charge_payment(order)
118
+ send_confirmation(order)
119
+ notify_warehouse(order)
120
+ end
121
+
122
+ def validate_inventory(order)
123
+ order.line_items.each do |item|
124
+ raise "Out of stock" if item.product.stock < item.quantity
125
+ end
126
+ end
127
+
128
+ def calculate_total(order)
129
+ order.total = order.line_items.sum { |li| li.quantity * li.price }
130
+ end
131
+
132
+ # ... 50 more lines of business logic
133
+ end
134
+ ```
135
+
136
+ ```ruby
137
+ # BAD: Concern used by only one controller
138
+ module OrdersControllerHelpers
139
+ extend ActiveSupport::Concern
140
+
141
+ private
142
+
143
+ def set_order
144
+ @order = current_user.orders.find(params[:id])
145
+ end
146
+
147
+ def order_params
148
+ params.require(:order).permit(:address, :notes)
149
+ end
150
+ end
151
+ ```
152
+
153
+ ## Why This Is Bad
154
+
155
+ - **Business logic in a controller concern is still business logic in a controller.** Moving `process_order` from the controller to a concern doesn't fix the architecture — it just moves the problem to a different file. This belongs in a service object.
156
+ - **Single-use concerns add indirection.** `OrdersControllerHelpers` is included in one controller. Opening the controller, you see `include OrdersControllerHelpers` and have to navigate to another file to find `set_order`. Just define `set_order` in the controller directly.
157
+ - **Fat concerns replace fat controllers.** If the concern is 100 lines of business logic, the controller's responsibilities haven't shrunk — they've been scattered.
158
+
159
+ ## When To Apply
160
+
161
+ - **Cross-cutting HTTP concerns** used by 3+ controllers: authentication, authorization, pagination, error handling, logging, CORS, request throttling.
162
+ - **Response formatting** shared across API controllers: consistent JSON error shapes, pagination metadata, HATEOAS links.
163
+ - **`before_action` chains** that are identical across controllers: `authenticate_user!`, `set_locale`, `verify_csrf_token`.
164
+
165
+ ## When NOT To Apply
166
+
167
+ - **Business logic.** Inventory validation, payment processing, email sending — these belong in service objects, not controller concerns.
168
+ - **Behavior for one controller.** If only `OrdersController` uses it, keep it in `OrdersController`. A concern for one consumer is just indirection.
169
+ - **Model-level logic.** If the concern accesses `ActiveRecord` methods or database queries, it probably belongs on the model or in a query object, not in a controller concern.
170
+
171
+ ## Edge Cases
172
+
173
+ **Concern needs configuration per controller:**
174
+ Use class methods or class attributes:
175
+
176
+ ```ruby
177
+ module RateLimitable
178
+ extend ActiveSupport::Concern
179
+
180
+ included do
181
+ class_attribute :rate_limit_per_minute, default: 60
182
+ before_action :check_rate_limit
183
+ end
184
+
185
+ private
186
+
187
+ def check_rate_limit
188
+ key = "rate_limit:#{current_user.id}:#{controller_name}"
189
+ count = Rails.cache.increment(key, 1, expires_in: 1.minute)
190
+ head :too_many_requests if count > self.class.rate_limit_per_minute
191
+ end
192
+ end
193
+
194
+ class Api::V1::AiController < Api::V1::BaseController
195
+ include RateLimitable
196
+ self.rate_limit_per_minute = 20 # Stricter limit for AI endpoints
197
+ end
198
+ ```
199
+
200
+ **Testing concerns in isolation:**
201
+ Create an anonymous controller in the spec:
202
+
203
+ ```ruby
204
+ RSpec.describe Authenticatable, type: :controller do
205
+ controller(ApplicationController) do
206
+ include Authenticatable
207
+
208
+ def index
209
+ render json: { user: current_user.email }
210
+ end
211
+ end
212
+
213
+ it "redirects unauthenticated users" do
214
+ get :index
215
+ expect(response).to redirect_to(login_path)
216
+ end
217
+ end
218
+ ```