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,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
|
+
```
|