servus 0.1.2 → 0.1.4

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 (117) hide show
  1. checksums.yaml +4 -4
  2. data/.yardopts +6 -0
  3. data/CHANGELOG.md +8 -1
  4. data/IDEAS.md +5 -0
  5. data/READme.md +147 -42
  6. data/Rakefile +33 -0
  7. data/builds/servus-0.1.2.gem +0 -0
  8. data/builds/servus-0.1.3.gem +0 -0
  9. data/builds/servus-0.1.4.gem +0 -0
  10. data/docs/core/1_overview.md +77 -0
  11. data/docs/core/2_architecture.md +92 -0
  12. data/docs/core/3_service_objects.md +121 -0
  13. data/docs/features/1_schema_validation.md +119 -0
  14. data/docs/features/2_error_handling.md +121 -0
  15. data/docs/features/3_async_execution.md +81 -0
  16. data/docs/features/4_logging.md +64 -0
  17. data/docs/guides/1_common_patterns.md +90 -0
  18. data/docs/guides/2_migration_guide.md +175 -0
  19. data/docs/integration/1_configuration.md +51 -0
  20. data/docs/integration/2_testing.md +164 -0
  21. data/docs/integration/3_rails_integration.md +99 -0
  22. data/docs/yard/Servus/Base.html +1645 -0
  23. data/docs/yard/Servus/Config.html +582 -0
  24. data/docs/yard/Servus/Extensions/Async/Call.html +400 -0
  25. data/docs/yard/Servus/Extensions/Async/Errors/AsyncError.html +140 -0
  26. data/docs/yard/Servus/Extensions/Async/Errors/JobEnqueueError.html +154 -0
  27. data/docs/yard/Servus/Extensions/Async/Errors/ServiceNotFoundError.html +154 -0
  28. data/docs/yard/Servus/Extensions/Async/Errors.html +128 -0
  29. data/docs/yard/Servus/Extensions/Async/Ext.html +119 -0
  30. data/docs/yard/Servus/Extensions/Async/Job.html +310 -0
  31. data/docs/yard/Servus/Extensions/Async.html +141 -0
  32. data/docs/yard/Servus/Extensions.html +117 -0
  33. data/docs/yard/Servus/Generators/ServiceGenerator.html +261 -0
  34. data/docs/yard/Servus/Generators.html +115 -0
  35. data/docs/yard/Servus/Helpers/ControllerHelpers.html +457 -0
  36. data/docs/yard/Servus/Helpers.html +115 -0
  37. data/docs/yard/Servus/Railtie.html +134 -0
  38. data/docs/yard/Servus/Support/Errors/AuthenticationError.html +287 -0
  39. data/docs/yard/Servus/Support/Errors/BadRequestError.html +283 -0
  40. data/docs/yard/Servus/Support/Errors/ForbiddenError.html +284 -0
  41. data/docs/yard/Servus/Support/Errors/InternalServerError.html +283 -0
  42. data/docs/yard/Servus/Support/Errors/NotFoundError.html +284 -0
  43. data/docs/yard/Servus/Support/Errors/ServiceError.html +489 -0
  44. data/docs/yard/Servus/Support/Errors/ServiceUnavailableError.html +290 -0
  45. data/docs/yard/Servus/Support/Errors/UnauthorizedError.html +200 -0
  46. data/docs/yard/Servus/Support/Errors/UnprocessableEntityError.html +288 -0
  47. data/docs/yard/Servus/Support/Errors/ValidationError.html +200 -0
  48. data/docs/yard/Servus/Support/Errors.html +140 -0
  49. data/docs/yard/Servus/Support/Logger.html +856 -0
  50. data/docs/yard/Servus/Support/Rescuer/BlockContext.html +585 -0
  51. data/docs/yard/Servus/Support/Rescuer/CallOverride.html +257 -0
  52. data/docs/yard/Servus/Support/Rescuer/ClassMethods.html +343 -0
  53. data/docs/yard/Servus/Support/Rescuer.html +267 -0
  54. data/docs/yard/Servus/Support/Response.html +574 -0
  55. data/docs/yard/Servus/Support/Validator.html +1150 -0
  56. data/docs/yard/Servus/Support.html +119 -0
  57. data/docs/yard/Servus/Testing/ExampleBuilders.html +523 -0
  58. data/docs/yard/Servus/Testing/ExampleExtractor.html +578 -0
  59. data/docs/yard/Servus/Testing.html +142 -0
  60. data/docs/yard/Servus.html +343 -0
  61. data/docs/yard/_index.html +535 -0
  62. data/docs/yard/class_list.html +54 -0
  63. data/docs/yard/css/common.css +1 -0
  64. data/docs/yard/css/full_list.css +58 -0
  65. data/docs/yard/css/style.css +503 -0
  66. data/docs/yard/file.1_common_patterns.html +154 -0
  67. data/docs/yard/file.1_configuration.html +115 -0
  68. data/docs/yard/file.1_overview.html +142 -0
  69. data/docs/yard/file.1_schema_validation.html +188 -0
  70. data/docs/yard/file.2_architecture.html +157 -0
  71. data/docs/yard/file.2_error_handling.html +190 -0
  72. data/docs/yard/file.2_migration_guide.html +242 -0
  73. data/docs/yard/file.2_testing.html +227 -0
  74. data/docs/yard/file.3_async_execution.html +145 -0
  75. data/docs/yard/file.3_rails_integration.html +160 -0
  76. data/docs/yard/file.3_service_objects.html +191 -0
  77. data/docs/yard/file.4_logging.html +135 -0
  78. data/docs/yard/file.ErrorHandling.html +190 -0
  79. data/docs/yard/file.READme.html +674 -0
  80. data/docs/yard/file.architecture.html +157 -0
  81. data/docs/yard/file.async_execution.html +145 -0
  82. data/docs/yard/file.common_patterns.html +154 -0
  83. data/docs/yard/file.configuration.html +115 -0
  84. data/docs/yard/file.error_handling.html +190 -0
  85. data/docs/yard/file.logging.html +135 -0
  86. data/docs/yard/file.migration_guide.html +242 -0
  87. data/docs/yard/file.overview.html +142 -0
  88. data/docs/yard/file.rails_integration.html +160 -0
  89. data/docs/yard/file.schema_validation.html +188 -0
  90. data/docs/yard/file.service_objects.html +191 -0
  91. data/docs/yard/file.testing.html +227 -0
  92. data/docs/yard/file_list.html +119 -0
  93. data/docs/yard/frames.html +22 -0
  94. data/docs/yard/index.html +674 -0
  95. data/docs/yard/js/app.js +344 -0
  96. data/docs/yard/js/full_list.js +242 -0
  97. data/docs/yard/js/jquery.js +4 -0
  98. data/docs/yard/method_list.html +542 -0
  99. data/docs/yard/top-level-namespace.html +110 -0
  100. data/lib/generators/servus/service/service_generator.rb +64 -1
  101. data/lib/generators/servus/service/templates/service.rb.erb +1 -1
  102. data/lib/servus/base.rb +258 -57
  103. data/lib/servus/config.rb +58 -12
  104. data/lib/servus/extensions/async/call.rb +50 -18
  105. data/lib/servus/extensions/async/errors.rb +23 -3
  106. data/lib/servus/extensions/async/ext.rb +10 -2
  107. data/lib/servus/extensions/async/job.rb +32 -11
  108. data/lib/servus/helpers/controller_helpers.rb +73 -37
  109. data/lib/servus/support/errors.rb +135 -45
  110. data/lib/servus/support/rescuer.rb +189 -36
  111. data/lib/servus/support/response.rb +49 -7
  112. data/lib/servus/support/validator.rb +120 -19
  113. data/lib/servus/testing/example_builders.rb +133 -0
  114. data/lib/servus/testing/example_extractor.rb +309 -0
  115. data/lib/servus/testing.rb +17 -0
  116. data/lib/servus/version.rb +1 -1
  117. metadata +118 -19
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0bddb5c486e4996dff5183d6a9bd57ef086739d7007a12125479d320464a0018
4
- data.tar.gz: 2d948b6202d0888664a75766cf4e9a3c607b4f819e7d9293ac0d0ff8f42c6c32
3
+ metadata.gz: af1900cb767ad43568bf7e2c8c14a98bebc479a9b3ec0cc5d76d5ed3c13c5289
4
+ data.tar.gz: d75379a7b744f9ae6d4938c9f21601af02dbedfe7befbd3b748410aff8b6e7d9
5
5
  SHA512:
