servus 0.2.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/generators/servus/event_handler/event_handler_generator.rb +1 -1
- data/lib/generators/servus/guard/guard_generator.rb +1 -1
- data/lib/generators/servus/guard/templates/guard.rb.erb +5 -3
- data/lib/generators/servus/service/service_generator.rb +1 -1
- data/lib/servus/base.rb +67 -9
- data/lib/servus/config.rb +71 -3
- data/lib/servus/events/bus.rb +29 -0
- data/lib/servus/events/emitter.rb +15 -0
- data/lib/servus/extensions/lazily/call.rb +82 -0
- data/lib/servus/extensions/lazily/errors.rb +37 -0
- data/lib/servus/extensions/lazily/ext.rb +23 -0
- data/lib/servus/extensions/lazily/resolver.rb +32 -0
- data/lib/servus/guard.rb +7 -6
- data/lib/servus/guards/falsey_guard.rb +3 -3
- data/lib/servus/guards/presence_guard.rb +4 -4
- data/lib/servus/guards/state_guard.rb +4 -5
- data/lib/servus/guards/truthy_guard.rb +3 -3
- data/lib/servus/helpers/controller_helpers.rb +40 -0
- data/lib/servus/railtie.rb +7 -1
- data/lib/servus/support/data_object.rb +80 -0
- data/lib/servus/support/errors.rb +16 -0
- data/lib/servus/support/lockdown.rb +94 -0
- data/lib/servus/support/logger.rb +16 -0
- data/lib/servus/support/response.rb +12 -1
- data/lib/servus/support/validator.rb +79 -34
- data/lib/servus/testing/example_builders.rb +74 -0
- data/lib/servus/testing/matchers.rb +99 -0
- data/lib/servus/version.rb +1 -1
- data/lib/servus.rb +2 -0
- metadata +16 -114
- data/.claude/commands/check-docs.md +0 -1
- data/.claude/commands/consistency-check.md +0 -1
- data/.claude/commands/fine-tooth-comb.md +0 -1
- data/.claude/commands/red-green-refactor.md +0 -5
- data/.claude/settings.json +0 -24
- data/.rspec +0 -3
- data/.rubocop.yml +0 -27
- data/.yardopts +0 -6
- data/CHANGELOG.md +0 -122
- data/CLAUDE.md +0 -10
- data/IDEAS.md +0 -5
- data/LICENSE.txt +0 -21
- data/READme.md +0 -856
- data/Rakefile +0 -45
- data/docs/core/1_overview.md +0 -77
- data/docs/core/2_architecture.md +0 -120
- data/docs/core/3_service_objects.md +0 -121
- data/docs/features/1_schema_validation.md +0 -119
- data/docs/features/2_error_handling.md +0 -121
- data/docs/features/3_async_execution.md +0 -81
- data/docs/features/4_logging.md +0 -64
- data/docs/features/5_event_bus.md +0 -244
- data/docs/features/6_guards.md +0 -356
- data/docs/features/guards_naming_convention.md +0 -540
- data/docs/guides/1_common_patterns.md +0 -90
- data/docs/guides/2_migration_guide.md +0 -175
- data/docs/integration/1_configuration.md +0 -154
- data/docs/integration/2_testing.md +0 -287
- data/docs/integration/3_rails_integration.md +0 -99
- data/docs/yard/Servus/Base.html +0 -1645
- data/docs/yard/Servus/Config.html +0 -582
- data/docs/yard/Servus/Extensions/Async/Call.html +0 -400
- data/docs/yard/Servus/Extensions/Async/Errors/AsyncError.html +0 -140
- data/docs/yard/Servus/Extensions/Async/Errors/JobEnqueueError.html +0 -154
- data/docs/yard/Servus/Extensions/Async/Errors/ServiceNotFoundError.html +0 -154
- data/docs/yard/Servus/Extensions/Async/Errors.html +0 -128
- data/docs/yard/Servus/Extensions/Async/Ext.html +0 -119
- data/docs/yard/Servus/Extensions/Async/Job.html +0 -310
- data/docs/yard/Servus/Extensions/Async.html +0 -141
- data/docs/yard/Servus/Extensions.html +0 -117
- data/docs/yard/Servus/Generators/ServiceGenerator.html +0 -261
- data/docs/yard/Servus/Generators.html +0 -115
- data/docs/yard/Servus/Helpers/ControllerHelpers.html +0 -457
- data/docs/yard/Servus/Helpers.html +0 -115
- data/docs/yard/Servus/Railtie.html +0 -134
- data/docs/yard/Servus/Support/Errors/AuthenticationError.html +0 -287
- data/docs/yard/Servus/Support/Errors/BadRequestError.html +0 -283
- data/docs/yard/Servus/Support/Errors/ForbiddenError.html +0 -284
- data/docs/yard/Servus/Support/Errors/InternalServerError.html +0 -283
- data/docs/yard/Servus/Support/Errors/NotFoundError.html +0 -284
- data/docs/yard/Servus/Support/Errors/ServiceError.html +0 -489
- data/docs/yard/Servus/Support/Errors/ServiceUnavailableError.html +0 -290
- data/docs/yard/Servus/Support/Errors/UnauthorizedError.html +0 -200
- data/docs/yard/Servus/Support/Errors/UnprocessableEntityError.html +0 -288
- data/docs/yard/Servus/Support/Errors/ValidationError.html +0 -200
- data/docs/yard/Servus/Support/Errors.html +0 -140
- data/docs/yard/Servus/Support/Logger.html +0 -856
- data/docs/yard/Servus/Support/Rescuer/BlockContext.html +0 -585
- data/docs/yard/Servus/Support/Rescuer/CallOverride.html +0 -257
- data/docs/yard/Servus/Support/Rescuer/ClassMethods.html +0 -343
- data/docs/yard/Servus/Support/Rescuer.html +0 -267
- data/docs/yard/Servus/Support/Response.html +0 -574
- data/docs/yard/Servus/Support/Validator.html +0 -1150
- data/docs/yard/Servus/Support.html +0 -119
- data/docs/yard/Servus/Testing/ExampleBuilders.html +0 -523
- data/docs/yard/Servus/Testing/ExampleExtractor.html +0 -578
- data/docs/yard/Servus/Testing.html +0 -142
- data/docs/yard/Servus.html +0 -343
- data/docs/yard/_index.html +0 -535
- data/docs/yard/class_list.html +0 -54
- data/docs/yard/css/common.css +0 -1
- data/docs/yard/css/full_list.css +0 -58
- data/docs/yard/css/style.css +0 -503
- data/docs/yard/file.1_common_patterns.html +0 -154
- data/docs/yard/file.1_configuration.html +0 -115
- data/docs/yard/file.1_overview.html +0 -142
- data/docs/yard/file.1_schema_validation.html +0 -188
- data/docs/yard/file.2_architecture.html +0 -157
- data/docs/yard/file.2_error_handling.html +0 -190
- data/docs/yard/file.2_migration_guide.html +0 -242
- data/docs/yard/file.2_testing.html +0 -227
- data/docs/yard/file.3_async_execution.html +0 -145
- data/docs/yard/file.3_rails_integration.html +0 -160
- data/docs/yard/file.3_service_objects.html +0 -191
- data/docs/yard/file.4_logging.html +0 -135
- data/docs/yard/file.ErrorHandling.html +0 -190
- data/docs/yard/file.READme.html +0 -674
- data/docs/yard/file.architecture.html +0 -157
- data/docs/yard/file.async_execution.html +0 -145
- data/docs/yard/file.common_patterns.html +0 -154
- data/docs/yard/file.configuration.html +0 -115
- data/docs/yard/file.error_handling.html +0 -190
- data/docs/yard/file.logging.html +0 -135
- data/docs/yard/file.migration_guide.html +0 -242
- data/docs/yard/file.overview.html +0 -142
- data/docs/yard/file.rails_integration.html +0 -160
- data/docs/yard/file.schema_validation.html +0 -188
- data/docs/yard/file.service_objects.html +0 -191
- data/docs/yard/file.testing.html +0 -227
- data/docs/yard/file_list.html +0 -119
- data/docs/yard/frames.html +0 -22
- data/docs/yard/index.html +0 -674
- data/docs/yard/js/app.js +0 -344
- data/docs/yard/js/full_list.js +0 -242
- data/docs/yard/js/jquery.js +0 -4
- data/docs/yard/method_list.html +0 -542
- data/docs/yard/top-level-namespace.html +0 -110
data/Rakefile
DELETED
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require 'fileutils'
|
|
4
|
-
require 'bundler/gem_tasks'
|
|
5
|
-
require 'rspec/core/rake_task'
|
|
6
|
-
|
|
7
|
-
# Constants
|
|
8
|
-
GEM_NAME = 'servus'
|
|
9
|
-
BUILDS_DIR = 'builds'
|
|
10
|
-
|
|
11
|
-
RSpec::Core::RakeTask.new(:spec)
|
|
12
|
-
|
|
13
|
-
require 'rubocop/rake_task'
|
|
14
|
-
|
|
15
|
-
RuboCop::RakeTask.new
|
|
16
|
-
|
|
17
|
-
# Build gem
|
|
18
|
-
task :build do
|
|
19
|
-
FileUtils.mkdir_p(BUILDS_DIR)
|
|
20
|
-
|
|
21
|
-
# Build gem in current directory
|
|
22
|
-
sh "gem build #{GEM_NAME}.gemspec"
|
|
23
|
-
|
|
24
|
-
# Move to builds directory
|
|
25
|
-
gem_file = Dir["#{GEM_NAME}-*.gem"].first
|
|
26
|
-
if gem_file
|
|
27
|
-
FileUtils.mv(gem_file, BUILDS_DIR)
|
|
28
|
-
puts "Moved #{gem_file} to #{BUILDS_DIR}/"
|
|
29
|
-
end
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
# Install gem locally
|
|
33
|
-
task install: :build do
|
|
34
|
-
gem_file = Dir["#{BUILDS_DIR}/#{GEM_NAME}-*.gem"].max_by { |f| File.mtime(f) }
|
|
35
|
-
sh "gem install #{gem_file}"
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
# Publish gem to RubyGems.org
|
|
39
|
-
task publish: :build do
|
|
40
|
-
gem_file = Dir["#{BUILDS_DIR}/#{GEM_NAME}-*.gem"].max_by { |f| File.mtime(f) }
|
|
41
|
-
puts "Publishing #{gem_file} to RubyGems.org..."
|
|
42
|
-
sh "gem push #{gem_file}"
|
|
43
|
-
end
|
|
44
|
-
|
|
45
|
-
task default: %i[spec rubocop]
|
data/docs/core/1_overview.md
DELETED
|
@@ -1,77 +0,0 @@
|
|
|
1
|
-
# @title Core / 1. Overview
|
|
2
|
-
|
|
3
|
-
# Servus Overview
|
|
4
|
-
|
|
5
|
-
Servus is a lightweight framework for implementing service objects in Ruby applications. It extracts business logic from controllers and models into testable, single-purpose classes with built-in validation, error handling, and logging.
|
|
6
|
-
|
|
7
|
-
## Core Concepts
|
|
8
|
-
|
|
9
|
-
### The Service Pattern
|
|
10
|
-
|
|
11
|
-
Services encapsulate one business operation. Each service inherits from `Servus::Base`, implements `initialize` and `call`, and returns a `Response` object indicating success or failure.
|
|
12
|
-
|
|
13
|
-
```ruby
|
|
14
|
-
class ProcessPayment::Service < Servus::Base
|
|
15
|
-
def initialize(user_id:, amount:)
|
|
16
|
-
@user_id = user_id
|
|
17
|
-
@amount = amount
|
|
18
|
-
end
|
|
19
|
-
|
|
20
|
-
def call
|
|
21
|
-
user = User.find(@user_id)
|
|
22
|
-
return failure("Insufficient funds") unless user.balance >= @amount
|
|
23
|
-
|
|
24
|
-
user.update!(balance: user.balance - @amount)
|
|
25
|
-
success(user: user, new_balance: user.balance)
|
|
26
|
-
end
|
|
27
|
-
end
|
|
28
|
-
|
|
29
|
-
# Usage
|
|
30
|
-
result = ProcessPayment::Service.call(user_id: 1, amount: 50)
|
|
31
|
-
result.success? # => true
|
|
32
|
-
result.data # => { user: #<User>, new_balance: 950 }
|
|
33
|
-
```
|
|
34
|
-
|
|
35
|
-
### Response Objects
|
|
36
|
-
|
|
37
|
-
Services return `Response` objects instead of raising exceptions for business failures. This makes success and failure paths explicit and enables service composition without exception handling.
|
|
38
|
-
|
|
39
|
-
```ruby
|
|
40
|
-
result = SomeService.call(params)
|
|
41
|
-
if result.success?
|
|
42
|
-
result.data # Hash or object returned by success()
|
|
43
|
-
else
|
|
44
|
-
result.error # ServiceError instance
|
|
45
|
-
result.error.message
|
|
46
|
-
result.error.api_error # { code: :symbol, message: "string" }
|
|
47
|
-
end
|
|
48
|
-
```
|
|
49
|
-
|
|
50
|
-
### Optional Schema Validation
|
|
51
|
-
|
|
52
|
-
Services can define JSON schemas for arguments and results. Validation happens automatically before/after execution but is entirely optional.
|
|
53
|
-
|
|
54
|
-
```ruby
|
|
55
|
-
class Service < Servus::Base
|
|
56
|
-
schema(
|
|
57
|
-
arguments: {
|
|
58
|
-
type: "object",
|
|
59
|
-
required: ["user_id", "amount"],
|
|
60
|
-
properties: {
|
|
61
|
-
user_id: { type: "integer" },
|
|
62
|
-
amount: { type: "number", minimum: 0.01 }
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
)
|
|
66
|
-
end
|
|
67
|
-
```
|
|
68
|
-
|
|
69
|
-
## When to Use Servus
|
|
70
|
-
|
|
71
|
-
**Good fits**: Multi-step workflows, operations spanning multiple models, external API calls, background jobs, complex business logic.
|
|
72
|
-
|
|
73
|
-
**Poor fits**: Simple CRUD, single-model operations, operations tightly coupled to one model.
|
|
74
|
-
|
|
75
|
-
## Framework Integration
|
|
76
|
-
|
|
77
|
-
Servus core works in any Ruby application. Rails-specific features (async via ActiveJob, controller helpers, generators) are optional additions. Services work without any configuration - just inherit from `Servus::Base` and implement your logic.
|
data/docs/core/2_architecture.md
DELETED
|
@@ -1,120 +0,0 @@
|
|
|
1
|
-
# @title Core / 2. Architecture
|
|
2
|
-
|
|
3
|
-
# Architecture
|
|
4
|
-
|
|
5
|
-
Servus wraps service execution with automatic validation, logging, and error handling. When you call `Service.call(**args)`, the framework orchestrates these concerns transparently.
|
|
6
|
-
|
|
7
|
-
## Execution Flow
|
|
8
|
-
|
|
9
|
-
```
|
|
10
|
-
Arguments → Validation → Service#call → Result Validation → Event Emission → Logging → Response
|
|
11
|
-
↓ ↓ ↓ ↓
|
|
12
|
-
ValidationError ValidationError EventHandlers Benchmark
|
|
13
|
-
```
|
|
14
|
-
|
|
15
|
-
The framework intercepts the `.call` class method to inject cross-cutting concerns before and after your business logic runs. Your `call` instance method contains only business logic - validation, logging, event emission, and timing happen automatically.
|
|
16
|
-
|
|
17
|
-
## Core Components
|
|
18
|
-
|
|
19
|
-
**Servus::Base** (`lib/servus/base.rb`): Foundation class providing `.call()` orchestration and response helpers (`success`, `failure`, `error!`)
|
|
20
|
-
|
|
21
|
-
**Support::Response** (`lib/servus/support/response.rb`): Immutable result object with `success?`, `data`, and `error` attributes
|
|
22
|
-
|
|
23
|
-
**Support::Validator** (`lib/servus/support/validator.rb`): JSON Schema validation for arguments (before execution) and results (after execution). Schemas are cached after first load.
|
|
24
|
-
|
|
25
|
-
**Support::Logger** (`lib/servus/support/logger.rb`): Automatic logging at DEBUG (calls with args), INFO (success), WARN (failures), ERROR (exceptions)
|
|
26
|
-
|
|
27
|
-
**Support::Rescuer** (`lib/servus/support/rescuer.rb`): Declarative exception handling via `rescue_from` class method
|
|
28
|
-
|
|
29
|
-
**Support::Errors** (`lib/servus/support/errors.rb`): HTTP-aligned error hierarchy (ServiceError, NotFoundError, ValidationError, etc.)
|
|
30
|
-
|
|
31
|
-
**Events::Emitter** (`lib/servus/events/emitter.rb`): DSL for declaring events that services emit on success/failure
|
|
32
|
-
|
|
33
|
-
**Events::Bus** (`lib/servus/events/bus.rb`): Central event router using ActiveSupport::Notifications for thread-safe dispatch
|
|
34
|
-
|
|
35
|
-
**EventHandler** (`lib/servus/event_handler.rb`): Base class for handlers that subscribe to events and invoke services
|
|
36
|
-
|
|
37
|
-
## Extension Points
|
|
38
|
-
|
|
39
|
-
### Schema Validation
|
|
40
|
-
|
|
41
|
-
Use the `schema` DSL method to define JSON Schema validation for arguments and results:
|
|
42
|
-
|
|
43
|
-
```ruby
|
|
44
|
-
class Service < Servus::Base
|
|
45
|
-
schema(
|
|
46
|
-
arguments: { type: "object", required: ["user_id"] },
|
|
47
|
-
result: { type: "object", required: ["user"] }
|
|
48
|
-
)
|
|
49
|
-
end
|
|
50
|
-
```
|
|
51
|
-
|
|
52
|
-
### Declarative Error Handling
|
|
53
|
-
|
|
54
|
-
Use `rescue_from` to convert exceptions into failures. Provide a custom error type or use a block for custom handling.
|
|
55
|
-
|
|
56
|
-
```ruby
|
|
57
|
-
class Service < Servus::Base
|
|
58
|
-
# Default error type
|
|
59
|
-
rescue_from Net::HTTPError, Timeout::Error, use: ServiceUnavailableError
|
|
60
|
-
|
|
61
|
-
# Custom handling with block
|
|
62
|
-
rescue_from ActiveRecord::RecordInvalid do |exception|
|
|
63
|
-
failure("Validation failed: #{exception.message}", type: ValidationError)
|
|
64
|
-
end
|
|
65
|
-
end
|
|
66
|
-
```
|
|
67
|
-
|
|
68
|
-
### Support Classes
|
|
69
|
-
|
|
70
|
-
Create helper classes in `app/services/service_name/support/*.rb`. These are namespaced to your service.
|
|
71
|
-
|
|
72
|
-
```
|
|
73
|
-
app/services/process_payment/
|
|
74
|
-
├── service.rb
|
|
75
|
-
└── support/
|
|
76
|
-
├── payment_gateway.rb
|
|
77
|
-
└── receipt_formatter.rb
|
|
78
|
-
```
|
|
79
|
-
|
|
80
|
-
## Async Execution
|
|
81
|
-
|
|
82
|
-
`Service.call_async(**args)` enqueues execution via ActiveJob. The service runs identically whether called sync or async.
|
|
83
|
-
|
|
84
|
-
```ruby
|
|
85
|
-
ProcessPayment::Service.call_async(
|
|
86
|
-
user_id: 1,
|
|
87
|
-
amount: 50,
|
|
88
|
-
queue: :critical,
|
|
89
|
-
wait: 5.minutes
|
|
90
|
-
)
|
|
91
|
-
```
|
|
92
|
-
|
|
93
|
-
## Event-Driven Architecture
|
|
94
|
-
|
|
95
|
-
Services can emit events that trigger downstream handlers. This decouples services from their side effects.
|
|
96
|
-
|
|
97
|
-
```ruby
|
|
98
|
-
# Service emits events
|
|
99
|
-
class CreateUser::Service < Servus::Base
|
|
100
|
-
emits :user_created, on: :success
|
|
101
|
-
end
|
|
102
|
-
|
|
103
|
-
# Handler reacts to events
|
|
104
|
-
class UserCreatedHandler < Servus::EventHandler
|
|
105
|
-
handles :user_created
|
|
106
|
-
|
|
107
|
-
invoke SendWelcomeEmail::Service, async: true do |payload|
|
|
108
|
-
{ user_id: payload[:user_id] }
|
|
109
|
-
end
|
|
110
|
-
end
|
|
111
|
-
```
|
|
112
|
-
|
|
113
|
-
See {file:docs/features/5_event_bus.md Event Bus} for full documentation.
|
|
114
|
-
|
|
115
|
-
## Performance
|
|
116
|
-
|
|
117
|
-
- Schema loading: Cached per class after first use
|
|
118
|
-
- Validation overhead: ~1-5ms when schemas defined
|
|
119
|
-
- Logging overhead: ~0.1ms per call
|
|
120
|
-
- Total framework overhead: < 10ms per service call
|
|
@@ -1,121 +0,0 @@
|
|
|
1
|
-
# @title Core / 3. Service Objects
|
|
2
|
-
|
|
3
|
-
# Service Objects
|
|
4
|
-
|
|
5
|
-
Service objects encapsulate one business operation into a testable, reusable class. They sit between controllers and models, handling orchestration logic that doesn't belong in either.
|
|
6
|
-
|
|
7
|
-
## The Pattern
|
|
8
|
-
|
|
9
|
-
Services implement two methods: `initialize` (sets up dependencies) and `call` (executes business logic). All services return a `Response` object indicating success or failure.
|
|
10
|
-
|
|
11
|
-
```ruby
|
|
12
|
-
module Users
|
|
13
|
-
module Create
|
|
14
|
-
class Service < Servus::Base
|
|
15
|
-
def initialize(email:, name:)
|
|
16
|
-
@email = email
|
|
17
|
-
@name = name
|
|
18
|
-
end
|
|
19
|
-
|
|
20
|
-
def call
|
|
21
|
-
return failure("Email taken") if User.exists?(email: @email)
|
|
22
|
-
|
|
23
|
-
user = User.create!(email: @email, name: @name)
|
|
24
|
-
send_welcome_email(user)
|
|
25
|
-
|
|
26
|
-
success(user: user)
|
|
27
|
-
end
|
|
28
|
-
end
|
|
29
|
-
end
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
# Usage
|
|
33
|
-
result = Users::Create::Service.call(email: "user@example.com", name: "John")
|
|
34
|
-
result.success? # => true
|
|
35
|
-
result.data[:user] # => #<User>
|
|
36
|
-
```
|
|
37
|
-
|
|
38
|
-
## Service Composition
|
|
39
|
-
|
|
40
|
-
Services can call other services. Use the returned Response to decide whether to continue or propagate the failure.
|
|
41
|
-
|
|
42
|
-
```ruby
|
|
43
|
-
def call
|
|
44
|
-
user_result = Users::Create::Service.call(user_params)
|
|
45
|
-
return user_result unless user_result.success? # propogates result failure
|
|
46
|
-
|
|
47
|
-
account_result = Accounts::Create::Service.call(
|
|
48
|
-
user: user_result.data[:user],
|
|
49
|
-
plan: @plan
|
|
50
|
-
)
|
|
51
|
-
return account_result unless account_result.success? # propogates result failure
|
|
52
|
-
|
|
53
|
-
success(
|
|
54
|
-
user: user_result.data[:user],
|
|
55
|
-
account: account_result.data[:account]
|
|
56
|
-
)
|
|
57
|
-
end
|
|
58
|
-
```
|
|
59
|
-
|
|
60
|
-
## When to Extract to Services
|
|
61
|
-
|
|
62
|
-
**Extract when**:
|
|
63
|
-
- Logic spans multiple models
|
|
64
|
-
- Complex conditional branching
|
|
65
|
-
- External API calls
|
|
66
|
-
- Background processing needed
|
|
67
|
-
- Testing requires extensive setup
|
|
68
|
-
|
|
69
|
-
**Don't extract when**:
|
|
70
|
-
- Simple CRUD operations
|
|
71
|
-
- Single-model updates
|
|
72
|
-
- Logic naturally belongs in model
|
|
73
|
-
|
|
74
|
-
## Directory Structure
|
|
75
|
-
|
|
76
|
-
Each service lives in its own namespace to avoid naming collisions and allow for support classes.
|
|
77
|
-
|
|
78
|
-
```
|
|
79
|
-
app/services/
|
|
80
|
-
├── users/
|
|
81
|
-
│ └── create/
|
|
82
|
-
│ ├── service.rb
|
|
83
|
-
│ └── support/
|
|
84
|
-
│ └── welcome_email.rb
|
|
85
|
-
└── orders/
|
|
86
|
-
└── process/
|
|
87
|
-
├── service.rb
|
|
88
|
-
└── support/
|
|
89
|
-
├── payment_gateway.rb
|
|
90
|
-
└── inventory_updater.rb
|
|
91
|
-
```
|
|
92
|
-
|
|
93
|
-
Support classes are private to their service - they should never be used by other services.
|
|
94
|
-
|
|
95
|
-
## Testing
|
|
96
|
-
|
|
97
|
-
Services are designed for easy testing with explicit inputs and outputs.
|
|
98
|
-
|
|
99
|
-
```ruby
|
|
100
|
-
RSpec.describe Users::Create::Service do
|
|
101
|
-
describe ".call" do
|
|
102
|
-
context "with valid params" do
|
|
103
|
-
it "creates user" do
|
|
104
|
-
result = described_class.call(email: "test@example.com", name: "Test")
|
|
105
|
-
expect(result.success?).to be true
|
|
106
|
-
expect(result.data[:user]).to be_persisted
|
|
107
|
-
end
|
|
108
|
-
end
|
|
109
|
-
|
|
110
|
-
context "with duplicate email" do
|
|
111
|
-
before { create(:user, email: "test@example.com") }
|
|
112
|
-
|
|
113
|
-
it "returns failure" do
|
|
114
|
-
result = described_class.call(email: "test@example.com", name: "Test")
|
|
115
|
-
expect(result.success?).to be false
|
|
116
|
-
expect(result.error.message).to eq("Email taken")
|
|
117
|
-
end
|
|
118
|
-
end
|
|
119
|
-
end
|
|
120
|
-
end
|
|
121
|
-
```
|
|
@@ -1,119 +0,0 @@
|
|
|
1
|
-
# @title Features / 1. Schema Validation
|
|
2
|
-
|
|
3
|
-
# Schema Validation
|
|
4
|
-
|
|
5
|
-
Servus provides optional JSON Schema validation for service arguments and results. Validation is opt-in - services work fine without schemas.
|
|
6
|
-
|
|
7
|
-
## How It Works
|
|
8
|
-
|
|
9
|
-
Define schemas using the `schema` DSL method (recommended) or as constants. The framework validates arguments before execution and results after execution. Invalid data raises `ValidationError`.
|
|
10
|
-
|
|
11
|
-
### Preferred: Schema DSL Method
|
|
12
|
-
|
|
13
|
-
```ruby
|
|
14
|
-
class ProcessPayment::Service < Servus::Base
|
|
15
|
-
schema(
|
|
16
|
-
arguments: {
|
|
17
|
-
type: "object",
|
|
18
|
-
required: ["user_id", "amount"],
|
|
19
|
-
properties: {
|
|
20
|
-
user_id: { type: "integer", example: 123 },
|
|
21
|
-
amount: { type: "number", minimum: 0.01, example: 100.0 }
|
|
22
|
-
}
|
|
23
|
-
},
|
|
24
|
-
result: {
|
|
25
|
-
type: "object",
|
|
26
|
-
required: ["transaction_id", "new_balance"],
|
|
27
|
-
properties: {
|
|
28
|
-
transaction_id: { type: "string", example: "txn_abc123" },
|
|
29
|
-
new_balance: { type: "number", example: 950.0 }
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
)
|
|
33
|
-
end
|
|
34
|
-
```
|
|
35
|
-
|
|
36
|
-
**Pro tip:** Add `example` or `examples` keywords to your schemas. These values can be automatically extracted in tests using `servus_arguments_example()` and `servus_result_example()` helpers. See the [Testing documentation](../integration/testing.md#schema-example-helpers) for details.
|
|
37
|
-
|
|
38
|
-
You can define just one schema if needed:
|
|
39
|
-
|
|
40
|
-
```ruby
|
|
41
|
-
class SendEmail::Service < Servus::Base
|
|
42
|
-
schema arguments: {
|
|
43
|
-
type: "object",
|
|
44
|
-
required: ["email", "subject"],
|
|
45
|
-
properties: {
|
|
46
|
-
email: { type: "string", format: "email" },
|
|
47
|
-
subject: { type: "string" }
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
end
|
|
51
|
-
```
|
|
52
|
-
|
|
53
|
-
### Alternative: Inline Constants
|
|
54
|
-
|
|
55
|
-
Constants are still supported for backwards compatibility:
|
|
56
|
-
|
|
57
|
-
```ruby
|
|
58
|
-
class ProcessPayment::Service < Servus::Base
|
|
59
|
-
ARGUMENTS_SCHEMA = {
|
|
60
|
-
type: "object",
|
|
61
|
-
required: ["user_id", "amount"],
|
|
62
|
-
properties: {
|
|
63
|
-
user_id: { type: "integer" },
|
|
64
|
-
amount: { type: "number", minimum: 0.01 }
|
|
65
|
-
}
|
|
66
|
-
}.freeze
|
|
67
|
-
|
|
68
|
-
RESULT_SCHEMA = {
|
|
69
|
-
type: "object",
|
|
70
|
-
required: ["transaction_id", "new_balance"],
|
|
71
|
-
properties: {
|
|
72
|
-
transaction_id: { type: "string" },
|
|
73
|
-
new_balance: { type: "number" }
|
|
74
|
-
}
|
|
75
|
-
}.freeze
|
|
76
|
-
end
|
|
77
|
-
```
|
|
78
|
-
|
|
79
|
-
## File-Based Schemas
|
|
80
|
-
|
|
81
|
-
For complex schemas, use JSON files instead of inline definitions. Create files at:
|
|
82
|
-
- `app/schemas/services/service_name/arguments.json`
|
|
83
|
-
- `app/schemas/services/service_name/result.json`
|
|
84
|
-
|
|
85
|
-
### Schema Lookup Precedence
|
|
86
|
-
|
|
87
|
-
Servus checks for schemas in this order:
|
|
88
|
-
1. **schema DSL method** (if defined)
|
|
89
|
-
2. **Inline constants** (ARGUMENTS_SCHEMA, RESULT_SCHEMA)
|
|
90
|
-
3. **JSON files** (in schema_root directory)
|
|
91
|
-
|
|
92
|
-
Schemas are cached after first load for performance.
|
|
93
|
-
|
|
94
|
-
## Three Layers of Validation
|
|
95
|
-
|
|
96
|
-
**Schema Validation** (Servus): Type safety and structure at service boundaries
|
|
97
|
-
|
|
98
|
-
**Business Rules** (Service Logic): Domain-specific constraints during execution
|
|
99
|
-
|
|
100
|
-
**Model Validation** (ActiveRecord): Database constraints before persistence
|
|
101
|
-
|
|
102
|
-
Each layer has a different purpose - don't duplicate validation across layers.
|
|
103
|
-
|
|
104
|
-
## Configuration
|
|
105
|
-
|
|
106
|
-
Change the schema file location if needed:
|
|
107
|
-
|
|
108
|
-
```ruby
|
|
109
|
-
# config/initializers/servus.rb
|
|
110
|
-
Servus.configure do |config|
|
|
111
|
-
config.schema_root = Rails.root.join('config/schemas')
|
|
112
|
-
end
|
|
113
|
-
```
|
|
114
|
-
|
|
115
|
-
Clear the schema cache during development when schemas change:
|
|
116
|
-
|
|
117
|
-
```ruby
|
|
118
|
-
Servus::Support::Validator.clear_cache!
|
|
119
|
-
```
|
|
@@ -1,121 +0,0 @@
|
|
|
1
|
-
# @title Features / 2. Error Handling
|
|
2
|
-
|
|
3
|
-
# Error Handling
|
|
4
|
-
|
|
5
|
-
Servus distinguishes between expected business failures (return failure) and unexpected system errors (raise exceptions). This separation makes error handling predictable and explicit.
|
|
6
|
-
|
|
7
|
-
## Failures vs Exceptions
|
|
8
|
-
|
|
9
|
-
**Use `failure()`** for expected business conditions:
|
|
10
|
-
- User not found
|
|
11
|
-
- Insufficient balance
|
|
12
|
-
- Invalid state transition
|
|
13
|
-
|
|
14
|
-
**Use `error!()` or raise** for unexpected system errors:
|
|
15
|
-
- Database connection failure
|
|
16
|
-
- Nil reference error
|
|
17
|
-
- External API timeout
|
|
18
|
-
|
|
19
|
-
Failures return a Response object so callers can handle them. Exceptions halt execution and bubble up.
|
|
20
|
-
|
|
21
|
-
```ruby
|
|
22
|
-
def call
|
|
23
|
-
user = User.find_by(id: user_id)
|
|
24
|
-
return failure("User not found", type: NotFoundError) unless user
|
|
25
|
-
return failure("Insufficient funds") unless user.balance >= amount
|
|
26
|
-
|
|
27
|
-
user.update!(balance: user.balance - amount) # Raises on system error
|
|
28
|
-
success(user: user)
|
|
29
|
-
end
|
|
30
|
-
```
|
|
31
|
-
|
|
32
|
-
## Error Classes
|
|
33
|
-
|
|
34
|
-
All error classes inherit from `ServiceError` and map to HTTP status codes. Use them for API-friendly errors.
|
|
35
|
-
|
|
36
|
-
```ruby
|
|
37
|
-
# Built-in errors
|
|
38
|
-
NotFoundError # 404
|
|
39
|
-
BadRequestError # 400
|
|
40
|
-
UnauthorizedError # 401
|
|
41
|
-
ForbiddenError # 403
|
|
42
|
-
ValidationError # 422
|
|
43
|
-
InternalServerError # 500
|
|
44
|
-
ServiceUnavailableError # 503
|
|
45
|
-
|
|
46
|
-
# Usage
|
|
47
|
-
failure("Resource not found", type: NotFoundError)
|
|
48
|
-
error!("Database corrupted", type: InternalServerError) # Raises exception
|
|
49
|
-
```
|
|
50
|
-
|
|
51
|
-
Each error has an `api_error` method returning `{ code: :symbol, message: "string" }` for JSON APIs.
|
|
52
|
-
|
|
53
|
-
## Declarative Exception Handling
|
|
54
|
-
|
|
55
|
-
Use `rescue_from` to convert specific exceptions into failures. Original exception details are preserved in error messages.
|
|
56
|
-
|
|
57
|
-
```ruby
|
|
58
|
-
class CallExternalApi::Service < Servus::Base
|
|
59
|
-
rescue_from Net::HTTPError, Timeout::Error use: ServiceUnavailableError
|
|
60
|
-
rescue_from JSON::ParserError, use: BadRequestError
|
|
61
|
-
|
|
62
|
-
def call
|
|
63
|
-
response = http_client.get(url) # May raise
|
|
64
|
-
data = JSON.parse(response.body) # May raise
|
|
65
|
-
success(data: data)
|
|
66
|
-
end
|
|
67
|
-
end
|
|
68
|
-
|
|
69
|
-
# If Net::HTTPError is raised, service returns:
|
|
70
|
-
# Response(success: false, error: ServiceUnavailableError("[Net::HTTPError]: original message"))
|
|
71
|
-
```
|
|
72
|
-
|
|
73
|
-
The `rescue_from` pattern keeps business logic clean while ensuring consistent error handling across services.
|
|
74
|
-
|
|
75
|
-
### Custom Error Handling with Blocks
|
|
76
|
-
|
|
77
|
-
For more control over error handling, provide a block to `rescue_from`. The block receives the exception and can return either success or failure:
|
|
78
|
-
|
|
79
|
-
```ruby
|
|
80
|
-
class ProcessPayment::Service < Servus::Base
|
|
81
|
-
# Custom failure with error details
|
|
82
|
-
rescue_from ActiveRecord::RecordInvalid do |exception|
|
|
83
|
-
failure("Payment failed: #{exception.record.errors.full_messages.join(', ')}",
|
|
84
|
-
type: ValidationError)
|
|
85
|
-
end
|
|
86
|
-
|
|
87
|
-
# Recover from certain errors with success
|
|
88
|
-
rescue_from Stripe::CardError do |exception|
|
|
89
|
-
if exception.code == 'card_declined'
|
|
90
|
-
failure("Card was declined", type: BadRequestError)
|
|
91
|
-
else
|
|
92
|
-
# Log and continue for other card errors
|
|
93
|
-
Rails.logger.warn("Stripe error: #{exception.message}")
|
|
94
|
-
success(recovered: true, fallback_used: true)
|
|
95
|
-
end
|
|
96
|
-
end
|
|
97
|
-
|
|
98
|
-
def call
|
|
99
|
-
# Service logic that may raise exceptions
|
|
100
|
-
end
|
|
101
|
-
end
|
|
102
|
-
```
|
|
103
|
-
|
|
104
|
-
The block has access to `success(data)` and `failure(message, type:)` methods. This allows conditional error handling and even recovering from exceptions.
|
|
105
|
-
|
|
106
|
-
## Custom Errors
|
|
107
|
-
|
|
108
|
-
Create domain-specific errors by inheriting from `ServiceError`:
|
|
109
|
-
|
|
110
|
-
```ruby
|
|
111
|
-
class InsufficientFundsError < Servus::Support::Errors::ServiceError
|
|
112
|
-
DEFAULT_MESSAGE = "Insufficient funds"
|
|
113
|
-
|
|
114
|
-
def api_error
|
|
115
|
-
{ code: :insufficient_funds, message: message }
|
|
116
|
-
end
|
|
117
|
-
end
|
|
118
|
-
|
|
119
|
-
# Usage
|
|
120
|
-
failure("Account balance too low", type: InsufficientFundsError)
|
|
121
|
-
```
|
|
@@ -1,81 +0,0 @@
|
|
|
1
|
-
# @title Features / 3. Async Execution
|
|
2
|
-
|
|
3
|
-
# Async Execution
|
|
4
|
-
|
|
5
|
-
Servus provides asynchronous execution via ActiveJob. Services run identically whether called sync or async - they're unaware of execution context.
|
|
6
|
-
|
|
7
|
-
## Usage
|
|
8
|
-
|
|
9
|
-
Call `.call_async(**args)` instead of `.call(**args)` to execute in the background. The service is enqueued immediately and executed by a worker.
|
|
10
|
-
|
|
11
|
-
```ruby
|
|
12
|
-
# Synchronous
|
|
13
|
-
result = ProcessReport::Service.call(user_id: user.id, report_type: :monthly)
|
|
14
|
-
result.data[:report] # Available immediately
|
|
15
|
-
|
|
16
|
-
# Asynchronous
|
|
17
|
-
ProcessReport::Service.call_async(user_id: user.id, report_type: :monthly)
|
|
18
|
-
# Returns true if enqueued successfully
|
|
19
|
-
# Result not available (service hasn't run yet)
|
|
20
|
-
```
|
|
21
|
-
|
|
22
|
-
Services must accept JSON-serializable arguments for async execution (primitives, hashes, arrays, ActiveRecord objects via GlobalID). Complex objects like Procs won't work.
|
|
23
|
-
|
|
24
|
-
## Queue and Scheduling Options
|
|
25
|
-
|
|
26
|
-
Pass ActiveJob options to control execution:
|
|
27
|
-
|
|
28
|
-
```ruby
|
|
29
|
-
ProcessReport::Service.call_async(
|
|
30
|
-
user_id: user.id,
|
|
31
|
-
queue: :critical, # Specify queue
|
|
32
|
-
priority: 10, # Higher priority
|
|
33
|
-
wait: 5.minutes # Delay execution
|
|
34
|
-
)
|
|
35
|
-
```
|
|
36
|
-
|
|
37
|
-
## Result Handling
|
|
38
|
-
|
|
39
|
-
Async services can't return results to callers (the service hasn't executed yet). If you need results, implement persistence in the service:
|
|
40
|
-
|
|
41
|
-
```ruby
|
|
42
|
-
class GenerateReport::Service < Servus::Base
|
|
43
|
-
def call
|
|
44
|
-
report_data = generate_report
|
|
45
|
-
|
|
46
|
-
# Persist result
|
|
47
|
-
Report.create!(
|
|
48
|
-
user_id: @user_id,
|
|
49
|
-
data: report_data,
|
|
50
|
-
status: 'completed'
|
|
51
|
-
)
|
|
52
|
-
|
|
53
|
-
# Optionally notify user
|
|
54
|
-
UserMailer.report_ready(@user_id).deliver_now
|
|
55
|
-
|
|
56
|
-
success(data: report_data)
|
|
57
|
-
end
|
|
58
|
-
end
|
|
59
|
-
|
|
60
|
-
# Controller creates placeholder, service updates it
|
|
61
|
-
report = Report.create!(user_id: user.id, status: 'pending')
|
|
62
|
-
GenerateReport::Service.call_async(user_id: user.id, report_id: report.id)
|
|
63
|
-
```
|
|
64
|
-
|
|
65
|
-
## Error Handling
|
|
66
|
-
|
|
67
|
-
Failures (business logic) don't trigger retries - the job completes successfully but returns a failure Response.
|
|
68
|
-
|
|
69
|
-
Exceptions (system errors) trigger ActiveJob retry logic. Use `rescue_from` to convert transient errors into exceptions:
|
|
70
|
-
|
|
71
|
-
```ruby
|
|
72
|
-
class Service < Servus::Base
|
|
73
|
-
rescue_from Net::HTTPError, Timeout::Error use: ServiceUnavailableError
|
|
74
|
-
end
|
|
75
|
-
```
|
|
76
|
-
|
|
77
|
-
## When to Use Async
|
|
78
|
-
|
|
79
|
-
**Good candidates**: Email sending, report generation, data imports, long-running API calls, cleanup tasks
|
|
80
|
-
|
|
81
|
-
**Poor candidates**: Operations requiring immediate feedback, fast operations (<100ms), critical path operations where user waits for result
|