railsmith 1.0.0 → 1.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.
data/docs/cookbook.md CHANGED
@@ -13,37 +13,37 @@ rails generate railsmith:model_service Post
13
13
  ```
14
14
 
15
15
  ```ruby
16
- # app/services/operations/post_service.rb
17
- module Operations
18
- class PostService < Railsmith::BaseService
19
- model(Post)
20
- end
16
+ # app/services/post_service.rb
17
+ class PostService < Railsmith::BaseService
18
+ model(Post)
21
19
  end
22
20
  ```
23
21
 
24
- The three default actions work immediately:
22
+ The five default actions work immediately (`context:` is optional on all calls):
25
23
 
26
24
  ```ruby
27
25
  # Create
28
- result = Operations::PostService.call(
26
+ result = PostService.call(
29
27
  action: :create,
30
- params: { attributes: { title: "Hello", body: "World" } },
31
- context: {}
28
+ params: { attributes: { title: "Hello", body: "World" } }
32
29
  )
33
30
 
34
31
  # Update
35
- result = Operations::PostService.call(
32
+ result = PostService.call(
36
33
  action: :update,
37
- params: { id: 1, attributes: { title: "Updated" } },
38
- context: {}
34
+ params: { id: 1, attributes: { title: "Updated" } }
39
35
  )
40
36
 
41
37
  # Destroy
42
- result = Operations::PostService.call(
43
- action: :destroy,
44
- params: { id: 1 },
45
- context: {}
46
- )
38
+ result = PostService.call(action: :destroy, params: { id: 1 })
39
+
40
+ # Find a single record by ID
41
+ result = PostService.call(action: :find, params: { id: 1 })
42
+ result.value # => <Post id=1>
43
+
44
+ # List all records
45
+ result = PostService.call(action: :list, params: {})
46
+ result.value # => [<Post>, ...]
47
47
  ```
48
48
 
49
49
  ---
@@ -53,15 +53,13 @@ result = Operations::PostService.call(
53
53
  Override `sanitize_attributes` to strip or transform attributes before the record is written:
54
54
 
55
55
  ```ruby
56
- module Operations
57
- class PostService < Railsmith::BaseService
58
- model(Post)
56
+ class PostService < Railsmith::BaseService
57
+ model(Post)
59
58
 
60
- private
59
+ private
61
60
 
62
- def sanitize_attributes(attributes)
63
- attributes.except(:admin_override, :internal_flag)
64
- end
61
+ def sanitize_attributes(attributes)
62
+ attributes.except(:admin_override, :internal_flag)
65
63
  end
66
64
  end
67
65
  ```
@@ -79,15 +77,28 @@ end
79
77
  ### Custom finder logic
80
78
 
81
79
  ```ruby
82
- module Operations
83
- class PostService < Railsmith::BaseService
84
- model(Post)
80
+ class PostService < Railsmith::BaseService
81
+ model(Post)
85
82
 
86
- private
83
+ private
87
84
 
88
- def find_record(model_klass, id)
89
- model_klass.published.find_by(id: id)
90
- end
85
+ def find_record(model_klass, id)
86
+ model_klass.published.find_by(id: id)
87
+ end
88
+ end
89
+ ```
90
+
91
+ ### Filtering list results
92
+
93
+ Override `list` to apply scopes or filters:
94
+
95
+ ```ruby
96
+ class PostService < Railsmith::BaseService
97
+ model(Post)
98
+
99
+ def list
100
+ posts = Post.where(published: params[:published]).order(:created_at)
101
+ Result.success(value: posts)
91
102
  end
92
103
  end
93
104
  ```
@@ -99,22 +110,20 @@ end
99
110
  Define a method with the action name. Use `Result` and `Errors` directly:
100
111
 
101
112
  ```ruby
102
- module Operations
103
- class PostService < Railsmith::BaseService
104
- model(Post)
113
+ class PostService < Railsmith::BaseService
114
+ model(Post)
105
115
 
106
- def publish
107
- id = params[:id]
108
- return Result.failure(error: Errors.validation_error(details: { missing: ["id"] })) unless id
116
+ def publish
117
+ id = params[:id]
118
+ return Result.failure(error: Errors.validation_error(details: { missing: ["id"] })) unless id
109
119
 
110
- post = Post.find_by(id: id)
111
- return Result.failure(error: Errors.not_found(message: "Post not found", details: { id: id })) unless post
120
+ post = Post.find_by(id: id)
121
+ return Result.failure(error: Errors.not_found(message: "Post not found", details: { id: id })) unless post
112
122
 
113
- return Result.failure(error: Errors.conflict(message: "Already published")) if post.published?
123
+ return Result.failure(error: Errors.conflict(message: "Already published")) if post.published?
114
124
 
115
- post.update!(published_at: Time.current)
116
- Result.success(value: post)
117
- end
125
+ post.update!(published_at: Time.current)
126
+ Result.success(value: post)
118
127
  end
119
128
  end
120
129
  ```
@@ -122,11 +131,7 @@ end
122
131
  Call it the same way:
123
132
 
124
133
  ```ruby
125
- result = Operations::PostService.call(
126
- action: :publish,
127
- params: { id: 42 },
128
- context: {}
129
- )
134
+ result = PostService.call(action: :publish, params: { id: 42 })
130
135
  ```
131
136
 
132
137
  ---
@@ -136,30 +141,29 @@ result = Operations::PostService.call(
136
141
  Pass `context` through to preserve domain tracking:
137
142
 
138
143
  ```ruby
139
- module Operations
140
- class OrderService < Railsmith::BaseService
141
- model(Order)
142
-
143
- def place
144
- # Validate stock via another service
145
- stock_result = Operations::InventoryService.call(
146
- action: :reserve,
147
- params: { sku: params[:sku], qty: params[:qty] },
148
- context: context # forward the same context
149
- )
150
- return stock_result if stock_result.failure?
151
-
152
- result = Operations::OrderService.call(
153
- action: :create,
154
- params: { attributes: { sku: params[:sku], qty: params[:qty] } },
155
- context: context
156
- )
157
- result
158
- end
144
+ class OrderService < Railsmith::BaseService
145
+ model(Order)
146
+
147
+ def place
148
+ # Validate stock via another service
149
+ stock_result = InventoryService.call(
150
+ action: :reserve,
151
+ params: { sku: params[:sku], qty: params[:qty] },
152
+ context: context # forward the same context
153
+ )
154
+ return stock_result if stock_result.failure?
155
+
156
+ OrderService.call(
157
+ action: :create,
158
+ params: { attributes: { sku: params[:sku], qty: params[:qty] } },
159
+ context: context
160
+ )
159
161
  end
160
162
  end
161
163
  ```
162
164
 
165
+ Alternatively, use thread-local context propagation (see [Thread-local context](#thread-local-context-propagation)) so you don't need to thread `context:` through every call.
166
+
163
167
  ---
164
168
 
165
169
  ## Bulk Operations
@@ -167,7 +171,7 @@ end
167
171
  ### bulk_create
168
172
 
169
173
  ```ruby
170
- result = Operations::UserService.call(
174
+ result = UserService.call(
171
175
  action: :bulk_create,
172
176
  params: {
173
177
  items: [
@@ -197,7 +201,7 @@ end
197
201
  Each item must include an `id` key and an `attributes` key:
198
202
 
199
203
  ```ruby
200
- result = Operations::UserService.call(
204
+ result = UserService.call(
201
205
  action: :bulk_update,
202
206
  params: {
203
207
  items: [
@@ -217,14 +221,14 @@ Pass IDs directly or as hashes:
217
221
 
218
222
  ```ruby
219
223
  # Array of IDs
220
- result = Operations::UserService.call(
224
+ result = UserService.call(
221
225
  action: :bulk_destroy,
222
226
  params: { items: [1, 2, 3] },
223
227
  context: {}
224
228
  )
225
229
 
226
230
  # Array of hashes
227
- result = Operations::UserService.call(
231
+ result = UserService.call(
228
232
  action: :bulk_destroy,
229
233
  params: { items: [{ id: 1 }, { id: 2 }] },
230
234
  context: {}
@@ -242,7 +246,7 @@ result = Operations::UserService.call(
242
246
 
243
247
  ```ruby
244
248
  # All-or-nothing import
245
- result = Operations::UserService.call(
249
+ result = UserService.call(
246
250
  action: :bulk_create,
247
251
  params: {
248
252
  items: rows,
@@ -309,7 +313,7 @@ module Billing
309
313
  module Services
310
314
  class InvoiceService < Railsmith::BaseService
311
315
  model(Billing::Invoice)
312
- service_domain :billing
316
+ domain :billing
313
317
  end
314
318
  end
315
319
  end
@@ -317,13 +321,16 @@ end
317
321
 
318
322
  ---
319
323
 
320
- ### Pass domain context on every call
324
+ ### Pass domain context on a call
325
+
326
+ Use `Railsmith::Context` to attach domain and tracing data. Extra keys (`actor_id`, `request_id`, etc.) are top-level — no nested `:meta` hash:
321
327
 
322
328
  ```ruby
323
- ctx = Railsmith::DomainContext.new(
324
- current_domain: :billing,
325
- meta: { request_id: "req-abc123", actor_id: current_user.id }
326
- ).to_h
329
+ ctx = Railsmith::Context.new(
330
+ domain: :billing,
331
+ actor_id: current_user.id
332
+ # request_id is auto-generated as a UUID when omitted
333
+ )
327
334
 
328
335
  result = Billing::Services::InvoiceService.call(
329
336
  action: :create,
@@ -332,13 +339,45 @@ result = Billing::Services::InvoiceService.call(
332
339
  )
333
340
  ```
334
341
 
335
- `DomainContext#to_h` merges `current_domain` and all `meta` keys into one flat hash, which is what `context:` expects.
342
+ To forward an existing request ID (e.g. from an HTTP header):
343
+
344
+ ```ruby
345
+ ctx = Railsmith::Context.new(
346
+ domain: :billing,
347
+ request_id: request.headers["X-Request-Id"],
348
+ actor_id: current_user.id
349
+ )
350
+ ```
351
+
352
+ ---
353
+
354
+ ### Thread-local context propagation
355
+
356
+ Set context once at the edge of a request instead of threading it through every call:
357
+
358
+ ```ruby
359
+ # app/controllers/application_controller.rb
360
+ around_action do |_, block|
361
+ Railsmith::Context.with(domain: :web, actor_id: current_user&.id) { block.call }
362
+ end
363
+ ```
364
+
365
+ Services automatically inherit the thread-local context when no explicit `context:` is passed:
366
+
367
+ ```ruby
368
+ # No context: needed — picked up from Context.with above
369
+ UserService.call(action: :create, params: { attributes: { name: "Alice" } })
370
+ ```
371
+
372
+ Resolution order: **explicit `context:` arg > `Context.current` > auto-built context**.
373
+
374
+ `Context.with` restores the previous value after the block, making it safe for nested calls and concurrent requests.
336
375
 
337
376
  ---
338
377
 
339
378
  ### Cross-domain detection
340
379
 
341
- When `current_domain` in the context differs from a service's declared `service_domain`, Railsmith emits a warning. By default this is non-blocking.
380
+ When the context domain differs from a service's declared `domain`, Railsmith emits a warning. By default this is non-blocking.
342
381
 
343
382
  ```ruby
344
383
  # context says :catalog, service declares :billing — warning fires
@@ -391,35 +430,39 @@ Allowlisted pairs do not emit warnings.
391
430
  rails generate railsmith:operation Billing::Invoices::Finalize
392
431
  ```
393
432
 
394
- Creates `app/domains/billing/operations/invoices/finalize.rb`:
433
+ Creates `app/domains/billing/invoices/finalize.rb`:
395
434
 
396
435
  ```ruby
397
436
  module Billing
398
- module Operations
399
- module Invoices
400
- class Finalize
401
- def self.call(params: {}, context: {})
402
- new(params:, context:).call
403
- end
404
-
405
- attr_reader :params, :context
406
-
407
- def initialize(params:, context:)
408
- @params = Railsmith.deep_dup(params || {})
409
- @context = Railsmith.deep_dup(context || {})
410
- end
411
-
412
- def call
413
- current_domain = Railsmith::DomainContext.normalize_current_domain(context[:current_domain])
414
- # Your logic here
415
- Railsmith::Result.success(value: { current_domain: current_domain })
416
- end
437
+ module Invoices
438
+ class Finalize
439
+ def self.call(params: {}, context: {})
440
+ new(params:, context:).call
441
+ end
442
+
443
+ attr_reader :params, :context
444
+
445
+ def initialize(params:, context:)
446
+ @params = Railsmith.deep_dup(params || {})
447
+ @context = Railsmith::Context.build(context)
448
+ end
449
+
450
+ def call
451
+ Railsmith::Result.success(value: {})
417
452
  end
418
453
  end
419
454
  end
420
455
  end
421
456
  ```
422
457
 
458
+ To keep the old `Operations` interstitial module:
459
+
460
+ ```bash
461
+ rails generate railsmith:operation Billing::Invoices::Finalize --namespace=Operations
462
+ # => app/domains/billing/operations/invoices/finalize.rb
463
+ # => Billing::Operations::Invoices::Finalize
464
+ ```
465
+
423
466
  ---
424
467
 
425
468
  ## Error Mapping
@@ -494,10 +537,10 @@ The default `create`, `update`, and `destroy` actions catch and map these except
494
537
  ```ruby
495
538
  class UsersController < ApplicationController
496
539
  def create
497
- result = Operations::UserService.call(
540
+ result = UserService.call(
498
541
  action: :create,
499
542
  params: { attributes: user_params },
500
- context: { current_domain: :identity }
543
+ context: Railsmith::Context.new(domain: :identity)
501
544
  )
502
545
 
503
546
  if result.success?
@@ -61,7 +61,7 @@ Pick the simplest model from your backlog (few validations, no callbacks). Gener
61
61
  rails generate railsmith:model_service Post
62
62
  ```
63
63
 
64
- Open `app/services/operations/post_service.rb`. For now, leave it as generated — the default CRUD actions are enough for the first replacement.
64
+ Open `app/services/post_service.rb` (or `app/services/operations/post_service.rb` if you passed `--namespace=Operations`). For now, leave it as generated — the default CRUD actions are enough for the first replacement.
65
65
 
66
66
  Find the controller that creates posts. Replace the inline model call:
67
67
 
@@ -83,10 +83,9 @@ end
83
83
 
84
84
  ```ruby
85
85
  def create
86
- result = Operations::PostService.call(
86
+ result = PostService.call(
87
87
  action: :create,
88
- params: { attributes: post_params.to_h },
89
- context: {}
88
+ params: { attributes: post_params.to_h }
90
89
  )
91
90
 
92
91
  if result.success?
@@ -126,20 +125,18 @@ end
126
125
  New service action:
127
126
 
128
127
  ```ruby
129
- # app/services/operations/user_service.rb
130
- module Operations
131
- class UserService < Railsmith::BaseService
132
- model(User)
133
-
134
- def upgrade
135
- id = params[:id]
136
- user = User.find_by(id: id)
137
- return Result.failure(error: Errors.not_found(details: { id: id })) unless user
138
-
139
- user.update!(plan: "paid", upgraded_at: Time.current)
140
- BillingMailer.upgraded(user).deliver_later
141
- Result.success(value: user)
142
- end
128
+ # app/services/user_service.rb
129
+ class UserService < Railsmith::BaseService
130
+ model(User)
131
+
132
+ def upgrade
133
+ id = params[:id]
134
+ user = User.find_by(id: id)
135
+ return Result.failure(error: Errors.not_found(details: { id: id })) unless user
136
+
137
+ user.update!(plan: "paid", upgraded_at: Time.current)
138
+ BillingMailer.upgraded(user).deliver_later
139
+ Result.success(value: user)
143
140
  end
144
141
  end
145
142
  ```
@@ -148,10 +145,9 @@ Controller:
148
145
 
149
146
  ```ruby
150
147
  def upgrade
151
- result = Operations::UserService.call(
148
+ result = UserService.call(
152
149
  action: :upgrade,
153
- params: { id: params[:id] },
154
- context: {}
150
+ params: { id: params[:id] }
155
151
  )
156
152
  result.success? ? redirect_to result.value : redirect_back(fallback_location: root_path)
157
153
  end
@@ -169,14 +165,14 @@ rails generate railsmith:model_service Billing::Invoice --domain=Billing
169
165
  rails generate railsmith:model_service Billing::Payment --domain=Billing
170
166
  ```
171
167
 
172
- Move the service files from `app/services/operations/` to `app/domains/billing/services/` and add `service_domain`:
168
+ Move the service files from `app/services/` (or `app/services/operations/` if you used the old default) into `app/domains/billing/services/` and add `domain`:
173
169
 
174
170
  ```ruby
175
171
  module Billing
176
172
  module Services
177
173
  class InvoiceService < Railsmith::BaseService
178
174
  model(Billing::Invoice)
179
- service_domain :billing
175
+ domain :billing
180
176
  end
181
177
  end
182
178
  end
@@ -231,14 +227,13 @@ Use this checklist per model:
231
227
  **For services**, write isolated unit specs:
232
228
 
233
229
  ```ruby
234
- # spec/services/operations/post_service_spec.rb
235
- RSpec.describe Operations::PostService do
230
+ # spec/services/post_service_spec.rb
231
+ RSpec.describe PostService do
236
232
  describe "#create" do
237
233
  it "creates a post" do
238
234
  result = described_class.call(
239
235
  action: :create,
240
- params: { attributes: { title: "Hello", body: "World" } },
241
- context: {}
236
+ params: { attributes: { title: "Hello", body: "World" } }
242
237
  )
243
238
  expect(result).to be_success
244
239
  expect(result.value).to be_a(Post)
@@ -248,8 +243,7 @@ RSpec.describe Operations::PostService do
248
243
  it "returns validation_error when title is blank" do
249
244
  result = described_class.call(
250
245
  action: :create,
251
- params: { attributes: { title: "", body: "World" } },
252
- context: {}
246
+ params: { attributes: { title: "", body: "World" } }
253
247
  )
254
248
  expect(result).to be_failure
255
249
  expect(result.code).to eq("validation_error")
@@ -274,7 +268,7 @@ return Result.failure(error: Errors.validation_error(
274
268
  ```
275
269
 
276
270
  **Forgetting to forward `context:`.**
277
- When a service calls another service, always pass `context: context` so domain tracking propagates.
271
+ When a service calls another service, pass `context: context` unless you rely on thread-local `Railsmith::Context.with` at the controller edge — then nested calls inherit the same context automatically.
278
272
 
279
273
  **Wrapping service calls in rescue.**
280
274
  Don't. Services return `Result.failure` for all expected error conditions. Rescuing exceptions at the caller layer bypasses error mapping and loses structure.
data/docs/quickstart.md CHANGED
@@ -14,7 +14,7 @@ Then:
14
14
  bundle install
15
15
  ```
16
16
 
17
- **Requirements**: Ruby >= 3.2.0, Rails 7.0–8.x.
17
+ **Requirements**: Ruby >= 3.1.0, Rails 7.0–8.x.
18
18
 
19
19
  ---
20
20
 
@@ -48,27 +48,32 @@ end
48
48
  rails generate railsmith:model_service User
49
49
  ```
50
50
 
51
- Creates `app/services/operations/user_service.rb`:
51
+ Creates `app/services/user_service.rb`:
52
52
 
53
53
  ```ruby
54
- module Operations
55
- class UserService < Railsmith::BaseService
56
- model(User)
57
- end
54
+ class UserService < Railsmith::BaseService
55
+ model(User)
58
56
  end
59
57
  ```
60
58
 
61
- The `model` declaration wires up the three default CRUD actions (`create`, `update`, `destroy`) and the three bulk actions (`bulk_create`, `bulk_update`, `bulk_destroy`) automatically — no extra code needed for standard cases.
59
+ The `model` declaration wires up the default CRUD actions (`create`, `update`, `destroy`, `find`, `list`) and the three bulk actions (`bulk_create`, `bulk_update`, `bulk_destroy`) automatically — no extra code needed for standard cases.
60
+
61
+ To generate under a namespace, pass `--namespace`:
62
+
63
+ ```bash
64
+ rails generate railsmith:model_service User --namespace=Operations
65
+ # => app/services/operations/user_service.rb
66
+ # => module Operations; class UserService
67
+ ```
62
68
 
63
69
  ---
64
70
 
65
71
  ## 4. Make Your First Call
66
72
 
67
73
  ```ruby
68
- result = Operations::UserService.call(
74
+ result = UserService.call(
69
75
  action: :create,
70
- params: { attributes: { name: "Alice", email: "alice@example.com" } },
71
- context: {}
76
+ params: { attributes: { name: "Alice", email: "alice@example.com" } }
72
77
  )
73
78
 
74
79
  if result.success?
@@ -81,6 +86,16 @@ end
81
86
 
82
87
  Every service call returns a `Railsmith::Result`. You never rescue exceptions from service calls — failures surface as structured `Result` objects.
83
88
 
89
+ `context:` is optional. When omitted, Railsmith builds a context automatically (with an auto-generated `request_id`). Pass one explicitly to attach domain, actor, or tracing data:
90
+
91
+ ```ruby
92
+ UserService.call(
93
+ action: :create,
94
+ params: { attributes: { name: "Alice", email: "alice@example.com" } },
95
+ context: Railsmith::Context.new(domain: :identity, actor_id: current_user.id)
96
+ )
97
+ ```
98
+
84
99
  ---
85
100
 
86
101
  ## 5. Result Contract at a Glance
@@ -106,5 +121,6 @@ result.error.to_h # => { code: ..., message: ..., details: ... }
106
121
 
107
122
  ## 6. Next Steps
108
123
 
109
- - **[Cookbook](cookbook.md)** — CRUD customization, bulk operations, domain context, error mapping, custom actions.
124
+ - **[Cookbook](cookbook.md)** — CRUD customization, bulk operations, domain context, thread-local context, error mapping, custom actions.
110
125
  - **[Legacy Adoption Guide](legacy-adoption.md)** — Incrementally migrate an existing Rails app to Railsmith.
126
+ - **[Migration Guide](../MIGRATION.md)** — Upgrade notes from 1.0.0 to 1.1.0.