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 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