source_monitor 0.2.0 → 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 +4 -4
- data/.claude/agents/rails-concern.md +464 -0
- data/.claude/agents/rails-controller.md +424 -0
- data/.claude/agents/rails-hotwire.md +446 -0
- data/.claude/agents/rails-implement.md +374 -0
- data/.claude/agents/rails-job.md +334 -0
- data/.claude/agents/rails-lint.md +294 -0
- data/.claude/agents/rails-mailer.md +371 -0
- data/.claude/agents/rails-migration.md +449 -0
- data/.claude/agents/rails-model.md +420 -0
- data/.claude/agents/rails-policy.md +443 -0
- data/.claude/agents/rails-presenter.md +427 -0
- data/.claude/agents/rails-query.md +412 -0
- data/.claude/agents/rails-review.md +490 -0
- data/.claude/agents/rails-service.md +458 -0
- data/.claude/agents/rails-state-records.md +465 -0
- data/.claude/agents/rails-tdd.md +314 -0
- data/.claude/agents/rails-test.md +441 -0
- data/.claude/agents/rails-view-component.md +418 -0
- data/.claude/hooks/block-secrets.sh +52 -0
- data/.claude/settings.json +85 -0
- data/.claude/skills/action-cable-patterns/SKILL.md +296 -0
- data/.claude/skills/action-mailer-patterns/SKILL.md +295 -0
- data/.claude/skills/active-storage-setup/SKILL.md +311 -0
- data/.claude/skills/api-versioning/SKILL.md +294 -0
- data/.claude/skills/authentication-flow/SKILL.md +335 -0
- data/.claude/skills/authentication-flow/reference/current.md +248 -0
- data/.claude/skills/authentication-flow/reference/passwordless.md +253 -0
- data/.claude/skills/authentication-flow/reference/sessions.md +201 -0
- data/.claude/skills/authorization-pundit/SKILL.md +462 -0
- data/.claude/skills/caching-strategies/SKILL.md +350 -0
- data/.claude/skills/database-migrations/SKILL.md +354 -0
- data/.claude/skills/form-object-patterns/SKILL.md +399 -0
- data/.claude/skills/hotwire-patterns/SKILL.md +247 -0
- data/.claude/skills/hotwire-patterns/reference/stimulus.md +307 -0
- data/.claude/skills/hotwire-patterns/reference/tailwind-integration.md +112 -0
- data/.claude/skills/hotwire-patterns/reference/turbo-frames.md +158 -0
- data/.claude/skills/hotwire-patterns/reference/turbo-streams.md +218 -0
- data/.claude/skills/i18n-patterns/SKILL.md +320 -0
- data/.claude/skills/install/SKILL.md +367 -0
- data/.claude/skills/performance-optimization/SKILL.md +311 -0
- data/.claude/skills/rails-architecture/SKILL.md +259 -0
- data/.claude/skills/rails-architecture/reference/error-handling.md +333 -0
- data/.claude/skills/rails-architecture/reference/event-tracking.md +142 -0
- data/.claude/skills/rails-architecture/reference/layer-interactions.md +417 -0
- data/.claude/skills/rails-architecture/reference/multi-tenancy.md +152 -0
- data/.claude/skills/rails-architecture/reference/query-patterns.md +342 -0
- data/.claude/skills/rails-architecture/reference/service-patterns.md +286 -0
- data/.claude/skills/rails-architecture/reference/state-records.md +250 -0
- data/.claude/skills/rails-architecture/reference/testing-strategy.md +326 -0
- data/.claude/skills/rails-concern/SKILL.md +399 -0
- data/.claude/skills/rails-controller/SKILL.md +336 -0
- data/.claude/skills/rails-model-generator/SKILL.md +321 -0
- data/.claude/skills/rails-model-generator/reference/validations.md +298 -0
- data/.claude/skills/rails-presenter/SKILL.md +274 -0
- data/.claude/skills/rails-query-object/SKILL.md +289 -0
- data/.claude/skills/rails-service-object/SKILL.md +349 -0
- data/.claude/skills/solid-queue-setup/SKILL.md +307 -0
- data/.claude/skills/tdd-cycle/SKILL.md +359 -0
- data/.claude/skills/viewcomponent-patterns/SKILL.md +333 -0
- data/.gitignore +1 -0
- data/.rubocop.yml +2 -0
- data/.ruby-version +1 -1
- data/.vbw-planning/.notification-log.jsonl +192 -0
- data/.vbw-planning/.session-log.jsonl +871 -0
- data/.vbw-planning/PROJECT.md +51 -0
- data/.vbw-planning/REQUIREMENTS.md +50 -0
- data/.vbw-planning/SHIPPED.md +28 -0
- data/.vbw-planning/codebase/ARCHITECTURE.md +147 -0
- data/.vbw-planning/codebase/CONCERNS.md +99 -0
- data/.vbw-planning/codebase/CONVENTIONS.md +97 -0
- data/.vbw-planning/codebase/DEPENDENCIES.md +100 -0
- data/.vbw-planning/codebase/INDEX.md +86 -0
- data/.vbw-planning/codebase/META.md +42 -0
- data/.vbw-planning/codebase/PATTERNS.md +262 -0
- data/.vbw-planning/codebase/STACK.md +101 -0
- data/.vbw-planning/codebase/STRUCTURE.md +324 -0
- data/.vbw-planning/codebase/TESTING.md +154 -0
- data/.vbw-planning/config.json +12 -0
- data/.vbw-planning/discovery.json +24 -0
- data/.vbw-planning/milestones/default/ROADMAP.md +115 -0
- data/.vbw-planning/milestones/default/STATE.md +83 -0
- data/.vbw-planning/milestones/default/phases/01-coverage-analysis-quick-wins/PLAN-01-SUMMARY.md +56 -0
- data/.vbw-planning/milestones/default/phases/01-coverage-analysis-quick-wins/PLAN-01.md +187 -0
- data/.vbw-planning/milestones/default/phases/01-coverage-analysis-quick-wins/PLAN-02-SUMMARY.md +64 -0
- data/.vbw-planning/milestones/default/phases/01-coverage-analysis-quick-wins/PLAN-02.md +137 -0
- data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-01-SUMMARY.md +67 -0
- data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-01.md +142 -0
- data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-02-SUMMARY.md +64 -0
- data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-02.md +138 -0
- data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-03-SUMMARY.md +85 -0
- data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-03.md +147 -0
- data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-04-SUMMARY.md +63 -0
- data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-04.md +129 -0
- data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-05-SUMMARY.md +74 -0
- data/.vbw-planning/milestones/default/phases/02-critical-path-test-coverage/PLAN-05.md +154 -0
- data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/03-VERIFICATION-wave1.md +303 -0
- data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/03-VERIFICATION.md +510 -0
- data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-01-SUMMARY.md +61 -0
- data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-01.md +161 -0
- data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-02-SUMMARY.md +66 -0
- data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-02.md +132 -0
- data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-03-SUMMARY.md +59 -0
- data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-03.md +171 -0
- data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-04-SUMMARY.md +56 -0
- data/.vbw-planning/milestones/default/phases/03-large-file-refactoring/PLAN-04.md +152 -0
- data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/04-CONTEXT.md +33 -0
- data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-01-SUMMARY.md +42 -0
- data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-01.md +119 -0
- data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-02-SUMMARY.md +52 -0
- data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-02.md +195 -0
- data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-03-SUMMARY.md +79 -0
- data/.vbw-planning/milestones/default/phases/04-code-quality-conventions-cleanup/PLAN-03.md +130 -0
- data/CHANGELOG.md +28 -0
- data/CLAUDE.md +179 -0
- data/Gemfile +8 -0
- data/Gemfile.lock +114 -101
- data/Rakefile +2 -0
- data/app/assets/builds/source_monitor/application.css +2076 -0
- data/app/assets/builds/source_monitor/application.js +2758 -0
- data/app/assets/builds/source_monitor/application.js.map +7 -0
- data/app/controllers/source_monitor/application_controller.rb +2 -0
- data/app/controllers/source_monitor/health_controller.rb +2 -0
- data/app/controllers/source_monitor/import_sessions/bulk_configuration.rb +106 -0
- data/app/controllers/source_monitor/import_sessions/entry_annotation.rb +187 -0
- data/app/controllers/source_monitor/import_sessions/health_check_management.rb +112 -0
- data/app/controllers/source_monitor/import_sessions/opml_parser.rb +130 -0
- data/app/controllers/source_monitor/import_sessions_controller.rb +6 -507
- data/app/controllers/source_monitor/items_controller.rb +2 -0
- data/app/controllers/source_monitor/sources_controller.rb +0 -14
- data/app/helpers/source_monitor/application_helper.rb +4 -112
- data/app/helpers/source_monitor/health_badge_helper.rb +69 -0
- data/app/helpers/source_monitor/table_sort_helper.rb +53 -0
- data/app/jobs/source_monitor/application_job.rb +2 -0
- data/app/models/source_monitor/application_record.rb +2 -0
- data/app/models/source_monitor/log_entry.rb +0 -2
- data/config/coverage_baseline.json +217 -1862
- data/config/routes.rb +2 -0
- data/db/migrate/20251009103000_add_feed_content_readability_to_sources.rb +2 -0
- data/db/migrate/20251014171659_add_performance_indexes.rb +2 -0
- data/db/migrate/20251014172525_add_fetch_status_check_constraint.rb +2 -0
- data/db/migrate/20251108120116_refresh_fetch_status_constraint.rb +2 -0
- data/db/migrate/20260210204022_add_composite_index_to_log_entries.rb +17 -0
- data/lib/source_monitor/assets/bundler.rb +2 -0
- data/lib/source_monitor/assets.rb +2 -0
- data/lib/source_monitor/configuration/authentication_settings.rb +62 -0
- data/lib/source_monitor/configuration/events.rb +60 -0
- data/lib/source_monitor/configuration/fetching_settings.rb +27 -0
- data/lib/source_monitor/configuration/health_settings.rb +27 -0
- data/lib/source_monitor/configuration/http_settings.rb +43 -0
- data/lib/source_monitor/configuration/model_definition.rb +108 -0
- data/lib/source_monitor/configuration/models.rb +36 -0
- data/lib/source_monitor/configuration/realtime_settings.rb +95 -0
- data/lib/source_monitor/configuration/retention_settings.rb +45 -0
- data/lib/source_monitor/configuration/scraper_registry.rb +67 -0
- data/lib/source_monitor/configuration/scraping_settings.rb +39 -0
- data/lib/source_monitor/configuration/validation_definition.rb +32 -0
- data/lib/source_monitor/configuration.rb +12 -579
- data/lib/source_monitor/dashboard/queries/recent_activity_query.rb +138 -0
- data/lib/source_monitor/dashboard/queries/stats_query.rb +71 -0
- data/lib/source_monitor/dashboard/queries.rb +2 -195
- data/lib/source_monitor/engine.rb +2 -0
- data/lib/source_monitor/fetching/feed_fetcher/adaptive_interval.rb +141 -0
- data/lib/source_monitor/fetching/feed_fetcher/entry_processor.rb +89 -0
- data/lib/source_monitor/fetching/feed_fetcher/source_updater.rb +200 -0
- data/lib/source_monitor/fetching/feed_fetcher.rb +37 -379
- data/lib/source_monitor/items/item_creator/content_extractor.rb +113 -0
- data/lib/source_monitor/items/item_creator/entry_parser/media_extraction.rb +96 -0
- data/lib/source_monitor/items/item_creator/entry_parser.rb +294 -0
- data/lib/source_monitor/items/item_creator.rb +28 -455
- data/lib/source_monitor/setup/bundle_installer.rb +2 -0
- data/lib/source_monitor/setup/cli.rb +2 -0
- data/lib/source_monitor/setup/dependency_checker.rb +2 -0
- data/lib/source_monitor/setup/detectors.rb +2 -0
- data/lib/source_monitor/setup/gemfile_editor.rb +2 -0
- data/lib/source_monitor/setup/initializer_patcher.rb +2 -0
- data/lib/source_monitor/setup/install_generator.rb +2 -0
- data/lib/source_monitor/setup/migration_installer.rb +2 -0
- data/lib/source_monitor/setup/node_installer.rb +2 -0
- data/lib/source_monitor/setup/prompter.rb +2 -0
- data/lib/source_monitor/setup/requirements.rb +2 -0
- data/lib/source_monitor/setup/shell_runner.rb +2 -0
- data/lib/source_monitor/setup/verification/action_cable_verifier.rb +2 -0
- data/lib/source_monitor/setup/verification/printer.rb +2 -0
- data/lib/source_monitor/setup/verification/result.rb +2 -0
- data/lib/source_monitor/setup/verification/runner.rb +2 -0
- data/lib/source_monitor/setup/verification/solid_queue_verifier.rb +2 -0
- data/lib/source_monitor/setup/verification/telemetry_logger.rb +2 -0
- data/lib/source_monitor/setup/workflow.rb +2 -0
- data/lib/source_monitor/version.rb +3 -1
- data/lib/source_monitor.rb +140 -58
- data/lib/tasks/source_monitor_assets.rake +2 -0
- data/lib/tasks/source_monitor_setup.rake +2 -0
- data/lib/tasks/source_monitor_tasks.rake +2 -0
- data/source_monitor.gemspec +3 -1
- metadata +144 -4
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: rails-architecture
|
|
3
|
+
description: Guides modern Rails 8 code architecture decisions and patterns. Use when deciding where to put code, choosing between patterns (service objects vs concerns vs query objects), designing feature architecture, refactoring for better organization, or when user mentions architecture, code organization, design patterns, or layered design.
|
|
4
|
+
allowed-tools: Read, Glob, Grep
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Modern Rails 8 Architecture Patterns
|
|
8
|
+
|
|
9
|
+
## Project Conventions
|
|
10
|
+
- **Testing:** Minitest + fixtures (NEVER RSpec or FactoryBot)
|
|
11
|
+
- **Components:** ViewComponents for reusable UI (partials OK for simple one-offs)
|
|
12
|
+
- **Authorization:** Pundit policies (deny by default)
|
|
13
|
+
- **Jobs:** Solid Queue, shallow jobs, `_later`/`_now` naming
|
|
14
|
+
- **Frontend:** Hotwire (Turbo + Stimulus) + Tailwind CSS
|
|
15
|
+
- **State:** State-as-records for business state (booleans only for technical flags)
|
|
16
|
+
- **Architecture:** Rich models first, service objects for multi-model orchestration
|
|
17
|
+
- **Routing:** Everything-is-CRUD (new resource over new action)
|
|
18
|
+
- **Quality:** RuboCop (omakase) + Brakeman
|
|
19
|
+
|
|
20
|
+
## Architecture Decision Tree
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
Where should this code go?
|
|
24
|
+
│
|
|
25
|
+
├─ Is it data validation, associations, or simple business logic?
|
|
26
|
+
│ └─ → Model (rich models first!)
|
|
27
|
+
│
|
|
28
|
+
├─ Is it shared behavior across models?
|
|
29
|
+
│ └─ → Concern
|
|
30
|
+
│
|
|
31
|
+
├─ Is it business state tracking (who/when/why)?
|
|
32
|
+
│ └─ → State Record (see: state-records pattern)
|
|
33
|
+
│
|
|
34
|
+
├─ Does it orchestrate 3+ models or call external APIs?
|
|
35
|
+
│ └─ → Service Object (with Result pattern)
|
|
36
|
+
│
|
|
37
|
+
├─ Is it a complex database query (3+ joins, aggregations)?
|
|
38
|
+
│ └─ → Query Object
|
|
39
|
+
│
|
|
40
|
+
├─ Is it view/display formatting?
|
|
41
|
+
│ └─ → Presenter (SimpleDelegator)
|
|
42
|
+
│
|
|
43
|
+
├─ Is it authorization logic?
|
|
44
|
+
│ └─ → Pundit Policy
|
|
45
|
+
│
|
|
46
|
+
├─ Is it reusable UI with logic?
|
|
47
|
+
│ └─ → ViewComponent
|
|
48
|
+
│
|
|
49
|
+
├─ Is it async/background work?
|
|
50
|
+
│ └─ → Shallow Job (Solid Queue)
|
|
51
|
+
│
|
|
52
|
+
├─ Is it a complex form (multi-model, wizard)?
|
|
53
|
+
│ └─ → Form Object
|
|
54
|
+
│
|
|
55
|
+
├─ Is it a transactional email?
|
|
56
|
+
│ └─ → Mailer
|
|
57
|
+
│
|
|
58
|
+
└─ Is it HTTP request/response handling only?
|
|
59
|
+
└─ → Controller (keep it thin!)
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Hybrid Philosophy: Models First, Services When Needed
|
|
63
|
+
|
|
64
|
+
### The Rule of Three
|
|
65
|
+
- **1 model affected** → Keep logic in the model
|
|
66
|
+
- **2 models affected** → Consider a concern or model method
|
|
67
|
+
- **3+ models affected** → Extract to a service object
|
|
68
|
+
|
|
69
|
+
### Rich Models (Default)
|
|
70
|
+
Models handle validations, associations, scopes, simple derived attributes, and single-model business logic. This is where most code belongs.
|
|
71
|
+
|
|
72
|
+
```ruby
|
|
73
|
+
class Order < ApplicationRecord
|
|
74
|
+
include Closeable # State-as-records concern
|
|
75
|
+
|
|
76
|
+
belongs_to :user
|
|
77
|
+
has_many :line_items, dependent: :destroy
|
|
78
|
+
|
|
79
|
+
validates :total_cents, presence: true, numericality: { greater_than: 0 }
|
|
80
|
+
|
|
81
|
+
scope :recent, -> { order(created_at: :desc) }
|
|
82
|
+
scope :pending, -> { where.missing(:closure) }
|
|
83
|
+
|
|
84
|
+
def add_item(product, quantity: 1)
|
|
85
|
+
line_items.create!(product: product, quantity: quantity, price_cents: product.price_cents)
|
|
86
|
+
recalculate_total!
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
private
|
|
90
|
+
|
|
91
|
+
def recalculate_total!
|
|
92
|
+
update!(total_cents: line_items.sum("price_cents * quantity"))
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### Service Objects (When Justified)
|
|
98
|
+
Use only when logic spans 3+ models, calls external APIs, or orchestrates complex workflows.
|
|
99
|
+
|
|
100
|
+
```ruby
|
|
101
|
+
module Orders
|
|
102
|
+
class CheckoutService
|
|
103
|
+
def call(user:, cart:, payment_method_id:)
|
|
104
|
+
order = nil
|
|
105
|
+
|
|
106
|
+
ActiveRecord::Base.transaction do
|
|
107
|
+
order = user.orders.create!(total_cents: cart.total_cents)
|
|
108
|
+
cart.items.each { |item| order.add_item(item.product, quantity: item.quantity) }
|
|
109
|
+
Inventory::ReserveService.new.call(order: order)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
Payments::ChargeService.new.call(order: order, payment_method_id: payment_method_id)
|
|
113
|
+
OrderMailer.confirmation(order).deliver_later
|
|
114
|
+
Result.new(success: true, data: order)
|
|
115
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
116
|
+
Result.new(success: false, error: e.message)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## Everything-is-CRUD Routing
|
|
123
|
+
|
|
124
|
+
Prefer creating a new resource over adding custom actions:
|
|
125
|
+
|
|
126
|
+
```ruby
|
|
127
|
+
# GOOD: New resource for publishing
|
|
128
|
+
resources :posts do
|
|
129
|
+
resource :publication, only: [:create, :destroy]
|
|
130
|
+
end
|
|
131
|
+
# POST /posts/:post_id/publication → Publications#create
|
|
132
|
+
# DELETE /posts/:post_id/publication → Publications#destroy
|
|
133
|
+
|
|
134
|
+
# BAD: Custom action
|
|
135
|
+
resources :posts do
|
|
136
|
+
member do
|
|
137
|
+
post :publish
|
|
138
|
+
post :unpublish
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
## Layer Responsibilities
|
|
144
|
+
|
|
145
|
+
| Layer | Responsibility | Should NOT contain |
|
|
146
|
+
|-------|---------------|-------------------|
|
|
147
|
+
| **Controller** | HTTP, params, authorize, render | Business logic, queries |
|
|
148
|
+
| **Model** | Data, validations, relations, scopes | Display logic, HTTP |
|
|
149
|
+
| **Concern** | Shared model/controller behavior | Unrelated cross-cutting logic |
|
|
150
|
+
| **Service** | Multi-model orchestration, external APIs | HTTP, display logic |
|
|
151
|
+
| **Query** | Complex database queries, reports | Business logic |
|
|
152
|
+
| **Presenter** | View formatting, badges | Business logic, queries |
|
|
153
|
+
| **Policy** | Authorization rules | Business logic |
|
|
154
|
+
| **Component** | Reusable UI encapsulation | Business logic |
|
|
155
|
+
| **Job** | Async delegation (shallow!) | Business logic |
|
|
156
|
+
|
|
157
|
+
## Project Directory Structure
|
|
158
|
+
|
|
159
|
+
```
|
|
160
|
+
app/
|
|
161
|
+
├── channels/ # Action Cable channels
|
|
162
|
+
├── components/ # ViewComponents (UI + logic)
|
|
163
|
+
├── controllers/
|
|
164
|
+
│ └── concerns/ # Shared controller behavior
|
|
165
|
+
├── forms/ # Form objects
|
|
166
|
+
├── jobs/ # Background jobs (Solid Queue)
|
|
167
|
+
├── mailers/ # Action Mailer classes
|
|
168
|
+
├── models/
|
|
169
|
+
│ └── concerns/ # Shared model behavior
|
|
170
|
+
├── policies/ # Pundit authorization
|
|
171
|
+
├── presenters/ # View formatting
|
|
172
|
+
├── queries/ # Complex queries
|
|
173
|
+
├── services/ # Business logic (use sparingly)
|
|
174
|
+
│ └── result.rb # Shared Result class
|
|
175
|
+
└── views/
|
|
176
|
+
└── components/ # ViewComponent templates
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
## When NOT to Abstract
|
|
180
|
+
|
|
181
|
+
| Situation | Keep It Simple | Don't Create |
|
|
182
|
+
|-----------|----------------|--------------|
|
|
183
|
+
| Simple CRUD (< 10 lines) | Keep in controller | Service object |
|
|
184
|
+
| Used only once | Inline the code | Abstraction |
|
|
185
|
+
| Simple query with 1-2 conditions | Model scope | Query object |
|
|
186
|
+
| Basic text formatting | Helper method | Presenter |
|
|
187
|
+
| Single model form | `form_with model:` | Form object |
|
|
188
|
+
| Simple partial without logic | Partial | ViewComponent |
|
|
189
|
+
|
|
190
|
+
## When TO Abstract
|
|
191
|
+
|
|
192
|
+
| Signal | Action |
|
|
193
|
+
|--------|--------|
|
|
194
|
+
| Same code in 3+ places | Extract to concern/service |
|
|
195
|
+
| Controller action > 15 lines | Extract to service |
|
|
196
|
+
| Model > 300 lines | Extract concerns |
|
|
197
|
+
| Complex conditionals | Extract to policy/service |
|
|
198
|
+
| Query joins 3+ tables | Extract to query object |
|
|
199
|
+
| Form spans multiple models | Extract to form object |
|
|
200
|
+
| Partial has > 5 lines of logic | Use ViewComponent |
|
|
201
|
+
|
|
202
|
+
## Result Object Pattern
|
|
203
|
+
|
|
204
|
+
All services return a consistent Result:
|
|
205
|
+
|
|
206
|
+
```ruby
|
|
207
|
+
# app/services/result.rb
|
|
208
|
+
class Result
|
|
209
|
+
attr_reader :data, :error, :code
|
|
210
|
+
|
|
211
|
+
def initialize(success:, data: nil, error: nil, code: nil)
|
|
212
|
+
@success = success
|
|
213
|
+
@data = data
|
|
214
|
+
@error = error
|
|
215
|
+
@code = code
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def success? = @success
|
|
219
|
+
def failure? = !@success
|
|
220
|
+
|
|
221
|
+
def self.success(data = nil) = new(success: true, data: data)
|
|
222
|
+
def self.failure(error, code: nil) = new(success: false, error: error, code: code)
|
|
223
|
+
end
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
## Testing Strategy by Layer
|
|
227
|
+
|
|
228
|
+
| Layer | Test Type | Location | Focus |
|
|
229
|
+
|-------|-----------|----------|-------|
|
|
230
|
+
| Model | Unit | `test/models/` | Validations, scopes, methods |
|
|
231
|
+
| Service | Unit | `test/services/` | Business logic, edge cases |
|
|
232
|
+
| Query | Unit | `test/queries/` | Query results, correctness |
|
|
233
|
+
| Presenter | Unit | `test/presenters/` | Formatting, HTML output |
|
|
234
|
+
| Controller | Integration | `test/controllers/` | HTTP flow, authorization |
|
|
235
|
+
| Component | Component | `test/components/` | Rendering, variants |
|
|
236
|
+
| Policy | Unit | `test/policies/` | Authorization rules |
|
|
237
|
+
| System | E2E | `test/system/` | Critical user paths |
|
|
238
|
+
|
|
239
|
+
## Anti-Patterns to Avoid
|
|
240
|
+
|
|
241
|
+
| Anti-Pattern | Problem | Solution |
|
|
242
|
+
|--------------|---------|----------|
|
|
243
|
+
| God Model | Model > 500 lines | Extract concerns |
|
|
244
|
+
| Fat Controller | Logic in controllers | Move to models/services |
|
|
245
|
+
| Premature Service | Service for 3 lines | Keep in model |
|
|
246
|
+
| Callback Hell | Complex model callbacks | Use services for orchestration |
|
|
247
|
+
| Boolean State | `approved: true` | State-as-records |
|
|
248
|
+
| N+1 Queries | Unoptimized queries | Use `.includes()` |
|
|
249
|
+
|
|
250
|
+
## References
|
|
251
|
+
|
|
252
|
+
- See [layer-interactions.md](reference/layer-interactions.md) for layer communication patterns
|
|
253
|
+
- See [service-patterns.md](reference/service-patterns.md) for service object patterns
|
|
254
|
+
- See [query-patterns.md](reference/query-patterns.md) for query object patterns
|
|
255
|
+
- See [error-handling.md](reference/error-handling.md) for error handling strategies
|
|
256
|
+
- See [testing-strategy.md](reference/testing-strategy.md) for comprehensive testing
|
|
257
|
+
- See [multi-tenancy.md](reference/multi-tenancy.md) for multi-tenant patterns
|
|
258
|
+
- See [event-tracking.md](reference/event-tracking.md) for domain event patterns
|
|
259
|
+
- See [state-records.md](reference/state-records.md) for state-as-records patterns
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
# Error Handling Strategies
|
|
2
|
+
|
|
3
|
+
## Result Object Pattern (Preferred)
|
|
4
|
+
|
|
5
|
+
Services return Result objects instead of raising exceptions:
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
# app/services/result.rb
|
|
9
|
+
class Result
|
|
10
|
+
attr_reader :data, :error, :code
|
|
11
|
+
|
|
12
|
+
def initialize(success:, data: nil, error: nil, code: nil)
|
|
13
|
+
@success = success
|
|
14
|
+
@data = data
|
|
15
|
+
@error = error
|
|
16
|
+
@code = code
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def success? = @success
|
|
20
|
+
def failure? = !@success
|
|
21
|
+
|
|
22
|
+
# Pattern matching support (Ruby 3+)
|
|
23
|
+
def deconstruct_keys(keys)
|
|
24
|
+
{ success: @success, data: @data, error: @error, code: @code }
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Error Code System
|
|
30
|
+
|
|
31
|
+
### Define Error Codes
|
|
32
|
+
|
|
33
|
+
```ruby
|
|
34
|
+
module Orders
|
|
35
|
+
class CreateService
|
|
36
|
+
ERROR_CODES = {
|
|
37
|
+
empty_cart: :empty_cart,
|
|
38
|
+
out_of_stock: :out_of_stock,
|
|
39
|
+
payment_declined: :payment_declined,
|
|
40
|
+
invalid_coupon: :invalid_coupon,
|
|
41
|
+
validation_failed: :validation_failed
|
|
42
|
+
}.freeze
|
|
43
|
+
|
|
44
|
+
MESSAGES = {
|
|
45
|
+
empty_cart: "Your cart is empty",
|
|
46
|
+
out_of_stock: "One or more items are out of stock",
|
|
47
|
+
payment_declined: "Your payment was declined",
|
|
48
|
+
invalid_coupon: "The coupon code is invalid",
|
|
49
|
+
validation_failed: "Please check your order details"
|
|
50
|
+
}.freeze
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### Return Typed Errors
|
|
56
|
+
|
|
57
|
+
```ruby
|
|
58
|
+
def call(params)
|
|
59
|
+
return error(:empty_cart) if params[:items].empty?
|
|
60
|
+
return error(:out_of_stock) unless inventory_available?(params[:items])
|
|
61
|
+
|
|
62
|
+
order = create_order(params)
|
|
63
|
+
success(order)
|
|
64
|
+
rescue PaymentGateway::Declined
|
|
65
|
+
error(:payment_declined)
|
|
66
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
67
|
+
error(:validation_failed, e.message)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def error(code, details = nil)
|
|
73
|
+
message = self.class::MESSAGES[code]
|
|
74
|
+
message = "#{message}: #{details}" if details
|
|
75
|
+
Result.new(success: false, error: message, code: code)
|
|
76
|
+
end
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Controller Error Handling
|
|
80
|
+
|
|
81
|
+
### Handle by Error Code
|
|
82
|
+
|
|
83
|
+
```ruby
|
|
84
|
+
class OrdersController < ApplicationController
|
|
85
|
+
def create
|
|
86
|
+
result = Orders::CreateService.new.call(order_params)
|
|
87
|
+
|
|
88
|
+
if result.success?
|
|
89
|
+
redirect_to result.data, notice: t(".success")
|
|
90
|
+
else
|
|
91
|
+
handle_error(result)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
private
|
|
96
|
+
|
|
97
|
+
def handle_error(result)
|
|
98
|
+
case result.code
|
|
99
|
+
when :empty_cart
|
|
100
|
+
redirect_to cart_path, alert: result.error
|
|
101
|
+
when :out_of_stock
|
|
102
|
+
flash.now[:alert] = result.error
|
|
103
|
+
@out_of_stock = true
|
|
104
|
+
render :new, status: :unprocessable_entity
|
|
105
|
+
when :payment_declined
|
|
106
|
+
redirect_to payment_path, alert: result.error
|
|
107
|
+
else
|
|
108
|
+
flash.now[:alert] = result.error
|
|
109
|
+
render :new, status: :unprocessable_entity
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
### Pattern Matching (Ruby 3+)
|
|
116
|
+
|
|
117
|
+
```ruby
|
|
118
|
+
def create
|
|
119
|
+
case Orders::CreateService.new.call(order_params)
|
|
120
|
+
in { success: true, data: order }
|
|
121
|
+
redirect_to order, notice: t(".success")
|
|
122
|
+
in { code: :empty_cart }
|
|
123
|
+
redirect_to cart_path, alert: t(".empty_cart")
|
|
124
|
+
in { code: :payment_declined, error: message }
|
|
125
|
+
redirect_to payment_path, alert: message
|
|
126
|
+
in { error: message }
|
|
127
|
+
flash.now[:alert] = message
|
|
128
|
+
render :new, status: :unprocessable_entity
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
## API Error Responses
|
|
134
|
+
|
|
135
|
+
### Consistent Error Format
|
|
136
|
+
|
|
137
|
+
```ruby
|
|
138
|
+
# app/controllers/api/base_controller.rb
|
|
139
|
+
module Api
|
|
140
|
+
class BaseController < ApplicationController
|
|
141
|
+
private
|
|
142
|
+
|
|
143
|
+
def render_error(result, status: :unprocessable_entity)
|
|
144
|
+
render json: {
|
|
145
|
+
error: {
|
|
146
|
+
code: result.code,
|
|
147
|
+
message: result.error,
|
|
148
|
+
details: result.data # Optional additional context
|
|
149
|
+
}
|
|
150
|
+
}, status: status
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def render_success(data, status: :ok)
|
|
154
|
+
render json: { data: data }, status: status
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
### HTTP Status Mapping
|
|
161
|
+
|
|
162
|
+
```ruby
|
|
163
|
+
ERROR_STATUS_MAP = {
|
|
164
|
+
not_found: :not_found,
|
|
165
|
+
unauthorized: :unauthorized,
|
|
166
|
+
forbidden: :forbidden,
|
|
167
|
+
validation_failed: :unprocessable_entity,
|
|
168
|
+
conflict: :conflict,
|
|
169
|
+
rate_limited: :too_many_requests
|
|
170
|
+
}.freeze
|
|
171
|
+
|
|
172
|
+
def render_service_result(result)
|
|
173
|
+
if result.success?
|
|
174
|
+
render_success(result.data)
|
|
175
|
+
else
|
|
176
|
+
status = ERROR_STATUS_MAP.fetch(result.code, :unprocessable_entity)
|
|
177
|
+
render_error(result, status: status)
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
## Exception Handling Layers
|
|
183
|
+
|
|
184
|
+
### Service Layer (Catch and Wrap)
|
|
185
|
+
|
|
186
|
+
```ruby
|
|
187
|
+
class ExternalApiService
|
|
188
|
+
def call(params)
|
|
189
|
+
response = client.request(params)
|
|
190
|
+
success(response.data)
|
|
191
|
+
rescue Faraday::TimeoutError
|
|
192
|
+
error(:timeout, "External service timed out")
|
|
193
|
+
rescue Faraday::ConnectionFailed
|
|
194
|
+
error(:connection_failed, "Could not connect to service")
|
|
195
|
+
rescue JSON::ParserError
|
|
196
|
+
error(:invalid_response, "Invalid response from service")
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
### Controller Layer (Rescue From)
|
|
202
|
+
|
|
203
|
+
```ruby
|
|
204
|
+
class ApplicationController < ActionController::Base
|
|
205
|
+
rescue_from ActiveRecord::RecordNotFound, with: :not_found
|
|
206
|
+
rescue_from Pundit::NotAuthorizedError, with: :forbidden
|
|
207
|
+
|
|
208
|
+
private
|
|
209
|
+
|
|
210
|
+
def not_found
|
|
211
|
+
respond_to do |format|
|
|
212
|
+
format.html { render "errors/not_found", status: :not_found }
|
|
213
|
+
format.json { render json: { error: "Not found" }, status: :not_found }
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def forbidden
|
|
218
|
+
respond_to do |format|
|
|
219
|
+
format.html { redirect_to root_path, alert: t("errors.forbidden") }
|
|
220
|
+
format.json { render json: { error: "Forbidden" }, status: :forbidden }
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
### Global Error Handler
|
|
227
|
+
|
|
228
|
+
```ruby
|
|
229
|
+
# config/initializers/error_handler.rb
|
|
230
|
+
Rails.application.config.exceptions_app = ->(env) {
|
|
231
|
+
ErrorsController.action(:show).call(env)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
# app/controllers/errors_controller.rb
|
|
235
|
+
class ErrorsController < ApplicationController
|
|
236
|
+
skip_before_action :authenticate_user!
|
|
237
|
+
|
|
238
|
+
def show
|
|
239
|
+
@status = request.env["PATH_INFO"].delete("/").to_i
|
|
240
|
+
render status: @status
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
## Validation Errors
|
|
246
|
+
|
|
247
|
+
### Model Validations to Result
|
|
248
|
+
|
|
249
|
+
```ruby
|
|
250
|
+
def call(params)
|
|
251
|
+
record = Model.new(params)
|
|
252
|
+
|
|
253
|
+
if record.save
|
|
254
|
+
success(record)
|
|
255
|
+
else
|
|
256
|
+
validation_error(record)
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def validation_error(record)
|
|
261
|
+
Result.new(
|
|
262
|
+
success: false,
|
|
263
|
+
error: record.errors.full_messages.join(", "),
|
|
264
|
+
code: :validation_failed,
|
|
265
|
+
data: record.errors.to_hash
|
|
266
|
+
)
|
|
267
|
+
end
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
### Display Validation Errors
|
|
271
|
+
|
|
272
|
+
```ruby
|
|
273
|
+
# In controller
|
|
274
|
+
if result.failure? && result.code == :validation_failed
|
|
275
|
+
@errors = result.data # Hash of field => [messages]
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# In view
|
|
279
|
+
<% if @errors&.dig(:email) %>
|
|
280
|
+
<p class="text-red-500"><%= @errors[:email].join(", ") %></p>
|
|
281
|
+
<% end %>
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
## Logging Errors
|
|
285
|
+
|
|
286
|
+
```ruby
|
|
287
|
+
class ApplicationService
|
|
288
|
+
private
|
|
289
|
+
|
|
290
|
+
def error(code, message = nil, exception: nil)
|
|
291
|
+
log_error(code, message, exception)
|
|
292
|
+
Result.new(success: false, error: message || default_message(code), code: code)
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def log_error(code, message, exception)
|
|
296
|
+
Rails.logger.error({
|
|
297
|
+
service: self.class.name,
|
|
298
|
+
error_code: code,
|
|
299
|
+
message: message,
|
|
300
|
+
exception: exception&.class&.name,
|
|
301
|
+
backtrace: exception&.backtrace&.first(5)
|
|
302
|
+
}.to_json)
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
## Error Tracking Integration
|
|
308
|
+
|
|
309
|
+
```ruby
|
|
310
|
+
# With Sentry/Rollbar
|
|
311
|
+
def error(code, message = nil, exception: nil)
|
|
312
|
+
if exception && should_report?(code)
|
|
313
|
+
Sentry.capture_exception(exception, extra: { code: code, message: message })
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
Result.new(success: false, error: message, code: code)
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def should_report?(code)
|
|
320
|
+
# Don't report expected errors
|
|
321
|
+
![:validation_failed, :not_found, :unauthorized].include?(code)
|
|
322
|
+
end
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
## Checklist
|
|
326
|
+
|
|
327
|
+
- [ ] Services return Result objects
|
|
328
|
+
- [ ] Error codes are typed symbols
|
|
329
|
+
- [ ] Controllers handle errors by code
|
|
330
|
+
- [ ] API responses have consistent format
|
|
331
|
+
- [ ] Unexpected errors logged with context
|
|
332
|
+
- [ ] Sensitive data not exposed in errors
|
|
333
|
+
- [ ] User-facing messages use I18n
|