better_service 1.0.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/MIT-LICENSE +20 -0
- data/README.md +1321 -0
- data/Rakefile +15 -0
- data/lib/better_service/cache_service.rb +310 -0
- data/lib/better_service/concerns/instrumentation.rb +242 -0
- data/lib/better_service/concerns/serviceable/authorizable.rb +106 -0
- data/lib/better_service/concerns/serviceable/cacheable.rb +97 -0
- data/lib/better_service/concerns/serviceable/messageable.rb +30 -0
- data/lib/better_service/concerns/serviceable/presentable.rb +66 -0
- data/lib/better_service/concerns/serviceable/transactional.rb +51 -0
- data/lib/better_service/concerns/serviceable/validatable.rb +58 -0
- data/lib/better_service/concerns/serviceable/viewable.rb +49 -0
- data/lib/better_service/concerns/serviceable.rb +12 -0
- data/lib/better_service/concerns/workflowable/callbacks.rb +116 -0
- data/lib/better_service/concerns/workflowable/context.rb +108 -0
- data/lib/better_service/concerns/workflowable/step.rb +141 -0
- data/lib/better_service/concerns/workflowable.rb +12 -0
- data/lib/better_service/configuration.rb +113 -0
- data/lib/better_service/errors/better_service_error.rb +271 -0
- data/lib/better_service/errors/configuration/configuration_error.rb +21 -0
- data/lib/better_service/errors/configuration/invalid_configuration_error.rb +28 -0
- data/lib/better_service/errors/configuration/invalid_schema_error.rb +28 -0
- data/lib/better_service/errors/configuration/nil_user_error.rb +37 -0
- data/lib/better_service/errors/configuration/schema_required_error.rb +29 -0
- data/lib/better_service/errors/runtime/authorization_error.rb +38 -0
- data/lib/better_service/errors/runtime/database_error.rb +38 -0
- data/lib/better_service/errors/runtime/execution_error.rb +27 -0
- data/lib/better_service/errors/runtime/resource_not_found_error.rb +38 -0
- data/lib/better_service/errors/runtime/runtime_error.rb +22 -0
- data/lib/better_service/errors/runtime/transaction_error.rb +34 -0
- data/lib/better_service/errors/runtime/validation_error.rb +42 -0
- data/lib/better_service/errors/workflowable/configuration/duplicate_step_error.rb +27 -0
- data/lib/better_service/errors/workflowable/configuration/invalid_step_error.rb +12 -0
- data/lib/better_service/errors/workflowable/configuration/step_not_found_error.rb +29 -0
- data/lib/better_service/errors/workflowable/configuration/workflow_configuration_error.rb +24 -0
- data/lib/better_service/errors/workflowable/runtime/rollback_error.rb +46 -0
- data/lib/better_service/errors/workflowable/runtime/step_execution_error.rb +47 -0
- data/lib/better_service/errors/workflowable/runtime/workflow_execution_error.rb +40 -0
- data/lib/better_service/errors/workflowable/runtime/workflow_runtime_error.rb +25 -0
- data/lib/better_service/railtie.rb +6 -0
- data/lib/better_service/services/action_service.rb +60 -0
- data/lib/better_service/services/base.rb +249 -0
- data/lib/better_service/services/create_service.rb +60 -0
- data/lib/better_service/services/destroy_service.rb +57 -0
- data/lib/better_service/services/index_service.rb +56 -0
- data/lib/better_service/services/show_service.rb +44 -0
- data/lib/better_service/services/update_service.rb +58 -0
- data/lib/better_service/subscribers/log_subscriber.rb +131 -0
- data/lib/better_service/subscribers/stats_subscriber.rb +208 -0
- data/lib/better_service/version.rb +3 -0
- data/lib/better_service/workflows/base.rb +106 -0
- data/lib/better_service/workflows/dsl.rb +59 -0
- data/lib/better_service/workflows/execution.rb +89 -0
- data/lib/better_service/workflows/result_builder.rb +67 -0
- data/lib/better_service/workflows/rollback_support.rb +44 -0
- data/lib/better_service/workflows/transaction_support.rb +32 -0
- data/lib/better_service.rb +28 -0
- data/lib/generators/serviceable/action_generator.rb +29 -0
- data/lib/generators/serviceable/create_generator.rb +27 -0
- data/lib/generators/serviceable/destroy_generator.rb +27 -0
- data/lib/generators/serviceable/index_generator.rb +27 -0
- data/lib/generators/serviceable/scaffold_generator.rb +70 -0
- data/lib/generators/serviceable/show_generator.rb +27 -0
- data/lib/generators/serviceable/templates/action_service.rb.tt +42 -0
- data/lib/generators/serviceable/templates/create_service.rb.tt +33 -0
- data/lib/generators/serviceable/templates/destroy_service.rb.tt +40 -0
- data/lib/generators/serviceable/templates/index_service.rb.tt +54 -0
- data/lib/generators/serviceable/templates/service_test.rb.tt +23 -0
- data/lib/generators/serviceable/templates/show_service.rb.tt +37 -0
- data/lib/generators/serviceable/templates/update_service.rb.tt +50 -0
- data/lib/generators/serviceable/update_generator.rb +27 -0
- data/lib/generators/workflowable/WORKFLOW_README +27 -0
- data/lib/generators/workflowable/templates/workflow.rb.tt +72 -0
- data/lib/generators/workflowable/templates/workflow_test.rb.tt +62 -0
- data/lib/generators/workflowable/workflow_generator.rb +60 -0
- data/lib/tasks/better_service_tasks.rake +4 -0
- metadata +180 -0
data/README.md
ADDED
|
@@ -0,0 +1,1321 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
3
|
+
# 💎 BetterService
|
|
4
|
+
|
|
5
|
+
### Clean, powerful Service Objects for Rails
|
|
6
|
+
|
|
7
|
+
[](https://badge.fury.io/rb/better_service)
|
|
8
|
+
[](https://opensource.org/licenses/MIT)
|
|
9
|
+
|
|
10
|
+
[Features](#-features) • [Installation](#-installation) • [Quick Start](#-quick-start) • [Documentation](#-documentation) • [Usage](#-usage) • [Error Handling](#%EF%B8%8F-error-handling) • [Examples](#-examples)
|
|
11
|
+
|
|
12
|
+
</div>
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
## ✨ Features
|
|
17
|
+
|
|
18
|
+
BetterService is a comprehensive Service Objects framework for Rails that brings clean architecture and powerful features to your business logic layer:
|
|
19
|
+
|
|
20
|
+
- 🎯 **5-Phase Flow Architecture**: Structured flow with search → process → transform → respond → viewer phases
|
|
21
|
+
- ✅ **Mandatory Schema Validation**: Built-in [Dry::Schema](https://dry-rb.org/gems/dry-schema/) validation for all params
|
|
22
|
+
- 🔄 **Transaction Support**: Automatic database transaction wrapping with rollback
|
|
23
|
+
- 🔐 **Flexible Authorization**: `authorize_with` DSL that works with any auth system (Pundit, CanCanCan, custom)
|
|
24
|
+
- ⚠️ **Rich Error Handling**: Pure Exception Pattern with hierarchical errors, rich context, and detailed debugging info
|
|
25
|
+
- 💾 **Cache Management**: Built-in `CacheService` for invalidating cache by context, user, or globally with async support
|
|
26
|
+
- 📊 **Metadata Tracking**: Automatic action metadata in all service responses
|
|
27
|
+
- 🔗 **Workflow Composition**: Chain multiple services into pipelines with conditional steps, rollback support, and lifecycle hooks
|
|
28
|
+
- 🏗️ **Powerful Generators**: 8 generators for rapid scaffolding (scaffold, index, show, create, update, destroy, action, workflow)
|
|
29
|
+
- 📦 **6 Service Types**: Specialized services for different use cases
|
|
30
|
+
- 🎨 **DSL-Based**: Clean, expressive DSL with `search_with`, `process_with`, `authorize_with`, etc.
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
## 📦 Installation
|
|
35
|
+
|
|
36
|
+
Add this line to your application's Gemfile:
|
|
37
|
+
|
|
38
|
+
```ruby
|
|
39
|
+
gem "better_service"
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
And then execute:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
bundle install
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Or install it yourself as:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
gem install better_service
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
## 🚀 Quick Start
|
|
57
|
+
|
|
58
|
+
### 1. Generate a Service
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
# Generate a complete CRUD scaffold
|
|
62
|
+
rails generate serviceable:scaffold Product
|
|
63
|
+
|
|
64
|
+
# Or generate individual services
|
|
65
|
+
rails generate serviceable:create Product
|
|
66
|
+
rails generate serviceable:update Product
|
|
67
|
+
rails generate serviceable:action Product publish
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### 2. Use the Service
|
|
71
|
+
|
|
72
|
+
```ruby
|
|
73
|
+
# Create a product
|
|
74
|
+
result = Product::CreateService.new(current_user, params: {
|
|
75
|
+
name: "MacBook Pro",
|
|
76
|
+
price: 2499.99
|
|
77
|
+
}).call
|
|
78
|
+
|
|
79
|
+
if result[:success]
|
|
80
|
+
product = result[:resource]
|
|
81
|
+
# => Product object
|
|
82
|
+
action = result[:metadata][:action]
|
|
83
|
+
# => :created
|
|
84
|
+
else
|
|
85
|
+
errors = result[:errors]
|
|
86
|
+
# => { name: ["can't be blank"], price: ["must be greater than 0"] }
|
|
87
|
+
end
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
---
|
|
91
|
+
|
|
92
|
+
## 📖 Documentation
|
|
93
|
+
|
|
94
|
+
Comprehensive guides and examples are available in the `/docs` directory:
|
|
95
|
+
|
|
96
|
+
### 🎓 Guides
|
|
97
|
+
|
|
98
|
+
- **[Getting Started](docs/getting-started.md)** - Installation, core concepts, your first service
|
|
99
|
+
- **[Service Types](docs/service-types.md)** - Deep dive into all 6 service types (Index, Show, Create, Update, Destroy, Action)
|
|
100
|
+
- **[Concerns Reference](docs/concerns-reference.md)** - Complete reference for all 8 concerns (Validatable, Authorizable, Cacheable, etc.)
|
|
101
|
+
|
|
102
|
+
### 💡 Examples
|
|
103
|
+
|
|
104
|
+
- **[E-commerce](docs/examples/e-commerce.md)** - Complete e-commerce implementation (products, cart, checkout)
|
|
105
|
+
|
|
106
|
+
### 🔧 Configuration
|
|
107
|
+
|
|
108
|
+
See `config/initializers/better_service.rb` for all configuration options including:
|
|
109
|
+
- Instrumentation & Observability
|
|
110
|
+
- Built-in LogSubscriber and StatsSubscriber
|
|
111
|
+
- Cache configuration
|
|
112
|
+
|
|
113
|
+
---
|
|
114
|
+
|
|
115
|
+
## 📚 Usage
|
|
116
|
+
|
|
117
|
+
### Service Structure
|
|
118
|
+
|
|
119
|
+
All services follow a 5-phase flow:
|
|
120
|
+
|
|
121
|
+
```ruby
|
|
122
|
+
class Product::CreateService < BetterService::Services::CreateService
|
|
123
|
+
# 1. Schema Validation (mandatory)
|
|
124
|
+
schema do
|
|
125
|
+
required(:name).filled(:string)
|
|
126
|
+
required(:price).filled(:decimal, gt?: 0)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# 2. Authorization (optional)
|
|
130
|
+
authorize_with do
|
|
131
|
+
user.admin? || user.can_create_products?
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# 3. Search Phase - Load data
|
|
135
|
+
search_with do
|
|
136
|
+
{ category: Category.find_by(id: params[:category_id]) }
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# 4. Process Phase - Business logic
|
|
140
|
+
process_with do |data|
|
|
141
|
+
product = user.products.create!(params)
|
|
142
|
+
{ resource: product }
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# 5. Respond Phase - Format response
|
|
146
|
+
respond_with do |data|
|
|
147
|
+
success_result("Product created successfully", data)
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
### Available Service Types
|
|
153
|
+
|
|
154
|
+
#### 1. 📋 IndexService - List Resources
|
|
155
|
+
|
|
156
|
+
```ruby
|
|
157
|
+
class Product::IndexService < BetterService::Services::IndexService
|
|
158
|
+
schema do
|
|
159
|
+
optional(:page).filled(:integer, gteq?: 1)
|
|
160
|
+
optional(:search).maybe(:string)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
search_with do
|
|
164
|
+
products = user.products
|
|
165
|
+
products = products.where("name LIKE ?", "%#{params[:search]}%") if params[:search]
|
|
166
|
+
{ items: products.to_a }
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
process_with do |data|
|
|
170
|
+
{
|
|
171
|
+
items: data[:items],
|
|
172
|
+
metadata: {
|
|
173
|
+
total: data[:items].count,
|
|
174
|
+
page: params[:page] || 1
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Usage
|
|
181
|
+
result = Product::IndexService.new(current_user, params: { search: "MacBook" }).call
|
|
182
|
+
products = result[:items] # => Array of products
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
#### 2. 👁️ ShowService - Show Single Resource
|
|
186
|
+
|
|
187
|
+
```ruby
|
|
188
|
+
class Product::ShowService < BetterService::Services::ShowService
|
|
189
|
+
schema do
|
|
190
|
+
required(:id).filled(:integer)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
search_with do
|
|
194
|
+
{ resource: user.products.find(params[:id]) }
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Usage
|
|
199
|
+
result = Product::ShowService.new(current_user, params: { id: 123 }).call
|
|
200
|
+
product = result[:resource]
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
#### 3. ➕ CreateService - Create Resource
|
|
204
|
+
|
|
205
|
+
```ruby
|
|
206
|
+
class Product::CreateService < BetterService::Services::CreateService
|
|
207
|
+
# Transaction enabled by default ✅
|
|
208
|
+
|
|
209
|
+
schema do
|
|
210
|
+
required(:name).filled(:string)
|
|
211
|
+
required(:price).filled(:decimal, gt?: 0)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
process_with do |data|
|
|
215
|
+
product = user.products.create!(params)
|
|
216
|
+
{ resource: product }
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Usage
|
|
221
|
+
result = Product::CreateService.new(current_user, params: {
|
|
222
|
+
name: "iPhone",
|
|
223
|
+
price: 999
|
|
224
|
+
}).call
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
#### 4. ✏️ UpdateService - Update Resource
|
|
228
|
+
|
|
229
|
+
```ruby
|
|
230
|
+
class Product::UpdateService < BetterService::Services::UpdateService
|
|
231
|
+
# Transaction enabled by default ✅
|
|
232
|
+
|
|
233
|
+
schema do
|
|
234
|
+
required(:id).filled(:integer)
|
|
235
|
+
optional(:price).filled(:decimal, gt?: 0)
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
authorize_with do
|
|
239
|
+
product = Product.find(params[:id])
|
|
240
|
+
product.user_id == user.id
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
search_with do
|
|
244
|
+
{ resource: user.products.find(params[:id]) }
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
process_with do |data|
|
|
248
|
+
product = data[:resource]
|
|
249
|
+
product.update!(params.except(:id))
|
|
250
|
+
{ resource: product }
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
#### 5. ❌ DestroyService - Delete Resource
|
|
256
|
+
|
|
257
|
+
```ruby
|
|
258
|
+
class Product::DestroyService < BetterService::Services::DestroyService
|
|
259
|
+
# Transaction enabled by default ✅
|
|
260
|
+
|
|
261
|
+
schema do
|
|
262
|
+
required(:id).filled(:integer)
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
authorize_with do
|
|
266
|
+
product = Product.find(params[:id])
|
|
267
|
+
user.admin? || product.user_id == user.id
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
search_with do
|
|
271
|
+
{ resource: user.products.find(params[:id]) }
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
process_with do |data|
|
|
275
|
+
data[:resource].destroy!
|
|
276
|
+
{ resource: data[:resource] }
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
#### 6. ⚡ ActionService - Custom Actions
|
|
282
|
+
|
|
283
|
+
```ruby
|
|
284
|
+
class Product::PublishService < BetterService::Services::ActionService
|
|
285
|
+
action_name :publish
|
|
286
|
+
|
|
287
|
+
schema do
|
|
288
|
+
required(:id).filled(:integer)
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
authorize_with do
|
|
292
|
+
user.can_publish_products?
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
search_with do
|
|
296
|
+
{ resource: user.products.find(params[:id]) }
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
process_with do |data|
|
|
300
|
+
product = data[:resource]
|
|
301
|
+
product.update!(published: true, published_at: Time.current)
|
|
302
|
+
{ resource: product }
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
# Usage
|
|
307
|
+
result = Product::PublishService.new(current_user, params: { id: 123 }).call
|
|
308
|
+
# => { success: true, resource: <Product>, metadata: { action: :publish } }
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
---
|
|
312
|
+
|
|
313
|
+
## 🔐 Authorization
|
|
314
|
+
|
|
315
|
+
BetterService provides a flexible `authorize_with` DSL that works with **any** authorization system:
|
|
316
|
+
|
|
317
|
+
### Simple Role-Based Authorization
|
|
318
|
+
|
|
319
|
+
```ruby
|
|
320
|
+
class Product::CreateService < BetterService::Services::CreateService
|
|
321
|
+
authorize_with do
|
|
322
|
+
user.admin?
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
### Resource Ownership Check
|
|
328
|
+
|
|
329
|
+
```ruby
|
|
330
|
+
class Product::UpdateService < BetterService::Services::UpdateService
|
|
331
|
+
authorize_with do
|
|
332
|
+
product = Product.find(params[:id])
|
|
333
|
+
product.user_id == user.id
|
|
334
|
+
end
|
|
335
|
+
end
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
### Pundit Integration
|
|
339
|
+
|
|
340
|
+
```ruby
|
|
341
|
+
class Product::UpdateService < BetterService::Services::UpdateService
|
|
342
|
+
authorize_with do
|
|
343
|
+
ProductPolicy.new(user, Product.find(params[:id])).update?
|
|
344
|
+
end
|
|
345
|
+
end
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
### CanCanCan Integration
|
|
349
|
+
|
|
350
|
+
```ruby
|
|
351
|
+
class Product::DestroyService < BetterService::Services::DestroyService
|
|
352
|
+
authorize_with do
|
|
353
|
+
Ability.new(user).can?(:destroy, :product)
|
|
354
|
+
end
|
|
355
|
+
end
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
### Authorization Failure
|
|
359
|
+
|
|
360
|
+
When authorization fails, the service returns:
|
|
361
|
+
|
|
362
|
+
```ruby
|
|
363
|
+
{
|
|
364
|
+
success: false,
|
|
365
|
+
errors: ["Not authorized to perform this action"],
|
|
366
|
+
code: :unauthorized
|
|
367
|
+
}
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
---
|
|
371
|
+
|
|
372
|
+
## 🔄 Transaction Support
|
|
373
|
+
|
|
374
|
+
Create, Update, and Destroy services have **automatic transaction support** enabled by default:
|
|
375
|
+
|
|
376
|
+
```ruby
|
|
377
|
+
class Product::CreateService < BetterService::Services::CreateService
|
|
378
|
+
# Transactions enabled by default ✅
|
|
379
|
+
|
|
380
|
+
process_with do |data|
|
|
381
|
+
product = user.products.create!(params)
|
|
382
|
+
|
|
383
|
+
# If anything fails here, the entire transaction rolls back
|
|
384
|
+
ProductHistory.create!(product: product, action: "created")
|
|
385
|
+
NotificationService.notify_admins(product)
|
|
386
|
+
|
|
387
|
+
{ resource: product }
|
|
388
|
+
end
|
|
389
|
+
end
|
|
390
|
+
```
|
|
391
|
+
|
|
392
|
+
### Disable Transactions
|
|
393
|
+
|
|
394
|
+
```ruby
|
|
395
|
+
class Product::CreateService < BetterService::Services::CreateService
|
|
396
|
+
with_transaction false # Disable transactions
|
|
397
|
+
|
|
398
|
+
# ...
|
|
399
|
+
end
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
---
|
|
403
|
+
|
|
404
|
+
## 📊 Metadata
|
|
405
|
+
|
|
406
|
+
All services automatically include metadata with the action name:
|
|
407
|
+
|
|
408
|
+
```ruby
|
|
409
|
+
result = Product::CreateService.new(user, params: { name: "Test" }).call
|
|
410
|
+
|
|
411
|
+
result[:metadata]
|
|
412
|
+
# => { action: :created }
|
|
413
|
+
|
|
414
|
+
result = Product::UpdateService.new(user, params: { id: 1, name: "Updated" }).call
|
|
415
|
+
|
|
416
|
+
result[:metadata]
|
|
417
|
+
# => { action: :updated }
|
|
418
|
+
|
|
419
|
+
result = Product::PublishService.new(user, params: { id: 1 }).call
|
|
420
|
+
|
|
421
|
+
result[:metadata]
|
|
422
|
+
# => { action: :publish }
|
|
423
|
+
```
|
|
424
|
+
|
|
425
|
+
You can add custom metadata in the `process_with` block:
|
|
426
|
+
|
|
427
|
+
```ruby
|
|
428
|
+
process_with do |data|
|
|
429
|
+
{
|
|
430
|
+
resource: product,
|
|
431
|
+
metadata: {
|
|
432
|
+
custom_field: "value",
|
|
433
|
+
processed_at: Time.current
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
end
|
|
437
|
+
```
|
|
438
|
+
|
|
439
|
+
---
|
|
440
|
+
|
|
441
|
+
## ⚠️ Error Handling
|
|
442
|
+
|
|
443
|
+
BetterService uses a **Pure Exception Pattern** where all errors raise exceptions with rich context information. This ensures consistent behavior across all environments (development, test, production).
|
|
444
|
+
|
|
445
|
+
### Exception Hierarchy
|
|
446
|
+
|
|
447
|
+
```
|
|
448
|
+
BetterServiceError (base class)
|
|
449
|
+
├── Configuration Errors (programming errors)
|
|
450
|
+
│ ├── SchemaRequiredError - Missing schema definition
|
|
451
|
+
│ ├── InvalidSchemaError - Invalid schema syntax
|
|
452
|
+
│ ├── InvalidConfigurationError - Invalid config settings
|
|
453
|
+
│ └── NilUserError - User is nil when required
|
|
454
|
+
│
|
|
455
|
+
├── Runtime Errors (execution errors)
|
|
456
|
+
│ ├── ValidationError - Parameter validation failed
|
|
457
|
+
│ ├── AuthorizationError - User not authorized
|
|
458
|
+
│ ├── ResourceNotFoundError - Record not found
|
|
459
|
+
│ ├── DatabaseError - Database operation failed
|
|
460
|
+
│ ├── TransactionError - Transaction rollback
|
|
461
|
+
│ └── ExecutionError - Unexpected error
|
|
462
|
+
│
|
|
463
|
+
└── Workflowable Errors (workflow errors)
|
|
464
|
+
├── Configuration
|
|
465
|
+
│ ├── WorkflowConfigurationError - Invalid workflow config
|
|
466
|
+
│ ├── StepNotFoundError - Step not found
|
|
467
|
+
│ ├── InvalidStepError - Invalid step definition
|
|
468
|
+
│ └── DuplicateStepError - Duplicate step name
|
|
469
|
+
└── Runtime
|
|
470
|
+
├── WorkflowExecutionError - Workflow execution failed
|
|
471
|
+
├── StepExecutionError - Step failed
|
|
472
|
+
└── RollbackError - Rollback failed
|
|
473
|
+
```
|
|
474
|
+
|
|
475
|
+
### Handling Errors
|
|
476
|
+
|
|
477
|
+
#### 1. Validation Errors
|
|
478
|
+
|
|
479
|
+
Validation errors are raised during service **initialization** (not in `call`):
|
|
480
|
+
|
|
481
|
+
```ruby
|
|
482
|
+
begin
|
|
483
|
+
service = Product::CreateService.new(current_user, params: {
|
|
484
|
+
name: "", # Invalid
|
|
485
|
+
price: -10 # Invalid
|
|
486
|
+
})
|
|
487
|
+
rescue BetterService::Errors::Runtime::ValidationError => e
|
|
488
|
+
e.message # => "Validation failed"
|
|
489
|
+
e.code # => :validation_failed
|
|
490
|
+
|
|
491
|
+
# Access validation errors from context
|
|
492
|
+
e.context[:validation_errors]
|
|
493
|
+
# => {
|
|
494
|
+
# name: ["must be filled"],
|
|
495
|
+
# price: ["must be greater than 0"]
|
|
496
|
+
# }
|
|
497
|
+
|
|
498
|
+
# Render in controller
|
|
499
|
+
render json: {
|
|
500
|
+
error: e.message,
|
|
501
|
+
errors: e.context[:validation_errors]
|
|
502
|
+
}, status: :unprocessable_entity
|
|
503
|
+
end
|
|
504
|
+
```
|
|
505
|
+
|
|
506
|
+
#### 2. Authorization Errors
|
|
507
|
+
|
|
508
|
+
Authorization errors are raised during `call`:
|
|
509
|
+
|
|
510
|
+
```ruby
|
|
511
|
+
begin
|
|
512
|
+
Product::DestroyService.new(current_user, params: { id: 1 }).call
|
|
513
|
+
rescue BetterService::Errors::Runtime::AuthorizationError => e
|
|
514
|
+
e.message # => "Not authorized to perform this action"
|
|
515
|
+
e.code # => :unauthorized
|
|
516
|
+
e.context[:service] # => "Product::DestroyService"
|
|
517
|
+
e.context[:user] # => user_id or "nil"
|
|
518
|
+
|
|
519
|
+
# Render in controller
|
|
520
|
+
render json: { error: e.message }, status: :forbidden
|
|
521
|
+
end
|
|
522
|
+
```
|
|
523
|
+
|
|
524
|
+
#### 3. Resource Not Found Errors
|
|
525
|
+
|
|
526
|
+
Raised when ActiveRecord records are not found:
|
|
527
|
+
|
|
528
|
+
```ruby
|
|
529
|
+
begin
|
|
530
|
+
Product::ShowService.new(current_user, params: { id: 99999 }).call
|
|
531
|
+
rescue BetterService::Errors::Runtime::ResourceNotFoundError => e
|
|
532
|
+
e.message # => "Resource not found: Couldn't find Product..."
|
|
533
|
+
e.code # => :resource_not_found
|
|
534
|
+
e.original_error # => ActiveRecord::RecordNotFound instance
|
|
535
|
+
|
|
536
|
+
# Render in controller
|
|
537
|
+
render json: { error: "Product not found" }, status: :not_found
|
|
538
|
+
end
|
|
539
|
+
```
|
|
540
|
+
|
|
541
|
+
#### 4. Database Errors
|
|
542
|
+
|
|
543
|
+
Raised for database constraint violations and record invalid errors:
|
|
544
|
+
|
|
545
|
+
```ruby
|
|
546
|
+
begin
|
|
547
|
+
Product::CreateService.new(current_user, params: {
|
|
548
|
+
name: "Duplicate", # Unique constraint violation
|
|
549
|
+
sku: "INVALID"
|
|
550
|
+
}).call
|
|
551
|
+
rescue BetterService::Errors::Runtime::DatabaseError => e
|
|
552
|
+
e.message # => "Database error: Validation failed..."
|
|
553
|
+
e.code # => :database_error
|
|
554
|
+
e.original_error # => ActiveRecord::RecordInvalid instance
|
|
555
|
+
|
|
556
|
+
# Render in controller
|
|
557
|
+
render json: { error: e.message }, status: :unprocessable_entity
|
|
558
|
+
end
|
|
559
|
+
```
|
|
560
|
+
|
|
561
|
+
#### 5. Workflow Errors
|
|
562
|
+
|
|
563
|
+
Workflows raise specific errors for step and rollback failures:
|
|
564
|
+
|
|
565
|
+
```ruby
|
|
566
|
+
begin
|
|
567
|
+
OrderPurchaseWorkflow.new(current_user, params: params).call
|
|
568
|
+
rescue BetterService::Errors::Workflowable::Runtime::StepExecutionError => e
|
|
569
|
+
e.message # => "Step charge_payment failed: Payment declined"
|
|
570
|
+
e.code # => :step_failed
|
|
571
|
+
e.context[:workflow] # => "OrderPurchaseWorkflow"
|
|
572
|
+
e.context[:step] # => :charge_payment
|
|
573
|
+
e.context[:steps_executed] # => [:create_order]
|
|
574
|
+
|
|
575
|
+
rescue BetterService::Errors::Workflowable::Runtime::RollbackError => e
|
|
576
|
+
e.message # => "Rollback failed for step charge_payment: Refund failed"
|
|
577
|
+
e.code # => :rollback_failed
|
|
578
|
+
e.context[:executed_steps] # => [:create_order, :charge_payment]
|
|
579
|
+
# ⚠️ Rollback errors indicate potential data inconsistency
|
|
580
|
+
end
|
|
581
|
+
```
|
|
582
|
+
|
|
583
|
+
### Error Information
|
|
584
|
+
|
|
585
|
+
All `BetterServiceError` exceptions provide rich debugging information:
|
|
586
|
+
|
|
587
|
+
```ruby
|
|
588
|
+
begin
|
|
589
|
+
service.call
|
|
590
|
+
rescue BetterService::BetterServiceError => e
|
|
591
|
+
# Basic info
|
|
592
|
+
e.message # Human-readable error message
|
|
593
|
+
e.code # Symbol code for programmatic handling
|
|
594
|
+
e.timestamp # When the error occurred
|
|
595
|
+
|
|
596
|
+
# Context info
|
|
597
|
+
e.context # Hash with service-specific context
|
|
598
|
+
# => { service: "MyService", params: {...}, validation_errors: {...} }
|
|
599
|
+
|
|
600
|
+
# Original error (if wrapping another exception)
|
|
601
|
+
e.original_error # The original exception that was caught
|
|
602
|
+
|
|
603
|
+
# Structured hash for logging
|
|
604
|
+
e.to_h
|
|
605
|
+
# => {
|
|
606
|
+
# error_class: "BetterService::Errors::Runtime::ValidationError",
|
|
607
|
+
# message: "Validation failed",
|
|
608
|
+
# code: :validation_failed,
|
|
609
|
+
# timestamp: "2025-11-09T10:30:00Z",
|
|
610
|
+
# context: { service: "MyService", validation_errors: {...} },
|
|
611
|
+
# original_error: { class: "StandardError", message: "...", backtrace: [...] },
|
|
612
|
+
# backtrace: [...]
|
|
613
|
+
# }
|
|
614
|
+
|
|
615
|
+
# Detailed message with all context
|
|
616
|
+
e.detailed_message
|
|
617
|
+
# => "Validation failed | Code: validation_failed | Context: {...} | Original: ..."
|
|
618
|
+
|
|
619
|
+
# Enhanced backtrace (includes original error backtrace)
|
|
620
|
+
e.backtrace
|
|
621
|
+
# => ["...", "--- Original Error Backtrace ---", "..."]
|
|
622
|
+
end
|
|
623
|
+
```
|
|
624
|
+
|
|
625
|
+
### Controller Pattern
|
|
626
|
+
|
|
627
|
+
Recommended pattern for handling errors in controllers:
|
|
628
|
+
|
|
629
|
+
```ruby
|
|
630
|
+
class ProductsController < ApplicationController
|
|
631
|
+
def create
|
|
632
|
+
result = Product::CreateService.new(current_user, params: product_params).call
|
|
633
|
+
render json: result, status: :created
|
|
634
|
+
|
|
635
|
+
rescue BetterService::Errors::Runtime::ValidationError => e
|
|
636
|
+
render json: {
|
|
637
|
+
error: e.message,
|
|
638
|
+
errors: e.context[:validation_errors]
|
|
639
|
+
}, status: :unprocessable_entity
|
|
640
|
+
|
|
641
|
+
rescue BetterService::Errors::Runtime::AuthorizationError => e
|
|
642
|
+
render json: { error: e.message }, status: :forbidden
|
|
643
|
+
|
|
644
|
+
rescue BetterService::Errors::Runtime::ResourceNotFoundError => e
|
|
645
|
+
render json: { error: "Resource not found" }, status: :not_found
|
|
646
|
+
|
|
647
|
+
rescue BetterService::Errors::Runtime::DatabaseError => e
|
|
648
|
+
render json: { error: e.message }, status: :unprocessable_entity
|
|
649
|
+
|
|
650
|
+
rescue BetterService::BetterServiceError => e
|
|
651
|
+
# Catch-all for other service errors
|
|
652
|
+
Rails.logger.error("Service error: #{e.to_h}")
|
|
653
|
+
render json: { error: "An error occurred" }, status: :internal_server_error
|
|
654
|
+
end
|
|
655
|
+
end
|
|
656
|
+
```
|
|
657
|
+
|
|
658
|
+
Or use a centralized error handler:
|
|
659
|
+
|
|
660
|
+
```ruby
|
|
661
|
+
class ApplicationController < ActionController::API
|
|
662
|
+
rescue_from BetterService::Errors::Runtime::ValidationError do |e|
|
|
663
|
+
render json: {
|
|
664
|
+
error: e.message,
|
|
665
|
+
errors: e.context[:validation_errors]
|
|
666
|
+
}, status: :unprocessable_entity
|
|
667
|
+
end
|
|
668
|
+
|
|
669
|
+
rescue_from BetterService::Errors::Runtime::AuthorizationError do |e|
|
|
670
|
+
render json: { error: e.message }, status: :forbidden
|
|
671
|
+
end
|
|
672
|
+
|
|
673
|
+
rescue_from BetterService::Errors::Runtime::ResourceNotFoundError do |e|
|
|
674
|
+
render json: { error: "Resource not found" }, status: :not_found
|
|
675
|
+
end
|
|
676
|
+
|
|
677
|
+
rescue_from BetterService::Errors::Runtime::DatabaseError do |e|
|
|
678
|
+
render json: { error: e.message }, status: :unprocessable_entity
|
|
679
|
+
end
|
|
680
|
+
|
|
681
|
+
rescue_from BetterService::BetterServiceError do |e|
|
|
682
|
+
Rails.logger.error("Service error: #{e.to_h}")
|
|
683
|
+
render json: { error: "An error occurred" }, status: :internal_server_error
|
|
684
|
+
end
|
|
685
|
+
end
|
|
686
|
+
```
|
|
687
|
+
|
|
688
|
+
---
|
|
689
|
+
|
|
690
|
+
## 💾 Cache Management
|
|
691
|
+
|
|
692
|
+
BetterService provides built-in cache management through the `BetterService::CacheService` module, which works seamlessly with services that use the `Cacheable` concern.
|
|
693
|
+
|
|
694
|
+
### Cache Invalidation
|
|
695
|
+
|
|
696
|
+
The CacheService provides several methods for cache invalidation:
|
|
697
|
+
|
|
698
|
+
#### Invalidate for Specific User and Context
|
|
699
|
+
|
|
700
|
+
```ruby
|
|
701
|
+
# Invalidate cache for a specific user and context
|
|
702
|
+
BetterService::CacheService.invalidate_for_context(current_user, "products")
|
|
703
|
+
# Deletes all cache keys like: products_index:user_123:*:products
|
|
704
|
+
|
|
705
|
+
# Invalidate asynchronously (requires ActiveJob)
|
|
706
|
+
BetterService::CacheService.invalidate_for_context(current_user, "products", async: true)
|
|
707
|
+
```
|
|
708
|
+
|
|
709
|
+
#### Invalidate Globally for a Context
|
|
710
|
+
|
|
711
|
+
```ruby
|
|
712
|
+
# Invalidate cache for all users in a specific context
|
|
713
|
+
BetterService::CacheService.invalidate_global("sidebar")
|
|
714
|
+
# Deletes all cache keys matching: *:sidebar
|
|
715
|
+
|
|
716
|
+
# Useful after updating global settings that affect all users
|
|
717
|
+
BetterService::CacheService.invalidate_global("navigation", async: true)
|
|
718
|
+
```
|
|
719
|
+
|
|
720
|
+
#### Invalidate All Cache for a User
|
|
721
|
+
|
|
722
|
+
```ruby
|
|
723
|
+
# Invalidate all cached data for a specific user
|
|
724
|
+
BetterService::CacheService.invalidate_for_user(current_user)
|
|
725
|
+
# Deletes all cache keys matching: *:user_123:*
|
|
726
|
+
|
|
727
|
+
# Useful when user permissions or roles change
|
|
728
|
+
BetterService::CacheService.invalidate_for_user(user, async: true)
|
|
729
|
+
```
|
|
730
|
+
|
|
731
|
+
#### Invalidate Specific Key
|
|
732
|
+
|
|
733
|
+
```ruby
|
|
734
|
+
# Delete a single cache key
|
|
735
|
+
BetterService::CacheService.invalidate_key("products_index:user_123:abc:products")
|
|
736
|
+
```
|
|
737
|
+
|
|
738
|
+
#### Clear All BetterService Cache
|
|
739
|
+
|
|
740
|
+
```ruby
|
|
741
|
+
# WARNING: Clears ALL BetterService cache
|
|
742
|
+
# Use with caution, preferably only in development/testing
|
|
743
|
+
BetterService::CacheService.clear_all
|
|
744
|
+
```
|
|
745
|
+
|
|
746
|
+
### Cache Utilities
|
|
747
|
+
|
|
748
|
+
#### Fetch with Caching
|
|
749
|
+
|
|
750
|
+
```ruby
|
|
751
|
+
# Wrapper around Rails.cache.fetch
|
|
752
|
+
result = BetterService::CacheService.fetch("my_key", expires_in: 1.hour) do
|
|
753
|
+
expensive_computation
|
|
754
|
+
end
|
|
755
|
+
```
|
|
756
|
+
|
|
757
|
+
#### Check Cache Existence
|
|
758
|
+
|
|
759
|
+
```ruby
|
|
760
|
+
if BetterService::CacheService.exist?("my_key")
|
|
761
|
+
# Key exists in cache
|
|
762
|
+
end
|
|
763
|
+
```
|
|
764
|
+
|
|
765
|
+
#### Get Cache Statistics
|
|
766
|
+
|
|
767
|
+
```ruby
|
|
768
|
+
stats = BetterService::CacheService.stats
|
|
769
|
+
# => {
|
|
770
|
+
# cache_store: "ActiveSupport::Cache::RedisStore",
|
|
771
|
+
# supports_pattern_deletion: true,
|
|
772
|
+
# supports_async: true
|
|
773
|
+
# }
|
|
774
|
+
```
|
|
775
|
+
|
|
776
|
+
### Integration with Services
|
|
777
|
+
|
|
778
|
+
The CacheService automatically works with services using the `Cacheable` concern:
|
|
779
|
+
|
|
780
|
+
```ruby
|
|
781
|
+
class Product::IndexService < BetterService::IndexService
|
|
782
|
+
cache_key "products_index"
|
|
783
|
+
cache_ttl 1.hour
|
|
784
|
+
cache_contexts "products", "sidebar"
|
|
785
|
+
|
|
786
|
+
# Service implementation...
|
|
787
|
+
end
|
|
788
|
+
|
|
789
|
+
# After creating a product, invalidate the cache
|
|
790
|
+
Product.create!(name: "New Product")
|
|
791
|
+
BetterService::CacheService.invalidate_for_context(current_user, "products")
|
|
792
|
+
|
|
793
|
+
# Or invalidate globally for all users
|
|
794
|
+
BetterService::CacheService.invalidate_global("products")
|
|
795
|
+
```
|
|
796
|
+
|
|
797
|
+
### Use Cases
|
|
798
|
+
|
|
799
|
+
#### After Model Updates
|
|
800
|
+
|
|
801
|
+
```ruby
|
|
802
|
+
class Product < ApplicationRecord
|
|
803
|
+
after_commit :invalidate_product_cache, on: [ :create, :update, :destroy ]
|
|
804
|
+
|
|
805
|
+
private
|
|
806
|
+
|
|
807
|
+
def invalidate_product_cache
|
|
808
|
+
# Invalidate for all users
|
|
809
|
+
BetterService::CacheService.invalidate_global("products")
|
|
810
|
+
end
|
|
811
|
+
end
|
|
812
|
+
```
|
|
813
|
+
|
|
814
|
+
#### After User Permission Changes
|
|
815
|
+
|
|
816
|
+
```ruby
|
|
817
|
+
class User < ApplicationRecord
|
|
818
|
+
after_update :invalidate_user_cache, if: :saved_change_to_role?
|
|
819
|
+
|
|
820
|
+
private
|
|
821
|
+
|
|
822
|
+
def invalidate_user_cache
|
|
823
|
+
# Invalidate all cache for this user
|
|
824
|
+
BetterService::CacheService.invalidate_for_user(self)
|
|
825
|
+
end
|
|
826
|
+
end
|
|
827
|
+
```
|
|
828
|
+
|
|
829
|
+
#### In Controllers
|
|
830
|
+
|
|
831
|
+
```ruby
|
|
832
|
+
class ProductsController < ApplicationController
|
|
833
|
+
def create
|
|
834
|
+
@product = Product.create!(product_params)
|
|
835
|
+
|
|
836
|
+
# Invalidate cache for the current user
|
|
837
|
+
BetterService::CacheService.invalidate_for_context(current_user, "products")
|
|
838
|
+
|
|
839
|
+
redirect_to @product
|
|
840
|
+
end
|
|
841
|
+
end
|
|
842
|
+
```
|
|
843
|
+
|
|
844
|
+
### Async Cache Invalidation
|
|
845
|
+
|
|
846
|
+
For better performance, use async invalidation with ActiveJob:
|
|
847
|
+
|
|
848
|
+
```ruby
|
|
849
|
+
# Queues a background job to invalidate cache
|
|
850
|
+
BetterService::CacheService.invalidate_for_context(
|
|
851
|
+
current_user,
|
|
852
|
+
"products",
|
|
853
|
+
async: true
|
|
854
|
+
)
|
|
855
|
+
```
|
|
856
|
+
|
|
857
|
+
**Note**: Async invalidation requires ActiveJob to be configured in your Rails application.
|
|
858
|
+
|
|
859
|
+
### Cache Store Compatibility
|
|
860
|
+
|
|
861
|
+
The CacheService works with any Rails cache store, but pattern-based deletion (`delete_matched`) requires:
|
|
862
|
+
- MemoryStore ✅
|
|
863
|
+
- RedisStore ✅
|
|
864
|
+
- RedisCacheStore ✅
|
|
865
|
+
- MemCachedStore ⚠️ (limited support)
|
|
866
|
+
- NullStore ⚠️ (no-op)
|
|
867
|
+
- FileStore ⚠️ (limited support)
|
|
868
|
+
|
|
869
|
+
---
|
|
870
|
+
|
|
871
|
+
## 🏗️ Generators
|
|
872
|
+
|
|
873
|
+
BetterService includes 8 powerful generators:
|
|
874
|
+
|
|
875
|
+
### Scaffold Generator
|
|
876
|
+
|
|
877
|
+
Generates all 5 CRUD services at once:
|
|
878
|
+
|
|
879
|
+
```bash
|
|
880
|
+
rails generate serviceable:scaffold Product
|
|
881
|
+
```
|
|
882
|
+
|
|
883
|
+
Creates:
|
|
884
|
+
- `app/services/product/index_service.rb`
|
|
885
|
+
- `app/services/product/show_service.rb`
|
|
886
|
+
- `app/services/product/create_service.rb`
|
|
887
|
+
- `app/services/product/update_service.rb`
|
|
888
|
+
- `app/services/product/destroy_service.rb`
|
|
889
|
+
|
|
890
|
+
### Individual Generators
|
|
891
|
+
|
|
892
|
+
```bash
|
|
893
|
+
# Index service
|
|
894
|
+
rails generate serviceable:index Product
|
|
895
|
+
|
|
896
|
+
# Show service
|
|
897
|
+
rails generate serviceable:show Product
|
|
898
|
+
|
|
899
|
+
# Create service
|
|
900
|
+
rails generate serviceable:create Product
|
|
901
|
+
|
|
902
|
+
# Update service
|
|
903
|
+
rails generate serviceable:update Product
|
|
904
|
+
|
|
905
|
+
# Destroy service
|
|
906
|
+
rails generate serviceable:destroy Product
|
|
907
|
+
|
|
908
|
+
# Custom action service
|
|
909
|
+
rails generate serviceable:action Product publish
|
|
910
|
+
|
|
911
|
+
# Workflow for composing services
|
|
912
|
+
rails generate serviceable:workflow OrderPurchase --steps create_order charge_payment
|
|
913
|
+
```
|
|
914
|
+
|
|
915
|
+
---
|
|
916
|
+
|
|
917
|
+
## 🎯 Examples
|
|
918
|
+
|
|
919
|
+
### Complete CRUD Workflow
|
|
920
|
+
|
|
921
|
+
```ruby
|
|
922
|
+
# 1. List products
|
|
923
|
+
index_result = Product::IndexService.new(current_user, params: {
|
|
924
|
+
search: "MacBook",
|
|
925
|
+
page: 1
|
|
926
|
+
}).call
|
|
927
|
+
|
|
928
|
+
products = index_result[:items]
|
|
929
|
+
|
|
930
|
+
# 2. Show a product
|
|
931
|
+
show_result = Product::ShowService.new(current_user, params: {
|
|
932
|
+
id: products.first.id
|
|
933
|
+
}).call
|
|
934
|
+
|
|
935
|
+
product = show_result[:resource]
|
|
936
|
+
|
|
937
|
+
# 3. Create a new product
|
|
938
|
+
create_result = Product::CreateService.new(current_user, params: {
|
|
939
|
+
name: "New Product",
|
|
940
|
+
price: 99.99
|
|
941
|
+
}).call
|
|
942
|
+
|
|
943
|
+
new_product = create_result[:resource]
|
|
944
|
+
|
|
945
|
+
# 4. Update the product
|
|
946
|
+
update_result = Product::UpdateService.new(current_user, params: {
|
|
947
|
+
id: new_product.id,
|
|
948
|
+
price: 149.99
|
|
949
|
+
}).call
|
|
950
|
+
|
|
951
|
+
# 5. Publish the product (custom action)
|
|
952
|
+
publish_result = Product::PublishService.new(current_user, params: {
|
|
953
|
+
id: new_product.id
|
|
954
|
+
}).call
|
|
955
|
+
|
|
956
|
+
# 6. Delete the product
|
|
957
|
+
destroy_result = Product::DestroyService.new(current_user, params: {
|
|
958
|
+
id: new_product.id
|
|
959
|
+
}).call
|
|
960
|
+
```
|
|
961
|
+
|
|
962
|
+
### Controller Integration
|
|
963
|
+
|
|
964
|
+
```ruby
|
|
965
|
+
class ProductsController < ApplicationController
|
|
966
|
+
def create
|
|
967
|
+
result = Product::CreateService.new(current_user, params: product_params).call
|
|
968
|
+
|
|
969
|
+
if result[:success]
|
|
970
|
+
render json: {
|
|
971
|
+
product: result[:resource],
|
|
972
|
+
message: result[:message],
|
|
973
|
+
metadata: result[:metadata]
|
|
974
|
+
}, status: :created
|
|
975
|
+
else
|
|
976
|
+
render json: {
|
|
977
|
+
errors: result[:errors]
|
|
978
|
+
}, status: :unprocessable_entity
|
|
979
|
+
end
|
|
980
|
+
end
|
|
981
|
+
|
|
982
|
+
private
|
|
983
|
+
|
|
984
|
+
def product_params
|
|
985
|
+
params.require(:product).permit(:name, :price, :description)
|
|
986
|
+
end
|
|
987
|
+
end
|
|
988
|
+
```
|
|
989
|
+
|
|
990
|
+
---
|
|
991
|
+
|
|
992
|
+
## 🔗 Workflows - Service Composition
|
|
993
|
+
|
|
994
|
+
Workflows allow you to compose multiple services into a pipeline with explicit data mapping, conditional execution, automatic rollback, and lifecycle hooks.
|
|
995
|
+
|
|
996
|
+
### Creating a Workflow
|
|
997
|
+
|
|
998
|
+
Generate a workflow with the generator:
|
|
999
|
+
|
|
1000
|
+
```bash
|
|
1001
|
+
rails generate serviceable:workflow OrderPurchase --steps create_order charge_payment send_email
|
|
1002
|
+
```
|
|
1003
|
+
|
|
1004
|
+
This creates `app/workflows/order_purchase_workflow.rb`:
|
|
1005
|
+
|
|
1006
|
+
```ruby
|
|
1007
|
+
class OrderPurchaseWorkflow < BetterService::Workflow
|
|
1008
|
+
# Enable database transactions for the entire workflow
|
|
1009
|
+
with_transaction true
|
|
1010
|
+
|
|
1011
|
+
# Lifecycle hooks
|
|
1012
|
+
before_workflow :validate_cart
|
|
1013
|
+
after_workflow :clear_cart
|
|
1014
|
+
around_step :log_step
|
|
1015
|
+
|
|
1016
|
+
# Step 1: Create order
|
|
1017
|
+
step :create_order,
|
|
1018
|
+
with: Order::CreateService,
|
|
1019
|
+
input: ->(ctx) { { items: ctx.cart_items, total: ctx.total } }
|
|
1020
|
+
|
|
1021
|
+
# Step 2: Charge payment with rollback
|
|
1022
|
+
step :charge_payment,
|
|
1023
|
+
with: Payment::ChargeService,
|
|
1024
|
+
input: ->(ctx) { { amount: ctx.order.total } },
|
|
1025
|
+
rollback: ->(ctx) { Payment::RefundService.new(ctx.user, params: { charge_id: ctx.charge.id }).call }
|
|
1026
|
+
|
|
1027
|
+
# Step 3: Send email (optional, won't stop workflow if fails)
|
|
1028
|
+
step :send_email,
|
|
1029
|
+
with: Email::ConfirmationService,
|
|
1030
|
+
input: ->(ctx) { { order_id: ctx.order.id } },
|
|
1031
|
+
optional: true,
|
|
1032
|
+
if: ->(ctx) { ctx.user.notifications_enabled? }
|
|
1033
|
+
|
|
1034
|
+
private
|
|
1035
|
+
|
|
1036
|
+
def validate_cart(context)
|
|
1037
|
+
context.fail!("Cart is empty") if context.cart_items.empty?
|
|
1038
|
+
end
|
|
1039
|
+
|
|
1040
|
+
def clear_cart(context)
|
|
1041
|
+
context.user.clear_cart! if context.success?
|
|
1042
|
+
end
|
|
1043
|
+
|
|
1044
|
+
def log_step(step, context)
|
|
1045
|
+
Rails.logger.info "Executing: #{step.name}"
|
|
1046
|
+
yield
|
|
1047
|
+
Rails.logger.info "Completed: #{step.name}"
|
|
1048
|
+
end
|
|
1049
|
+
end
|
|
1050
|
+
```
|
|
1051
|
+
|
|
1052
|
+
### Using a Workflow
|
|
1053
|
+
|
|
1054
|
+
```ruby
|
|
1055
|
+
# In your controller
|
|
1056
|
+
result = OrderPurchaseWorkflow.new(current_user, params: {
|
|
1057
|
+
cart_items: [...],
|
|
1058
|
+
payment_method: "card_123"
|
|
1059
|
+
}).call
|
|
1060
|
+
|
|
1061
|
+
if result[:success]
|
|
1062
|
+
# Access context data
|
|
1063
|
+
order = result[:context].order
|
|
1064
|
+
charge = result[:context].charge_payment
|
|
1065
|
+
|
|
1066
|
+
render json: {
|
|
1067
|
+
order: order,
|
|
1068
|
+
metadata: result[:metadata]
|
|
1069
|
+
}, status: :created
|
|
1070
|
+
else
|
|
1071
|
+
render json: {
|
|
1072
|
+
errors: result[:errors],
|
|
1073
|
+
failed_at: result[:metadata][:failed_step]
|
|
1074
|
+
}, status: :unprocessable_entity
|
|
1075
|
+
end
|
|
1076
|
+
```
|
|
1077
|
+
|
|
1078
|
+
### Workflow Features
|
|
1079
|
+
|
|
1080
|
+
#### 1. **Explicit Input Mapping**
|
|
1081
|
+
|
|
1082
|
+
Each step defines how data flows from the context to the service:
|
|
1083
|
+
|
|
1084
|
+
```ruby
|
|
1085
|
+
step :charge_payment,
|
|
1086
|
+
with: Payment::ChargeService,
|
|
1087
|
+
input: ->(ctx) {
|
|
1088
|
+
{
|
|
1089
|
+
amount: ctx.order.total,
|
|
1090
|
+
currency: ctx.order.currency,
|
|
1091
|
+
payment_method: ctx.payment_method
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
```
|
|
1095
|
+
|
|
1096
|
+
#### 2. **Conditional Steps**
|
|
1097
|
+
|
|
1098
|
+
Steps can execute conditionally:
|
|
1099
|
+
|
|
1100
|
+
```ruby
|
|
1101
|
+
step :send_sms,
|
|
1102
|
+
with: SMS::NotificationService,
|
|
1103
|
+
input: ->(ctx) { { order_id: ctx.order.id } },
|
|
1104
|
+
if: ->(ctx) { ctx.user.sms_enabled? && ctx.order.total > 100 }
|
|
1105
|
+
```
|
|
1106
|
+
|
|
1107
|
+
#### 3. **Optional Steps**
|
|
1108
|
+
|
|
1109
|
+
Optional steps won't stop the workflow if they fail:
|
|
1110
|
+
|
|
1111
|
+
```ruby
|
|
1112
|
+
step :update_analytics,
|
|
1113
|
+
with: Analytics::TrackService,
|
|
1114
|
+
input: ->(ctx) { { event: 'order_created', order_id: ctx.order.id } },
|
|
1115
|
+
optional: true # Won't fail workflow if analytics service is down
|
|
1116
|
+
```
|
|
1117
|
+
|
|
1118
|
+
#### 4. **Automatic Rollback**
|
|
1119
|
+
|
|
1120
|
+
Define rollback logic for each step:
|
|
1121
|
+
|
|
1122
|
+
```ruby
|
|
1123
|
+
step :charge_payment,
|
|
1124
|
+
with: Payment::ChargeService,
|
|
1125
|
+
input: ->(ctx) { { amount: ctx.order.total } },
|
|
1126
|
+
rollback: ->(ctx) {
|
|
1127
|
+
# Automatically called if a later step fails
|
|
1128
|
+
Stripe::Refund.create(charge: ctx.charge_payment.id)
|
|
1129
|
+
}
|
|
1130
|
+
```
|
|
1131
|
+
|
|
1132
|
+
When a step fails, all previously executed steps' rollback blocks are called in reverse order.
|
|
1133
|
+
|
|
1134
|
+
#### 5. **Transaction Support**
|
|
1135
|
+
|
|
1136
|
+
Wrap the entire workflow in a database transaction:
|
|
1137
|
+
|
|
1138
|
+
```ruby
|
|
1139
|
+
class MyWorkflow < BetterService::Workflow
|
|
1140
|
+
with_transaction true # DB changes are rolled back if workflow fails
|
|
1141
|
+
end
|
|
1142
|
+
```
|
|
1143
|
+
|
|
1144
|
+
#### 6. **Lifecycle Hooks**
|
|
1145
|
+
|
|
1146
|
+
**before_workflow**: Runs before any step executes
|
|
1147
|
+
|
|
1148
|
+
```ruby
|
|
1149
|
+
before_workflow :validate_prerequisites
|
|
1150
|
+
|
|
1151
|
+
def validate_prerequisites(context)
|
|
1152
|
+
context.fail!("User not verified") unless context.user.verified?
|
|
1153
|
+
end
|
|
1154
|
+
```
|
|
1155
|
+
|
|
1156
|
+
**after_workflow**: Runs after all steps complete (success or failure)
|
|
1157
|
+
|
|
1158
|
+
```ruby
|
|
1159
|
+
after_workflow :log_completion
|
|
1160
|
+
|
|
1161
|
+
def log_completion(context)
|
|
1162
|
+
Rails.logger.info "Workflow completed: success=#{context.success?}"
|
|
1163
|
+
end
|
|
1164
|
+
```
|
|
1165
|
+
|
|
1166
|
+
**around_step**: Wraps each step execution
|
|
1167
|
+
|
|
1168
|
+
```ruby
|
|
1169
|
+
around_step :measure_performance
|
|
1170
|
+
|
|
1171
|
+
def measure_performance(step, context)
|
|
1172
|
+
start = Time.current
|
|
1173
|
+
yield # Execute the step
|
|
1174
|
+
duration = Time.current - start
|
|
1175
|
+
Rails.logger.info "Step #{step.name}: #{duration}s"
|
|
1176
|
+
end
|
|
1177
|
+
```
|
|
1178
|
+
|
|
1179
|
+
### Workflow Response
|
|
1180
|
+
|
|
1181
|
+
Workflows return a standardized response:
|
|
1182
|
+
|
|
1183
|
+
```ruby
|
|
1184
|
+
{
|
|
1185
|
+
success: true/false,
|
|
1186
|
+
message: "Workflow completed successfully",
|
|
1187
|
+
context: <Context object with all data>,
|
|
1188
|
+
metadata: {
|
|
1189
|
+
workflow: "OrderPurchaseWorkflow",
|
|
1190
|
+
steps_executed: [:create_order, :charge_payment, :send_email],
|
|
1191
|
+
steps_skipped: [],
|
|
1192
|
+
failed_step: nil, # :step_name if failed
|
|
1193
|
+
duration_ms: 245.67
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
```
|
|
1197
|
+
|
|
1198
|
+
### Context Object
|
|
1199
|
+
|
|
1200
|
+
The context object stores all workflow data and is accessible across all steps:
|
|
1201
|
+
|
|
1202
|
+
```ruby
|
|
1203
|
+
# Set data
|
|
1204
|
+
context.order = Order.create!(...)
|
|
1205
|
+
context.add(:custom_key, value)
|
|
1206
|
+
|
|
1207
|
+
# Get data
|
|
1208
|
+
order = context.order
|
|
1209
|
+
value = context.get(:custom_key)
|
|
1210
|
+
|
|
1211
|
+
# Check status
|
|
1212
|
+
context.success? # => true
|
|
1213
|
+
context.failure? # => false
|
|
1214
|
+
|
|
1215
|
+
# Fail manually
|
|
1216
|
+
context.fail!("Custom error message", field: "error detail")
|
|
1217
|
+
```
|
|
1218
|
+
|
|
1219
|
+
### Generator Options
|
|
1220
|
+
|
|
1221
|
+
```bash
|
|
1222
|
+
# Basic workflow
|
|
1223
|
+
rails generate serviceable:workflow OrderPurchase
|
|
1224
|
+
|
|
1225
|
+
# With steps
|
|
1226
|
+
rails generate serviceable:workflow OrderPurchase --steps create charge notify
|
|
1227
|
+
|
|
1228
|
+
# With transaction enabled
|
|
1229
|
+
rails generate serviceable:workflow OrderPurchase --transaction
|
|
1230
|
+
|
|
1231
|
+
# Skip test file
|
|
1232
|
+
rails generate serviceable:workflow OrderPurchase --skip-test
|
|
1233
|
+
```
|
|
1234
|
+
|
|
1235
|
+
---
|
|
1236
|
+
|
|
1237
|
+
## 🧪 Testing
|
|
1238
|
+
|
|
1239
|
+
BetterService includes comprehensive test coverage. Run tests with:
|
|
1240
|
+
|
|
1241
|
+
```bash
|
|
1242
|
+
# Run all tests
|
|
1243
|
+
bundle exec rake
|
|
1244
|
+
|
|
1245
|
+
# Or
|
|
1246
|
+
bundle exec rake test
|
|
1247
|
+
```
|
|
1248
|
+
|
|
1249
|
+
### Manual Testing
|
|
1250
|
+
|
|
1251
|
+
A manual test script is included for hands-on verification:
|
|
1252
|
+
|
|
1253
|
+
```bash
|
|
1254
|
+
cd test/dummy
|
|
1255
|
+
rails console
|
|
1256
|
+
load '../../manual_test.rb'
|
|
1257
|
+
```
|
|
1258
|
+
|
|
1259
|
+
This runs 8 comprehensive tests covering all service types with automatic database rollback.
|
|
1260
|
+
|
|
1261
|
+
---
|
|
1262
|
+
|
|
1263
|
+
## 🤝 Contributing
|
|
1264
|
+
|
|
1265
|
+
Contributions are welcome! Here's how you can help:
|
|
1266
|
+
|
|
1267
|
+
1. Fork the repository
|
|
1268
|
+
2. Create your feature branch (`git checkout -b feature/amazing-feature`)
|
|
1269
|
+
3. Commit your changes (`git commit -m 'Add amazing feature'`)
|
|
1270
|
+
4. Push to the branch (`git push origin feature/amazing-feature`)
|
|
1271
|
+
5. Open a Pull Request
|
|
1272
|
+
|
|
1273
|
+
Please make sure to:
|
|
1274
|
+
- Add tests for new features
|
|
1275
|
+
- Update documentation
|
|
1276
|
+
- Follow the existing code style
|
|
1277
|
+
|
|
1278
|
+
---
|
|
1279
|
+
|
|
1280
|
+
## 🎉 Recent Features
|
|
1281
|
+
|
|
1282
|
+
### Observability & Instrumentation ✨
|
|
1283
|
+
|
|
1284
|
+
BetterService now includes comprehensive instrumentation powered by ActiveSupport::Notifications:
|
|
1285
|
+
|
|
1286
|
+
- **Automatic Event Publishing**: `service.started`, `service.completed`, `service.failed`, `cache.hit`, `cache.miss`
|
|
1287
|
+
- **Built-in Subscribers**: LogSubscriber and StatsSubscriber for monitoring
|
|
1288
|
+
- **Easy Integration**: DataDog, New Relic, Grafana, and custom subscribers
|
|
1289
|
+
- **Zero Configuration**: Works out of the box, fully configurable
|
|
1290
|
+
|
|
1291
|
+
```ruby
|
|
1292
|
+
# Enable monitoring in config/initializers/better_service.rb
|
|
1293
|
+
BetterService.configure do |config|
|
|
1294
|
+
config.instrumentation_enabled = true
|
|
1295
|
+
config.log_subscriber_enabled = true
|
|
1296
|
+
config.stats_subscriber_enabled = true
|
|
1297
|
+
end
|
|
1298
|
+
|
|
1299
|
+
# Custom subscriber
|
|
1300
|
+
ActiveSupport::Notifications.subscribe("service.completed") do |name, start, finish, id, payload|
|
|
1301
|
+
DataDog.histogram("service.duration", payload[:duration])
|
|
1302
|
+
end
|
|
1303
|
+
```
|
|
1304
|
+
|
|
1305
|
+
See [Configuration](docs/getting-started.md#configuration) for more details.
|
|
1306
|
+
|
|
1307
|
+
---
|
|
1308
|
+
|
|
1309
|
+
## 📄 License
|
|
1310
|
+
|
|
1311
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
|
1312
|
+
|
|
1313
|
+
---
|
|
1314
|
+
|
|
1315
|
+
<div align="center">
|
|
1316
|
+
|
|
1317
|
+
**Made with ❤️ by [Alessio Bussolari](https://github.com/alessiobussolari)**
|
|
1318
|
+
|
|
1319
|
+
[Report Bug](https://github.com/alessiobussolari/better_service/issues) · [Request Feature](https://github.com/alessiobussolari/better_service/issues) · [Documentation](https://github.com/alessiobussolari/better_service)
|
|
1320
|
+
|
|
1321
|
+
</div>
|