6
- metadata.gz: 18a6ffd8d72e5a452bda53b5d91a621d0b963872c08d72b8ee4a533160babfde16ed9690a1f9a0b2c5aa1a4c330f3e6209b9ada2ffdba3635bad9cf0bd351d8e
7
- data.tar.gz: 59b87da3ae33ec0a586275892632b6fe84810b44d75759d9c6c2733fdb9224093d1ed9740467438908eb4a5640b41b6468f1c8381c65cc07adf843e23979fb80
6
+ metadata.gz: 011756b6fc695253bca1cedd31c4f3be14426305ed1d698f1e8963f62734a7ceb19dab0f77c75892b6aef326ce04d2ce6d07b22f97c9622131988ff4c24b9989
7
+ data.tar.gz: 04d948bd35038d768f0786ec253c771c50abc4e8d95d85b39bb2a573b411ee5e4486daec6b460548562e1a87fc5b7b4b3a47ddf2a27105d3ecb3cedd6f09c779
data/.yardopts ADDED
@@ -0,0 +1,6 @@
1
+ --output-dir ./docs/yard
2
+ --readme READme.md
3
+ --files docs/**/*.md
4
+ --markup markdown
5
+ --markup-provider redcarpet
6
+ --title "Servus | Service Object Framework"
data/CHANGELOG.md CHANGED
@@ -1,6 +1,13 @@
1
1
  ## [Unreleased]
