rubino-agent 0.3.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/.rspec +3 -0
- data/.rubocop.yml +115 -0
- data/.rubocop_todo.yml +955 -0
- data/.ruby-version +1 -0
- data/AGENTS.md +97 -0
- data/CHANGELOG.md +344 -0
- data/CONTRIBUTING.md +69 -0
- data/LICENSE +21 -0
- data/README.md +200 -0
- data/Rakefile +8 -0
- data/docs/agents.md +190 -0
- data/docs/api/v1.md +414 -0
- data/docs/architecture.md +177 -0
- data/docs/commands.md +375 -0
- data/docs/configuration.md +590 -0
- data/docs/getting-started.md +143 -0
- data/docs/jobs.md +332 -0
- data/docs/mcp.md +128 -0
- data/docs/memory.md +98 -0
- data/docs/models-and-keys.md +173 -0
- data/docs/oauth-providers.md +145 -0
- data/docs/plugins.md +195 -0
- data/docs/security.md +145 -0
- data/docs/skills.md +322 -0
- data/docs/tools.md +395 -0
- data/docs/troubleshooting.md +73 -0
- data/exe/rubino +9 -0
- data/install.sh +275 -0
- data/lib/rubino/active_skill.rb +50 -0
- data/lib/rubino/agent/agent_registry.rb +120 -0
- data/lib/rubino/agent/backoff_policy.rb +116 -0
- data/lib/rubino/agent/definition.rb +128 -0
- data/lib/rubino/agent/degenerate_recovery.rb +271 -0
- data/lib/rubino/agent/fallback_chain.rb +194 -0
- data/lib/rubino/agent/iteration_budget.rb +50 -0
- data/lib/rubino/agent/loop.rb +617 -0
- data/lib/rubino/agent/model_call_runner.rb +383 -0
- data/lib/rubino/agent/prompts/build.txt +69 -0
- data/lib/rubino/agent/prompts/compaction.txt +20 -0
- data/lib/rubino/agent/prompts/explore.txt +19 -0
- data/lib/rubino/agent/prompts/general.txt +20 -0
- data/lib/rubino/agent/prompts/plan.txt +31 -0
- data/lib/rubino/agent/response_validator.rb +70 -0
- data/lib/rubino/agent/router.rb +65 -0
- data/lib/rubino/agent/runner.rb +195 -0
- data/lib/rubino/agent/tool_executor.rb +402 -0
- data/lib/rubino/agent/truncation_continuation.rb +137 -0
- data/lib/rubino/api/middleware/auth.rb +43 -0
- data/lib/rubino/api/middleware/error_handler.rb +65 -0
- data/lib/rubino/api/middleware/json_parser.rb +100 -0
- data/lib/rubino/api/middleware/observability.rb +59 -0
- data/lib/rubino/api/middleware/rate_limit.rb +136 -0
- data/lib/rubino/api/operations/approvals/decide_operation.rb +49 -0
- data/lib/rubino/api/operations/clarifications/decide_operation.rb +44 -0
- data/lib/rubino/api/operations/cron_jobs/create_operation.rb +46 -0
- data/lib/rubino/api/operations/cron_jobs/delete_operation.rb +36 -0
- data/lib/rubino/api/operations/cron_jobs/list_operation.rb +55 -0
- data/lib/rubino/api/operations/cron_jobs/pause_operation.rb +34 -0
- data/lib/rubino/api/operations/cron_jobs/resume_operation.rb +34 -0
- data/lib/rubino/api/operations/cron_jobs/schedule_validation.rb +30 -0
- data/lib/rubino/api/operations/cron_jobs/show_operation.rb +32 -0
- data/lib/rubino/api/operations/cron_jobs/trigger_operation.rb +38 -0
- data/lib/rubino/api/operations/cron_jobs/update_operation.rb +42 -0
- data/lib/rubino/api/operations/files/read_operation.rb +40 -0
- data/lib/rubino/api/operations/files/upload_operation.rb +175 -0
- data/lib/rubino/api/operations/health_operation.rb +46 -0
- data/lib/rubino/api/operations/memory/delete_operation.rb +32 -0
- data/lib/rubino/api/operations/memory/index_operation.rb +80 -0
- data/lib/rubino/api/operations/memory/stats_operation.rb +28 -0
- data/lib/rubino/api/operations/metrics_operation.rb +18 -0
- data/lib/rubino/api/operations/mode/show_operation.rb +29 -0
- data/lib/rubino/api/operations/mode/update_operation.rb +42 -0
- data/lib/rubino/api/operations/models/list_operation.rb +45 -0
- data/lib/rubino/api/operations/oauth/connections/disconnect_operation.rb +77 -0
- data/lib/rubino/api/operations/oauth/connections/list_operation.rb +36 -0
- data/lib/rubino/api/operations/oauth/providers/callback_operation.rb +82 -0
- data/lib/rubino/api/operations/oauth/providers/connect_operation.rb +44 -0
- data/lib/rubino/api/operations/oauth/providers/list_operation.rb +35 -0
- data/lib/rubino/api/operations/oauth/serializer.rb +21 -0
- data/lib/rubino/api/operations/runs/create_operation.rb +77 -0
- data/lib/rubino/api/operations/runs/events_operation.rb +195 -0
- data/lib/rubino/api/operations/runs/stop_operation.rb +34 -0
- data/lib/rubino/api/operations/sessions/create_operation.rb +46 -0
- data/lib/rubino/api/operations/sessions/delete_operation.rb +33 -0
- data/lib/rubino/api/operations/sessions/index_operation.rb +82 -0
- data/lib/rubino/api/operations/sessions/retry_operation.rb +45 -0
- data/lib/rubino/api/operations/sessions/show_operation.rb +59 -0
- data/lib/rubino/api/operations/sessions/undo_operation.rb +38 -0
- data/lib/rubino/api/operations/skills/list_operation.rb +34 -0
- data/lib/rubino/api/operations/skills/toggle_operation.rb +40 -0
- data/lib/rubino/api/operations/tasks/index_operation.rb +30 -0
- data/lib/rubino/api/operations/tasks/serializer.rb +60 -0
- data/lib/rubino/api/operations/tasks/show_operation.rb +33 -0
- data/lib/rubino/api/operations/tasks/stop_operation.rb +47 -0
- data/lib/rubino/api/request.rb +54 -0
- data/lib/rubino/api/responses.rb +64 -0
- data/lib/rubino/api/router.rb +72 -0
- data/lib/rubino/api/schemas.rb +103 -0
- data/lib/rubino/api/server.rb +102 -0
- data/lib/rubino/api/tls.rb +108 -0
- data/lib/rubino/attachments/classification.rb +16 -0
- data/lib/rubino/attachments/classify.rb +171 -0
- data/lib/rubino/attachments/defang.rb +47 -0
- data/lib/rubino/attachments/policy.rb +36 -0
- data/lib/rubino/attachments/preamble.rb +120 -0
- data/lib/rubino/boot/encryption_key.rb +32 -0
- data/lib/rubino/cli/chat/bang_shell.rb +257 -0
- data/lib/rubino/cli/chat/completion_builder.rb +290 -0
- data/lib/rubino/cli/chat/idle_card_host.rb +69 -0
- data/lib/rubino/cli/chat/image_inbox.rb +168 -0
- data/lib/rubino/cli/chat/session_resolver.rb +176 -0
- data/lib/rubino/cli/chat_command.rb +1674 -0
- data/lib/rubino/cli/commands.rb +250 -0
- data/lib/rubino/cli/config_command.rb +96 -0
- data/lib/rubino/cli/doctor_command.rb +251 -0
- data/lib/rubino/cli/jobs_command.rb +60 -0
- data/lib/rubino/cli/memory_command.rb +135 -0
- data/lib/rubino/cli/onboarding_wizard.rb +207 -0
- data/lib/rubino/cli/server_command.rb +139 -0
- data/lib/rubino/cli/session_command.rb +125 -0
- data/lib/rubino/cli/setup_command.rb +107 -0
- data/lib/rubino/cli/skills_command.rb +85 -0
- data/lib/rubino/cli/tools_command.rb +81 -0
- data/lib/rubino/cli/trust_gate.rb +71 -0
- data/lib/rubino/commands/built_ins.rb +46 -0
- data/lib/rubino/commands/command.rb +116 -0
- data/lib/rubino/commands/executor.rb +550 -0
- data/lib/rubino/commands/handlers/agents.rb +510 -0
- data/lib/rubino/commands/handlers/config.rb +88 -0
- data/lib/rubino/commands/handlers/help.rb +148 -0
- data/lib/rubino/commands/handlers/jobs.rb +71 -0
- data/lib/rubino/commands/handlers/mcp.rb +229 -0
- data/lib/rubino/commands/handlers/memory.rb +200 -0
- data/lib/rubino/commands/handlers/sessions.rb +207 -0
- data/lib/rubino/commands/handlers/skills.rb +195 -0
- data/lib/rubino/commands/handlers/status.rb +211 -0
- data/lib/rubino/commands/loader.rb +90 -0
- data/lib/rubino/config/configuration.rb +455 -0
- data/lib/rubino/config/defaults.rb +569 -0
- data/lib/rubino/config/loader.rb +115 -0
- data/lib/rubino/config/reasoning_prefs.rb +67 -0
- data/lib/rubino/config/writer.rb +72 -0
- data/lib/rubino/context/compressor.rb +149 -0
- data/lib/rubino/context/environment_inspector.rb +176 -0
- data/lib/rubino/context/file_discovery.rb +45 -0
- data/lib/rubino/context/message_boundary.rb +39 -0
- data/lib/rubino/context/prompt_assembler.rb +382 -0
- data/lib/rubino/context/summary_builder.rb +159 -0
- data/lib/rubino/context/token_budget.rb +68 -0
- data/lib/rubino/context/tool_pair_sanitizer.rb +70 -0
- data/lib/rubino/database/connection.rb +77 -0
- data/lib/rubino/database/migrations/001_create_initial_schema.rb +156 -0
- data/lib/rubino/database/migrations/002_create_runs.rb +45 -0
- data/lib/rubino/database/migrations/003_create_skill_states.rb +15 -0
- data/lib/rubino/database/migrations/004_create_cron_jobs.rb +36 -0
- data/lib/rubino/database/migrations/005_create_oauth_connections.rb +27 -0
- data/lib/rubino/database/migrations/006_create_webhook_deliveries.rb +34 -0
- data/lib/rubino/database/migrations/007_create_messages_fts.rb +59 -0
- data/lib/rubino/database/migrations/008_create_memory_facts.rb +75 -0
- data/lib/rubino/database/migrations/009_create_memory_graph.rb +55 -0
- data/lib/rubino/database/migrations/010_add_owner_pid_to_sessions.rb +20 -0
- data/lib/rubino/database/migrator.rb +48 -0
- data/lib/rubino/documents/converters/csv.rb +79 -0
- data/lib/rubino/documents/converters/docx.rb +129 -0
- data/lib/rubino/documents/converters/html.rb +28 -0
- data/lib/rubino/documents/converters/json.rb +35 -0
- data/lib/rubino/documents/converters/pdf.rb +59 -0
- data/lib/rubino/documents/converters/plain.rb +68 -0
- data/lib/rubino/documents/converters/pptx.rb +64 -0
- data/lib/rubino/documents/converters/xlsx.rb +62 -0
- data/lib/rubino/documents/converters/xml.rb +45 -0
- data/lib/rubino/documents/html.rb +71 -0
- data/lib/rubino/documents/registry.rb +68 -0
- data/lib/rubino/documents/table.rb +63 -0
- data/lib/rubino/documents.rb +50 -0
- data/lib/rubino/errors.rb +119 -0
- data/lib/rubino/files/workspace.rb +93 -0
- data/lib/rubino/interaction/cancel_token.rb +43 -0
- data/lib/rubino/interaction/clipboard_image.rb +84 -0
- data/lib/rubino/interaction/event_bus.rb +48 -0
- data/lib/rubino/interaction/events.rb +101 -0
- data/lib/rubino/interaction/image_input.rb +127 -0
- data/lib/rubino/interaction/input_queue.rb +117 -0
- data/lib/rubino/interaction/lifecycle.rb +299 -0
- data/lib/rubino/interaction/probe.rb +65 -0
- data/lib/rubino/interaction/state.rb +56 -0
- data/lib/rubino/jobs/cron_job_repository.rb +75 -0
- data/lib/rubino/jobs/handlers/cleanup_sessions_job.rb +32 -0
- data/lib/rubino/jobs/handlers/compact_session_job.rb +21 -0
- data/lib/rubino/jobs/handlers/distill_skill_job.rb +186 -0
- data/lib/rubino/jobs/handlers/extract_memory_job.rb +37 -0
- data/lib/rubino/jobs/handlers/summarize_session_job.rb +21 -0
- data/lib/rubino/jobs/queue.rb +184 -0
- data/lib/rubino/jobs/registry.rb +45 -0
- data/lib/rubino/jobs/runner.rb +79 -0
- data/lib/rubino/jobs/scheduler.rb +138 -0
- data/lib/rubino/jobs/webhook_delivery.rb +225 -0
- data/lib/rubino/jobs/worker.rb +59 -0
- data/lib/rubino/llm/adapter_factory.rb +47 -0
- data/lib/rubino/llm/adapter_response.rb +65 -0
- data/lib/rubino/llm/auxiliary_client.rb +61 -0
- data/lib/rubino/llm/bedrock_bearer_client.rb +235 -0
- data/lib/rubino/llm/content_builder.rb +55 -0
- data/lib/rubino/llm/credential_check.rb +93 -0
- data/lib/rubino/llm/error_classifier.rb +364 -0
- data/lib/rubino/llm/fake_provider.rb +292 -0
- data/lib/rubino/llm/inline_think_filter.rb +58 -0
- data/lib/rubino/llm/model_catalog.rb +29 -0
- data/lib/rubino/llm/provider_resolver.rb +48 -0
- data/lib/rubino/llm/reasoning_manager.rb +100 -0
- data/lib/rubino/llm/request.rb +56 -0
- data/lib/rubino/llm/ruby_llm_adapter.rb +794 -0
- data/lib/rubino/llm/scenario_loader.rb +68 -0
- data/lib/rubino/llm/scenario_selector.rb +80 -0
- data/lib/rubino/llm/scenarios/agent-creates-cron-failure.yml +29 -0
- data/lib/rubino/llm/scenarios/agent-creates-cron.yml +36 -0
- data/lib/rubino/llm/scenarios/analysis.yml +501 -0
- data/lib/rubino/llm/scenarios/complex-analysis.yml +598 -0
- data/lib/rubino/llm/scenarios/failure.yml +65 -0
- data/lib/rubino/llm/scenarios/happy-path.yml +24 -0
- data/lib/rubino/llm/scenarios/provider-quota-completed.yml +14 -0
- data/lib/rubino/llm/scenarios/wide-table.yml +121 -0
- data/lib/rubino/llm/scenarios/with-approvals.yml +50 -0
- data/lib/rubino/llm/scenarios/with-artifacts.yml +98 -0
- data/lib/rubino/llm/scenarios/with-clarify.yml +32 -0
- data/lib/rubino/llm/scenarios/with-reasoning.yml +175 -0
- data/lib/rubino/llm/scenarios/with-uploads.yml +104 -0
- data/lib/rubino/llm/thinking_support.rb +84 -0
- data/lib/rubino/llm/tool_bridge.rb +89 -0
- data/lib/rubino/logger.rb +99 -0
- data/lib/rubino/mcp/manager.rb +180 -0
- data/lib/rubino/mcp/mcp_tool_wrapper.rb +69 -0
- data/lib/rubino/mcp.rb +57 -0
- data/lib/rubino/memory/backend.rb +104 -0
- data/lib/rubino/memory/backends/default.rb +101 -0
- data/lib/rubino/memory/backends/sqlite.rb +653 -0
- data/lib/rubino/memory/backends.rb +53 -0
- data/lib/rubino/memory/deduplicator.rb +74 -0
- data/lib/rubino/memory/extractor.rb +85 -0
- data/lib/rubino/memory/flusher.rb +31 -0
- data/lib/rubino/memory/retriever.rb +50 -0
- data/lib/rubino/memory/sqlite_extraction_prompt.rb +70 -0
- data/lib/rubino/memory/sqlite_graph.rb +154 -0
- data/lib/rubino/memory/store.rb +228 -0
- data/lib/rubino/memory/threat_scanner.rb +68 -0
- data/lib/rubino/metrics.rb +175 -0
- data/lib/rubino/modes.rb +93 -0
- data/lib/rubino/oauth/connection_repository.rb +95 -0
- data/lib/rubino/oauth/provider/github.rb +75 -0
- data/lib/rubino/oauth/provider/google.rb +59 -0
- data/lib/rubino/oauth/provider.rb +149 -0
- data/lib/rubino/oauth/registry.rb +86 -0
- data/lib/rubino/oauth/token_encryptor.rb +87 -0
- data/lib/rubino/plugins/registry.rb +75 -0
- data/lib/rubino/plugins.rb +86 -0
- data/lib/rubino/run/approval_gate.rb +243 -0
- data/lib/rubino/run/attachment_downloader.rb +166 -0
- data/lib/rubino/run/event_store.rb +74 -0
- data/lib/rubino/run/executor.rb +383 -0
- data/lib/rubino/run/gate_registry.rb +39 -0
- data/lib/rubino/run/recorder.rb +69 -0
- data/lib/rubino/run/repository.rb +118 -0
- data/lib/rubino/run/session_approval_cache.rb +118 -0
- data/lib/rubino/security/allowlist_persister.rb +55 -0
- data/lib/rubino/security/approval_policy.rb +227 -0
- data/lib/rubino/security/command_allowlist.rb +24 -0
- data/lib/rubino/security/dangerous_patterns.rb +118 -0
- data/lib/rubino/security/deny_persister.rb +73 -0
- data/lib/rubino/security/doom_loop_detector.rb +43 -0
- data/lib/rubino/security/hardline_guard.rb +105 -0
- data/lib/rubino/security/pattern_matcher.rb +62 -0
- data/lib/rubino/security/prefix_deriver.rb +124 -0
- data/lib/rubino/security/readonly_commands.rb +211 -0
- data/lib/rubino/session/exporter.rb +101 -0
- data/lib/rubino/session/message.rb +77 -0
- data/lib/rubino/session/repository.rb +295 -0
- data/lib/rubino/session/store.rb +198 -0
- data/lib/rubino/session/summary_store.rb +65 -0
- data/lib/rubino/skills/prompt_index.rb +85 -0
- data/lib/rubino/skills/registry.rb +208 -0
- data/lib/rubino/skills/skill.rb +176 -0
- data/lib/rubino/skills/skill_tool.rb +215 -0
- data/lib/rubino/skills/state_repository.rb +37 -0
- data/lib/rubino/skills/toggle.rb +26 -0
- data/lib/rubino/tools/answer_child_tool.rb +83 -0
- data/lib/rubino/tools/ask_parent_tool.rb +232 -0
- data/lib/rubino/tools/attach_file_tool.rb +120 -0
- data/lib/rubino/tools/background_tasks.rb +520 -0
- data/lib/rubino/tools/base.rb +222 -0
- data/lib/rubino/tools/custom_tool_loader.rb +119 -0
- data/lib/rubino/tools/edit_tool.rb +122 -0
- data/lib/rubino/tools/git_tool.rb +71 -0
- data/lib/rubino/tools/github_tool.rb +233 -0
- data/lib/rubino/tools/glob_tool.rb +69 -0
- data/lib/rubino/tools/grep_tool.rb +206 -0
- data/lib/rubino/tools/memory_tool.rb +184 -0
- data/lib/rubino/tools/multi_edit_tool.rb +110 -0
- data/lib/rubino/tools/patch_tool.rb +260 -0
- data/lib/rubino/tools/probe_tool.rb +175 -0
- data/lib/rubino/tools/question_tool.rb +128 -0
- data/lib/rubino/tools/read_attachment_tool.rb +180 -0
- data/lib/rubino/tools/read_tool.rb +212 -0
- data/lib/rubino/tools/read_tracker.rb +98 -0
- data/lib/rubino/tools/registry.rb +166 -0
- data/lib/rubino/tools/result.rb +113 -0
- data/lib/rubino/tools/ruby_tool.rb +0 -0
- data/lib/rubino/tools/session_search_tool.rb +103 -0
- data/lib/rubino/tools/shell_input_tool.rb +96 -0
- data/lib/rubino/tools/shell_kill_tool.rb +76 -0
- data/lib/rubino/tools/shell_output_tool.rb +72 -0
- data/lib/rubino/tools/shell_registry.rb +158 -0
- data/lib/rubino/tools/shell_tail_tool.rb +118 -0
- data/lib/rubino/tools/shell_tool.rb +330 -0
- data/lib/rubino/tools/steer_tool.rb +118 -0
- data/lib/rubino/tools/subagent_probe.rb +89 -0
- data/lib/rubino/tools/summarize_file_tool.rb +182 -0
- data/lib/rubino/tools/task_result_tool.rb +90 -0
- data/lib/rubino/tools/task_stop_tool.rb +80 -0
- data/lib/rubino/tools/task_tool.rb +622 -0
- data/lib/rubino/tools/test_tool.rb +454 -0
- data/lib/rubino/tools/todo_tool.rb +93 -0
- data/lib/rubino/tools/tool_call_repository.rb +33 -0
- data/lib/rubino/tools/vision_tool.rb +85 -0
- data/lib/rubino/tools/webfetch_tool.rb +153 -0
- data/lib/rubino/tools/websearch_tool.rb +179 -0
- data/lib/rubino/tools/write_tool.rb +61 -0
- data/lib/rubino/trust.rb +88 -0
- data/lib/rubino/ui/api.rb +296 -0
- data/lib/rubino/ui/base.rb +252 -0
- data/lib/rubino/ui/bottom_composer.rb +1599 -0
- data/lib/rubino/ui/cli.rb +1987 -0
- data/lib/rubino/ui/completion_menu.rb +321 -0
- data/lib/rubino/ui/completion_source.rb +284 -0
- data/lib/rubino/ui/escape_reader.rb +169 -0
- data/lib/rubino/ui/indented_io.rb +88 -0
- data/lib/rubino/ui/input_history.rb +108 -0
- data/lib/rubino/ui/live_region.rb +183 -0
- data/lib/rubino/ui/markdown_renderer.rb +506 -0
- data/lib/rubino/ui/notifier.rb +163 -0
- data/lib/rubino/ui/null.rb +195 -0
- data/lib/rubino/ui/paste_store.rb +176 -0
- data/lib/rubino/ui/printer_base.rb +79 -0
- data/lib/rubino/ui/probe_wait_indicator.rb +75 -0
- data/lib/rubino/ui/queued_indicators.rb +66 -0
- data/lib/rubino/ui/status_bar.rb +100 -0
- data/lib/rubino/ui/stdout_proxy.rb +161 -0
- data/lib/rubino/ui/streaming_markdown.rb +186 -0
- data/lib/rubino/ui/subagent_cards.rb +134 -0
- data/lib/rubino/ui/subagent_view.rb +255 -0
- data/lib/rubino/ui.rb +21 -0
- data/lib/rubino/update_check.rb +187 -0
- data/lib/rubino/util/duration.rb +23 -0
- data/lib/rubino/util/hyperlink.rb +105 -0
- data/lib/rubino/util/output.rb +145 -0
- data/lib/rubino/util/secrets_mask.rb +83 -0
- data/lib/rubino/version.rb +5 -0
- data/lib/rubino/workspace.rb +85 -0
- data/lib/rubino-agent.rb +5 -0
- data/lib/rubino.rb +318 -0
- data/mise.toml +2 -0
- data/rubino-agent.gemspec +103 -0
- data/skills/ruby-expert/SKILL.md +67 -0
- data/skills/ruby-expert/references/concurrency.md +357 -0
- data/skills/ruby-expert/references/datetime-and-encoding.md +363 -0
- data/skills/ruby-expert/references/errors-and-types.md +460 -0
- data/skills/ruby-expert/references/gem-authoring.md +459 -0
- data/skills/ruby-expert/references/language-idioms.md +465 -0
- data/skills/ruby-expert/references/metaprogramming.md +339 -0
- data/skills/ruby-expert/references/oo-design.md +553 -0
- data/skills/ruby-expert/references/performance.md +383 -0
- data/skills/ruby-expert/references/rails.md +424 -0
- data/skills/ruby-expert/references/security.md +404 -0
- data/skills/ruby-expert/references/testing.md +473 -0
- data/skills/ruby-expert/references/tooling.md +466 -0
- metadata +856 -0
|
@@ -0,0 +1,553 @@
|
|
|
1
|
+
# Object-oriented design
|
|
2
|
+
|
|
3
|
+
Idiomatic Ruby OO for Ruby 3.2–3.4 / Rails 7.1–8.x. Ruby is dynamically typed with
|
|
4
|
+
duck typing, open classes, and mixins — so the "OO design" advice from statically
|
|
5
|
+
typed languages mostly applies, but the *mechanics* differ. Lean on objects that
|
|
6
|
+
respond to messages, not on type hierarchies.
|
|
7
|
+
|
|
8
|
+
Cross-references (don't re-explain these here):
|
|
9
|
+
- Metaprogramming mechanics (define_method, method_missing, eigenclass) → `references/metaprogramming.md`
|
|
10
|
+
- Exception hierarchy & rescue rules → `references/errors-and-types.md`
|
|
11
|
+
- Rails layering (concerns, app/services, callbacks) → `references/rails.md`
|
|
12
|
+
|
|
13
|
+
## SOLID, the Ruby way
|
|
14
|
+
|
|
15
|
+
SOLID is a set of pressures, not laws. Apply with duck typing in mind.
|
|
16
|
+
|
|
17
|
+
**S — Single Responsibility.** A class should have one reason to change. The Ruby
|
|
18
|
+
smell test: can you describe the class in one sentence without "and"? If `User`
|
|
19
|
+
both persists data *and* renders emails *and* charges cards, split it.
|
|
20
|
+
|
|
21
|
+
**O — Open/Closed.** Extend behavior without editing existing code. In Ruby this is
|
|
22
|
+
usually composition + injection, not abstract base classes. New behavior = a new
|
|
23
|
+
object passed in, not a new `elsif`.
|
|
24
|
+
|
|
25
|
+
```ruby
|
|
26
|
+
# WRONG — every new format edits this method (closed to extension)
|
|
27
|
+
def export(data, format)
|
|
28
|
+
case format
|
|
29
|
+
when :csv then CSVWriter.new.write(data)
|
|
30
|
+
when :json then JSONWriter.new.write(data)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# RIGHT — inject a writer that responds to #write; add formats without editing
|
|
35
|
+
def export(data, writer:)
|
|
36
|
+
writer.write(data)
|
|
37
|
+
end
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
**L — Liskov Substitution.** Any object passed where another is expected must honor
|
|
41
|
+
the same *implicit contract* (same messages, same return shape, no surprise
|
|
42
|
+
exceptions). In Ruby the "type" is the set of messages it answers — the role, not
|
|
43
|
+
the class. A `NullLogger` must accept `#info`/`#error` just like `Logger`.
|
|
44
|
+
|
|
45
|
+
**I — Interface Segregation.** Don't force collaborators to depend on messages they
|
|
46
|
+
don't use. Keep modules/roles small; a giant `include Everything` couples callers
|
|
47
|
+
to methods they never call.
|
|
48
|
+
|
|
49
|
+
**D — Dependency Inversion.** Depend on roles (duck types), not concrete classes.
|
|
50
|
+
Pass collaborators in; don't `SomeClass.new` deep inside a method.
|
|
51
|
+
|
|
52
|
+
```ruby
|
|
53
|
+
# WRONG — hard-wired dependency; untestable without hitting Stripe
|
|
54
|
+
class Checkout
|
|
55
|
+
def call(order)
|
|
56
|
+
Stripe::Charge.create(amount: order.total)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# RIGHT — depend on a "gateway" role; default to the real one
|
|
61
|
+
class Checkout
|
|
62
|
+
def initialize(gateway: StripeGateway.new)
|
|
63
|
+
@gateway = gateway
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def call(order)
|
|
67
|
+
@gateway.charge(order.total)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Sandi Metz's rules — heuristics, not dogma
|
|
73
|
+
|
|
74
|
+
Treat these as tripwires that prompt a second look, not hard failures:
|
|
75
|
+
|
|
76
|
+
- Classes ≤ **100 lines**.
|
|
77
|
+
- Methods ≤ **5 lines** (a body of 5 lines, excluding `def`/`end`).
|
|
78
|
+
- Method signatures ≤ **4 parameters** (kwargs count individually).
|
|
79
|
+
- Controllers/views: **one instance variable** per view, send one message to it.
|
|
80
|
+
|
|
81
|
+
You may break a rule only if you can convince a teammate it's justified. A 6-line
|
|
82
|
+
method that's clearer than two 3-line methods is fine. The point is the *friction*:
|
|
83
|
+
when you blow past 100 lines, ask whether a second object is hiding inside.
|
|
84
|
+
|
|
85
|
+
Also useful — the "squint test" and: extract a class the moment a method needs
|
|
86
|
+
data from another object more than from `self` (feature envy).
|
|
87
|
+
|
|
88
|
+
## Composition over inheritance
|
|
89
|
+
|
|
90
|
+
Prefer **has-a** over **is-a**. Inheritance couples subclass to superclass internals
|
|
91
|
+
and forces a single rigid axis of variation. Composition lets you swap parts.
|
|
92
|
+
|
|
93
|
+
```ruby
|
|
94
|
+
# WRONG — inheritance for code reuse; deep, brittle hierarchy
|
|
95
|
+
class Report; def header; ...; end; end
|
|
96
|
+
class PdfReport < Report; end # now coupled to Report's privates
|
|
97
|
+
class CsvReport < Report; end
|
|
98
|
+
|
|
99
|
+
# RIGHT — compose a formatter (a role), inject it
|
|
100
|
+
class Report
|
|
101
|
+
def initialize(formatter:)
|
|
102
|
+
@formatter = formatter
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def render(rows) = @formatter.format(rows)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
Report.new(formatter: PdfFormatter.new)
|
|
109
|
+
Report.new(formatter: CsvFormatter.new)
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Use inheritance only for genuine *is-a* specialization where the subclass is
|
|
113
|
+
substitutable and the hierarchy is shallow (1–2 levels). Template Method (abstract
|
|
114
|
+
superclass calling subclass hooks) is the one inheritance pattern that stays clean.
|
|
115
|
+
|
|
116
|
+
## Mixins / modules — the Ruby way to share behavior, and the traps
|
|
117
|
+
|
|
118
|
+
Modules let you share behavior across unrelated classes. Three insertion points:
|
|
119
|
+
|
|
120
|
+
```ruby
|
|
121
|
+
module Greet
|
|
122
|
+
def hello = "hi from #{name}"
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
class A; include Greet; end # instance methods
|
|
126
|
+
class B; extend Greet; end # class/singleton methods
|
|
127
|
+
class C; prepend Greet; end # inserted BEFORE C in ancestors (wraps methods)
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Use a module when it represents a **role** several classes can play (`Comparable`,
|
|
131
|
+
`Enumerable`, `Trackable`). Define the small "core" method in the class and let the
|
|
132
|
+
module build on it — exactly how `Comparable` needs only `<=>`:
|
|
133
|
+
|
|
134
|
+
```ruby
|
|
135
|
+
class Version
|
|
136
|
+
include Comparable
|
|
137
|
+
attr_reader :n
|
|
138
|
+
def initialize(n) = @n = n
|
|
139
|
+
def <=>(other) = n <=> other.n # Comparable gives ==, <, >, between?, clamp
|
|
140
|
+
end
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
**Pitfalls:**
|
|
144
|
+
- **Hidden coupling on host state.** A module method calling `name`/`@total`
|
|
145
|
+
silently requires every host to provide it. Document the required interface.
|
|
146
|
+
- **Namespace/method collisions.** `include`d methods land directly in the
|
|
147
|
+
ancestor chain; two modules defining `process` clash silently (last wins).
|
|
148
|
+
- **"Concern soup."** Don't use modules as a junk drawer to shrink a class — that
|
|
149
|
+
hides the size, it doesn't fix the design. If a module only makes sense with one
|
|
150
|
+
host and shares its ivars, it's not a role; it's that class wanting to be split
|
|
151
|
+
into a *collaborator* (see composition). Rails-specific concern guidance lives in
|
|
152
|
+
`references/rails.md`.
|
|
153
|
+
- Modules can't be instantiated and carry no per-instance state of their own.
|
|
154
|
+
|
|
155
|
+
Rule of thumb: **composition for state + behavior, mixins for stateless behavior /
|
|
156
|
+
shared roles.**
|
|
157
|
+
|
|
158
|
+
## Duck typing, "tell don't ask", Law of Demeter
|
|
159
|
+
|
|
160
|
+
**Duck typing:** depend on what an object *does*, not its class.
|
|
161
|
+
|
|
162
|
+
```ruby
|
|
163
|
+
# WRONG — type-checking defeats polymorphism
|
|
164
|
+
def total(items)
|
|
165
|
+
items.sum { |i| i.is_a?(Discounted) ? i.discount_price : i.price }
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# RIGHT — every item answers #price; let it decide
|
|
169
|
+
def total(items) = items.sum(&:price)
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Avoid `is_a?`/`kind_of?`/`respond_to?` branching for control flow. If you must check
|
|
173
|
+
capability, `respond_to?` is the least-bad option, but a polymorphic method or a
|
|
174
|
+
Null Object is usually better.
|
|
175
|
+
|
|
176
|
+
**Tell, don't ask:** send a command instead of pulling state out to decide.
|
|
177
|
+
|
|
178
|
+
```ruby
|
|
179
|
+
# WRONG — ask then act (logic leaks to the caller)
|
|
180
|
+
if account.balance >= amount
|
|
181
|
+
account.balance -= amount
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# RIGHT — tell the object; it enforces its own invariants
|
|
185
|
+
account.withdraw(amount)
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
**Law of Demeter** ("only talk to your immediate neighbors"): avoid chains that
|
|
189
|
+
reach through objects. `a.b.c.d` couples you to the whole graph.
|
|
190
|
+
|
|
191
|
+
```ruby
|
|
192
|
+
# WRONG — train wreck; knows about company AND address internals
|
|
193
|
+
user.company.address.zip_code
|
|
194
|
+
|
|
195
|
+
# RIGHT — delegate, exposing intent not structure
|
|
196
|
+
class User
|
|
197
|
+
def company_zip = company.zip_code
|
|
198
|
+
end
|
|
199
|
+
# or in Rails:
|
|
200
|
+
delegate :zip_code, to: :company, prefix: true # user.company_zip_code
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
Chains on a *collection pipeline* (`items.select{}.map{}.sum`) are fine — that's one
|
|
204
|
+
object (Enumerable), not a Demeter violation.
|
|
205
|
+
|
|
206
|
+
## Value objects (Data.define)
|
|
207
|
+
|
|
208
|
+
Immutable, compared by value, no identity. Use `Data.define` (Ruby 3.2+) for these —
|
|
209
|
+
it gives `==`, `hash`, `eql?`, keyword + positional init, `with`, and `deconstruct`.
|
|
210
|
+
|
|
211
|
+
```ruby
|
|
212
|
+
Money = Data.define(:cents, :currency) do
|
|
213
|
+
def +(other)
|
|
214
|
+
raise ArgumentError, "currency mismatch" unless currency == other.currency
|
|
215
|
+
with(cents: cents + other.cents) # returns a NEW Money
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def to_s = format("%.2f %s", cents / 100.0, currency)
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
a = Money.new(cents: 500, currency: "USD")
|
|
222
|
+
b = Money[300, "USD"] # positional via []
|
|
223
|
+
a + b # => #<data Money cents=800 ...>
|
|
224
|
+
a == Money.new(cents: 500, currency: "USD") # => true (value equality)
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
`Data` instances are frozen and have **no setters** — that's the point. Use `Struct`
|
|
228
|
+
only when you genuinely need mutability or array-style access; otherwise prefer
|
|
229
|
+
`Data`. (Struct vs Data detail → `references/language-idioms.md`.)
|
|
230
|
+
|
|
231
|
+
## Service objects — one public `#call`
|
|
232
|
+
|
|
233
|
+
A service object models a *verb* / use case. Convention: one public method, named
|
|
234
|
+
`#call`, collaborators injected in `#initialize`, returns a Result (below).
|
|
235
|
+
|
|
236
|
+
```ruby
|
|
237
|
+
class RegisterUser
|
|
238
|
+
def self.call(...) = new(...).call # convenience entry point
|
|
239
|
+
|
|
240
|
+
def initialize(repo: UserRepository.new, mailer: WelcomeMailer)
|
|
241
|
+
@repo = repo
|
|
242
|
+
@mailer = mailer
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def call(email:, password:)
|
|
246
|
+
return Result.failure(:invalid_email) unless email.include?("@")
|
|
247
|
+
|
|
248
|
+
user = @repo.create(email:, password:)
|
|
249
|
+
@mailer.deliver(user)
|
|
250
|
+
Result.success(user)
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
private attr_reader :repo, :mailer # private + everything else below
|
|
254
|
+
end
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
Do: keep `#call` thin and orchestrative; push real logic into domain objects.
|
|
258
|
+
Don't: stuff seven public methods in and call it a "service" — that's just a class
|
|
259
|
+
with a vague name. (Placement under `app/services` → `references/rails.md`.)
|
|
260
|
+
|
|
261
|
+
## Form / query / policy objects, decorators/presenters
|
|
262
|
+
|
|
263
|
+
**Form object** — coordinates validation/persistence across multiple models or
|
|
264
|
+
non-AR input. Wraps params, exposes `valid?` + `save`, keeps controllers thin.
|
|
265
|
+
|
|
266
|
+
```ruby
|
|
267
|
+
class SignupForm
|
|
268
|
+
include ActiveModel::Model # gives validations + #valid?
|
|
269
|
+
attr_accessor :email, :company_name
|
|
270
|
+
validates :email, presence: true
|
|
271
|
+
|
|
272
|
+
def save
|
|
273
|
+
return false unless valid?
|
|
274
|
+
ActiveRecord::Base.transaction { create_company! && create_user! }
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
**Query object** — encapsulates a non-trivial DB query (reuse, testability) instead
|
|
280
|
+
of fat scopes or controller-built relations.
|
|
281
|
+
|
|
282
|
+
```ruby
|
|
283
|
+
class ActivePremiumUsers
|
|
284
|
+
def initialize(relation = User.all) = @relation = relation
|
|
285
|
+
def call = @relation.where(active: true).where(plan: :premium).order(:created_at)
|
|
286
|
+
end
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
**Policy object** — answers a yes/no authorization/business question.
|
|
290
|
+
|
|
291
|
+
```ruby
|
|
292
|
+
class PublishPolicy
|
|
293
|
+
def initialize(user, post) = (@user, @post = user, post)
|
|
294
|
+
def allowed? = @user.editor? && @post.draft?
|
|
295
|
+
end
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
**Decorator / presenter** — adds view/display behavior to an object *without*
|
|
299
|
+
touching the model. A decorator wraps and forwards; a presenter is the same idea
|
|
300
|
+
focused on view formatting. Use plain Ruby + `SimpleDelegator`:
|
|
301
|
+
|
|
302
|
+
```ruby
|
|
303
|
+
class UserPresenter < SimpleDelegator
|
|
304
|
+
def display_name = full_name.presence || email.split("@").first
|
|
305
|
+
def joined = created_at.strftime("%b %Y")
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
UserPresenter.new(user).display_name # forwards full_name/email to the wrapped user
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
Don't put `created_at.strftime(...)` logic in the model — display concerns belong in
|
|
312
|
+
the presenter/decorator layer.
|
|
313
|
+
|
|
314
|
+
## Result / Either objects vs exceptions for control flow
|
|
315
|
+
|
|
316
|
+
Use exceptions for *exceptional, unexpected* conditions. Use a **Result** object for
|
|
317
|
+
*expected* success/failure branches (validation fails, payment declined). Exceptions
|
|
318
|
+
as flow control are slow and hide the happy path.
|
|
319
|
+
|
|
320
|
+
```ruby
|
|
321
|
+
# WRONG — exceptions to model an expected outcome
|
|
322
|
+
def charge(order)
|
|
323
|
+
raise PaymentDeclined unless gateway.ok?(order)
|
|
324
|
+
...
|
|
325
|
+
end
|
|
326
|
+
begin; charge(order); rescue PaymentDeclined; show_error; end
|
|
327
|
+
|
|
328
|
+
# RIGHT — explicit Result; both branches are visible at the call site
|
|
329
|
+
Result = Data.define(:success, :value, :error) do
|
|
330
|
+
def self.success(value) = new(success: true, value:, error: nil)
|
|
331
|
+
def self.failure(error) = new(success: false, value: nil, error:)
|
|
332
|
+
def success? = success
|
|
333
|
+
def on_success = (yield value if success?; self)
|
|
334
|
+
def on_failure = (yield error unless success?; self)
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
result = Charge.new.call(order)
|
|
338
|
+
result.on_success { |receipt| notify(receipt) }
|
|
339
|
+
.on_failure { |err| log(err) }
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
Pattern matching pairs beautifully with results (deconstruct → see
|
|
343
|
+
`references/language-idioms.md`):
|
|
344
|
+
|
|
345
|
+
```ruby
|
|
346
|
+
case Charge.new.call(order)
|
|
347
|
+
in { success: true, value: } then redirect_to(value)
|
|
348
|
+
in { success: false, error: } then flash[:error] = error
|
|
349
|
+
end
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
For larger flows, the `dry-monads` gem (`Success`/`Failure`, `Do` notation) is the
|
|
353
|
+
idiomatic library choice — but a 10-line `Result` like above is often enough.
|
|
354
|
+
|
|
355
|
+
## Dependency injection in plain Ruby
|
|
356
|
+
|
|
357
|
+
No DI framework needed. Pass collaborators as keyword args with sensible defaults.
|
|
358
|
+
This keeps production wiring zero-config while making tests trivial.
|
|
359
|
+
|
|
360
|
+
```ruby
|
|
361
|
+
class ReportMailer
|
|
362
|
+
def initialize(clock: Time, transport: SMTP.new) # defaults = real objects
|
|
363
|
+
@clock = clock
|
|
364
|
+
@transport = transport
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
def send_daily
|
|
368
|
+
@transport.deliver(at: @clock.now)
|
|
369
|
+
end
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
# Test: inject fakes, no stubbing of globals
|
|
373
|
+
ReportMailer.new(clock: FrozenClock.new, transport: FakeTransport.new).send_daily
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
Do: default to the production object so callers write `ReportMailer.new`.
|
|
377
|
+
Don't: instantiate hard dependencies inside business methods, or reach for a global
|
|
378
|
+
`Container[:thing]` when a constructor arg does the job.
|
|
379
|
+
|
|
380
|
+
## Null Object pattern
|
|
381
|
+
|
|
382
|
+
Replace `nil`-checks scattered across the codebase with an object that answers the
|
|
383
|
+
same messages with do-nothing/neutral behavior. Honors LSP; removes `if x` noise.
|
|
384
|
+
|
|
385
|
+
```ruby
|
|
386
|
+
# WRONG — every caller must nil-check
|
|
387
|
+
user.account&.notify or default_notify
|
|
388
|
+
|
|
389
|
+
# RIGHT
|
|
390
|
+
class GuestUser
|
|
391
|
+
def name = "Guest"
|
|
392
|
+
def admin? = false
|
|
393
|
+
def notify(*) = nil # quietly does nothing
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
def current_user = session_user || GuestUser.new
|
|
397
|
+
|
|
398
|
+
current_user.name # always safe, no &. needed
|
|
399
|
+
```
|
|
400
|
+
|
|
401
|
+
Caveat: a Null Object that *silently* swallows everything can mask bugs. Make it
|
|
402
|
+
explicit and narrow; don't `method_missing`-everything-to-nil.
|
|
403
|
+
|
|
404
|
+
## GoF patterns that are idiomatic in Ruby (and ones to skip)
|
|
405
|
+
|
|
406
|
+
Ruby's blocks/procs and open classes collapse several patterns to almost nothing.
|
|
407
|
+
|
|
408
|
+
**Strategy → a block or proc.** Don't build a class hierarchy for a one-method
|
|
409
|
+
strategy; pass a callable.
|
|
410
|
+
|
|
411
|
+
```ruby
|
|
412
|
+
# WRONG — Strategy-as-classes ceremony
|
|
413
|
+
class SumStrategy; def apply(a) = a.sum; end
|
|
414
|
+
calc.strategy = SumStrategy.new
|
|
415
|
+
|
|
416
|
+
# RIGHT — the strategy IS a block
|
|
417
|
+
def calculate(items, &strategy) = strategy.call(items)
|
|
418
|
+
calculate(items) { |xs| xs.sum }
|
|
419
|
+
# or store a proc:
|
|
420
|
+
PRICERS = { flat: ->(o) { o.qty * 10 }, tiered: ->(o) { ... } }
|
|
421
|
+
PRICERS.fetch(plan).call(order)
|
|
422
|
+
```
|
|
423
|
+
|
|
424
|
+
**Observer → `Observable` / plain callbacks.** Use the stdlib `observer` mixin, or
|
|
425
|
+
just hold an array of subscribers (procs) and `each(&:call)`.
|
|
426
|
+
|
|
427
|
+
```ruby
|
|
428
|
+
class Publisher
|
|
429
|
+
def initialize = @subs = []
|
|
430
|
+
def subscribe(&blk) = @subs << blk
|
|
431
|
+
def publish(event) = @subs.each { |s| s.call(event) }
|
|
432
|
+
end
|
|
433
|
+
```
|
|
434
|
+
|
|
435
|
+
**Adapter → a thin wrapper exposing the role you need.** Idiomatic and common
|
|
436
|
+
(e.g. wrapping a third-party client to present your app's interface).
|
|
437
|
+
|
|
438
|
+
```ruby
|
|
439
|
+
class SlackNotifier # adapts Slack::Client to a #notify role
|
|
440
|
+
def initialize(client) = @client = client
|
|
441
|
+
def notify(msg) = @client.chat_postMessage(channel: "#ops", text: msg)
|
|
442
|
+
end
|
|
443
|
+
```
|
|
444
|
+
|
|
445
|
+
**Decorator → `SimpleDelegator`** (shown above). **Iterator → `Enumerable` + `each`**
|
|
446
|
+
(don't hand-roll). **Template Method → small inheritance with hook methods** (fine).
|
|
447
|
+
|
|
448
|
+
**Skip / avoid in Ruby:** Singleton-as-global-state (use a plain object passed in;
|
|
449
|
+
the `Singleton` mixin is rarely worth it). Abstract Factory / heavy Factory classes
|
|
450
|
+
(a method returning the right object, or a hash lookup, is enough). Visitor (usually
|
|
451
|
+
pattern matching is cleaner). Any pattern whose only job is to fake first-class
|
|
452
|
+
functions or interfaces — Ruby already has those.
|
|
453
|
+
|
|
454
|
+
## Cohesion, coupling, naming
|
|
455
|
+
|
|
456
|
+
- **High cohesion:** a class's methods and ivars all relate to one job. Methods that
|
|
457
|
+
ignore the ivars (only touch their args) probably belong elsewhere or want to be a
|
|
458
|
+
module function.
|
|
459
|
+
- **Low coupling:** depend on roles (duck types) and few of them. Count the
|
|
460
|
+
collaborators a class names; more than ~3–4 concrete ones is a smell.
|
|
461
|
+
- **Connascence (the deep version of coupling):** prefer connascence of *name*
|
|
462
|
+
(rename safely) over of *position* (use kwargs!) over of *meaning/algorithm*
|
|
463
|
+
(magic numbers — extract a constant). Keep strong connascence *inside* one class.
|
|
464
|
+
|
|
465
|
+
```ruby
|
|
466
|
+
# WRONG — connascence of position; caller must remember the order
|
|
467
|
+
def schedule(user, time, retries, urgent); end
|
|
468
|
+
schedule(u, t, 3, true)
|
|
469
|
+
|
|
470
|
+
# RIGHT — connascence of name; order-independent, self-documenting
|
|
471
|
+
def schedule(user:, at:, retries: 0, urgent: false); end
|
|
472
|
+
schedule(user: u, at: t, urgent: true)
|
|
473
|
+
```
|
|
474
|
+
|
|
475
|
+
**Naming:** classes are nouns (`Invoice`, `PaymentGateway`); service objects can be
|
|
476
|
+
verb-phrases (`RegisterUser`, `ChargeOrder`). Methods asking a question end in `?`
|
|
477
|
+
and return boolean; mutating/bang methods end in `!`. Booleans read as predicates
|
|
478
|
+
(`active?` not `is_active`). Reveal *intent*, not implementation
|
|
479
|
+
(`overdue?` not `days_since_due > 30`). Avoid `Manager`/`Helper`/`Util`/`Data` class
|
|
480
|
+
names — they signal a missing abstraction.
|
|
481
|
+
|
|
482
|
+
## Refactoring before/after (full example)
|
|
483
|
+
|
|
484
|
+
```ruby
|
|
485
|
+
# BEFORE — fat method: validation, branching on type, persistence, email, all here
|
|
486
|
+
class OrdersController
|
|
487
|
+
def create
|
|
488
|
+
if params[:email] =~ /@/
|
|
489
|
+
order = Order.new(params)
|
|
490
|
+
if params[:kind] == "gift"
|
|
491
|
+
order.total = order.items.sum(&:price) * 0.9
|
|
492
|
+
else
|
|
493
|
+
order.total = order.items.sum(&:price)
|
|
494
|
+
end
|
|
495
|
+
order.save
|
|
496
|
+
Mailer.confirm(order.id, params[:email]).deliver_now
|
|
497
|
+
redirect_to order
|
|
498
|
+
else
|
|
499
|
+
render :new
|
|
500
|
+
end
|
|
501
|
+
end
|
|
502
|
+
end
|
|
503
|
+
```
|
|
504
|
+
|
|
505
|
+
```ruby
|
|
506
|
+
# AFTER — controller stays skinny; logic lives in well-named collaborators
|
|
507
|
+
class OrdersController
|
|
508
|
+
def create
|
|
509
|
+
PlaceOrder.call(params: order_params)
|
|
510
|
+
.on_success { |order| redirect_to order }
|
|
511
|
+
.on_failure { render :new }
|
|
512
|
+
end
|
|
513
|
+
end
|
|
514
|
+
|
|
515
|
+
class PlaceOrder
|
|
516
|
+
def self.call(...) = new(...).call
|
|
517
|
+
def initialize(params:, pricer: Pricer.for(params[:kind]), mailer: Mailer)
|
|
518
|
+
@params, @pricer, @mailer = params, pricer, mailer
|
|
519
|
+
end
|
|
520
|
+
|
|
521
|
+
def call
|
|
522
|
+
return Result.failure(:bad_email) unless @params[:email].to_s.include?("@")
|
|
523
|
+
order = Order.create!(@params.merge(total: @pricer.call(@params)))
|
|
524
|
+
@mailer.confirm(order).deliver_later
|
|
525
|
+
Result.success(order)
|
|
526
|
+
end
|
|
527
|
+
end
|
|
528
|
+
|
|
529
|
+
# Strategy as injected callable — no type branching in the use case
|
|
530
|
+
Pricer = Module.new
|
|
531
|
+
def Pricer.for(kind) = kind == "gift" ? ->(p){ subtotal(p) * 0.9 } : ->(p){ subtotal(p) }
|
|
532
|
+
```
|
|
533
|
+
|
|
534
|
+
What changed: SRP (controller orchestrates, service decides, pricer prices), DI
|
|
535
|
+
(mailer/pricer injected → testable), no `is_a?` branching (strategy proc), explicit
|
|
536
|
+
Result instead of nested `if`, Demeter respected.
|
|
537
|
+
|
|
538
|
+
## Quick checklist
|
|
539
|
+
|
|
540
|
+
- One reason to change per class; describe it without "and".
|
|
541
|
+
- Inject collaborators (kwargs, real-object defaults); don't `new` deep dependencies.
|
|
542
|
+
- Compose by default; inherit only for true, shallow *is-a* + substitutability.
|
|
543
|
+
- Modules = stateless roles; document the host interface they require; beware collisions.
|
|
544
|
+
- Duck-type on messages; avoid `is_a?`/`respond_to?` branching for flow.
|
|
545
|
+
- Tell, don't ask; obey Law of Demeter (delegate; chains OK only on Enumerable).
|
|
546
|
+
- Value objects → `Data.define` (immutable, value-equal). Mutable? then `Struct`.
|
|
547
|
+
- Service object: one public `#call`, returns a Result; logic lives in domain objects.
|
|
548
|
+
- Result/Either for *expected* failure; exceptions only for *exceptional* (`errors-and-types.md`).
|
|
549
|
+
- Null Object instead of scattered nil-checks (but keep it explicit, not magic).
|
|
550
|
+
- Strategy → block/proc; Observer → callbacks; Adapter → wrapper; skip Singleton/Visitor/heavy Factory.
|
|
551
|
+
- Prefer kwargs (connascence of name) over positional args; extract magic numbers.
|
|
552
|
+
- Names reveal intent; `?`/`!` conventions; avoid `Manager`/`Helper`/`Util`.
|
|
553
|
+
- Sandi's rules (100 lines / 5 lines / 4 params / 1 ivar-per-view) are tripwires — justify breaking them.
|