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
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BetterService
|
|
4
|
+
module Subscribers
|
|
5
|
+
# StatsSubscriber - Built-in subscriber that collects service statistics
|
|
6
|
+
#
|
|
7
|
+
# This subscriber tracks execution metrics for all services:
|
|
8
|
+
# - Total executions
|
|
9
|
+
# - Success/failure counts
|
|
10
|
+
# - Average duration
|
|
11
|
+
# - Cache hit rate
|
|
12
|
+
#
|
|
13
|
+
# @example Enable in initializer
|
|
14
|
+
# BetterService.configure do |config|
|
|
15
|
+
# config.stats_subscriber_enabled = true
|
|
16
|
+
# end
|
|
17
|
+
#
|
|
18
|
+
# @example Access statistics
|
|
19
|
+
# BetterService::Subscribers::StatsSubscriber.stats
|
|
20
|
+
# # => {
|
|
21
|
+
# # "ProductsIndexService" => {
|
|
22
|
+
# # executions: 150,
|
|
23
|
+
# # successes: 148,
|
|
24
|
+
# # failures: 2,
|
|
25
|
+
# # total_duration: 4500.0,
|
|
26
|
+
# # avg_duration: 30.0,
|
|
27
|
+
# # cache_hits: 120,
|
|
28
|
+
# # cache_misses: 30
|
|
29
|
+
# # }
|
|
30
|
+
# # }
|
|
31
|
+
class StatsSubscriber
|
|
32
|
+
class << self
|
|
33
|
+
# Storage for service statistics
|
|
34
|
+
#
|
|
35
|
+
# @return [Hash] Statistics hash
|
|
36
|
+
attr_reader :stats
|
|
37
|
+
|
|
38
|
+
# Storage for ActiveSupport::Notifications subscriptions
|
|
39
|
+
#
|
|
40
|
+
# @return [Array<ActiveSupport::Notifications::Fanout::Subscriber>]
|
|
41
|
+
attr_reader :subscriptions
|
|
42
|
+
|
|
43
|
+
# Attach the subscriber to ActiveSupport::Notifications
|
|
44
|
+
#
|
|
45
|
+
# This method is called automatically when subscriber is enabled.
|
|
46
|
+
#
|
|
47
|
+
# @return [void]
|
|
48
|
+
def attach
|
|
49
|
+
reset!
|
|
50
|
+
@subscriptions ||= []
|
|
51
|
+
subscribe_to_service_events
|
|
52
|
+
subscribe_to_cache_events
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Reset all statistics
|
|
56
|
+
#
|
|
57
|
+
# Useful for testing or periodic reset in production.
|
|
58
|
+
#
|
|
59
|
+
# @return [void]
|
|
60
|
+
def reset!
|
|
61
|
+
# Unsubscribe from all existing subscriptions
|
|
62
|
+
if @subscriptions
|
|
63
|
+
@subscriptions.each do |subscription|
|
|
64
|
+
ActiveSupport::Notifications.unsubscribe(subscription)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
@subscriptions = []
|
|
68
|
+
@stats = {}
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Get statistics for a specific service
|
|
72
|
+
#
|
|
73
|
+
# @param service_name [String] Name of service class
|
|
74
|
+
# @return [Hash, nil] Service statistics or nil if not found
|
|
75
|
+
def stats_for(service_name)
|
|
76
|
+
@stats[service_name]
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Get statistics summary across all services
|
|
80
|
+
#
|
|
81
|
+
# @return [Hash] Aggregated statistics
|
|
82
|
+
def summary
|
|
83
|
+
total_executions = @stats.values.sum { |s| s[:executions] }
|
|
84
|
+
total_successes = @stats.values.sum { |s| s[:successes] }
|
|
85
|
+
total_failures = @stats.values.sum { |s| s[:failures] }
|
|
86
|
+
total_duration = @stats.values.sum { |s| s[:total_duration] }
|
|
87
|
+
total_cache_hits = @stats.values.sum { |s| s[:cache_hits] }
|
|
88
|
+
total_cache_misses = @stats.values.sum { |s| s[:cache_misses] }
|
|
89
|
+
|
|
90
|
+
{
|
|
91
|
+
total_services: @stats.keys.size,
|
|
92
|
+
total_executions: total_executions,
|
|
93
|
+
total_successes: total_successes,
|
|
94
|
+
total_failures: total_failures,
|
|
95
|
+
success_rate: total_executions > 0 ? (total_successes.to_f / total_executions * 100).round(2) : 0,
|
|
96
|
+
avg_duration: total_executions > 0 ? (total_duration / total_executions).round(2) : 0,
|
|
97
|
+
cache_hit_rate: (total_cache_hits + total_cache_misses) > 0 ? (total_cache_hits.to_f / (total_cache_hits + total_cache_misses) * 100).round(2) : 0
|
|
98
|
+
}
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
private
|
|
102
|
+
|
|
103
|
+
# Subscribe to service lifecycle events
|
|
104
|
+
#
|
|
105
|
+
# @return [void]
|
|
106
|
+
def subscribe_to_service_events
|
|
107
|
+
@subscriptions << ActiveSupport::Notifications.subscribe("service.completed") do |name, start, finish, id, payload|
|
|
108
|
+
record_completion(payload)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
@subscriptions << ActiveSupport::Notifications.subscribe("service.failed") do |name, start, finish, id, payload|
|
|
112
|
+
record_failure(payload)
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Subscribe to cache events
|
|
117
|
+
#
|
|
118
|
+
# @return [void]
|
|
119
|
+
def subscribe_to_cache_events
|
|
120
|
+
@subscriptions << ActiveSupport::Notifications.subscribe("cache.hit") do |name, start, finish, id, payload|
|
|
121
|
+
record_cache_hit(payload)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
@subscriptions << ActiveSupport::Notifications.subscribe("cache.miss") do |name, start, finish, id, payload|
|
|
125
|
+
record_cache_miss(payload)
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Record service completion
|
|
130
|
+
#
|
|
131
|
+
# @param payload [Hash] Event payload
|
|
132
|
+
# @return [void]
|
|
133
|
+
def record_completion(payload)
|
|
134
|
+
service_name = payload[:service_name]
|
|
135
|
+
ensure_service_stats(service_name)
|
|
136
|
+
|
|
137
|
+
@stats[service_name][:executions] += 1
|
|
138
|
+
@stats[service_name][:successes] += 1
|
|
139
|
+
@stats[service_name][:total_duration] += payload[:duration]
|
|
140
|
+
@stats[service_name][:avg_duration] = (@stats[service_name][:total_duration] / @stats[service_name][:executions]).round(2)
|
|
141
|
+
|
|
142
|
+
# Record cache hit if present
|
|
143
|
+
if payload[:cache_hit]
|
|
144
|
+
@stats[service_name][:cache_hits] += 1
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Record service failure
|
|
149
|
+
#
|
|
150
|
+
# @param payload [Hash] Event payload
|
|
151
|
+
# @return [void]
|
|
152
|
+
def record_failure(payload)
|
|
153
|
+
service_name = payload[:service_name]
|
|
154
|
+
ensure_service_stats(service_name)
|
|
155
|
+
|
|
156
|
+
@stats[service_name][:executions] += 1
|
|
157
|
+
@stats[service_name][:failures] += 1
|
|
158
|
+
@stats[service_name][:total_duration] += payload[:duration]
|
|
159
|
+
@stats[service_name][:avg_duration] = (@stats[service_name][:total_duration] / @stats[service_name][:executions]).round(2)
|
|
160
|
+
|
|
161
|
+
# Track error types
|
|
162
|
+
error_class = payload[:error_class]
|
|
163
|
+
@stats[service_name][:errors][error_class] ||= 0
|
|
164
|
+
@stats[service_name][:errors][error_class] += 1
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Record cache hit
|
|
168
|
+
#
|
|
169
|
+
# @param payload [Hash] Event payload
|
|
170
|
+
# @return [void]
|
|
171
|
+
def record_cache_hit(payload)
|
|
172
|
+
service_name = payload[:service_name]
|
|
173
|
+
ensure_service_stats(service_name)
|
|
174
|
+
|
|
175
|
+
@stats[service_name][:cache_hits] += 1
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Record cache miss
|
|
179
|
+
#
|
|
180
|
+
# @param payload [Hash] Event payload
|
|
181
|
+
# @return [void]
|
|
182
|
+
def record_cache_miss(payload)
|
|
183
|
+
service_name = payload[:service_name]
|
|
184
|
+
ensure_service_stats(service_name)
|
|
185
|
+
|
|
186
|
+
@stats[service_name][:cache_misses] += 1
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Ensure service has stats entry
|
|
190
|
+
#
|
|
191
|
+
# @param service_name [String] Name of service class
|
|
192
|
+
# @return [void]
|
|
193
|
+
def ensure_service_stats(service_name)
|
|
194
|
+
@stats[service_name] ||= {
|
|
195
|
+
executions: 0,
|
|
196
|
+
successes: 0,
|
|
197
|
+
failures: 0,
|
|
198
|
+
total_duration: 0.0,
|
|
199
|
+
avg_duration: 0.0,
|
|
200
|
+
cache_hits: 0,
|
|
201
|
+
cache_misses: 0,
|
|
202
|
+
errors: {}
|
|
203
|
+
}
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BetterService
|
|
4
|
+
# Workflow - Base class for composing multiple services into a pipeline
|
|
5
|
+
#
|
|
6
|
+
# Workflows allow you to chain multiple services together with explicit
|
|
7
|
+
# data mapping, conditional execution, rollback support, and lifecycle hooks.
|
|
8
|
+
#
|
|
9
|
+
# Example:
|
|
10
|
+
# class OrderPurchaseWorkflow < BetterService::Workflow
|
|
11
|
+
# with_transaction true
|
|
12
|
+
#
|
|
13
|
+
# before_workflow :validate_cart
|
|
14
|
+
# after_workflow :clear_cart
|
|
15
|
+
#
|
|
16
|
+
# step :create_order,
|
|
17
|
+
# with: Order::CreateService,
|
|
18
|
+
# input: ->(ctx) { { items: ctx.cart_items, total: ctx.total } }
|
|
19
|
+
#
|
|
20
|
+
# step :charge_payment,
|
|
21
|
+
# with: Payment::ChargeService,
|
|
22
|
+
# input: ->(ctx) { { amount: ctx.order.total } },
|
|
23
|
+
# rollback: ->(ctx) { Payment::RefundService.new(ctx.user, params: { charge_id: ctx.charge.id }).call }
|
|
24
|
+
#
|
|
25
|
+
# step :send_email,
|
|
26
|
+
# with: Email::ConfirmationService,
|
|
27
|
+
# input: ->(ctx) { { order_id: ctx.order.id } },
|
|
28
|
+
# optional: true,
|
|
29
|
+
# if: ->(ctx) { ctx.user.notifications_enabled? }
|
|
30
|
+
#
|
|
31
|
+
# private
|
|
32
|
+
#
|
|
33
|
+
# def validate_cart(context)
|
|
34
|
+
# context.fail!("Cart is empty") if context.cart_items.empty?
|
|
35
|
+
# end
|
|
36
|
+
#
|
|
37
|
+
# def clear_cart(context)
|
|
38
|
+
# context.user.clear_cart! if context.success?
|
|
39
|
+
# end
|
|
40
|
+
# end
|
|
41
|
+
#
|
|
42
|
+
# # Usage:
|
|
43
|
+
# result = OrderPurchaseWorkflow.new(current_user, params: { cart_items: [...] }).call
|
|
44
|
+
# if result[:success]
|
|
45
|
+
# order = result[:context].order
|
|
46
|
+
# else
|
|
47
|
+
# errors = result[:errors]
|
|
48
|
+
# end
|
|
49
|
+
module Workflows
|
|
50
|
+
class Base
|
|
51
|
+
include Concerns::Workflowable::Callbacks
|
|
52
|
+
include DSL
|
|
53
|
+
include TransactionSupport
|
|
54
|
+
include Execution
|
|
55
|
+
include RollbackSupport
|
|
56
|
+
include ResultBuilder
|
|
57
|
+
|
|
58
|
+
attr_reader :user, :params, :context
|
|
59
|
+
|
|
60
|
+
# Initialize a new workflow
|
|
61
|
+
#
|
|
62
|
+
# @param user [Object] The current user executing the workflow
|
|
63
|
+
# @param params [Hash] Parameters for the workflow
|
|
64
|
+
def initialize(user, params: {})
|
|
65
|
+
@user = user
|
|
66
|
+
@params = params
|
|
67
|
+
@context = Workflowable::Context.new(user, **params)
|
|
68
|
+
@executed_steps = []
|
|
69
|
+
@start_time = nil
|
|
70
|
+
@end_time = nil
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Main entry point - executes the workflow
|
|
74
|
+
#
|
|
75
|
+
# Runs before_workflow callbacks, executes the workflow (with or without
|
|
76
|
+
# transaction), and runs after_workflow callbacks. Tracks timing and
|
|
77
|
+
# ensures callbacks run even if execution fails.
|
|
78
|
+
#
|
|
79
|
+
# @return [Hash] Result hash with success status, context, and metadata
|
|
80
|
+
def call
|
|
81
|
+
@start_time = Time.current
|
|
82
|
+
@context.called!
|
|
83
|
+
|
|
84
|
+
# Run before_workflow callbacks
|
|
85
|
+
run_before_workflow_callbacks(@context)
|
|
86
|
+
|
|
87
|
+
# If callbacks failed the context, return early
|
|
88
|
+
if @context.failure?
|
|
89
|
+
@end_time = Time.current
|
|
90
|
+
return build_failure_result
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Execute workflow with or without transaction
|
|
94
|
+
if self.class._use_transaction
|
|
95
|
+
execute_with_transaction
|
|
96
|
+
else
|
|
97
|
+
execute_workflow
|
|
98
|
+
end
|
|
99
|
+
ensure
|
|
100
|
+
@end_time ||= Time.current
|
|
101
|
+
# Always run after_workflow callbacks
|
|
102
|
+
run_after_workflow_callbacks(@context) if @context.called?
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BetterService
|
|
4
|
+
module Workflows
|
|
5
|
+
# DSL - Provides class-level DSL methods for defining workflow steps
|
|
6
|
+
#
|
|
7
|
+
# This module adds the `step` and `with_transaction` class methods that
|
|
8
|
+
# allow declarative workflow definition using a clean DSL syntax.
|
|
9
|
+
module DSL
|
|
10
|
+
extend ActiveSupport::Concern
|
|
11
|
+
|
|
12
|
+
included do
|
|
13
|
+
class_attribute :_steps, default: []
|
|
14
|
+
class_attribute :_use_transaction, default: false
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
class_methods do
|
|
18
|
+
# DSL method to define a step in the workflow
|
|
19
|
+
#
|
|
20
|
+
# @param name [Symbol] Name of the step
|
|
21
|
+
# @param with [Class] Service class to execute
|
|
22
|
+
# @param input [Proc] Lambda to map context data to service params
|
|
23
|
+
# @param optional [Boolean] Whether step failure should stop the workflow
|
|
24
|
+
# @param if [Proc] Condition to determine if step should execute
|
|
25
|
+
# @param rollback [Proc] Block to execute if rollback is needed
|
|
26
|
+
#
|
|
27
|
+
# @example Define a workflow step
|
|
28
|
+
# step :create_order,
|
|
29
|
+
# with: Order::CreateService,
|
|
30
|
+
# input: ->(ctx) { { items: ctx.cart_items } },
|
|
31
|
+
# rollback: ->(ctx) { ctx.order.destroy! }
|
|
32
|
+
def step(name, with:, input: nil, optional: false, if: nil, rollback: nil)
|
|
33
|
+
step = Workflowable::Step.new(
|
|
34
|
+
name: name,
|
|
35
|
+
service_class: with,
|
|
36
|
+
input: input,
|
|
37
|
+
optional: optional,
|
|
38
|
+
condition: binding.local_variable_get(:if), # Use binding to get the 'if' keyword param
|
|
39
|
+
rollback: rollback
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
self._steps += [ step ]
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Enable or disable database transactions for the entire workflow
|
|
46
|
+
#
|
|
47
|
+
# @param enabled [Boolean] Whether to use transactions
|
|
48
|
+
#
|
|
49
|
+
# @example Enable transactions
|
|
50
|
+
# class MyWorkflow < BetterService::Workflow
|
|
51
|
+
# with_transaction true
|
|
52
|
+
# end
|
|
53
|
+
def with_transaction(enabled)
|
|
54
|
+
self._use_transaction = enabled
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BetterService
|
|
4
|
+
module Workflows
|
|
5
|
+
# Execution - Core workflow execution engine
|
|
6
|
+
#
|
|
7
|
+
# This module handles the sequential execution of workflow steps,
|
|
8
|
+
# error handling, and step tracking.
|
|
9
|
+
module Execution
|
|
10
|
+
private
|
|
11
|
+
|
|
12
|
+
# Execute workflow steps sequentially
|
|
13
|
+
#
|
|
14
|
+
# Iterates through all defined steps, executing each with around_step callbacks.
|
|
15
|
+
# Tracks executed and skipped steps. Handles step failures by rolling back
|
|
16
|
+
# previously executed steps and raising appropriate errors.
|
|
17
|
+
#
|
|
18
|
+
# @return [Hash] Success or failure result
|
|
19
|
+
# @raise [Errors::Workflowable::Runtime::StepExecutionError] If a step fails
|
|
20
|
+
# @raise [Errors::Workflowable::Runtime::WorkflowExecutionError] If workflow execution fails
|
|
21
|
+
def execute_workflow
|
|
22
|
+
steps_executed = []
|
|
23
|
+
steps_skipped = []
|
|
24
|
+
|
|
25
|
+
self.class._steps.each do |step|
|
|
26
|
+
# Execute step with around_step callbacks
|
|
27
|
+
result = nil
|
|
28
|
+
run_around_step_callbacks(step, @context) do
|
|
29
|
+
result = step.call(@context, @user, @params)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Track skipped steps
|
|
33
|
+
if result[:skipped]
|
|
34
|
+
steps_skipped << step.name
|
|
35
|
+
next
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# If step failed and it's not optional, stop and rollback
|
|
39
|
+
if result[:success] == false && !result[:optional_failure]
|
|
40
|
+
# With Pure Exception Pattern, all failures raise exceptions
|
|
41
|
+
rollback_steps
|
|
42
|
+
|
|
43
|
+
raise Errors::Workflowable::Runtime::StepExecutionError.new(
|
|
44
|
+
"Step #{step.name} failed: #{result[:error] || result[:message]}",
|
|
45
|
+
code: ErrorCodes::STEP_FAILED,
|
|
46
|
+
context: {
|
|
47
|
+
workflow: self.class.name,
|
|
48
|
+
step: step.name,
|
|
49
|
+
steps_executed: steps_executed,
|
|
50
|
+
errors: result[:errors] || {}
|
|
51
|
+
}
|
|
52
|
+
)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Track successful execution
|
|
56
|
+
@executed_steps << step
|
|
57
|
+
steps_executed << step.name
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# All steps succeeded
|
|
61
|
+
@end_time = Time.current
|
|
62
|
+
build_success_result(
|
|
63
|
+
steps_executed: steps_executed,
|
|
64
|
+
steps_skipped: steps_skipped
|
|
65
|
+
)
|
|
66
|
+
rescue Errors::Workflowable::Runtime::StepExecutionError
|
|
67
|
+
# Step error already raised, just re-raise
|
|
68
|
+
raise
|
|
69
|
+
rescue StandardError => e
|
|
70
|
+
# Unexpected error during workflow execution
|
|
71
|
+
rollback_steps
|
|
72
|
+
|
|
73
|
+
Rails.logger.error "Workflow error: #{e.message}" if defined?(Rails)
|
|
74
|
+
Rails.logger.error e.backtrace.join("\n") if defined?(Rails)
|
|
75
|
+
|
|
76
|
+
raise Errors::Workflowable::Runtime::WorkflowExecutionError.new(
|
|
77
|
+
"Workflow execution failed: #{e.message}",
|
|
78
|
+
code: ErrorCodes::WORKFLOW_FAILED,
|
|
79
|
+
original_error: e,
|
|
80
|
+
context: {
|
|
81
|
+
workflow: self.class.name,
|
|
82
|
+
steps_executed: steps_executed,
|
|
83
|
+
steps_skipped: steps_skipped
|
|
84
|
+
}
|
|
85
|
+
)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BetterService
|
|
4
|
+
module Workflows
|
|
5
|
+
# ResultBuilder - Handles construction of success and failure result hashes
|
|
6
|
+
#
|
|
7
|
+
# This module provides methods for building consistent result structures
|
|
8
|
+
# for workflow execution, including metadata tracking and duration measurement.
|
|
9
|
+
module ResultBuilder
|
|
10
|
+
private
|
|
11
|
+
|
|
12
|
+
# Build success result
|
|
13
|
+
#
|
|
14
|
+
# @param steps_executed [Array<Symbol>] Names of steps that were executed
|
|
15
|
+
# @param steps_skipped [Array<Symbol>] Names of steps that were skipped
|
|
16
|
+
# @return [Hash] Success result with context and metadata
|
|
17
|
+
def build_success_result(steps_executed: [], steps_skipped: [])
|
|
18
|
+
{
|
|
19
|
+
success: true,
|
|
20
|
+
message: "Workflow completed successfully",
|
|
21
|
+
context: @context,
|
|
22
|
+
metadata: {
|
|
23
|
+
workflow: self.class.name,
|
|
24
|
+
steps_executed: steps_executed,
|
|
25
|
+
steps_skipped: steps_skipped,
|
|
26
|
+
duration_ms: duration_ms
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Build failure result
|
|
32
|
+
#
|
|
33
|
+
# @param message [String, nil] Error message
|
|
34
|
+
# @param errors [Hash] Error details
|
|
35
|
+
# @param failed_step [Symbol, nil] Name of the step that failed
|
|
36
|
+
# @param steps_executed [Array<Symbol>] Names of steps that were executed before failure
|
|
37
|
+
# @param steps_skipped [Array<Symbol>] Names of steps that were skipped
|
|
38
|
+
# @return [Hash] Failure result with error details and metadata
|
|
39
|
+
def build_failure_result(message: nil, errors: {}, failed_step: nil, steps_executed: [], steps_skipped: [])
|
|
40
|
+
result = {
|
|
41
|
+
success: false,
|
|
42
|
+
error: message || @context.errors[:message] || "Workflow failed",
|
|
43
|
+
errors: errors.any? ? errors : @context.errors,
|
|
44
|
+
context: @context,
|
|
45
|
+
metadata: {
|
|
46
|
+
workflow: self.class.name,
|
|
47
|
+
failed_step: failed_step,
|
|
48
|
+
steps_executed: steps_executed,
|
|
49
|
+
steps_skipped: steps_skipped,
|
|
50
|
+
duration_ms: duration_ms
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
result[:metadata].delete(:failed_step) if failed_step.nil?
|
|
55
|
+
result
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Calculate duration in milliseconds
|
|
59
|
+
#
|
|
60
|
+
# @return [Float, nil] Duration in milliseconds or nil if not available
|
|
61
|
+
def duration_ms
|
|
62
|
+
return nil unless @start_time && @end_time
|
|
63
|
+
(((@end_time - @start_time) * 1000).round(2))
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BetterService
|
|
4
|
+
module Workflows
|
|
5
|
+
# RollbackSupport - Handles rollback of executed steps when workflow fails
|
|
6
|
+
#
|
|
7
|
+
# This module provides the rollback mechanism that executes step rollback
|
|
8
|
+
# blocks in reverse order when a workflow execution fails.
|
|
9
|
+
module RollbackSupport
|
|
10
|
+
private
|
|
11
|
+
|
|
12
|
+
# Rollback all executed steps in reverse order
|
|
13
|
+
#
|
|
14
|
+
# Iterates through executed steps in reverse and calls their rollback method.
|
|
15
|
+
# If any rollback fails, raises a RollbackError with context about which
|
|
16
|
+
# step failed and what steps were executed.
|
|
17
|
+
#
|
|
18
|
+
# @raise [Errors::Workflowable::Runtime::RollbackError] If any rollback fails
|
|
19
|
+
# @return [void]
|
|
20
|
+
def rollback_steps
|
|
21
|
+
@executed_steps.reverse_each do |step|
|
|
22
|
+
begin
|
|
23
|
+
step.rollback(@context)
|
|
24
|
+
rescue StandardError => e
|
|
25
|
+
# Rollback failure is serious - raise exception
|
|
26
|
+
Rails.logger.error "Rollback failed for step #{step.name}: #{e.message}" if defined?(Rails)
|
|
27
|
+
Rails.logger.error e.backtrace.join("\n") if defined?(Rails)
|
|
28
|
+
|
|
29
|
+
raise Errors::Workflowable::Runtime::RollbackError.new(
|
|
30
|
+
"Rollback failed for step #{step.name}: #{e.message}",
|
|
31
|
+
code: ErrorCodes::ROLLBACK_FAILED,
|
|
32
|
+
original_error: e,
|
|
33
|
+
context: {
|
|
34
|
+
workflow: self.class.name,
|
|
35
|
+
step: step.name,
|
|
36
|
+
executed_steps: @executed_steps.map(&:name)
|
|
37
|
+
}
|
|
38
|
+
)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BetterService
|
|
4
|
+
module Workflows
|
|
5
|
+
# TransactionSupport - Handles database transaction wrapping for workflows
|
|
6
|
+
#
|
|
7
|
+
# This module provides the ability to execute workflows within a database
|
|
8
|
+
# transaction, with automatic rollback on failure.
|
|
9
|
+
module TransactionSupport
|
|
10
|
+
private
|
|
11
|
+
|
|
12
|
+
# Execute workflow with transaction wrapper
|
|
13
|
+
#
|
|
14
|
+
# Wraps the workflow execution in an ActiveRecord transaction. If the
|
|
15
|
+
# workflow fails (returns success: false), triggers a rollback.
|
|
16
|
+
#
|
|
17
|
+
# @return [Hash] Result from execute_workflow
|
|
18
|
+
def execute_with_transaction
|
|
19
|
+
result = nil
|
|
20
|
+
ActiveRecord::Base.transaction do
|
|
21
|
+
result = execute_workflow
|
|
22
|
+
# If workflow failed, raise to trigger rollback
|
|
23
|
+
raise ActiveRecord::Rollback if result[:success] == false
|
|
24
|
+
end
|
|
25
|
+
result
|
|
26
|
+
rescue ActiveRecord::Rollback
|
|
27
|
+
# Rollback was triggered, result already contains failure
|
|
28
|
+
result
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
require "better_service/version"
|
|
2
|
+
require "better_service/railtie"
|
|
3
|
+
require "better_service/configuration"
|
|
4
|
+
require "better_service/errors/better_service_error"
|
|
5
|
+
require "better_service/cache_service"
|
|
6
|
+
require "better_service/concerns/instrumentation"
|
|
7
|
+
require "better_service/subscribers/log_subscriber"
|
|
8
|
+
require "better_service/subscribers/stats_subscriber"
|
|
9
|
+
require "better_service/services/base"
|
|
10
|
+
require "better_service/services/index_service"
|
|
11
|
+
require "better_service/services/show_service"
|
|
12
|
+
require "better_service/services/create_service"
|
|
13
|
+
require "better_service/services/update_service"
|
|
14
|
+
require "better_service/services/destroy_service"
|
|
15
|
+
require "better_service/services/action_service"
|
|
16
|
+
require "better_service/concerns/workflowable/context"
|
|
17
|
+
require "better_service/concerns/workflowable/step"
|
|
18
|
+
require "better_service/concerns/workflowable/callbacks"
|
|
19
|
+
require "better_service/workflows/result_builder"
|
|
20
|
+
require "better_service/workflows/rollback_support"
|
|
21
|
+
require "better_service/workflows/transaction_support"
|
|
22
|
+
require "better_service/workflows/dsl"
|
|
23
|
+
require "better_service/workflows/execution"
|
|
24
|
+
require "better_service/workflows/base"
|
|
25
|
+
|
|
26
|
+
module BetterService
|
|
27
|
+
# Your code goes here...
|
|
28
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators/named_base"
|
|
4
|
+
|
|
5
|
+
module Serviceable
|
|
6
|
+
module Generators
|
|
7
|
+
class ActionGenerator < Rails::Generators::NamedBase
|
|
8
|
+
source_root File.expand_path("templates", __dir__)
|
|
9
|
+
|
|
10
|
+
argument :action_name, type: :string, desc: "The action name (e.g., accept, reject, publish)"
|
|
11
|
+
|
|
12
|
+
desc "Generate an Action service for custom state transitions"
|
|
13
|
+
|
|
14
|
+
def create_service_file
|
|
15
|
+
template "action_service.rb.tt", File.join("app/services", class_path, "#{file_name}/#{action_name}_service.rb")
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def create_test_file
|
|
19
|
+
template "service_test.rb.tt", File.join("test/services", class_path, "#{file_name}/#{action_name}_service_test.rb")
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def service_class_name
|
|
25
|
+
"#{class_name}::#{action_name.camelize}Service"
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|