2
2
 
3
- ## [0.1.2] - 2025-10-10
3
+ ## [0.1.4] - 2025-11-21
4
+ - Added: Test helpers (`servus_arguments_example` and `servus_result_example`) to extract example values from schemas for testing
5
+ - Added: YARD documentation configuration with README homepage and markdown file support
6
+ - Added: Added `schema` DSL method for cleaner schema definition. Supports `schema arguments: {...}, result: {...}` syntax. Fully backwards compatible with existing `ARGUMENTS_SCHEMA` and `RESULT_SCHEMA` constants.
7
+ - Added: Added support from blocks on `rescue_from` to override default failure handler.
8
+ - Fixed: YARD link resolution warnings in documentation
9
+
10
+ ## [0.1.3] - 2025-10-10
4
11
  - Added: Added `call_async` method to `Servus::Base` to enqueue a job for calling the service asynchronously
5
12
  - Added: Added `Async::Job` to handle async enqueing with support for ActiveJob set options
6
13
 
data/IDEAS.md ADDED
@@ -0,0 +1,5 @@
1
+ 1. Make sure Async Jobs don't retry when the job class cant be constantized.
2
+
3
+ 2. Improve error handling with an error registry that can be referenced by codes as opposed to fully qualified class names.
4
+
5
+ 3. Mock result objects from schema defaults
data/READme.md CHANGED
@@ -171,6 +171,7 @@ Always use the class method `call` instead of manual instantiation. The `call
171
171
  2. Calls the instance-level `call` method
172
172
  3. Handles schema validation of inputs and outputs
173
173
  4. Handles logging of inputs and results
174
+ 5. Automatically benchmarks execution time for performance monitoring
174
175
 
175
176
  ```ruby
176
177
  # Good ✅
@@ -246,25 +247,25 @@ class SomeServiceObject::Service < Servus::Base
246
247
  def call
247
248
  # Return default ServiceError with custom message
248
249
  failure("That didn't work for some reason")
