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,237 @@
1
+ # SOLID: Interface Segregation Principle (ISP)
2
+
3
+ ## Pattern
4
+
5
+ No client should be forced to depend on methods it doesn't use. In Ruby — where interfaces are implicit (duck typing) — ISP means: keep your modules, mixins, and object contracts small and focused. Don't force an object to implement capabilities it doesn't need.
6
+
7
+ ```ruby
8
+ # GOOD: Focused, small interfaces via separate modules
9
+
10
+ module Printable
11
+ def to_pdf
12
+ raise NotImplementedError
13
+ end
14
+ end
15
+
16
+ module Exportable
17
+ def to_csv
18
+ raise NotImplementedError
19
+ end
20
+
21
+ def to_json(*)
22
+ raise NotImplementedError
23
+ end
24
+ end
25
+
26
+ module Notifiable
27
+ def send_notification
28
+ raise NotImplementedError
29
+ end
30
+ end
31
+
32
+ # Order needs all three
33
+ class Order < ApplicationRecord
34
+ include Printable
35
+ include Exportable
36
+ include Notifiable
37
+
38
+ def to_pdf
39
+ OrderPdfGenerator.new(self).generate
40
+ end
41
+
42
+ def to_csv
43
+ [reference, user.email, total, status].join(",")
44
+ end
45
+
46
+ def to_json(*)
47
+ { reference: reference, total: total, status: status }.to_json
48
+ end
49
+
50
+ def send_notification
51
+ OrderMailer.confirmation(self).deliver_later
52
+ end
53
+ end
54
+
55
+ # Receipt only needs printing — not forced to implement export or notifications
56
+ class Receipt < ApplicationRecord
57
+ include Printable
58
+
59
+ def to_pdf
60
+ ReceiptPdfGenerator.new(self).generate
61
+ end
62
+ end
63
+
64
+ # Report only needs export — not forced to implement printing or notifications
65
+ class MonthlyReport
66
+ include Exportable
67
+
68
+ def to_csv
69
+ # ... generate CSV
70
+ end
71
+
72
+ def to_json(*)
73
+ # ... generate JSON
74
+ end
75
+ end
76
+ ```
77
+
78
+ ISP applied to service dependencies:
79
+
80
+ ```ruby
81
+ # GOOD: Service depends only on what it needs
82
+
83
+ # Instead of depending on the entire User model:
84
+ class WelcomeEmailService
85
+ # Only needs an email address and a name — not 30 User methods
86
+ def call(email:, name:)
87
+ WelcomeMailer.send(email: email, name: name).deliver_later
88
+ end
89
+ end
90
+
91
+ # Caller provides only what's needed
92
+ WelcomeEmailService.new.call(email: user.email, name: user.name)
93
+
94
+ # The service can also be called with non-User data:
95
+ WelcomeEmailService.new.call(email: "invite@example.com", name: "New Friend")
96
+ ```
97
+
98
+ ISP with dependency injection — narrow interfaces:
99
+
100
+ ```ruby
101
+ # GOOD: The indexer only needs objects that respond to #embed
102
+ # It doesn't care if the client also has #health, #version, #warm_up
103
+
104
+ class Codebase::Indexer
105
+ def initialize(embedder:)
106
+ @embedder = embedder # Only needs: embedder.embed(texts) → Array<Array<Float>>
107
+ end
108
+
109
+ def index(project, files)
110
+ files.each do |path, content|
111
+ chunks = Chunker.split(content)
112
+ vectors = @embedder.embed(chunks.map(&:text)) # The only method we call
113
+ chunks.zip(vectors).each do |chunk, vector|
114
+ project.code_embeddings.create!(
115
+ file_path: path,
116
+ chunk_content: chunk.text,
117
+ embedding: vector
118
+ )
119
+ end
120
+ end
121
+ end
122
+ end
123
+
124
+ # Any of these work — they all respond to #embed
125
+ Codebase::Indexer.new(embedder: EmbeddingClient.new) # Real client
126
+ Codebase::Indexer.new(embedder: FakeEmbedder.new) # Test double
127
+ Codebase::Indexer.new(embedder: CachedEmbedder.new(client)) # Decorator
128
+ ```
129
+
130
+ ## Why This Is Good
131
+
132
+ - **Models include only what they need.** `Receipt` includes `Printable` but not `Exportable`. It's never forced to stub out `to_csv` or `to_json` with `raise NotImplementedError`.
133
+ - **Services depend on narrow interfaces.** `WelcomeEmailService` needs an email and a name — not a 30-method User object. It works with any data source that provides those two values.
134
+ - **Testing is simpler.** To test `Codebase::Indexer`, you provide an object that responds to `embed`. You don't need to mock the 5 other methods on `EmbeddingClient`.
135
+ - **Changes are isolated.** If `Exportable` adds a `to_xml` method, only classes that include `Exportable` are affected. `Receipt` (which only includes `Printable`) is untouched.
136
+
137
+ ## Anti-Pattern
138
+
139
+ A fat module that forces every includer to implement everything:
140
+
141
+ ```ruby
142
+ # BAD: One massive module forces all methods on every includer
143
+ module DocumentCapabilities
144
+ def to_pdf
145
+ raise NotImplementedError
146
+ end
147
+
148
+ def to_csv
149
+ raise NotImplementedError
150
+ end
151
+
152
+ def to_json(*)
153
+ raise NotImplementedError
154
+ end
155
+
156
+ def to_xml
157
+ raise NotImplementedError
158
+ end
159
+
160
+ def send_email
161
+ raise NotImplementedError
162
+ end
163
+
164
+ def send_sms
165
+ raise NotImplementedError
166
+ end
167
+
168
+ def archive
169
+ raise NotImplementedError
170
+ end
171
+
172
+ def encrypt
173
+ raise NotImplementedError
174
+ end
175
+ end
176
+
177
+ # Receipt only needs PDF but is forced to "implement" everything
178
+ class Receipt < ApplicationRecord
179
+ include DocumentCapabilities
180
+
181
+ def to_pdf
182
+ ReceiptPdfGenerator.new(self).generate
183
+ end
184
+
185
+ # These all raise NotImplementedError — they shouldn't exist on Receipt at all
186
+ # But the module forced them in
187
+ end
188
+ ```
189
+
190
+ ## Why This Is Bad
191
+
192
+ - **Receipt responds to 8 methods it can't do.** `receipt.respond_to?(:send_sms)` returns `true`, but calling it raises `NotImplementedError`. The interface lies about the object's capabilities.
193
+ - **Forced implementation of irrelevant methods.** A developer including `DocumentCapabilities` must consider all 8 methods. They waste time figuring out which ones their class needs and stub the rest.
194
+ - **Brittle to change.** Adding a new method to `DocumentCapabilities` (say, `to_parquet`) requires every includer to either implement it or get a `NotImplementedError`. One module change ripples across all including classes.
195
+ - **Violates LSP.** If code calls `.send_sms` on any object including `DocumentCapabilities`, some objects work and others raise. The contract is unreliable.
196
+
197
+ ## When To Apply
198
+
199
+ - **When a module/mixin has more than 4-5 methods and not all includers need all of them.** Split it into focused sub-modules.
200
+ - **When a service or method accepts a complex object but only uses 1-2 attributes.** Accept those attributes directly instead of the whole object.
201
+ - **When you inject dependencies.** Define the narrowest interface the consumer needs. Document what methods are required. Don't pass the kitchen sink.
202
+ - **In gems and libraries.** Public interfaces should be minimal. Don't force gem users to configure 10 options when they only need 2.
203
+
204
+ ## When NOT To Apply
205
+
206
+ - **Don't split a 3-method module into 3 single-method modules.** ISP is about avoiding *fat* interfaces, not achieving one-method-per-module granularity.
207
+ - **ActiveRecord models inherently have many methods.** That's the framework's design. Don't fight it by wrapping every model in a narrow interface object for internal use.
208
+ - **Small, cohesive modules are already ISP-compliant.** A `Sluggable` module with `generate_slug` and `to_param` is fine — both methods are part of the same concept.
209
+
210
+ ## Edge Cases
211
+
212
+ **Ruby's duck typing IS interface segregation:**
213
+ When you write a method that calls `object.each`, you've defined a one-method interface. Any Enumerable works. Ruby's duck typing naturally encourages narrow interfaces — lean into it.
214
+
215
+ ```ruby
216
+ # This method's "interface" is: responds to .each and yields items with .email
217
+ def collect_emails(collection)
218
+ collection.each_with_object([]) { |item, emails| emails << item.email }
219
+ end
220
+
221
+ # Works with any collection of objects that have .email
222
+ collect_emails(User.active)
223
+ collect_emails([subscriber_a, subscriber_b])
224
+ collect_emails(team.members)
225
+ ```
226
+
227
+ **Frozen value objects as narrow interfaces:**
228
+ Instead of passing a User model to a service, pass a data object with only the needed attributes:
229
+
230
+ ```ruby
231
+ NotificationPayload = Data.define(:email, :name, :phone)
232
+
233
+ payload = NotificationPayload.new(email: user.email, name: user.name, phone: user.phone)
234
+ NotificationService.call(payload)
235
+ ```
236
+
237
+ This makes the dependency explicit and narrow.
@@ -0,0 +1,263 @@
1
+ # SOLID: Liskov Substitution Principle (LSP)
2
+
3
+ ## Pattern
4
+
5
+ If code works with a parent type, it must also work with any subtype without knowing the difference. Subtypes must honor the parent's contract — same method signatures, compatible return types, no strengthened preconditions, no weakened postconditions.
6
+
7
+ In Ruby, LSP applies to duck typing: any object that claims to implement an interface must behave consistently with other objects that implement that interface.
8
+
9
+ ```ruby
10
+ # GOOD: Both notifiers honor the same contract
11
+ # Contract: #deliver(user, message) → sends a notification, returns a Result
12
+
13
+ class Notifications::EmailNotifier
14
+ def deliver(user, message)
15
+ NotificationMailer.notify(user.email, message).deliver_later
16
+ Result.new(success: true, channel: :email)
17
+ rescue Net::SMTPError => e
18
+ Result.new(success: false, channel: :email, error: e.message)
19
+ end
20
+ end
21
+
22
+ class Notifications::SmsNotifier
23
+ def deliver(user, message)
24
+ truncated = message.truncate(160)
25
+ SmsClient.send(user.phone, truncated)
26
+ Result.new(success: true, channel: :sms)
27
+ rescue SmsClient::DeliveryError => e
28
+ Result.new(success: false, channel: :sms, error: e.message)
29
+ end
30
+ end
31
+
32
+ class Notifications::SlackNotifier
33
+ def deliver(user, message)
34
+ SlackClient.post(channel: user.slack_channel, text: message)
35
+ Result.new(success: true, channel: :slack)
36
+ rescue Slack::Web::Api::Errors::ChannelNotFound => e
37
+ Result.new(success: false, channel: :slack, error: e.message)
38
+ end
39
+ end
40
+
41
+ # The dispatcher doesn't know or care which notifier it's using
42
+ # Any notifier is substitutable for any other — LSP satisfied
43
+ class Notifications::Dispatcher
44
+ def initialize(notifiers:)
45
+ @notifiers = notifiers
46
+ end
47
+
48
+ def broadcast(user, message)
49
+ @notifiers.map { |notifier| notifier.deliver(user, message) }
50
+ end
51
+ end
52
+
53
+ # All substitutable
54
+ dispatcher = Notifications::Dispatcher.new(notifiers: [
55
+ Notifications::EmailNotifier.new,
56
+ Notifications::SmsNotifier.new,
57
+ Notifications::SlackNotifier.new
58
+ ])
59
+ results = dispatcher.broadcast(user, "Your order shipped!")
60
+ ```
61
+
62
+ LSP with inheritance:
63
+
64
+ ```ruby
65
+ # GOOD: Subclasses extend, they don't contradict
66
+ class Report
67
+ def generate(start_date, end_date)
68
+ data = fetch_data(start_date, end_date)
69
+ format(data)
70
+ end
71
+
72
+ private
73
+
74
+ def fetch_data(start_date, end_date)
75
+ raise NotImplementedError
76
+ end
77
+
78
+ def format(data)
79
+ raise NotImplementedError
80
+ end
81
+ end
82
+
83
+ class RevenueReport < Report
84
+ private
85
+
86
+ def fetch_data(start_date, end_date)
87
+ Order.where(created_at: start_date..end_date).group(:status).sum(:total)
88
+ end
89
+
90
+ def format(data)
91
+ data.map { |status, total| "#{status}: $#{total}" }.join("\n")
92
+ end
93
+ end
94
+
95
+ class UserActivityReport < Report
96
+ private
97
+
98
+ def fetch_data(start_date, end_date)
99
+ User.where(last_active_at: start_date..end_date).group_by_day(:last_active_at).count
100
+ end
101
+
102
+ def format(data)
103
+ data.map { |date, count| "#{date}: #{count} active users" }.join("\n")
104
+ end
105
+ end
106
+
107
+ # Any Report subclass can be used anywhere a Report is expected
108
+ def email_report(report, recipient, start_date, end_date)
109
+ content = report.generate(start_date, end_date) # Works for any subclass
110
+ ReportMailer.send(recipient, content).deliver_later
111
+ end
112
+
113
+ email_report(RevenueReport.new, "cfo@company.com", 30.days.ago, Date.today)
114
+ email_report(UserActivityReport.new, "pm@company.com", 7.days.ago, Date.today)
115
+ ```
116
+
117
+ ## Why This Is Good
118
+
119
+ - **Substitutable objects enable polymorphism.** The `Dispatcher` works with any notifier. `email_report` works with any report. Code that depends on the interface is decoupled from specific implementations.
120
+ - **Consistent contracts prevent surprises.** Every notifier returns a `Result` with `success?`, `channel`, and `error`. Code that processes results doesn't need special handling for each notifier type.
121
+ - **Error handling is uniform.** Each notifier catches its own exceptions and returns a `Result`. The dispatcher never sees a raw `Net::SMTPError` or `Slack::Web::Api::Errors::ChannelNotFound` — the notifiers normalize errors into the shared contract.
122
+ - **New types are safe.** Adding a `PushNotifier` is safe as long as it returns a `Result` from `deliver`. No existing code needs to know about push notifications.
123
+
124
+ ## Anti-Pattern
125
+
126
+ A subtype that violates the parent's contract:
127
+
128
+ ```ruby
129
+ class FileStorage
130
+ def save(key, content)
131
+ File.write(storage_path(key), content)
132
+ true
133
+ end
134
+
135
+ def read(key)
136
+ File.read(storage_path(key))
137
+ end
138
+
139
+ def delete(key)
140
+ File.delete(storage_path(key))
141
+ true
142
+ end
143
+ end
144
+
145
+ class ReadOnlyStorage < FileStorage
146
+ def save(key, content)
147
+ raise NotImplementedError, "ReadOnlyStorage cannot save"
148
+ end
149
+
150
+ def delete(key)
151
+ raise NotImplementedError, "ReadOnlyStorage cannot delete"
152
+ end
153
+ end
154
+
155
+ # This breaks LSP:
156
+ def backup(storage, data)
157
+ storage.save("backup-#{Date.today}", data) # BOOM for ReadOnlyStorage
158
+ end
159
+
160
+ backup(FileStorage.new, data) # Works
161
+ backup(ReadOnlyStorage.new, data) # Raises NotImplementedError
162
+ ```
163
+
164
+ ```ruby
165
+ # Another violation: changing return types
166
+ class UserFinder
167
+ def find(id)
168
+ User.find(id) # Returns a User or raises RecordNotFound
169
+ end
170
+ end
171
+
172
+ class CachedUserFinder < UserFinder
173
+ def find(id)
174
+ Rails.cache.fetch("user:#{id}") do
175
+ User.find_by(id: id) # Returns nil instead of raising! Contract broken.
176
+ end
177
+ end
178
+ end
179
+ ```
180
+
181
+ ## Why This Is Bad
182
+
183
+ - **`ReadOnlyStorage` can't substitute for `FileStorage`.** Any code expecting a `FileStorage` that calls `save` will crash. The subclass has *strengthened the precondition* (you can't call save) — a direct LSP violation.
184
+ - **`CachedUserFinder` changes the contract.** `UserFinder#find` raises on missing records. `CachedUserFinder#find` returns `nil`. Code that relies on the exception for flow control will silently get `nil` and crash later with a `NoMethodError` on `nil`.
185
+ - **Type checks appear.** When subtypes are unreliable, callers start adding `is_a?` checks: `if storage.is_a?(ReadOnlyStorage)`. This defeats the purpose of polymorphism and creates brittle, coupled code.
186
+
187
+ ## When To Apply
188
+
189
+ - **Whenever you use duck typing.** If two objects respond to the same method, they must behave the same way — same parameters accepted, same return type, same error behavior.
190
+ - **Whenever you inherit.** Subclasses must not remove capabilities, change return types, or raise unexpected exceptions. If a subclass needs to behave differently, it probably shouldn't be a subclass.
191
+ - **When designing interfaces for plugins or strategies.** Document the contract: what methods, what parameters, what return types, what errors. Every implementation must honor the contract.
192
+
193
+ ## When NOT To Apply
194
+
195
+ - **Template Method pattern legitimately varies behavior.** `Report#fetch_data` raises `NotImplementedError` in the base class — subclasses are *expected* to override it. This isn't an LSP violation because the base class is abstract; no code calls `Report.new.generate` directly.
196
+ - **Ruby doesn't have formal interfaces.** LSP in Ruby is about behavioral contracts, not type signatures. Two objects can have different class hierarchies and still be LSP-compliant if they honor the same duck-type contract.
197
+
198
+ ## Edge Cases
199
+
200
+ **How to fix the ReadOnlyStorage problem:**
201
+ Don't inherit. Use separate interfaces:
202
+
203
+ ```ruby
204
+ module Readable
205
+ def read(key)
206
+ raise NotImplementedError
207
+ end
208
+ end
209
+
210
+ module Writable
211
+ def save(key, content)
212
+ raise NotImplementedError
213
+ end
214
+
215
+ def delete(key)
216
+ raise NotImplementedError
217
+ end
218
+ end
219
+
220
+ class FileStorage
221
+ include Readable
222
+ include Writable
223
+ # ... implements all methods
224
+ end
225
+
226
+ class ReadOnlyStorage
227
+ include Readable
228
+ # Only read, never promises write
229
+ end
230
+
231
+ # Code that needs to write asks for Writable:
232
+ def backup(storage, data)
233
+ # storage must include Writable — ReadOnlyStorage won't be passed here
234
+ storage.save("backup", data)
235
+ end
236
+ ```
237
+
238
+ **Testing LSP compliance:**
239
+ Shared examples enforce the contract across all implementations:
240
+
241
+ ```ruby
242
+ RSpec.shared_examples "a notifier" do
243
+ it "returns a Result from deliver" do
244
+ result = subject.deliver(user, "test message")
245
+ expect(result).to respond_to(:success?, :channel, :error)
246
+ end
247
+
248
+ it "returns success: true or false, never nil" do
249
+ result = subject.deliver(user, "test")
250
+ expect(result.success?).to be(true).or be(false)
251
+ end
252
+ end
253
+
254
+ RSpec.describe Notifications::EmailNotifier do
255
+ subject { described_class.new }
256
+ it_behaves_like "a notifier"
257
+ end
258
+
259
+ RSpec.describe Notifications::SmsNotifier do
260
+ subject { described_class.new }
261
+ it_behaves_like "a notifier"
262
+ end
263
+ ```
@@ -0,0 +1,212 @@
1
+ # SOLID: Open/Closed Principle (OCP)
2
+
3
+ ## Pattern
4
+
5
+ Software entities should be open for extension but closed for modification. Add new behavior by writing new code (new classes, modules, or configurations) — not by editing existing, working code.
6
+
7
+ In Ruby, OCP is achieved through polymorphism, duck typing, dependency injection, and the Strategy pattern — not through inheritance hierarchies.
8
+
9
+ ```ruby
10
+ # GOOD: New payment methods added without modifying existing code
11
+
12
+ # Each payment processor implements the same interface
13
+ class Payments::StripeProcessor
14
+ def charge(amount_cents, payment_method_token)
15
+ Stripe::Charge.create(
16
+ amount: amount_cents,
17
+ currency: "usd",
18
+ source: payment_method_token
19
+ )
20
+ Result.new(success: true)
21
+ rescue Stripe::CardError => e
22
+ Result.new(success: false, error: e.message)
23
+ end
24
+ end
25
+
26
+ class Payments::PaypalProcessor
27
+ def charge(amount_cents, payment_method_token)
28
+ PayPal::SDK::REST::Payment.new(
29
+ intent: "sale",
30
+ payer: { payment_method: "paypal" },
31
+ transactions: [{ amount: { total: (amount_cents / 100.0).to_s, currency: "USD" } }]
32
+ ).create
33
+ Result.new(success: true)
34
+ rescue PayPal::SDK::Core::Exceptions::ServerError => e
35
+ Result.new(success: false, error: e.message)
36
+ end
37
+ end
38
+
39
+ # Adding Braintree? Write a new class. Don't touch Stripe or PayPal.
40
+ class Payments::BraintreeProcessor
41
+ def charge(amount_cents, payment_method_token)
42
+ result = Braintree::Transaction.sale(
43
+ amount: (amount_cents / 100.0).round(2),
44
+ payment_method_nonce: payment_method_token
45
+ )
46
+ Result.new(success: result.success?, error: result.message)
47
+ end
48
+ end
49
+
50
+ # The service accepts any processor — open for extension via injection
51
+ class Orders::ChargeService
52
+ def initialize(processor:)
53
+ @processor = processor
54
+ end
55
+
56
+ def call(order)
57
+ result = @processor.charge(order.total_cents, order.payment_token)
58
+
59
+ if result.success?
60
+ order.update!(status: :paid, paid_at: Time.current)
61
+ else
62
+ order.update!(status: :payment_failed)
63
+ end
64
+
65
+ result
66
+ end
67
+ end
68
+
69
+ # Usage — new processors slot in without touching ChargeService
70
+ processor = Payments::StripeProcessor.new
71
+ Orders::ChargeService.new(processor: processor).call(order)
72
+ ```
73
+
74
+ Another common Ruby OCP pattern — registry/plugin architecture:
75
+
76
+ ```ruby
77
+ # Notification channels — add new ones without modifying the dispatcher
78
+ class Notifications::Dispatcher
79
+ REGISTRY = {}
80
+
81
+ def self.register(channel_name, handler_class)
82
+ REGISTRY[channel_name] = handler_class
83
+ end
84
+
85
+ def self.dispatch(user, message)
86
+ user.notification_preferences.each do |channel|
87
+ handler = REGISTRY[channel]
88
+ handler&.new&.deliver(user, message)
89
+ end
90
+ end
91
+ end
92
+
93
+ # Each channel registers itself — no switch statements, no modification to Dispatcher
94
+ class Notifications::EmailHandler
95
+ def deliver(user, message)
96
+ NotificationMailer.notify(user, message).deliver_later
97
+ end
98
+ end
99
+ Notifications::Dispatcher.register(:email, Notifications::EmailHandler)
100
+
101
+ class Notifications::SmsHandler
102
+ def deliver(user, message)
103
+ SmsClient.send(user.phone, message)
104
+ end
105
+ end
106
+ Notifications::Dispatcher.register(:sms, Notifications::SmsHandler)
107
+
108
+ # Adding push notifications? New file, new class, one register call.
109
+ class Notifications::PushHandler
110
+ def deliver(user, message)
111
+ PushService.send(user.device_token, message)
112
+ end
113
+ end
114
+ Notifications::Dispatcher.register(:push, Notifications::PushHandler)
115
+ ```
116
+
117
+ ## Why This Is Good
118
+
119
+ - **Existing code stays untouched.** Adding a new payment processor doesn't require editing `ChargeService`, `StripeProcessor`, or `PaypalProcessor`. Tested, deployed code remains stable.
120
+ - **Reduced regression risk.** When you don't modify existing code, you can't break existing behavior. The new `BraintreeProcessor` can only break Braintree payments.
121
+ - **Ruby duck typing makes this natural.** No need for explicit interfaces or abstract base classes. Any object that responds to `charge(amount_cents, token)` works as a processor. Ruby's flexibility makes OCP lightweight.
122
+ - **Dependency injection is the mechanism.** `ChargeService.new(processor: processor)` accepts any processor at runtime. The service doesn't know or care which processor it gets — it just calls `charge`.
123
+
124
+ ## Anti-Pattern
125
+
126
+ A case/when statement that grows every time a new type is added:
127
+
128
+ ```ruby
129
+ class Orders::ChargeService
130
+ def call(order)
131
+ case order.payment_method
132
+ when "stripe"
133
+ charge_with_stripe(order)
134
+ when "paypal"
135
+ charge_with_paypal(order)
136
+ when "braintree"
137
+ charge_with_braintree(order)
138
+ when "apple_pay"
139
+ charge_with_apple_pay(order)
140
+ # Every new payment method adds another branch HERE
141
+ end
142
+ end
143
+
144
+ private
145
+
146
+ def charge_with_stripe(order)
147
+ # 20 lines of Stripe-specific code
148
+ end
149
+
150
+ def charge_with_paypal(order)
151
+ # 20 lines of PayPal-specific code
152
+ end
153
+
154
+ def charge_with_braintree(order)
155
+ # 20 lines of Braintree-specific code
156
+ end
157
+
158
+ def charge_with_apple_pay(order)
159
+ # 20 lines of Apple Pay-specific code
160
+ end
161
+ end
162
+ ```
163
+
164
+ ## Why This Is Bad
165
+
166
+ - **Every new type modifies existing code.** Adding Apple Pay means opening `ChargeService` and adding a new `when` branch and a new private method. The class is modified, not extended.
167
+ - **Growing case statements.** With 10 payment methods, this class has 10 branches and 10 private methods. It's 200+ lines of unrelated payment logic in one file.
168
+ - **Impossible to test in isolation.** Testing Stripe logic means loading the entire `ChargeService` with all its payment method dependencies. You can't test one processor without the others being present.
169
+ - **Violates SRP too.** `ChargeService` now has 4 reasons to change — one for each payment provider's API changes.
170
+
171
+ ## When To Apply
172
+
173
+ - **You see a `case` or `if/elsif` that switches on a type.** `case record.type`, `if method == :stripe`, `when "csv"` — these are branching on type, which is polymorphism waiting to happen.
174
+ - **You expect more variants in the future.** If you have 2 payment methods and expect 5, design for extension now. If you have 2 and will only ever have 2, a simple `if` is fine.
175
+ - **Multiple team members add different variants.** If one developer adds Stripe while another adds PayPal, separate classes prevent merge conflicts and enable parallel work.
176
+
177
+ ## When NOT To Apply
178
+
179
+ - **Stable, finite branching.** A method that handles `success` and `failure` doesn't need polymorphism. Two branches that will never grow are fine as an `if/else`.
180
+ - **Don't create abstract factories for 2 classes.** OCP is about enabling future extension, not building frameworks. If you have 2 processors and no plans for a third, injecting a concrete processor is sufficient.
181
+ - **Rails conventions already handle this.** STI, enums with methods, and ActiveSupport::Concern are Rails' way of achieving OCP. Don't reinvent a plugin architecture when Rails patterns suffice.
182
+
183
+ ## Edge Cases
184
+
185
+ **Ruby blocks as the ultimate OCP mechanism:**
186
+ Blocks let you inject behavior without any class:
187
+
188
+ ```ruby
189
+ def process_items(items, &formatter)
190
+ items.each { |item| puts formatter.call(item) }
191
+ end
192
+
193
+ process_items(orders) { |o| "#{o.reference}: $#{o.total}" }
194
+ process_items(orders) { |o| o.to_json }
195
+ ```
196
+
197
+ **When the switch is on YOUR domain types (enums):**
198
+ Rails enums with methods on the model can be a pragmatic alternative to full polymorphism:
199
+
200
+ ```ruby
201
+ class Order < ApplicationRecord
202
+ enum :status, { pending: 0, confirmed: 1, shipped: 2 }
203
+
204
+ def status_label
205
+ { "pending" => "Awaiting Confirmation",
206
+ "confirmed" => "Processing",
207
+ "shipped" => "On Its Way" }[status]
208
+ end
209
+ end
210
+ ```
211
+
212
+ This is fine for display logic. For complex behavior that differs by status (different validations, different transitions, different side effects), use the State pattern instead.