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,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.
|