249
- #=> Response(false, nil, ApplicationService::Support::Errors::ServiceError("That didn't work for some reason"))
250
+ #=> Response(false, nil, Servus::Support::Errors::ServiceError("That didn't work for some reason"))
250
251
  #
251
252
  # OR
252
253
  #
253
254
  # Specify ServiceError type with custom message
254
255
  failure("Custom message", type: Servus::Support::Errors::NotFoundError)
255
- #=> Response(false, nil, ApplicationService::Support::Errors::NotFoundError("Custom message"))
256
+ #=> Response(false, nil, Servus::Support::Errors::NotFoundError("Custom message"))
256
257
  #
257
258
  # OR
258
259
  #
259
260
  # Specify ServiceError type with default message
260
261
  failure(type: Servus::Support::Errors::NotFoundError)
261
- #=> Response(false, nil, ApplicationService::Support::Errors::NotFoundError("Record not found"))
262
+ #=> Response(false, nil, Servus::Support::Errors::NotFoundError("Not found"))
262
263
  #
263
264
  # OR
264
265
  #
265
266
  # Accept all defaults
266
267
  failure
267
- #=> Response(false, nil, ApplicationService::Support::Errors::ServiceError("An error occurred"))
268
+ #=> Response(false, nil, Servus::Support::Errors::ServiceError("An error occurred"))
268
269
  end
269
270
  end
270
271
 
@@ -329,6 +330,28 @@ The `rescue_from` method will rescue from the specified errors and use the speci
329
330
  the custom error. It helps eliminate the need to manually rescue many errors and create failure responses within the call method of
330
331
  a service object.
331
332
 
333
+ You can also provide a block for custom error handling:
334
+
335
+ ```ruby
336
+ class SomeServiceObject::Service < Servus::Base
337
+ # Custom error handling with a block
338
+ rescue_from ActiveRecord::RecordInvalid do |exception|
339
+ failure("Validation failed: #{exception.message}", type: ValidationError)
340
+ end
341
+
342
+ rescue_from Net::HTTPError do |exception|
343
+ # Can even return success to recover from errors
344
+ success(recovered: true, error_message: exception.message)
345
+ end
346
+
347
+ def call
348
+ # Service logic
349
+ end
350
+ end
351
+ ```
352
+
353
+ The block receives the exception and has access to `success` and `failure` methods for creating the response.
354
+
332
355
  ## Controller Helpers
333
356
 
334
357
  Service objects can be called from controllers using the `run_service` and `render_service_object_error` helpers.
@@ -437,43 +460,59 @@ Example `result.json`:
437
460
 
438
461
  ### 2. Inline Schema Validation
439
462
 
440
- Alternatively, schemas can be declared directly within the service class using `ARGUMENTS_SCHEMA` and `RESULT_SCHEMA` constants.
463
+ Schemas can be declared directly within the service class using the `schema` DSL method:
441
464
 
