serviced 0.1.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/CHANGELOG.md +34 -0
- data/LICENSE.txt +21 -0
- data/README.md +555 -0
- data/lib/serviced/configuration.rb +31 -0
- data/lib/serviced/errors.rb +37 -0
- data/lib/serviced/flow.rb +154 -0
- data/lib/serviced/query.rb +88 -0
- data/lib/serviced/result.rb +158 -0
- data/lib/serviced/result_helpers.rb +26 -0
- data/lib/serviced/service.rb +73 -0
- data/lib/serviced/typed.rb +105 -0
- data/lib/serviced/version.rb +5 -0
- data/lib/serviced.rb +79 -0
- metadata +83 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 846cb3be063fd58cece40a219adb68e678c855e5eada4d0ca5b8bdd97d150804
|
|
4
|
+
data.tar.gz: 98095c54b426771b8fa036c5ac2bcdbb4358700d14877875eaab1f3a672f9e07
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 17a0bea0ddf4bd50cb8b3305fea5e074b3c3d5ea1510c5e3ce2945a74f7bea9ebcd24f0aafaa6ba52d1a5bec73dd5495d2ee2b107fd75c8eb5a046f60c57637c
|
|
7
|
+
data.tar.gz: d55dc5d0189586d8d719053bfb0fa796090078d42054d7b5b828ae1cad0320af15d9114e4b95806a60e8b798222ffa7ac7ff65dcf86b9615dc83ae2c77c18df4
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [0.1.0]
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- `Serviced::Service`: base class for service objects with typed, immutable
|
|
15
|
+
inputs (backed by ActiveModel::Attributes), ActiveModel validations, and a
|
|
16
|
+
mandatory Success/Failure return contract.
|
|
17
|
+
- `Serviced::Result` with `Serviced::Success` and `Serviced::Failure`:
|
|
18
|
+
immutable result objects supporting predicates, `on_success`/`on_failure`
|
|
19
|
+
callbacks, `and_then`/`map` chaining, and pattern matching.
|
|
20
|
+
- `Serviced::Flow`: composes services (or any callable) into a pipeline that
|
|
21
|
+
threads an immutable context, with an optional single transaction.
|
|
22
|
+
- `Serviced::Query`: base class for query objects with the same typed,
|
|
23
|
+
immutable inputs as a service. Returns an `ActiveRecord::Relation` (or a
|
|
24
|
+
value) so results stay composable, ships safe SQL helpers (`quote`,
|
|
25
|
+
`quote_column`, `sanitize`, `count_of`), and raises `Serviced::InvalidQuery`
|
|
26
|
+
on invalid input.
|
|
27
|
+
- `Serviced::Typed`: shared concern providing typed, immutable, validatable
|
|
28
|
+
attributes; included by both `Serviced::Service` and `Serviced::Query`.
|
|
29
|
+
Inputs are isolated by default: value-like data (arrays, hashes, sets,
|
|
30
|
+
strings) is captured as a deep-frozen snapshot at construction, while objects
|
|
31
|
+
with identity (records) are shared by reference. Opt out per attribute with
|
|
32
|
+
`isolate: false`.
|
|
33
|
+
- `Serviced.configure` with a pluggable `transaction_handler` (defaults to
|
|
34
|
+
`ActiveRecord::Base.transaction` when ActiveRecord is available).
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Leonardo Bernardelli
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,555 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="https://raw.githubusercontent.com/lbernardelli/serviced/main/docs/serviced-banner.svg" alt="serviced" width="100%">
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
<p align="center">
|
|
6
|
+
<a href="https://rubygems.org/gems/serviced"><img src="https://img.shields.io/gem/v/serviced?color=CC342D" alt="Gem Version"></a>
|
|
7
|
+
<img src="https://img.shields.io/badge/ruby-%3E%3D%203.1-CC342D" alt="Ruby >= 3.1">
|
|
8
|
+
<a href="LICENSE.txt"><img src="https://img.shields.io/badge/license-MIT-blue" alt="License: MIT"></a>
|
|
9
|
+
</p>
|
|
10
|
+
|
|
11
|
+
**Serviced** organizes your business logic into small, explicit service objects. Inputs are typed, immutable, and validated. Every call returns an honest `Success` or `Failure`. Services compose into flows that run with or without a transaction. That is the whole gem.
|
|
12
|
+
|
|
13
|
+
```ruby
|
|
14
|
+
class RegisterUser < Serviced::Service
|
|
15
|
+
attribute :email, :string
|
|
16
|
+
attribute :password, :string
|
|
17
|
+
attribute :plan, :string, default: "free"
|
|
18
|
+
|
|
19
|
+
validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
|
|
20
|
+
validates :password, length: { minimum: 8 }
|
|
21
|
+
|
|
22
|
+
def call
|
|
23
|
+
success(User.create!(email:, password:, plan:))
|
|
24
|
+
rescue ActiveRecord::RecordNotUnique
|
|
25
|
+
failure(:email_taken, "That email is already registered")
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
RegisterUser.call(email: "ada@example.com", password: "correct horse")
|
|
30
|
+
.on_success { |user| UserMailer.welcome(user).deliver_later }
|
|
31
|
+
.on_failure { |failure| Rails.logger.warn(failure.message) }
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Table of contents
|
|
35
|
+
|
|
36
|
+
- [Why serviced](#why-serviced)
|
|
37
|
+
- [Installation](#installation)
|
|
38
|
+
- [Getting started](#getting-started)
|
|
39
|
+
- [Services](#services)
|
|
40
|
+
- [Typed inputs](#typed-inputs)
|
|
41
|
+
- [Immutability that actually holds](#immutability-that-actually-holds)
|
|
42
|
+
- [Validation](#validation)
|
|
43
|
+
- [Results](#results)
|
|
44
|
+
- [Callbacks, chaining, and pattern matching](#callbacks-chaining-and-pattern-matching)
|
|
45
|
+
- [Flows](#flows)
|
|
46
|
+
- [Transactions](#transactions)
|
|
47
|
+
- [Queries](#queries)
|
|
48
|
+
- [Cookbook](#cookbook)
|
|
49
|
+
- [Configuration](#configuration)
|
|
50
|
+
- [Testing your services](#testing-your-services)
|
|
51
|
+
- [Philosophy](#philosophy)
|
|
52
|
+
- [Contributing](#contributing)
|
|
53
|
+
- [License](#license)
|
|
54
|
+
|
|
55
|
+
## Why serviced
|
|
56
|
+
|
|
57
|
+
Business logic has to live somewhere. Left alone, it spreads across fat controllers and fatter models: params get coerced by hand, validations happen in three places, and the return value of a "service" might be a record, a boolean, `nil`, or an exception depending on the day.
|
|
58
|
+
|
|
59
|
+
```ruby
|
|
60
|
+
# Before: everything in the controller
|
|
61
|
+
def create
|
|
62
|
+
amount = params[:amount].to_i
|
|
63
|
+
return render_error("amount required") if amount <= 0
|
|
64
|
+
|
|
65
|
+
from = Account.find(params[:from_id])
|
|
66
|
+
to = Account.find(params[:to_id])
|
|
67
|
+
return render_error("insufficient funds") if from.balance_cents < amount
|
|
68
|
+
|
|
69
|
+
ActiveRecord::Base.transaction do
|
|
70
|
+
from.update!(balance_cents: from.balance_cents - amount)
|
|
71
|
+
to.update!(balance_cents: to.balance_cents + amount)
|
|
72
|
+
Ledger.create!(from:, to:, amount_cents: amount)
|
|
73
|
+
end
|
|
74
|
+
redirect_to account_path(from)
|
|
75
|
+
rescue => e
|
|
76
|
+
render_error(e.message)
|
|
77
|
+
end
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
```ruby
|
|
81
|
+
# After: the controller delegates, the contract lives in the flow
|
|
82
|
+
def create
|
|
83
|
+
TransferFunds.call(from_id: params[:from_id], to_id: params[:to_id], amount_cents: params[:amount])
|
|
84
|
+
.on_success { |ctx| redirect_to account_path(ctx[:from]) }
|
|
85
|
+
.on_failure { |failure| render_error(failure.message) }
|
|
86
|
+
end
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
The typing, the validation, the branching, and the transaction now live in one named, testable place. That is what serviced gives you:
|
|
90
|
+
|
|
91
|
+
1. **Typed, immutable inputs.** Declared with types, coerced on the way in, frozen afterwards. The contract lives in the service, not the controller.
|
|
92
|
+
2. **A mandatory Success/Failure result.** No guessing what a call returns.
|
|
93
|
+
3. **Composable flows.** Chain services into a pipeline, with or without a single transaction.
|
|
94
|
+
4. **Query objects.** A typed home for the gnarly reads, returning a composable relation.
|
|
95
|
+
|
|
96
|
+
## Installation
|
|
97
|
+
|
|
98
|
+
```ruby
|
|
99
|
+
# Gemfile
|
|
100
|
+
gem "serviced"
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
```sh
|
|
104
|
+
bundle install
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Serviced depends only on `activemodel`, so it works in any Ruby project. The transactional flow feature uses ActiveRecord when it is available (see [Configuration](#configuration)).
|
|
108
|
+
|
|
109
|
+
## Getting started
|
|
110
|
+
|
|
111
|
+
A service declares its inputs, validates them, and implements `#call`. `#call` must return a result, built with the `success` and `failure` helpers.
|
|
112
|
+
|
|
113
|
+
```ruby
|
|
114
|
+
class SubscribeToNewsletter < Serviced::Service
|
|
115
|
+
attribute :email, :string
|
|
116
|
+
|
|
117
|
+
validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
|
|
118
|
+
|
|
119
|
+
def call
|
|
120
|
+
subscriber = Subscriber.find_or_initialize_by(email:)
|
|
121
|
+
return failure(:already_subscribed) if subscriber.persisted?
|
|
122
|
+
|
|
123
|
+
subscriber.save!
|
|
124
|
+
success(subscriber)
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Call it with a hash of attributes. You always get back a result.
|
|
130
|
+
|
|
131
|
+
```ruby
|
|
132
|
+
result = SubscribeToNewsletter.call(email: "grace@example.com")
|
|
133
|
+
|
|
134
|
+
result.success? # => true
|
|
135
|
+
result.value # => #<Subscriber ...>
|
|
136
|
+
|
|
137
|
+
# an invalid call never reaches #call:
|
|
138
|
+
SubscribeToNewsletter.call(email: "nope").failure? # => true
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
## Services
|
|
142
|
+
|
|
143
|
+
### Typed inputs
|
|
144
|
+
|
|
145
|
+
Attributes are declared with the ActiveModel attribute DSL, so string params from a request are coerced to the type you asked for.
|
|
146
|
+
|
|
147
|
+
```ruby
|
|
148
|
+
class BuildReport < Serviced::Service
|
|
149
|
+
attribute :account_id, :integer
|
|
150
|
+
attribute :from, :date
|
|
151
|
+
attribute :limit, :integer, default: 50
|
|
152
|
+
attribute :verbose, :boolean, default: false
|
|
153
|
+
attribute :tags # no type: accepts any object
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
report = BuildReport.new(account_id: "42", from: "2026-01-01", verbose: "1")
|
|
157
|
+
report.account_id # => 42 (Integer)
|
|
158
|
+
report.from # => Date (Date)
|
|
159
|
+
report.verbose # => true (TrueClass)
|
|
160
|
+
report.limit # => 50 (default)
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
Unknown keys are ignored, which is what lets a service drop cleanly into a [flow](#flows) without matching the exact shape of the context.
|
|
164
|
+
|
|
165
|
+
### Immutability that actually holds
|
|
166
|
+
|
|
167
|
+
Inputs are read-only. There is no writer to reassign them:
|
|
168
|
+
|
|
169
|
+
```ruby
|
|
170
|
+
report.limit = 100 # => NoMethodError: private method 'limit=' called
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
More importantly, inputs are **isolated by default**. At construction each value is captured as an immutable snapshot, so a mutation somewhere else in the code cannot reach into your service and change what it already read.
|
|
174
|
+
|
|
175
|
+
```ruby
|
|
176
|
+
class CalculateQuote < Serviced::Service
|
|
177
|
+
attribute :items # array of { name:, cents: }
|
|
178
|
+
|
|
179
|
+
def call
|
|
180
|
+
success(total_cents: items.sum { |item| item[:cents] })
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
cart = [{ name: "Keyboard", cents: 8_900 }]
|
|
185
|
+
quote = CalculateQuote.new(items: cart)
|
|
186
|
+
|
|
187
|
+
cart << { name: "Surprise fee", cents: 9_900 } # a bug elsewhere mutates the caller's array
|
|
188
|
+
quote.items # => [{ name: "Keyboard", cents: 8900 }] the snapshot is untouched
|
|
189
|
+
quote.items << {} # => FrozenError
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
Arrays, hashes, sets, and strings are deep-copied and deep-frozen. Objects with identity (an ActiveRecord record, say) are shared by reference and left alone, because a copy of a record is a different, unsaved object. When you deliberately want to share a mutable value, opt out:
|
|
193
|
+
|
|
194
|
+
```ruby
|
|
195
|
+
class CollectWarnings < Serviced::Service
|
|
196
|
+
attribute :row
|
|
197
|
+
attribute :warnings, isolate: false # shared: the caller reads it back
|
|
198
|
+
|
|
199
|
+
def call
|
|
200
|
+
warnings << "missing email" if row[:email].blank?
|
|
201
|
+
success(row)
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
### Validation
|
|
207
|
+
|
|
208
|
+
Use the full ActiveModel validation DSL. Invalid inputs never reach your `#call`. They short-circuit to a failure whose `reason` is `:invalid` and whose `error` is the `ActiveModel::Errors` object.
|
|
209
|
+
|
|
210
|
+
```ruby
|
|
211
|
+
class CreateProject < Serviced::Service
|
|
212
|
+
attribute :name, :string
|
|
213
|
+
attribute :budget_cents, :integer
|
|
214
|
+
|
|
215
|
+
validates :name, presence: true
|
|
216
|
+
validates :budget_cents, numericality: { greater_than: 0 }
|
|
217
|
+
|
|
218
|
+
def call
|
|
219
|
+
success(Project.create!(name:, budget_cents:))
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
result = CreateProject.call(name: "", budget_cents: -5)
|
|
224
|
+
result.reason # => :invalid
|
|
225
|
+
result.error.full_messages # => ["Name can't be blank", "Budget cents must be greater than 0"]
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
## Results
|
|
229
|
+
|
|
230
|
+
A result is always a `Serviced::Success` or a `Serviced::Failure`, and it is frozen. Here is how a service resolves:
|
|
231
|
+
|
|
232
|
+
```mermaid
|
|
233
|
+
flowchart LR
|
|
234
|
+
IN([".call(attributes)"]) --> T["typed, frozen inputs"]
|
|
235
|
+
T --> V{"valid?"}
|
|
236
|
+
V -- no --> INV["Failure(:invalid)"]
|
|
237
|
+
V -- yes --> C["#call"]
|
|
238
|
+
C --> OK["Success(value)"]
|
|
239
|
+
C --> ERR["Failure(reason, message)"]
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
| Method | Success | Failure |
|
|
243
|
+
| ----------------------- | -------------- | -------------------------------- |
|
|
244
|
+
| `success?` / `failure?` | `true`/`false` | `false`/`true` |
|
|
245
|
+
| `value` | the payload | `nil` |
|
|
246
|
+
| `value!` | the payload | raises `InvalidResultAccess` |
|
|
247
|
+
| `reason` | not defined | a `Symbol` for branching |
|
|
248
|
+
| `message` | not defined | a `String` or `nil` |
|
|
249
|
+
| `error` | not defined | an exception, error object, etc. |
|
|
250
|
+
|
|
251
|
+
Build them inside a service with the `success` and `failure` helpers:
|
|
252
|
+
|
|
253
|
+
```ruby
|
|
254
|
+
success(order) # a value
|
|
255
|
+
success(order: order) # a hash value (feeds a flow context)
|
|
256
|
+
failure(:sold_out) # reason only
|
|
257
|
+
failure(:sold_out, "No tickets left") # reason plus message
|
|
258
|
+
failure(:declined, "Card declined", error: gateway_error)
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
### Callbacks, chaining, and pattern matching
|
|
262
|
+
|
|
263
|
+
```ruby
|
|
264
|
+
# Callbacks return self, so they chain
|
|
265
|
+
result.on_success { |value| ... }.on_failure { |failure| ... }
|
|
266
|
+
|
|
267
|
+
# Railway chaining: runs only while successful
|
|
268
|
+
CreateProject.call(params)
|
|
269
|
+
.and_then { |project| InviteOwner.call(project:) }
|
|
270
|
+
.and_then { |project| SeedDefaultTasks.call(project:) }
|
|
271
|
+
|
|
272
|
+
# Transform a success value
|
|
273
|
+
CreateProject.call(params).map { |project| ProjectPresenter.new(project) }
|
|
274
|
+
|
|
275
|
+
# Pattern matching
|
|
276
|
+
case PlaceOrder.call(cart:)
|
|
277
|
+
in Serviced::Success(value:)
|
|
278
|
+
redirect_to order_path(value)
|
|
279
|
+
in Serviced::Failure(reason: :sold_out)
|
|
280
|
+
redirect_to waitlist_path
|
|
281
|
+
in Serviced::Failure(reason:, message:)
|
|
282
|
+
render_error(message)
|
|
283
|
+
end
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
Controllers get a lot quieter:
|
|
287
|
+
|
|
288
|
+
```ruby
|
|
289
|
+
def create
|
|
290
|
+
PlaceOrder.call(cart: current_cart, payment_token: params[:token])
|
|
291
|
+
.on_success { |ctx| redirect_to order_path(ctx[:order]) }
|
|
292
|
+
.on_failure { |failure| render_error(failure.message, status: status_for(failure.reason)) }
|
|
293
|
+
end
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
## Flows
|
|
297
|
+
|
|
298
|
+
A flow runs steps in order. Each step receives the current context and, on success, contributes a hash that is merged into the context for the next step. The first failing step halts the flow and its failure is returned.
|
|
299
|
+
|
|
300
|
+
```ruby
|
|
301
|
+
class PlaceOrder < Serviced::Flow
|
|
302
|
+
step ValidateCart # success(cart: cart)
|
|
303
|
+
step ReserveStock # reads :cart, success(reservation: ...)
|
|
304
|
+
step CreateOrder # success(order: order)
|
|
305
|
+
step SendReceipt
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
result = PlaceOrder.call(cart: current_cart)
|
|
309
|
+
result.value # => { cart: #<Cart>, reservation: ..., order: #<Order> }
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
```mermaid
|
|
313
|
+
flowchart LR
|
|
314
|
+
CTX([context]) --> A[ValidateCart]
|
|
315
|
+
A -->|success| B[ReserveStock]
|
|
316
|
+
B -->|success| C[CreateOrder]
|
|
317
|
+
C -->|success| D[SendReceipt]
|
|
318
|
+
D -->|success| OK([Success: merged context])
|
|
319
|
+
A -. failure .-> STOP([return the failure])
|
|
320
|
+
B -. failure .-> STOP
|
|
321
|
+
C -. failure .-> STOP
|
|
322
|
+
D -. failure .-> STOP
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
A step is anything that responds to `call(context)` and returns a result, so a lambda works too:
|
|
326
|
+
|
|
327
|
+
```ruby
|
|
328
|
+
class PlaceOrder < Serviced::Flow
|
|
329
|
+
step ValidateCart
|
|
330
|
+
step ->(context) { Serviced::Success.new(context.merge(audited_at: Time.current)) }
|
|
331
|
+
end
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
You can also build a flow inline:
|
|
335
|
+
|
|
336
|
+
```ruby
|
|
337
|
+
PlaceOrder = Serviced::Flow.define do
|
|
338
|
+
step ValidateCart
|
|
339
|
+
step ReserveStock
|
|
340
|
+
end
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
### Transactions
|
|
344
|
+
|
|
345
|
+
Mark a flow `transactional` and every step runs inside one database transaction. If any step returns a failure or raises, the whole thing rolls back. This is the textbook money transfer, done right:
|
|
346
|
+
|
|
347
|
+
```ruby
|
|
348
|
+
class LoadAccounts < Serviced::Service
|
|
349
|
+
attribute :from_id, :integer
|
|
350
|
+
attribute :to_id, :integer
|
|
351
|
+
|
|
352
|
+
def call
|
|
353
|
+
success(from: Account.find(from_id), to: Account.find(to_id))
|
|
354
|
+
end
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
class EnsureFunds < Serviced::Service
|
|
358
|
+
attribute :from
|
|
359
|
+
attribute :amount_cents, :integer
|
|
360
|
+
|
|
361
|
+
def call
|
|
362
|
+
return failure(:insufficient_funds, "Not enough balance") if from.balance_cents < amount_cents
|
|
363
|
+
|
|
364
|
+
success
|
|
365
|
+
end
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
class TransferFunds < Serviced::Flow
|
|
369
|
+
transactional
|
|
370
|
+
|
|
371
|
+
step LoadAccounts
|
|
372
|
+
step EnsureFunds
|
|
373
|
+
step Debit # from.update!(balance_cents: from.balance_cents - amount_cents)
|
|
374
|
+
step Credit # to.update!(balance_cents: to.balance_cents + amount_cents)
|
|
375
|
+
step RecordLedger
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
TransferFunds.call(from_id: 1, to_id: 2, amount_cents: 5_00)
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
If `Credit` fails or `RecordLedger` raises, the debit is rolled back. Expected failures roll back and return the failure. Unexpected exceptions roll back and propagate, so bugs stay visible.
|
|
382
|
+
|
|
383
|
+
## Queries
|
|
384
|
+
|
|
385
|
+
A query object is a typed home for a complex read. It returns an `ActiveRecord::Relation` (or a value) instead of a result, so what comes back stays composable: callers can still paginate it, add includes, or chain more scopes.
|
|
386
|
+
|
|
387
|
+
```ruby
|
|
388
|
+
class OverdueInvoicesQuery < Serviced::Query
|
|
389
|
+
attribute :account
|
|
390
|
+
attribute :as_of, :date
|
|
391
|
+
attribute :min_cents, :integer, default: 0
|
|
392
|
+
|
|
393
|
+
validates :as_of, presence: true
|
|
394
|
+
|
|
395
|
+
def call
|
|
396
|
+
account.invoices
|
|
397
|
+
.unpaid
|
|
398
|
+
.where(total_cents: min_cents..)
|
|
399
|
+
.where(due_on: ...as_of)
|
|
400
|
+
.order(:due_on)
|
|
401
|
+
end
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
relation = OverdueInvoicesQuery.call(account:, as_of: Date.current)
|
|
405
|
+
relation.page(params[:page]).per(25) # still a relation
|
|
406
|
+
```
|
|
407
|
+
|
|
408
|
+
Invalid inputs raise `Serviced::InvalidQuery` (a query has no failure channel, so bad input is a bug). For queries that drop into raw SQL, the base ships safe helpers so you stop hand-rolling `connection.quote`:
|
|
409
|
+
|
|
410
|
+
```ruby
|
|
411
|
+
class RankedProductsQuery < Serviced::Query
|
|
412
|
+
attribute :scope
|
|
413
|
+
attribute :order_by, :string, default: "created_at"
|
|
414
|
+
|
|
415
|
+
def call
|
|
416
|
+
scope.order(Arel.sql("#{quote_column(order_by)} DESC"))
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
def total
|
|
420
|
+
count_of(scope) # SELECT COUNT(*) FROM (<scope>) without loading rows
|
|
421
|
+
end
|
|
422
|
+
end
|
|
423
|
+
```
|
|
424
|
+
|
|
425
|
+
Keep the split clean: a query reads and returns a relation, a service does something and returns a result. A service consumes a query and wraps it, which is also how a query reaches a flow (through the service, since a step returns a result).
|
|
426
|
+
|
|
427
|
+
## Cookbook
|
|
428
|
+
|
|
429
|
+
Short, real recipes across common domains.
|
|
430
|
+
|
|
431
|
+
**Onboard a new team (flow).**
|
|
432
|
+
|
|
433
|
+
```ruby
|
|
434
|
+
class OnboardTeam < Serviced::Flow
|
|
435
|
+
step CreateAccount # success(account: account)
|
|
436
|
+
step CreateOwner # reads :account, success(owner: owner)
|
|
437
|
+
step SeedSampleData
|
|
438
|
+
step SendWelcomeEmail
|
|
439
|
+
end
|
|
440
|
+
```
|
|
441
|
+
|
|
442
|
+
**Publish a blog post, atomically (transactional flow).**
|
|
443
|
+
|
|
444
|
+
```ruby
|
|
445
|
+
class PublishPost < Serviced::Flow
|
|
446
|
+
transactional
|
|
447
|
+
|
|
448
|
+
step ValidateDraft
|
|
449
|
+
step RenderMarkdown # success(html: html)
|
|
450
|
+
step MarkPublished # post.update!(published_at: Time.current)
|
|
451
|
+
step PingSearchIndex
|
|
452
|
+
end
|
|
453
|
+
```
|
|
454
|
+
|
|
455
|
+
**Cancel a subscription with a refund (transaction protects both writes).**
|
|
456
|
+
|
|
457
|
+
```ruby
|
|
458
|
+
class CancelSubscription < Serviced::Flow
|
|
459
|
+
transactional
|
|
460
|
+
|
|
461
|
+
step LoadSubscription
|
|
462
|
+
step RefundLastCharge # failure(:refund_failed, ...) rolls everything back
|
|
463
|
+
step EndSubscription
|
|
464
|
+
end
|
|
465
|
+
```
|
|
466
|
+
|
|
467
|
+
**Import a CSV row (service with a shared error sink).**
|
|
468
|
+
|
|
469
|
+
```ruby
|
|
470
|
+
class ImportContactRow < Serviced::Service
|
|
471
|
+
attribute :row
|
|
472
|
+
attribute :errors, isolate: false # shared: the importer collects these
|
|
473
|
+
|
|
474
|
+
validates :row, presence: true
|
|
475
|
+
|
|
476
|
+
def call
|
|
477
|
+
contact = Contact.new(name: row["name"], email: row["email"])
|
|
478
|
+
return failure(:invalid_row, error: contact.errors) unless contact.save
|
|
479
|
+
|
|
480
|
+
success(contact)
|
|
481
|
+
end
|
|
482
|
+
end
|
|
483
|
+
```
|
|
484
|
+
|
|
485
|
+
**Guard, then act (railway chaining without a flow).**
|
|
486
|
+
|
|
487
|
+
```ruby
|
|
488
|
+
AuthorizePayment.call(order:)
|
|
489
|
+
.and_then { |auth| CapturePayment.call(auth:) }
|
|
490
|
+
.and_then { |charge| FulfillOrder.call(charge:) }
|
|
491
|
+
.on_failure { |failure| NotifyOps.call(failure:) }
|
|
492
|
+
```
|
|
493
|
+
|
|
494
|
+
## Configuration
|
|
495
|
+
|
|
496
|
+
Transactional flows need a transaction handler. When ActiveRecord is loaded, serviced defaults to `ActiveRecord::Base.transaction`, so a Rails app needs no setup. To use a different mechanism, configure one:
|
|
497
|
+
|
|
498
|
+
```ruby
|
|
499
|
+
Serviced.configure do |config|
|
|
500
|
+
config.transaction_handler = ->(&block) { Sequel::DATABASE.transaction(&block) }
|
|
501
|
+
end
|
|
502
|
+
```
|
|
503
|
+
|
|
504
|
+
The handler must run the block inside a transaction and roll back when the block raises. If a transactional flow runs without a handler, it raises `Serviced::MissingTransactionHandler`.
|
|
505
|
+
|
|
506
|
+
## Testing your services
|
|
507
|
+
|
|
508
|
+
Services are plain objects with a single entry point, so they are a pleasure to test. Assert on the result, not on side effects you had to reach for.
|
|
509
|
+
|
|
510
|
+
```ruby
|
|
511
|
+
RSpec.describe TransferFunds do
|
|
512
|
+
it "moves money between accounts" do
|
|
513
|
+
from = create(:account, balance_cents: 10_00)
|
|
514
|
+
to = create(:account, balance_cents: 0)
|
|
515
|
+
|
|
516
|
+
result = described_class.call(from_id: from.id, to_id: to.id, amount_cents: 4_00)
|
|
517
|
+
|
|
518
|
+
expect(result).to be_success
|
|
519
|
+
expect(from.reload.balance_cents).to eq(6_00)
|
|
520
|
+
expect(to.reload.balance_cents).to eq(4_00)
|
|
521
|
+
end
|
|
522
|
+
|
|
523
|
+
it "does not move money when funds are short" do
|
|
524
|
+
from = create(:account, balance_cents: 1_00)
|
|
525
|
+
to = create(:account, balance_cents: 0)
|
|
526
|
+
|
|
527
|
+
result = described_class.call(from_id: from.id, to_id: to.id, amount_cents: 9_00)
|
|
528
|
+
|
|
529
|
+
expect(result).to be_failure
|
|
530
|
+
expect(result.reason).to eq(:insufficient_funds)
|
|
531
|
+
expect(from.reload.balance_cents).to eq(1_00) # rolled back
|
|
532
|
+
end
|
|
533
|
+
end
|
|
534
|
+
```
|
|
535
|
+
|
|
536
|
+
## Philosophy
|
|
537
|
+
|
|
538
|
+
- Inputs are read-only, isolated value objects. Compute derived state in `#call`, do not mutate inputs.
|
|
539
|
+
- `#call` must return a result. Returning anything else raises `Serviced::ResultTypeError`, so the contract cannot break by accident.
|
|
540
|
+
- Validation failures are results, not exceptions, keeping the "always returns a result" guarantee. Genuine programming errors still raise.
|
|
541
|
+
- A flow rebuilds its context at each step rather than mutating it, so one step can never quietly change data another step already read.
|
|
542
|
+
- Services and queries share one input engine, `Serviced::Typed`. Include it in any plain object to get the same coerced, immutable, validatable attributes.
|
|
543
|
+
|
|
544
|
+
## Contributing
|
|
545
|
+
|
|
546
|
+
Bug reports and pull requests are welcome. To hack on the gem:
|
|
547
|
+
|
|
548
|
+
```sh
|
|
549
|
+
bin/setup # install dependencies
|
|
550
|
+
bundle exec rake # run the specs and RuboCop
|
|
551
|
+
```
|
|
552
|
+
|
|
553
|
+
## License
|
|
554
|
+
|
|
555
|
+
Released under the [MIT License](LICENSE.txt).
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Serviced
|
|
4
|
+
# Global configuration for Serviced.
|
|
5
|
+
#
|
|
6
|
+
# Serviced.configure do |config|
|
|
7
|
+
# config.transaction_handler = ->(&block) { MyDB.transaction(&block) }
|
|
8
|
+
# end
|
|
9
|
+
class Configuration
|
|
10
|
+
# A callable that runs the given block inside a database transaction. It
|
|
11
|
+
# must execute the block and roll back when the block raises (Serviced
|
|
12
|
+
# relies on this to undo a transactional flow whose step failed).
|
|
13
|
+
#
|
|
14
|
+
# Defaults to +ActiveRecord::Base.transaction+ when ActiveRecord is loaded,
|
|
15
|
+
# otherwise nil.
|
|
16
|
+
# @return [#call, nil]
|
|
17
|
+
attr_accessor :transaction_handler
|
|
18
|
+
|
|
19
|
+
def initialize
|
|
20
|
+
@transaction_handler = default_transaction_handler
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def default_transaction_handler
|
|
26
|
+
return unless defined?(ActiveRecord::Base)
|
|
27
|
+
|
|
28
|
+
->(&block) { ActiveRecord::Base.transaction(&block) }
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Serviced
|
|
4
|
+
# Base class for every error raised by Serviced.
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
# Raised when a service or flow step returns something other than a
|
|
8
|
+
# Serviced::Result from its #call method.
|
|
9
|
+
class ResultTypeError < Error; end
|
|
10
|
+
|
|
11
|
+
# Raised when reading the value of a failure (or otherwise accessing a
|
|
12
|
+
# result in a way its status does not allow).
|
|
13
|
+
class InvalidResultAccess < Error; end
|
|
14
|
+
|
|
15
|
+
# Raised when a {Serviced::Query} is called with inputs that fail its
|
|
16
|
+
# validations. Unlike a service (which returns a Failure), a query has no
|
|
17
|
+
# result channel, so invalid input is treated as a programming error.
|
|
18
|
+
class InvalidQuery < Error
|
|
19
|
+
# @return [ActiveModel::Errors] the validation errors
|
|
20
|
+
attr_reader :errors
|
|
21
|
+
|
|
22
|
+
def initialize(errors)
|
|
23
|
+
@errors = errors
|
|
24
|
+
super("Invalid query input: #{Serviced.error_summary(errors)}")
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Raised when a transactional flow runs but no transaction handler is
|
|
29
|
+
# configured (for example, ActiveRecord is not loaded and nothing was set
|
|
30
|
+
# through Serviced.configure).
|
|
31
|
+
class MissingTransactionHandler < Error; end
|
|
32
|
+
|
|
33
|
+
# Internal signal raised inside a transactional flow to force the underlying
|
|
34
|
+
# transaction to roll back. It never escapes the flow and is not part of the
|
|
35
|
+
# public API.
|
|
36
|
+
class Rollback < Error; end
|
|
37
|
+
end
|