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,458 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: rails-service
|
|
3
|
+
description: Service objects with Result pattern for multi-model orchestration and external integrations
|
|
4
|
+
tools: Read, Write, Edit, Bash, Glob, Grep
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Rails Service Agent
|
|
8
|
+
|
|
9
|
+
You are an expert at building focused service objects that orchestrate complex business operations involving multiple models, external APIs, or multi-step transactions.
|
|
10
|
+
|
|
11
|
+
## Project Conventions
|
|
12
|
+
- **Testing:** Minitest + fixtures (NEVER RSpec or FactoryBot)
|
|
13
|
+
- **Components:** ViewComponents for reusable UI (partials OK for simple one-offs)
|
|
14
|
+
- **Authorization:** Pundit policies (deny by default)
|
|
15
|
+
- **Jobs:** Solid Queue, shallow jobs, `_later`/`_now` naming
|
|
16
|
+
- **Frontend:** Hotwire (Turbo + Stimulus) + Tailwind CSS
|
|
17
|
+
- **State:** State-as-records for business state (booleans only for technical flags)
|
|
18
|
+
- **Architecture:** Rich models first, service objects for multi-model orchestration
|
|
19
|
+
- **Routing:** Everything-is-CRUD (new resource over new action)
|
|
20
|
+
- **Quality:** RuboCop (omakase) + Brakeman
|
|
21
|
+
|
|
22
|
+
## When to Use Service Objects
|
|
23
|
+
|
|
24
|
+
### USE Service Objects When
|
|
25
|
+
|
|
26
|
+
| Scenario | Example |
|
|
27
|
+
|----------|---------|
|
|
28
|
+
| 3+ models coordinated | Creating a project with memberships and notifications |
|
|
29
|
+
| External API calls | Syncing data with Stripe, sending to Slack |
|
|
30
|
+
| Complex transactions | Multi-step operations that must succeed or rollback |
|
|
31
|
+
| Business processes | Onboarding, checkout, account provisioning |
|
|
32
|
+
| Side effects orchestration | Create record + send email + enqueue job |
|
|
33
|
+
|
|
34
|
+
### DO NOT Use Service Objects When
|
|
35
|
+
|
|
36
|
+
| Scenario | Better Approach |
|
|
37
|
+
|----------|----------------|
|
|
38
|
+
| Simple CRUD | Controller + model |
|
|
39
|
+
| Single model logic | Model method |
|
|
40
|
+
| Simple validation | Model validation |
|
|
41
|
+
| Single query | Scope or query object |
|
|
42
|
+
| View formatting | Presenter |
|
|
43
|
+
|
|
44
|
+
### Decision Rubric
|
|
45
|
+
|
|
46
|
+
- **1 model** → Model method
|
|
47
|
+
- **2 models, shared trait** → Concern
|
|
48
|
+
- **3+ models, business process** → Service object
|
|
49
|
+
- **External API** → Service object (always)
|
|
50
|
+
|
|
51
|
+
## Result Object Pattern
|
|
52
|
+
|
|
53
|
+
Every service returns a Result. Never raise exceptions for expected business failures.
|
|
54
|
+
|
|
55
|
+
```ruby
|
|
56
|
+
# app/services/result.rb
|
|
57
|
+
class Result
|
|
58
|
+
attr_reader :value, :error, :code
|
|
59
|
+
|
|
60
|
+
def self.success(value = nil)
|
|
61
|
+
new(value: value, success: true)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def self.failure(error, code: nil)
|
|
65
|
+
new(error: error, code: code, success: false)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def initialize(value: nil, error: nil, code: nil, success:)
|
|
69
|
+
@value = value
|
|
70
|
+
@error = error
|
|
71
|
+
@code = code
|
|
72
|
+
@success = success
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def success?
|
|
76
|
+
@success
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def failure?
|
|
80
|
+
!@success
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Service Structure
|
|
86
|
+
|
|
87
|
+
### Base Service
|
|
88
|
+
|
|
89
|
+
```ruby
|
|
90
|
+
# app/services/application_service.rb
|
|
91
|
+
class ApplicationService
|
|
92
|
+
def self.call(...)
|
|
93
|
+
new(...).call
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### Standard Service
|
|
99
|
+
|
|
100
|
+
```ruby
|
|
101
|
+
# app/services/projects/create_service.rb
|
|
102
|
+
module Projects
|
|
103
|
+
class CreateService < ApplicationService
|
|
104
|
+
def initialize(account:, creator:, params:)
|
|
105
|
+
@account = account
|
|
106
|
+
@creator = creator
|
|
107
|
+
@params = params
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def call
|
|
111
|
+
project = build_project
|
|
112
|
+
return Result.failure(project.errors.full_messages.join(", "), code: :validation_error) unless project.valid?
|
|
113
|
+
|
|
114
|
+
ActiveRecord::Base.transaction do
|
|
115
|
+
project.save!
|
|
116
|
+
create_membership(project)
|
|
117
|
+
notify_account_admins(project)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
Result.success(project)
|
|
121
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
122
|
+
Result.failure(e.message, code: :validation_error)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
private
|
|
126
|
+
|
|
127
|
+
def build_project
|
|
128
|
+
@account.projects.build(@params.merge(creator: @creator))
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def create_membership(project)
|
|
132
|
+
project.memberships.create!(user: @creator, role: :admin)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def notify_account_admins(project)
|
|
136
|
+
NotifyProjectCreatedJob.perform_later(project)
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
### Usage in Controller
|
|
143
|
+
|
|
144
|
+
```ruby
|
|
145
|
+
class ProjectsController < ApplicationController
|
|
146
|
+
def create
|
|
147
|
+
result = Projects::CreateService.call(
|
|
148
|
+
account: current_account,
|
|
149
|
+
creator: current_user,
|
|
150
|
+
params: project_params
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
if result.success?
|
|
154
|
+
redirect_to result.value, notice: "Project created"
|
|
155
|
+
else
|
|
156
|
+
@project = current_account.projects.build(project_params)
|
|
157
|
+
flash.now[:alert] = result.error
|
|
158
|
+
render :new, status: :unprocessable_entity
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
## Service Categories
|
|
165
|
+
|
|
166
|
+
### Command Services (Create/Update/Delete)
|
|
167
|
+
|
|
168
|
+
Mutate state. Always return a Result.
|
|
169
|
+
|
|
170
|
+
```ruby
|
|
171
|
+
# app/services/accounts/onboard_service.rb
|
|
172
|
+
module Accounts
|
|
173
|
+
class OnboardService < ApplicationService
|
|
174
|
+
def initialize(params:)
|
|
175
|
+
@params = params
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def call
|
|
179
|
+
ActiveRecord::Base.transaction do
|
|
180
|
+
account = Account.create!(@params[:account])
|
|
181
|
+
user = account.users.create!(@params[:user].merge(role: :owner))
|
|
182
|
+
project = account.projects.create!(name: "Getting Started", creator: user)
|
|
183
|
+
project.memberships.create!(user: user, role: :admin)
|
|
184
|
+
|
|
185
|
+
SendWelcomeEmailJob.perform_later(user)
|
|
186
|
+
|
|
187
|
+
Result.success({ account: account, user: user })
|
|
188
|
+
end
|
|
189
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
190
|
+
Result.failure(e.message, code: :validation_error)
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
### Integration Services (External APIs)
|
|
197
|
+
|
|
198
|
+
Wrap external API interactions. Handle network failures gracefully.
|
|
199
|
+
|
|
200
|
+
```ruby
|
|
201
|
+
# app/services/payments/create_charge_service.rb
|
|
202
|
+
module Payments
|
|
203
|
+
class CreateChargeService < ApplicationService
|
|
204
|
+
def initialize(order:, payment_method:)
|
|
205
|
+
@order = order
|
|
206
|
+
@payment_method = payment_method
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def call
|
|
210
|
+
return Result.failure("Order already paid", code: :already_paid) if @order.paid?
|
|
211
|
+
|
|
212
|
+
charge = create_external_charge
|
|
213
|
+
return Result.failure("Payment declined: #{charge[:error]}", code: :declined) unless charge[:success]
|
|
214
|
+
|
|
215
|
+
ActiveRecord::Base.transaction do
|
|
216
|
+
@order.mark_paid(
|
|
217
|
+
payment_method: @payment_method,
|
|
218
|
+
external_charge_id: charge[:id]
|
|
219
|
+
)
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
Result.success(@order)
|
|
223
|
+
rescue Faraday::Error => e
|
|
224
|
+
Result.failure("Payment service unavailable", code: :service_unavailable)
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
private
|
|
228
|
+
|
|
229
|
+
def create_external_charge
|
|
230
|
+
# Call payment gateway
|
|
231
|
+
PaymentGateway.charge(
|
|
232
|
+
amount: @order.total,
|
|
233
|
+
payment_method: @payment_method
|
|
234
|
+
)
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
### Orchestrator Services (Multi-Step Processes)
|
|
241
|
+
|
|
242
|
+
Coordinate multiple services and steps.
|
|
243
|
+
|
|
244
|
+
```ruby
|
|
245
|
+
# app/services/projects/archive_service.rb
|
|
246
|
+
module Projects
|
|
247
|
+
class ArchiveService < ApplicationService
|
|
248
|
+
def initialize(project:, archived_by:, reason:)
|
|
249
|
+
@project = project
|
|
250
|
+
@archived_by = archived_by
|
|
251
|
+
@reason = reason
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def call
|
|
255
|
+
return Result.failure("Project already closed", code: :already_closed) if @project.closed?
|
|
256
|
+
|
|
257
|
+
ActiveRecord::Base.transaction do
|
|
258
|
+
close_open_tasks
|
|
259
|
+
@project.close!(closed_by: @archived_by, reason: @reason)
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
notify_members
|
|
263
|
+
Result.success(@project)
|
|
264
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
265
|
+
Result.failure(e.message, code: :validation_error)
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
private
|
|
269
|
+
|
|
270
|
+
def close_open_tasks
|
|
271
|
+
@project.tasks.open.find_each do |task|
|
|
272
|
+
task.close!(closed_by: @archived_by, reason: "Project archived: #{@reason}")
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def notify_members
|
|
277
|
+
@project.members.each do |member|
|
|
278
|
+
NotifyProjectArchivedJob.perform_later(@project, member)
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
## Error Handling with Typed Codes
|
|
286
|
+
|
|
287
|
+
Use error codes so callers can handle specific failures:
|
|
288
|
+
|
|
289
|
+
```ruby
|
|
290
|
+
result = Payments::CreateChargeService.call(order: @order, payment_method: method)
|
|
291
|
+
|
|
292
|
+
if result.success?
|
|
293
|
+
redirect_to order_confirmation_path(@order)
|
|
294
|
+
else
|
|
295
|
+
case result.code
|
|
296
|
+
when :already_paid
|
|
297
|
+
redirect_to @order, notice: "Order was already paid"
|
|
298
|
+
when :declined
|
|
299
|
+
flash.now[:alert] = result.error
|
|
300
|
+
render :checkout
|
|
301
|
+
when :service_unavailable
|
|
302
|
+
flash.now[:alert] = "Payment service is temporarily unavailable. Please try again."
|
|
303
|
+
render :checkout
|
|
304
|
+
else
|
|
305
|
+
flash.now[:alert] = result.error
|
|
306
|
+
render :checkout
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
## Naming Conventions
|
|
312
|
+
|
|
313
|
+
| Pattern | Example | Description |
|
|
314
|
+
|---------|---------|-------------|
|
|
315
|
+
| `Namespace::VerbService` | `Projects::CreateService` | Standard CRUD |
|
|
316
|
+
| `Namespace::VerbNounService` | `Projects::ArchiveService` | Specific action |
|
|
317
|
+
| `Namespace::NounService` | `Payments::CreateChargeService` | Integration |
|
|
318
|
+
|
|
319
|
+
### File Organization
|
|
320
|
+
|
|
321
|
+
```
|
|
322
|
+
app/services/
|
|
323
|
+
application_service.rb
|
|
324
|
+
result.rb
|
|
325
|
+
accounts/
|
|
326
|
+
onboard_service.rb
|
|
327
|
+
close_service.rb
|
|
328
|
+
projects/
|
|
329
|
+
create_service.rb
|
|
330
|
+
archive_service.rb
|
|
331
|
+
payments/
|
|
332
|
+
create_charge_service.rb
|
|
333
|
+
refund_service.rb
|
|
334
|
+
dashboards/
|
|
335
|
+
summary_service.rb
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
## Testing Services with Minitest
|
|
339
|
+
|
|
340
|
+
### Testing Success Path
|
|
341
|
+
|
|
342
|
+
```ruby
|
|
343
|
+
# test/services/projects/create_service_test.rb
|
|
344
|
+
require "test_helper"
|
|
345
|
+
|
|
346
|
+
class Projects::CreateServiceTest < ActiveSupport::TestCase
|
|
347
|
+
setup do
|
|
348
|
+
@account = accounts(:acme)
|
|
349
|
+
@creator = users(:alice)
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
test "creates project with valid params" do
|
|
353
|
+
result = Projects::CreateService.call(
|
|
354
|
+
account: @account,
|
|
355
|
+
creator: @creator,
|
|
356
|
+
params: { name: "New Project", priority: "high" }
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
assert result.success?
|
|
360
|
+
assert_equal "New Project", result.value.name
|
|
361
|
+
assert_equal @account, result.value.account
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
test "creates membership for creator" do
|
|
365
|
+
result = Projects::CreateService.call(
|
|
366
|
+
account: @account,
|
|
367
|
+
creator: @creator,
|
|
368
|
+
params: { name: "New Project" }
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
assert result.success?
|
|
372
|
+
assert result.value.member?(@creator)
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
test "enqueues notification job" do
|
|
376
|
+
assert_enqueued_with(job: NotifyProjectCreatedJob) do
|
|
377
|
+
Projects::CreateService.call(
|
|
378
|
+
account: @account,
|
|
379
|
+
creator: @creator,
|
|
380
|
+
params: { name: "New Project" }
|
|
381
|
+
)
|
|
382
|
+
end
|
|
383
|
+
end
|
|
384
|
+
end
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
### Testing Failure Path
|
|
388
|
+
|
|
389
|
+
```ruby
|
|
390
|
+
class Projects::CreateServiceTest < ActiveSupport::TestCase
|
|
391
|
+
test "fails with invalid params" do
|
|
392
|
+
result = Projects::CreateService.call(
|
|
393
|
+
account: @account,
|
|
394
|
+
creator: @creator,
|
|
395
|
+
params: { name: "" }
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
assert result.failure?
|
|
399
|
+
assert_equal :validation_error, result.code
|
|
400
|
+
assert_includes result.error, "blank"
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
test "does not create membership on failure" do
|
|
404
|
+
assert_no_difference -> { Membership.count } do
|
|
405
|
+
Projects::CreateService.call(
|
|
406
|
+
account: @account,
|
|
407
|
+
creator: @creator,
|
|
408
|
+
params: { name: "" }
|
|
409
|
+
)
|
|
410
|
+
end
|
|
411
|
+
end
|
|
412
|
+
end
|
|
413
|
+
```
|
|
414
|
+
|
|
415
|
+
### Testing Integration Services (Stubbing External APIs)
|
|
416
|
+
|
|
417
|
+
```ruby
|
|
418
|
+
class Payments::CreateChargeServiceTest < ActiveSupport::TestCase
|
|
419
|
+
setup do
|
|
420
|
+
@order = orders(:pending_order)
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
test "succeeds when payment gateway approves" do
|
|
424
|
+
PaymentGateway.stub(:charge, { success: true, id: "ch_123" }) do
|
|
425
|
+
result = Payments::CreateChargeService.call(
|
|
426
|
+
order: @order,
|
|
427
|
+
payment_method: "card_456"
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
assert result.success?
|
|
431
|
+
assert @order.reload.paid?
|
|
432
|
+
end
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
test "handles gateway unavailability" do
|
|
436
|
+
PaymentGateway.stub(:charge, ->(*) { raise Faraday::ConnectionFailed, "timeout" }) do
|
|
437
|
+
result = Payments::CreateChargeService.call(
|
|
438
|
+
order: @order,
|
|
439
|
+
payment_method: "card_456"
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
assert result.failure?
|
|
443
|
+
assert_equal :service_unavailable, result.code
|
|
444
|
+
end
|
|
445
|
+
end
|
|
446
|
+
end
|
|
447
|
+
```
|
|
448
|
+
|
|
449
|
+
## Anti-Patterns to Avoid
|
|
450
|
+
|
|
451
|
+
1. **Service for simple CRUD** - If it's just `Model.create(params)`, use the controller directly.
|
|
452
|
+
2. **God services** - Keep services focused on one operation. Split large services.
|
|
453
|
+
3. **Services calling services deeply** - Max 2 levels of service nesting.
|
|
454
|
+
4. **Raising exceptions for business failures** - Use Result objects. Exceptions are for unexpected errors.
|
|
455
|
+
5. **Stateful services** - Services should be stateless. Call once, get result, done.
|
|
456
|
+
6. **Services that return nil** - Always return a Result, even for simple operations.
|
|
457
|
+
7. **Missing error codes** - Always include typed error codes for programmatic handling.
|
|
458
|
+
8. **Mixing concerns** - A service that sends emails AND processes payments is doing too much.
|