442
465
  ```ruby
443
466
  class Services::ProcessPayment::Service < Servus::Base
444
- ARGUMENTS_SCHEMA = {
445
- type: "object",
446
- required: ["user_id", "amount", "payment_method"],
447
- properties: {
448
- user_id: { type: "integer" },
449
- amount: {
450
- type: "integer",
451
- minimum: 1
452
- },
453
- payment_method: {
454
- type: "string",
455
- enum: ["credit_card", "paypal", "bank_transfer"]
456
- },
457
- currency: {
458
- type: "string",
459
- default: "USD"
460
- }
461
- },
462
- additionalProperties: false
463
- }
464
-
465
- RESULT_SCHEMA = {
466
- type: "object",
467
- required: ["transaction_id", "status"],
468
- properties: {
469
- transaction_id: { type: "string" },
470
- status: {
471
- type: "string",
472
- enum: ["approved", "pending", "declined"]
473
- },
474
- receipt_url: { type: "string" }
475
- }
476
- }
467
+ schema(
468
+ arguments: {
469
+ type: "object",
470
+ required: ["user_id", "amount", "payment_method"],
471
+ properties: {
472
+ user_id: { type: "integer" },
473
+ amount: {
474
+ type: "integer",
475
+ minimum: 1
476
+ },
477
+ payment_method: {
478
+ type: "string",
479
+ enum: ["credit_card", "paypal", "bank_transfer"]
480
+ },
481
+ currency: {
482
+ type: "string",
483
+ default: "USD"
484
+ }
485
+ },
486
+ additionalProperties: false
487
+ },
488
+ result: {
489
+ type: "object",
490
+ required: ["transaction_id", "status"],
491
+ properties: {
492
+ transaction_id: { type: "string" },
493
+ status: {
494
+ type: "string",
495
+ enum: ["approved", "pending", "declined"]
496
+ },
497
+ receipt_url: { type: "string" }
498
+ }
499
+ }
500
+ )
501
+
502
+ def initialize(user_id:, amount:, payment_method:, currency: 'USD')
503
+ @user_id = user_id
504
+ @amount = amount
505
+ @payment_method = payment_method
506
+ @currency = currency
507
+ end
508
+
509
+ def call
510
+ # Service logic...
511
+ success({
512
+ transaction_id: "txn_1",
513
+ status: "approved"
514
+ })
515
+ end
477
516
  end
478
517
  ```
479
518
 
@@ -485,9 +524,10 @@ These schemas use JSON Schema format to enforce type safety and input/output con
485
524
 
486
525
  The validation system follows this precedence:
487
526
 
488
- 1. Checks for inline schema constants (`ARGUMENTS_SCHEMA` or `RESULT_SCHEMA`)
489
- 2. Falls back to JSON files if no inline schema is found
490
- 3. Returns nil if neither exists
527
+ 1. Schemas defined via `schema` DSL method (recommended)
528
+ 2. Inline schema constants (`ARGUMENTS_SCHEMA` or `RESULT_SCHEMA`) - legacy support
529
+ 3. JSON files in schema_root directory - legacy support
530
+ 4. Returns nil if no schema is found (validation is opt-in)
491
531
 
492
532
  ### Schema Caching
493
533
 
@@ -495,4 +535,69 @@ Both file-based and inline schemas are automatically cached:
495
535
 
496
536
  - First validation request loads and caches the schema
497
537
  - Subsequent validations use the cached version
498
- - Cache can be cleared using `SchemaValidation.clear_cache!`
538
+ - Cache can be cleared using `Servus::Support::Validator.clear_cache!`
539
+
540
+ ## **Logging**
541
+
542
+ Servus automatically logs service execution details, making it easy to track and debug service calls.
543
+
544
+ ### Automatic Logging
545
+
546
+ Every service call automatically logs:
547
+
548
+ - **Service invocation** with input arguments
549
+ - **Success results** with execution duration
550
+ - **Failure results** with error details and duration
551
+ - **Validation errors** for schema violations
552
+ - **Uncaught exceptions** with error messages
553
+
554
+ ### Logger Configuration
555
+
556
+ The logger automatically adapts to your environment:
557
+
558
+ - **Rails applications**: Uses `Rails.logger`
559
+ - **Non-Rails applications**: Uses stdout logger
560
+
561
+ ### Log Output Examples
562
+
563
+ ```ruby
564
+ # Success
565
+ INFO -- : Calling Services::ProcessPayment::Service with args: {:user_id=>123, :amount=>50}
566
+ INFO -- : Services::ProcessPayment::Service succeeded in 0.245s
567
+
568
+ # Failure
569
+ INFO -- : Calling Services::ProcessPayment::Service with args: {:user_id=>123, :amount=>50}
570
+ WARN -- : Services::ProcessPayment::Service failed in 0.156s with error: Insufficient funds
571
+
572
+ # Validation Error
573
+ ERROR -- : Services::ProcessPayment::Service validation error: The property '#/amount' value -10 was less than minimum value 1
574
+
575
+ # Exception
576
+ ERROR -- : Services::ProcessPayment::Service uncaught exception: NoMethodError - undefined method 'charge' for nil:NilClass
577
+ ```
578
+
579
+ All logging happens transparently when using the class-level `.call` method. This is one of the reasons why direct instantiation (bypassing `.call`) is discouraged.
580
+
581
+ ## **Configuration**
582
+
583
+ Servus can be configured to customize behavior for your application needs.
584
+
585
+ ### Schema Root Directory
586
+
587
+ By default, Servus looks for schema files in `app/schemas/services/`. You can customize this location:
588
+
589
+ ```ruby
590
+ # config/initializers/servus.rb
591
+ Servus.configure do |config|
592
+ config.schema_root = Rails.root.join('lib/schemas')
593
+ end
594
+ ```
595
+
596
+ ### Default Behavior
597
+
598
+ Without explicit configuration:
599
+
600
+ - **Rails applications**: Schema root defaults to `Rails.root/app/schemas/services`
601
+ - **Non-Rails applications**: Schema root defaults to `./app/schemas/services` relative to the gem installation
602
+
603
+ The configuration is accessed through the singleton `Servus.config` instance and can be modified using `Servus.configure`.
data/Rakefile CHANGED
@@ -1,12 +1,45 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'fileutils'
3
4
  require 'bundler/gem_tasks'
