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.
Files changed (78) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +1321 -0
  4. data/Rakefile +15 -0
  5. data/lib/better_service/cache_service.rb +310 -0
  6. data/lib/better_service/concerns/instrumentation.rb +242 -0
  7. data/lib/better_service/concerns/serviceable/authorizable.rb +106 -0
  8. data/lib/better_service/concerns/serviceable/cacheable.rb +97 -0
  9. data/lib/better_service/concerns/serviceable/messageable.rb +30 -0
  10. data/lib/better_service/concerns/serviceable/presentable.rb +66 -0
  11. data/lib/better_service/concerns/serviceable/transactional.rb +51 -0
  12. data/lib/better_service/concerns/serviceable/validatable.rb +58 -0
  13. data/lib/better_service/concerns/serviceable/viewable.rb +49 -0
  14. data/lib/better_service/concerns/serviceable.rb +12 -0
  15. data/lib/better_service/concerns/workflowable/callbacks.rb +116 -0
  16. data/lib/better_service/concerns/workflowable/context.rb +108 -0
  17. data/lib/better_service/concerns/workflowable/step.rb +141 -0
  18. data/lib/better_service/concerns/workflowable.rb +12 -0
  19. data/lib/better_service/configuration.rb +113 -0
  20. data/lib/better_service/errors/better_service_error.rb +271 -0
  21. data/lib/better_service/errors/configuration/configuration_error.rb +21 -0
  22. data/lib/better_service/errors/configuration/invalid_configuration_error.rb +28 -0
  23. data/lib/better_service/errors/configuration/invalid_schema_error.rb +28 -0
  24. data/lib/better_service/errors/configuration/nil_user_error.rb +37 -0
  25. data/lib/better_service/errors/configuration/schema_required_error.rb +29 -0
  26. data/lib/better_service/errors/runtime/authorization_error.rb +38 -0
  27. data/lib/better_service/errors/runtime/database_error.rb +38 -0
  28. data/lib/better_service/errors/runtime/execution_error.rb +27 -0
  29. data/lib/better_service/errors/runtime/resource_not_found_error.rb +38 -0
  30. data/lib/better_service/errors/runtime/runtime_error.rb +22 -0
  31. data/lib/better_service/errors/runtime/transaction_error.rb +34 -0
  32. data/lib/better_service/errors/runtime/validation_error.rb +42 -0
  33. data/lib/better_service/errors/workflowable/configuration/duplicate_step_error.rb +27 -0
  34. data/lib/better_service/errors/workflowable/configuration/invalid_step_error.rb +12 -0
  35. data/lib/better_service/errors/workflowable/configuration/step_not_found_error.rb +29 -0
  36. data/lib/better_service/errors/workflowable/configuration/workflow_configuration_error.rb +24 -0
  37. data/lib/better_service/errors/workflowable/runtime/rollback_error.rb +46 -0
  38. data/lib/better_service/errors/workflowable/runtime/step_execution_error.rb +47 -0
  39. data/lib/better_service/errors/workflowable/runtime/workflow_execution_error.rb +40 -0
  40. data/lib/better_service/errors/workflowable/runtime/workflow_runtime_error.rb +25 -0
  41. data/lib/better_service/railtie.rb +6 -0
  42. data/lib/better_service/services/action_service.rb +60 -0
  43. data/lib/better_service/services/base.rb +249 -0
  44. data/lib/better_service/services/create_service.rb +60 -0
  45. data/lib/better_service/services/destroy_service.rb +57 -0
  46. data/lib/better_service/services/index_service.rb +56 -0
  47. data/lib/better_service/services/show_service.rb +44 -0
  48. data/lib/better_service/services/update_service.rb +58 -0
  49. data/lib/better_service/subscribers/log_subscriber.rb +131 -0
  50. data/lib/better_service/subscribers/stats_subscriber.rb +208 -0
  51. data/lib/better_service/version.rb +3 -0
  52. data/lib/better_service/workflows/base.rb +106 -0
  53. data/lib/better_service/workflows/dsl.rb +59 -0
  54. data/lib/better_service/workflows/execution.rb +89 -0
  55. data/lib/better_service/workflows/result_builder.rb +67 -0
  56. data/lib/better_service/workflows/rollback_support.rb +44 -0
  57. data/lib/better_service/workflows/transaction_support.rb +32 -0
  58. data/lib/better_service.rb +28 -0
  59. data/lib/generators/serviceable/action_generator.rb +29 -0
  60. data/lib/generators/serviceable/create_generator.rb +27 -0
  61. data/lib/generators/serviceable/destroy_generator.rb +27 -0
  62. data/lib/generators/serviceable/index_generator.rb +27 -0
  63. data/lib/generators/serviceable/scaffold_generator.rb +70 -0
  64. data/lib/generators/serviceable/show_generator.rb +27 -0
  65. data/lib/generators/serviceable/templates/action_service.rb.tt +42 -0
  66. data/lib/generators/serviceable/templates/create_service.rb.tt +33 -0
  67. data/lib/generators/serviceable/templates/destroy_service.rb.tt +40 -0
  68. data/lib/generators/serviceable/templates/index_service.rb.tt +54 -0
  69. data/lib/generators/serviceable/templates/service_test.rb.tt +23 -0
  70. data/lib/generators/serviceable/templates/show_service.rb.tt +37 -0
  71. data/lib/generators/serviceable/templates/update_service.rb.tt +50 -0
  72. data/lib/generators/serviceable/update_generator.rb +27 -0
  73. data/lib/generators/workflowable/WORKFLOW_README +27 -0
  74. data/lib/generators/workflowable/templates/workflow.rb.tt +72 -0
  75. data/lib/generators/workflowable/templates/workflow_test.rb.tt +62 -0
  76. data/lib/generators/workflowable/workflow_generator.rb +60 -0
  77. data/lib/tasks/better_service_tasks.rake +4 -0
  78. 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
+ [![Gem Version](https://badge.fury.io/rb/better_service.svg)](https://badge.fury.io/rb/better_service)
8
+ [![License](https://img.shields.io/badge/license-MIT-blue.svg)](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>