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,233 @@
1
+ # Rails: Security
2
+
3
+ ## Pattern
4
+
5
+ Security is not a feature — it's a property of every feature. Rails provides strong defaults, but you must use them correctly. This document covers the critical security practices for every Rails application.
6
+
7
+ ### Strong Parameters (Mass Assignment Protection)
8
+
9
+ ```ruby
10
+ # GOOD: Explicitly permit only expected params
11
+ class OrdersController < ApplicationController
12
+ private
13
+
14
+ def order_params
15
+ params.require(:order).permit(:shipping_address, :notes,
16
+ line_items_attributes: [:product_id, :quantity])
17
+ end
18
+ end
19
+
20
+ # BAD: Permitting everything
21
+ def order_params
22
+ params.require(:order).permit! # NEVER do this
23
+ end
24
+
25
+ # BAD: Permitting role or admin fields
26
+ def user_params
27
+ params.require(:user).permit(:name, :email, :role, :admin) # User can make themselves admin!
28
+ end
29
+
30
+ # GOOD: Separate param sets for different contexts
31
+ def user_params
32
+ params.require(:user).permit(:name, :email)
33
+ end
34
+
35
+ def admin_user_params
36
+ params.require(:user).permit(:name, :email, :role, :admin) # Only in admin controllers
37
+ end
38
+ ```
39
+
40
+ ### SQL Injection Prevention
41
+
42
+ ```ruby
43
+ # GOOD: Parameterized queries (Rails does this by default)
44
+ User.where(email: params[:email])
45
+ User.where("email = ?", params[:email])
46
+ User.where("email = :email", email: params[:email])
47
+ Order.where(status: params[:status], user_id: current_user.id)
48
+
49
+ # BAD: String interpolation in SQL
50
+ User.where("email = '#{params[:email]}'") # SQL injection!
51
+ Order.where("status = #{params[:status]}") # SQL injection!
52
+ User.order("#{params[:sort_column]} #{params[:sort_direction]}") # SQL injection!
53
+
54
+ # GOOD: Safe column sorting
55
+ ALLOWED_SORT_COLUMNS = %w[created_at total status].freeze
56
+ ALLOWED_DIRECTIONS = %w[asc desc].freeze
57
+
58
+ def safe_order(scope)
59
+ column = ALLOWED_SORT_COLUMNS.include?(params[:sort]) ? params[:sort] : "created_at"
60
+ direction = ALLOWED_DIRECTIONS.include?(params[:dir]) ? params[:dir] : "desc"
61
+ scope.order(column => direction)
62
+ end
63
+ ```
64
+
65
+ ### XSS (Cross-Site Scripting) Prevention
66
+
67
+ ```ruby
68
+ # GOOD: Rails auto-escapes by default in ERB
69
+ <%= user.name %> <%# Automatically escaped — safe %>
70
+
71
+ # BAD: raw/html_safe bypasses escaping
72
+ <%= raw user.bio %> # If bio contains <script>, it executes!
73
+ <%= user.bio.html_safe %> # Same vulnerability
74
+
75
+ # GOOD: When you need HTML, sanitize it
76
+ <%= sanitize user.bio, tags: %w[p br strong em a], attributes: %w[href] %>
77
+
78
+ # GOOD: JSON in script tags (Rails 7+)
79
+ <script>
80
+ const data = <%= raw json_escape(data.to_json) %>;
81
+ </script>
82
+
83
+ # Or better — use data attributes
84
+ <div data-order="<%= order.to_json %>">
85
+ ```
86
+
87
+ ### CSRF Protection
88
+
89
+ ```ruby
90
+ # Rails includes CSRF protection by default for HTML forms
91
+ class ApplicationController < ActionController::Base
92
+ protect_from_forgery with: :exception
93
+ end
94
+
95
+ # API controllers skip CSRF (they use token auth instead)
96
+ class Api::BaseController < ActionController::API
97
+ # No CSRF — API uses Bearer token authentication
98
+ end
99
+ ```
100
+
101
+ ### Authentication Security
102
+
103
+ ```ruby
104
+ # GOOD: Secure API key storage — hash the key, store the hash
105
+ class ApiKey < ApplicationRecord
106
+ before_create :generate_key_pair
107
+
108
+ # Store only the hash — never the raw key
109
+ def self.find_by_token(raw_token)
110
+ hashed = Digest::SHA256.hexdigest(raw_token)
111
+ find_by(key_hash: hashed, revoked_at: nil)
112
+ end
113
+
114
+ private
115
+
116
+ def generate_key_pair
117
+ raw_key = SecureRandom.urlsafe_base64(32)
118
+ self.key_hash = Digest::SHA256.hexdigest(raw_key)
119
+ self.key_prefix = raw_key[0..7] # For identification in UI
120
+
121
+ # Return the raw key ONCE — it's never stored
122
+ @raw_key = raw_key
123
+ end
124
+ end
125
+
126
+ # GOOD: Constant-time comparison to prevent timing attacks
127
+ def authenticate_token(provided_token)
128
+ expected_hash = Digest::SHA256.hexdigest(provided_token)
129
+ api_key = ApiKey.find_by(key_prefix: provided_token[0..7])
130
+ return nil unless api_key
131
+
132
+ # Constant-time comparison
133
+ ActiveSupport::SecurityUtils.secure_compare(api_key.key_hash, expected_hash) ? api_key : nil
134
+ end
135
+
136
+ # GOOD: Password requirements with Devise
137
+ class User < ApplicationRecord
138
+ devise :database_authenticatable, :registerable,
139
+ :recoverable, :rememberable, :validatable,
140
+ :lockable, :trackable
141
+
142
+ validates :password, length: { minimum: 8 }, if: :password_required?
143
+ end
144
+ ```
145
+
146
+ ### Authorization (Scoping Queries)
147
+
148
+ ```ruby
149
+ # GOOD: Always scope queries to the current user
150
+ class OrdersController < ApplicationController
151
+ def show
152
+ @order = current_user.orders.find(params[:id])
153
+ # If the order doesn't belong to current_user, raises RecordNotFound (404)
154
+ end
155
+
156
+ def index
157
+ @orders = current_user.orders.recent
158
+ # Never see other users' orders
159
+ end
160
+ end
161
+
162
+ # BAD: Global lookup — any user can access any order
163
+ class OrdersController < ApplicationController
164
+ def show
165
+ @order = Order.find(params[:id]) # IDOR vulnerability!
166
+ end
167
+ end
168
+ ```
169
+
170
+ ### Secrets Management
171
+
172
+ ```ruby
173
+ # GOOD: Use Rails credentials
174
+ # Edit: rails credentials:edit
175
+ # Access:
176
+ Rails.application.credentials.anthropic_api_key
177
+ Rails.application.credentials.dig(:database, :password)
178
+
179
+ # GOOD: Environment variables for deployment
180
+ ENV.fetch("ANTHROPIC_API_KEY") # Fails loudly if missing
181
+ ENV["OPTIONAL_KEY"] # Returns nil if missing
182
+
183
+ # BAD: Secrets in code
184
+ ANTHROPIC_API_KEY = "sk-ant-abc123..." # NEVER commit secrets
185
+
186
+ # BAD: Secrets in database seeds
187
+ User.create!(api_key: "real-production-key")
188
+ ```
189
+
190
+ ### Content Security Policy
191
+
192
+ ```ruby
193
+ # config/initializers/content_security_policy.rb
194
+ Rails.application.configure do
195
+ config.content_security_policy do |policy|
196
+ policy.default_src :self
197
+ policy.font_src :self, "https://fonts.googleapis.com"
198
+ policy.img_src :self, :data, "https://gravatar.com"
199
+ policy.script_src :self
200
+ policy.style_src :self, :unsafe_inline # Required for some frameworks
201
+ policy.connect_src :self
202
+ end
203
+
204
+ config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s }
205
+ config.content_security_policy_nonce_directives = %w[script-src]
206
+ end
207
+ ```
208
+
209
+ ### Rate Limiting (Rails 8+)
210
+
211
+ ```ruby
212
+ class Api::V1::AiController < Api::V1::BaseController
213
+ rate_limit to: 20, within: 1.minute, by: -> { current_user.id }, with: -> {
214
+ render json: { error: "Rate limited. Try again in a moment." }, status: :too_many_requests
215
+ }
216
+ end
217
+ ```
218
+
219
+ ## Security Checklist
220
+
221
+ Every Rails app should verify:
222
+
223
+ - [ ] Strong parameters on every controller action
224
+ - [ ] No string interpolation in SQL queries
225
+ - [ ] No `raw` or `html_safe` on user input
226
+ - [ ] CSRF protection enabled for web controllers
227
+ - [ ] API authentication via tokens (not cookies)
228
+ - [ ] All queries scoped to `current_user` (no IDOR)
229
+ - [ ] Secrets in credentials or ENV, never in code
230
+ - [ ] `force_ssl` enabled in production
231
+ - [ ] Dependencies updated regularly (`bundle audit`)
232
+ - [ ] Rate limiting on expensive endpoints
233
+ - [ ] CSP headers configured
@@ -0,0 +1,243 @@
1
+ # Rails: Serializers
2
+
3
+ ## Pattern
4
+
5
+ Serializers control the exact shape of your JSON API responses. They decouple the API surface from the database schema, prevent accidental exposure of internal fields, and provide a single place to manage field inclusion, formatting, and nested associations.
6
+
7
+ ### Manual Serializer (Simplest, No Gems)
8
+
9
+ ```ruby
10
+ # app/serializers/order_serializer.rb
11
+ class OrderSerializer
12
+ def initialize(order)
13
+ @order = order
14
+ end
15
+
16
+ def as_json(*)
17
+ {
18
+ id: @order.id,
19
+ reference: @order.reference,
20
+ status: @order.status,
21
+ total_cents: @order.total,
22
+ total_formatted: format_currency(@order.total),
23
+ shipping_address: @order.shipping_address,
24
+ line_items: serialize_line_items,
25
+ created_at: @order.created_at.iso8601,
26
+ updated_at: @order.updated_at.iso8601
27
+ }
28
+ end
29
+
30
+ private
31
+
32
+ def serialize_line_items
33
+ @order.line_items.map do |item|
34
+ {
35
+ id: item.id,
36
+ product_name: item.product.name,
37
+ quantity: item.quantity,
38
+ unit_price_cents: item.unit_price,
39
+ total_cents: item.quantity * item.unit_price
40
+ }
41
+ end
42
+ end
43
+
44
+ def format_currency(cents)
45
+ "$#{format('%.2f', cents / 100.0)}"
46
+ end
47
+ end
48
+
49
+ # Controller usage
50
+ class Api::V1::OrdersController < Api::V1::BaseController
51
+ def show
52
+ order = current_user.orders.includes(line_items: :product).find(params[:id])
53
+ render json: { order: OrderSerializer.new(order).as_json }
54
+ end
55
+
56
+ def index
57
+ orders = current_user.orders.includes(line_items: :product).recent
58
+ render json: {
59
+ orders: orders.map { |o| OrderSerializer.new(o).as_json },
60
+ meta: pagination_meta(orders)
61
+ }
62
+ end
63
+ end
64
+ ```
65
+
66
+ ### Collection Serializer Helper
67
+
68
+ ```ruby
69
+ # app/serializers/base_serializer.rb
70
+ class BaseSerializer
71
+ def initialize(object)
72
+ @object = object
73
+ end
74
+
75
+ def as_json(*)
76
+ raise NotImplementedError
77
+ end
78
+
79
+ # Class method for serializing collections
80
+ def self.collection(objects)
81
+ objects.map { |obj| new(obj).as_json }
82
+ end
83
+ end
84
+
85
+ class OrderSerializer < BaseSerializer
86
+ def as_json(*)
87
+ {
88
+ id: @object.id,
89
+ reference: @object.reference,
90
+ status: @object.status,
91
+ total_cents: @object.total,
92
+ created_at: @object.created_at.iso8601
93
+ }
94
+ end
95
+ end
96
+
97
+ # Usage
98
+ render json: { orders: OrderSerializer.collection(orders) }
99
+ ```
100
+
101
+ ### Conditional Fields and Includes
102
+
103
+ ```ruby
104
+ class OrderSerializer
105
+ def initialize(order, includes: [])
106
+ @order = order
107
+ @includes = includes.map(&:to_sym)
108
+ end
109
+
110
+ def as_json(*)
111
+ data = {
112
+ id: @order.id,
113
+ reference: @order.reference,
114
+ status: @order.status,
115
+ total_cents: @order.total,
116
+ created_at: @order.created_at.iso8601
117
+ }
118
+
119
+ data[:line_items] = serialize_line_items if include?(:line_items)
120
+ data[:user] = serialize_user if include?(:user)
121
+ data[:shipment] = serialize_shipment if include?(:shipment)
122
+
123
+ data
124
+ end
125
+
126
+ private
127
+
128
+ def include?(association)
129
+ @includes.include?(association)
130
+ end
131
+
132
+ def serialize_line_items
133
+ @order.line_items.map { |li| LineItemSerializer.new(li).as_json }
134
+ end
135
+
136
+ def serialize_user
137
+ UserSerializer.new(@order.user).as_json
138
+ end
139
+
140
+ def serialize_shipment
141
+ return nil unless @order.shipment
142
+ ShipmentSerializer.new(@order.shipment).as_json
143
+ end
144
+ end
145
+
146
+ # Controller — caller decides what to include
147
+ def show
148
+ order = current_user.orders.find(params[:id])
149
+ includes = (params[:include] || "").split(",").map(&:strip)
150
+
151
+ # Preload only what's requested
152
+ order = preload_includes(order, includes)
153
+
154
+ render json: { order: OrderSerializer.new(order, includes: includes).as_json }
155
+ end
156
+ ```
157
+
158
+ ### Jbuilder (Rails Built-In)
159
+
160
+ ```ruby
161
+ # app/views/api/v1/orders/show.json.jbuilder
162
+ json.order do
163
+ json.id @order.id
164
+ json.reference @order.reference
165
+ json.status @order.status
166
+ json.total_cents @order.total
167
+ json.total_formatted number_to_currency(@order.total / 100.0)
168
+ json.created_at @order.created_at.iso8601
169
+
170
+ json.line_items @order.line_items do |item|
171
+ json.id item.id
172
+ json.product_name item.product.name
173
+ json.quantity item.quantity
174
+ json.unit_price_cents item.unit_price
175
+ end
176
+ end
177
+
178
+ # app/views/api/v1/orders/index.json.jbuilder
179
+ json.orders @orders do |order|
180
+ json.partial! "api/v1/orders/order", order: order
181
+ end
182
+ json.meta do
183
+ json.total_count @orders.total_count
184
+ json.current_page @orders.current_page
185
+ end
186
+ ```
187
+
188
+ ## Why This Is Good
189
+
190
+ - **API surface is explicit.** The serializer lists every field the API returns. Adding or removing a field is a one-line change in one file.
191
+ - **No accidental exposure.** `render json: @order` would expose `password_digest`, `internal_notes`, `api_cost_usd`, and every other column. Serializers whitelist only the fields clients should see.
192
+ - **Formatting is centralized.** Dates are always ISO8601, money is always in cents with a formatted version, statuses are always lowercase. Clients get consistent data shapes.
193
+ - **Nested associations are controlled.** You decide how deep the nesting goes. Clients can request includes, but you control what's available.
194
+ - **Testable.** `OrderSerializer.new(order).as_json` returns a hash you can assert on without HTTP.
195
+
196
+ ## Anti-Pattern
197
+
198
+ ```ruby
199
+ # BAD: Rendering the model directly
200
+ render json: @order
201
+ # Exposes EVERYTHING: password_digest, internal_notes, admin fields, timestamps you don't want
202
+
203
+ # BAD: Inline hash construction in the controller
204
+ render json: {
205
+ id: @order.id,
206
+ ref: @order.reference, # Inconsistent naming
207
+ total: "$#{@order.total / 100.0}", # Formatting in controller
208
+ items: @order.line_items.map { |li| { name: li.product.name, qty: li.quantity } }
209
+ }
210
+ # Repeated in every controller, inconsistent across endpoints
211
+
212
+ # BAD: as_json override on the model
213
+ class Order < ApplicationRecord
214
+ def as_json(options = {})
215
+ super(only: [:id, :reference, :status, :total], include: { line_items: { only: [:id, :quantity] } })
216
+ end
217
+ end
218
+ # Now EVERY json render uses this shape — can't have different shapes for different endpoints
219
+ ```
220
+
221
+ ## When To Apply
222
+
223
+ - **Every JSON API endpoint.** No exceptions. Even a simple `{ id: 1, name: "test" }` should go through a serializer once you have more than 2 API endpoints.
224
+ - **When different endpoints need different shapes.** A list endpoint shows `id, reference, status, total`. A detail endpoint adds `line_items, user, shipment`. Serializers with `includes:` handle this cleanly.
225
+ - **When you need versioning.** `Api::V1::OrderSerializer` and `Api::V2::OrderSerializer` can coexist. Can't do that with `as_json` on the model.
226
+
227
+ ## When NOT To Apply
228
+
229
+ - **HTML-only apps.** Views render HTML directly from models/presenters. Serializers are for JSON APIs.
230
+ - **Single internal endpoint.** A health check returning `{ status: "ok" }` doesn't need a serializer class.
231
+ - **GraphQL.** GraphQL types serve the same purpose as serializers. Don't layer serializers on top of GraphQL.
232
+
233
+ ## Gem Alternatives
234
+
235
+ | Approach | Pros | Cons |
236
+ |---|---|---|
237
+ | Manual class | Zero dependencies, full control, simplest | More boilerplate for large APIs |
238
+ | Jbuilder | Ships with Rails, template-based | Slower (renders views), harder to test |
239
+ | `jsonapi-serializer` | JSON:API compliant, relationships, sparse fieldsets | Heavy for simple APIs |
240
+ | `alba` | Fast, flexible, modern | Another dependency |
241
+ | `blueprinter` | Declarative DSL, views, associations | Another dependency |
242
+
243
+ **Recommendation:** Start with manual serializers. They're fast, testable, and dependency-free. Switch to a gem only when you have 20+ serializers and need features like sparse fieldsets or JSON:API compliance.
@@ -0,0 +1,184 @@
1
+ # Rails: Service Object Extraction
2
+
3
+ ## Pattern
4
+
5
+ Extract business logic from controllers into service objects when the action does more than receive params and persist a single record.
6
+
7
+ Service objects live in `app/services/`, namespaced by resource. They have a single public class method `.call` that returns a result object.
8
+
9
+ ```ruby
10
+ # app/services/orders/create_service.rb
11
+ module Orders
12
+ class CreateService
13
+ def self.call(params, user)
14
+ new(params, user).call
15
+ end
16
+
17
+ def initialize(params, user)
18
+ @params = params
19
+ @user = user
20
+ end
21
+
22
+ def call
23
+ order = @user.orders.build(@params)
24
+
25
+ unless inventory_available?(order)
26
+ order.errors.add(:base, "Insufficient inventory")
27
+ return Result.new(success: false, order: order)
28
+ end
29
+
30
+ if order.save
31
+ send_confirmation(order)
32
+ notify_warehouse(order)
33
+ Result.new(success: true, order: order)
34
+ else
35
+ Result.new(success: false, order: order)
36
+ end
37
+ end
38
+
39
+ private
40
+
41
+ def inventory_available?(order)
42
+ order.line_items.all? { |item| item.product.stock >= item.quantity }
43
+ end
44
+
45
+ def send_confirmation(order)
46
+ OrderMailer.confirmation(order).deliver_later
47
+ end
48
+
49
+ def notify_warehouse(order)
50
+ WarehouseNotificationJob.perform_later(order.id)
51
+ end
52
+
53
+ Result = Struct.new(:success, :order, keyword_init: true) do
54
+ alias_method :success?, :success
55
+ end
56
+ end
57
+ end
58
+ ```
59
+
60
+ The controller becomes a thin delegation layer:
61
+
62
+ ```ruby
63
+ # app/controllers/orders_controller.rb
64
+ class OrdersController < ApplicationController
65
+ def create
66
+ result = Orders::CreateService.call(order_params, current_user)
67
+
68
+ if result.success?
69
+ redirect_to result.order, notice: "Order placed successfully."
70
+ else
71
+ @order = result.order
72
+ render :new, status: :unprocessable_entity
73
+ end
74
+ end
75
+
76
+ private
77
+
78
+ def order_params
79
+ params.require(:order).permit(:shipping_address, line_items_attributes: [:product_id, :quantity])
80
+ end
81
+ end
82
+ ```
83
+
84
+ ## Why This Is Good
85
+
86
+ - **Testable in isolation.** The service object can be tested without routing, request/response cycles, or controller setup. Pass in params and a user, assert the result.
87
+ - **Single responsibility.** The controller handles HTTP concerns (params, redirects, status codes). The service handles business logic (validation, persistence, side effects).
88
+ - **Reusable.** When the same order creation logic is needed from an API endpoint, a Sidekiq job, or a rake task, call the same service. No duplication.
89
+ - **Readable.** A 5-line controller action tells you instantly what happens. The service object's private methods read like a checklist of the business process.
90
+ - **Debuggable.** When order creation breaks, you look at one file — the service. Not a 40-line controller action mixed with HTTP logic.
91
+
92
+ ## Anti-Pattern
93
+
94
+ A controller action that handles business logic, persistence, mailer calls, and external notifications directly:
95
+
96
+ ```ruby
97
+ # app/controllers/orders_controller.rb
98
+ class OrdersController < ApplicationController
99
+ def create
100
+ @order = current_user.orders.build(order_params)
101
+
102
+ @order.line_items.each do |item|
103
+ if item.product.stock < item.quantity
104
+ @order.errors.add(:base, "#{item.product.name} is out of stock")
105
+ render :new, status: :unprocessable_entity
106
+ return
107
+ end
108
+ end
109
+
110
+ if @order.save
111
+ @order.line_items.each do |item|
112
+ item.product.update!(stock: item.product.stock - item.quantity)
113
+ end
114
+
115
+ OrderMailer.confirmation(@order).deliver_later
116
+
117
+ payload = { order_id: @order.id, items: @order.line_items.map(&:id) }
118
+ WarehouseNotificationJob.perform_later(payload.to_json)
119
+
120
+ if @order.total > 1000
121
+ HighValueOrderNotificationJob.perform_later(@order.id)
122
+ end
123
+
124
+ redirect_to @order, notice: "Order placed successfully."
125
+ else
126
+ render :new, status: :unprocessable_entity
127
+ end
128
+ end
129
+ end
130
+ ```
131
+
132
+ ## Why This Is Bad
133
+
134
+ - **Untestable without full stack.** Testing this requires building a request, setting up authentication, creating products with stock, and asserting redirects — all to test business logic that has nothing to do with HTTP.
135
+ - **Impossible to reuse.** When you need to create orders from an API controller, you copy-paste this logic. When the logic changes, you update it in two places (or forget one).
136
+ - **Hard to read.** A developer looking at this action has to mentally separate "what's HTTP" from "what's business logic" from "what's side effects." At 30+ lines, that takes real effort.
137
+ - **Fragile.** Stock decrementation, mailer calls, and warehouse notifications are scattered in the controller. Missing one in a new code path causes inventory errors or silent failures.
138
+ - **Violates SRP.** The controller is handling params, validation, persistence, stock management, email, background jobs, and conditional notifications. That's 7 responsibilities in one method.
139
+
140
+ ## When To Apply
141
+
142
+ Extract to a service object when ANY of these are true:
143
+
144
+ - The action exceeds **8 lines** of logic (excluding param handling and response rendering)
145
+ - The action touches **2 or more models** (e.g., creates an order AND updates product stock)
146
+ - The action has **side effects** beyond persistence — sending emails, enqueuing jobs, calling external APIs, publishing events
147
+ - The **same business logic** is needed in more than one place (API controller, background job, rake task, console)
148
+ - The action contains **conditional business logic** (if order > $1000, do X)
149
+
150
+ ## When NOT To Apply
151
+
152
+ Do NOT extract to a service object when:
153
+
154
+ - The action is **simple CRUD** — receives params, saves one record, responds. A 4-line create action does not need a service object. The overhead of an extra file and class adds complexity without benefit.
155
+ - The action only **reads data** — index and show actions that query and render rarely need services. Use scopes on the model or query objects instead.
156
+ - You're extracting **just to extract**. If the service object would contain 3 lines that mirror what the controller already does, it's not adding value.
157
+
158
+ ```ruby
159
+ # This does NOT need a service object. Leave it in the controller.
160
+ def create
161
+ @comment = @post.comments.build(comment_params)
162
+ @comment.user = current_user
163
+
164
+ if @comment.save
165
+ redirect_to @post, notice: "Comment added."
166
+ else
167
+ render :show, status: :unprocessable_entity
168
+ end
169
+ end
170
+ ```
171
+
172
+ ## Edge Cases
173
+
174
+ **The action is 10 lines but only touches one model:**
175
+ Look at what those lines do. If it's complex validation logic, consider a form object instead. If it's complex querying, consider a query object. Service objects are best for multi-step processes with side effects.
176
+
177
+ **The service object would only be called from one place:**
178
+ That's fine. Single-use services are still valuable for testability and readability. The reuse benefit is a bonus, not a requirement.
179
+
180
+ **The team uses `interactor` or `dry-transaction` gems:**
181
+ Follow the team's established pattern. If they use interactors, write an interactor. Rubyn adapts to the project's conventions (detected via codebase memory), not the other way around.
182
+
183
+ **The existing codebase has no `app/services/` directory:**
184
+ Create it. This is a standard Rails convention even though Rails doesn't generate it by default. Place the service at `app/services/{resource}/{action}_service.rb`.