4
5
  require 'rspec/core/rake_task'
5
6
 
7
+ # Constants
8
+ GEM_NAME = 'servus'
9
+ BUILDS_DIR = 'builds'
10
+
6
11
  RSpec::Core::RakeTask.new(:spec)
7
12
 
8
13
  require 'rubocop/rake_task'
9
14
 
10
15
  RuboCop::RakeTask.new
11
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
+
12
45
  task default: %i[spec rubocop]
Binary file
Binary file
Binary file
@@ -0,0 +1,77 @@
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.
@@ -0,0 +1,92 @@
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 → Logging → Response
11
+ ↓ ↓ ↓
12
+ ValidationError ValidationError 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, 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
+ ## Extension Points
32
+
33
+ ### Schema Validation
34
+
35
+ Use the `schema` DSL method to define JSON Schema validation for arguments and results:
36
+
37
+ ```ruby
38
+ class Service < Servus::Base
39
+ schema(
40
+ arguments: { type: "object", required: ["user_id"] },
41
+ result: { type: "object", required: ["user"] }
42
+ )
43
+ end
44
+ ```
45
+
46
+ ### Declarative Error Handling
47
+
48
+ Use `rescue_from` to convert exceptions into failures. Provide a custom error type or use a block for custom handling.
49
+
50
+ ```ruby
51
+ class Service < Servus::Base
52
+ # Default error type
53
+ rescue_from Net::HTTPError, Timeout::Error, use: ServiceUnavailableError
54
+
55
+ # Custom handling with block
56
+ rescue_from ActiveRecord::RecordInvalid do |exception|
57
+ failure("Validation failed: #{exception.message}", type: ValidationError)
58
+ end
59
+ end
60
+ ```
61
+
62
+ ### Support Classes
63
+
64
+ Create helper classes in `app/services/service_name/support/*.rb`. These are namespaced to your service.
65
+
66
+ ```
67
+ app/services/process_payment/
68
+ ├── service.rb
69
+ └── support/
70
+ ├── payment_gateway.rb
71
+ └── receipt_formatter.rb
72
+ ```
73
+
74
+ ## Async Execution
75
+
76
+ `Service.call_async(**args)` enqueues execution via ActiveJob. The service runs identically whether called sync or async.
77
+
78
+ ```ruby
79
+ ProcessPayment::Service.call_async(
80
+ user_id: 1,
81
+ amount: 50,
82
+ queue: :critical,
83
+ wait: 5.minutes
84
+ )
85
+ ```
86
+
87
+ ## Performance
88
+
89
+ - Schema loading: Cached per class after first use
90
+ - Validation overhead: ~1-5ms when schemas defined
91
+ - Logging overhead: ~0.1ms per call
92
+ - Total framework overhead: < 10ms per service call
@@ -0,0 +1,121 @@
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
+ ```