railsmith 1.0.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.
Files changed (51) hide show
  1. checksums.yaml +7 -0
  2. data/.tool-versions +1 -0
  3. data/CHANGELOG.md +64 -0
  4. data/LICENSE.txt +21 -0
  5. data/MIGRATION.md +156 -0
  6. data/README.md +249 -0
  7. data/Rakefile +14 -0
  8. data/docs/cookbook.md +605 -0
  9. data/docs/legacy-adoption.md +283 -0
  10. data/docs/quickstart.md +110 -0
  11. data/lib/generators/railsmith/domain/domain_generator.rb +57 -0
  12. data/lib/generators/railsmith/domain/templates/domain.rb.tt +14 -0
  13. data/lib/generators/railsmith/install/install_generator.rb +21 -0
  14. data/lib/generators/railsmith/install/templates/railsmith.rb +10 -0
  15. data/lib/generators/railsmith/model_service/model_service_generator.rb +121 -0
  16. data/lib/generators/railsmith/model_service/templates/model_service.rb.tt +28 -0
  17. data/lib/generators/railsmith/operation/operation_generator.rb +88 -0
  18. data/lib/generators/railsmith/operation/templates/operation.rb.tt +27 -0
  19. data/lib/railsmith/arch_checks/cli.rb +79 -0
  20. data/lib/railsmith/arch_checks/direct_model_access_checker.rb +94 -0
  21. data/lib/railsmith/arch_checks/missing_service_usage_checker.rb +206 -0
  22. data/lib/railsmith/arch_checks/violation.rb +14 -0
  23. data/lib/railsmith/arch_checks.rb +7 -0
  24. data/lib/railsmith/arch_report.rb +96 -0
  25. data/lib/railsmith/base_service/bulk_actions.rb +77 -0
  26. data/lib/railsmith/base_service/bulk_contract.rb +56 -0
  27. data/lib/railsmith/base_service/bulk_execution.rb +68 -0
  28. data/lib/railsmith/base_service/bulk_params.rb +56 -0
  29. data/lib/railsmith/base_service/crud_actions.rb +63 -0
  30. data/lib/railsmith/base_service/crud_error_mapping.rb +78 -0
  31. data/lib/railsmith/base_service/crud_model_resolution.rb +36 -0
  32. data/lib/railsmith/base_service/crud_record_helpers.rb +60 -0
  33. data/lib/railsmith/base_service/crud_transactions.rb +31 -0
  34. data/lib/railsmith/base_service/domain_context_propagation.rb +29 -0
  35. data/lib/railsmith/base_service/dup_helpers.rb +15 -0
  36. data/lib/railsmith/base_service/validation.rb +67 -0
  37. data/lib/railsmith/base_service.rb +96 -0
  38. data/lib/railsmith/configuration.rb +18 -0
  39. data/lib/railsmith/cross_domain_guard.rb +90 -0
  40. data/lib/railsmith/cross_domain_warning_formatter.rb +66 -0
  41. data/lib/railsmith/deep_dup.rb +20 -0
  42. data/lib/railsmith/domain_context.rb +44 -0
  43. data/lib/railsmith/errors.rb +50 -0
  44. data/lib/railsmith/instrumentation.rb +64 -0
  45. data/lib/railsmith/railtie.rb +10 -0
  46. data/lib/railsmith/result.rb +60 -0
  47. data/lib/railsmith/version.rb +5 -0
  48. data/lib/railsmith.rb +31 -0
  49. data/lib/tasks/railsmith.rake +24 -0
  50. data/sig/railsmith.rbs +4 -0
  51. metadata +116 -0
data/docs/cookbook.md ADDED
@@ -0,0 +1,605 @@
1
+ # Railsmith Cookbook
2
+
3
+ Recipes for common patterns. Each section is self-contained.
4
+
5
+ ---
6
+
7
+ ## CRUD
8
+
9
+ ### Default CRUD with no customization
10
+
11
+ ```bash
12
+ rails generate railsmith:model_service Post
13
+ ```
14
+
15
+ ```ruby
16
+ # app/services/operations/post_service.rb
17
+ module Operations
18
+ class PostService < Railsmith::BaseService
19
+ model(Post)
20
+ end
21
+ end
22
+ ```
23
+
24
+ The three default actions work immediately:
25
+
26
+ ```ruby
27
+ # Create
28
+ result = Operations::PostService.call(
29
+ action: :create,
30
+ params: { attributes: { title: "Hello", body: "World" } },
31
+ context: {}
32
+ )
33
+
34
+ # Update
35
+ result = Operations::PostService.call(
36
+ action: :update,
37
+ params: { id: 1, attributes: { title: "Updated" } },
38
+ context: {}
39
+ )
40
+
41
+ # Destroy
42
+ result = Operations::PostService.call(
43
+ action: :destroy,
44
+ params: { id: 1 },
45
+ context: {}
46
+ )
47
+ ```
48
+
49
+ ---
50
+
51
+ ### Customizing attribute extraction
52
+
53
+ Override `sanitize_attributes` to strip or transform attributes before the record is written:
54
+
55
+ ```ruby
56
+ module Operations
57
+ class PostService < Railsmith::BaseService
58
+ model(Post)
59
+
60
+ private
61
+
62
+ def sanitize_attributes(attributes)
63
+ attributes.except(:admin_override, :internal_flag)
64
+ end
65
+ end
66
+ end
67
+ ```
68
+
69
+ Override `attributes_params` to change where attributes are read from:
70
+
71
+ ```ruby
72
+ def attributes_params
73
+ params[:post] # instead of params[:attributes]
74
+ end
75
+ ```
76
+
77
+ ---
78
+
79
+ ### Custom finder logic
80
+
81
+ ```ruby
82
+ module Operations
83
+ class PostService < Railsmith::BaseService
84
+ model(Post)
85
+
86
+ private
87
+
88
+ def find_record(model_klass, id)
89
+ model_klass.published.find_by(id: id)
90
+ end
91
+ end
92
+ end
93
+ ```
94
+
95
+ ---
96
+
97
+ ### Custom action
98
+
99
+ Define a method with the action name. Use `Result` and `Errors` directly:
100
+
101
+ ```ruby
102
+ module Operations
103
+ class PostService < Railsmith::BaseService
104
+ model(Post)
105
+
106
+ def publish
107
+ id = params[:id]
108
+ return Result.failure(error: Errors.validation_error(details: { missing: ["id"] })) unless id
109
+
110
+ post = Post.find_by(id: id)
111
+ return Result.failure(error: Errors.not_found(message: "Post not found", details: { id: id })) unless post
112
+
113
+ return Result.failure(error: Errors.conflict(message: "Already published")) if post.published?
114
+
115
+ post.update!(published_at: Time.current)
116
+ Result.success(value: post)
117
+ end
118
+ end
119
+ end
120
+ ```
121
+
122
+ Call it the same way:
123
+
124
+ ```ruby
125
+ result = Operations::PostService.call(
126
+ action: :publish,
127
+ params: { id: 42 },
128
+ context: {}
129
+ )
130
+ ```
131
+
132
+ ---
133
+
134
+ ### Chaining service calls
135
+
136
+ Pass `context` through to preserve domain tracking:
137
+
138
+ ```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
159
+ end
160
+ end
161
+ ```
162
+
163
+ ---
164
+
165
+ ## Bulk Operations
166
+
167
+ ### bulk_create
168
+
169
+ ```ruby
170
+ result = Operations::UserService.call(
171
+ action: :bulk_create,
172
+ params: {
173
+ items: [
174
+ { name: "Alice", email: "alice@example.com" },
175
+ { name: "Bob", email: "bob@example.com" }
176
+ ]
177
+ },
178
+ context: {}
179
+ )
180
+
181
+ summary = result.value[:summary]
182
+ # => { total: 2, success_count: 2, failure_count: 0, all_succeeded: true }
183
+
184
+ result.value[:items].each do |item|
185
+ if item[:success]
186
+ puts "Created #{item[:value].id}"
187
+ else
188
+ puts "Failed item #{item[:index]}: #{item[:error][:message]}"
189
+ end
190
+ end
191
+ ```
192
+
193
+ ---
194
+
195
+ ### bulk_update
196
+
197
+ Each item must include an `id` key and an `attributes` key:
198
+
199
+ ```ruby
200
+ result = Operations::UserService.call(
201
+ action: :bulk_update,
202
+ params: {
203
+ items: [
204
+ { id: 1, attributes: { name: "Alice Smith" } },
205
+ { id: 2, attributes: { name: "Bob Jones" } }
206
+ ]
207
+ },
208
+ context: {}
209
+ )
210
+ ```
211
+
212
+ ---
213
+
214
+ ### bulk_destroy
215
+
216
+ Pass IDs directly or as hashes:
217
+
218
+ ```ruby
219
+ # Array of IDs
220
+ result = Operations::UserService.call(
221
+ action: :bulk_destroy,
222
+ params: { items: [1, 2, 3] },
223
+ context: {}
224
+ )
225
+
226
+ # Array of hashes
227
+ result = Operations::UserService.call(
228
+ action: :bulk_destroy,
229
+ params: { items: [{ id: 1 }, { id: 2 }] },
230
+ context: {}
231
+ )
232
+ ```
233
+
234
+ ---
235
+
236
+ ### Transaction modes
237
+
238
+ | Mode | Behavior |
239
+ |------|----------|
240
+ | `:best_effort` (default) | Each item in its own transaction. Partial success is persisted. |
241
+ | `:all_or_nothing` | All items in one transaction. Any failure rolls back the entire batch. |
242
+
243
+ ```ruby
244
+ # All-or-nothing import
245
+ result = Operations::UserService.call(
246
+ action: :bulk_create,
247
+ params: {
248
+ items: rows,
249
+ transaction_mode: :all_or_nothing,
250
+ limit: 500,
251
+ batch_size: 50
252
+ },
253
+ context: {}
254
+ )
255
+
256
+ unless result.value[:summary][:all_succeeded]
257
+ # Roll back handled automatically; inspect failures:
258
+ result.value[:items].select { |i| !i[:success] }.each do |i|
259
+ puts "Row #{i[:index]}: #{i[:error][:message]}"
260
+ end
261
+ end
262
+ ```
263
+
264
+ ---
265
+
266
+ ### Bulk result shape
267
+
268
+ ```ruby
269
+ result.value
270
+ # {
271
+ # operation: "bulk_create",
272
+ # transaction_mode: "best_effort",
273
+ # items: [
274
+ # { index: 0, input: {...}, success: true, value: <User>, error: nil },
275
+ # { index: 1, input: {...}, success: false, value: nil, error: {...} }
276
+ # ],
277
+ # summary: {
278
+ # total: 2,
279
+ # success_count: 1,
280
+ # failure_count: 1,
281
+ # all_succeeded: false
282
+ # }
283
+ # }
284
+
285
+ result.meta
286
+ # { model: "User", operation: "bulk_create", transaction_mode: "best_effort", limit: 1000 }
287
+ ```
288
+
289
+ ---
290
+
291
+ ## Domain Context and Boundaries
292
+
293
+ ### Declare a domain
294
+
295
+ ```bash
296
+ rails generate railsmith:domain Billing
297
+ ```
298
+
299
+ Creates `app/domains/billing.rb` and subdirectories `app/domains/billing/operations/` and `app/domains/billing/services/`.
300
+
301
+ ```bash
302
+ rails generate railsmith:model_service Billing::Invoice --domain=Billing
303
+ ```
304
+
305
+ Creates `app/domains/billing/services/invoice_service.rb`:
306
+
307
+ ```ruby
308
+ module Billing
309
+ module Services
310
+ class InvoiceService < Railsmith::BaseService
311
+ model(Billing::Invoice)
312
+ service_domain :billing
313
+ end
314
+ end
315
+ end
316
+ ```
317
+
318
+ ---
319
+
320
+ ### Pass domain context on every call
321
+
322
+ ```ruby
323
+ ctx = Railsmith::DomainContext.new(
324
+ current_domain: :billing,
325
+ meta: { request_id: "req-abc123", actor_id: current_user.id }
326
+ ).to_h
327
+
328
+ result = Billing::Services::InvoiceService.call(
329
+ action: :create,
330
+ params: { attributes: { amount: 100_00, currency: "USD" } },
331
+ context: ctx
332
+ )
333
+ ```
334
+
335
+ `DomainContext#to_h` merges `current_domain` and all `meta` keys into one flat hash, which is what `context:` expects.
336
+
337
+ ---
338
+
339
+ ### Cross-domain detection
340
+
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.
342
+
343
+ ```ruby
344
+ # context says :catalog, service declares :billing — warning fires
345
+ result = Billing::Services::InvoiceService.call(
346
+ action: :create,
347
+ params: { ... },
348
+ context: { current_domain: :catalog }
349
+ )
350
+ ```
351
+
352
+ Subscribe to cross-domain warnings for logging:
353
+
354
+ ```ruby
355
+ Railsmith::Instrumentation.subscribe("cross_domain.warning.railsmith") do |_event, payload|
356
+ Rails.logger.warn("[cross-domain] #{payload.inspect}")
357
+ end
358
+ ```
359
+
360
+ ---
361
+
362
+ ### Configure domain enforcement
363
+
364
+ ```ruby
365
+ # config/initializers/railsmith.rb
366
+ Railsmith.configure do |config|
367
+ # Warn on all cross-domain calls (default: true)
368
+ config.warn_on_cross_domain_calls = true
369
+
370
+ # Strict mode: run a custom hook on every violation
371
+ config.strict_mode = true
372
+ config.on_cross_domain_violation = ->(payload) {
373
+ Honeybadger.notify("Cross-domain call", context: payload)
374
+ }
375
+
376
+ # Allowlist known approved cross-domain pairs
377
+ config.cross_domain_allowlist = [
378
+ { from: :catalog, to: :billing },
379
+ [:shipping, :inventory]
380
+ ]
381
+ end
382
+ ```
383
+
384
+ Allowlisted pairs do not emit warnings.
385
+
386
+ ---
387
+
388
+ ### Generate a domain operation
389
+
390
+ ```bash
391
+ rails generate railsmith:operation Billing::Invoices::Finalize
392
+ ```
393
+
394
+ Creates `app/domains/billing/operations/invoices/finalize.rb`:
395
+
396
+ ```ruby
397
+ 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
417
+ end
418
+ end
419
+ end
420
+ end
421
+ ```
422
+
423
+ ---
424
+
425
+ ## Error Mapping
426
+
427
+ ### Error types
428
+
429
+ | Code | Factory method | Typical trigger |
430
+ |------|---------------|-----------------|
431
+ | `validation_error` | `Errors.validation_error(...)` | ActiveModel validation failure |
432
+ | `not_found` | `Errors.not_found(...)` | `find_by` returns nil |
433
+ | `conflict` | `Errors.conflict(...)` | Duplicate unique constraint |
434
+ | `unauthorized` | `Errors.unauthorized(...)` | Permission check fails |
435
+ | `unexpected` | `Errors.unexpected(...)` | Unhandled exception |
436
+
437
+ ---
438
+
439
+ ### Building errors manually
440
+
441
+ ```ruby
442
+ # Validation
443
+ error = Railsmith::Errors.validation_error(
444
+ message: "Validation failed",
445
+ details: { errors: { email: ["is invalid"], name: ["can't be blank"] } }
446
+ )
447
+
448
+ # Not found
449
+ error = Railsmith::Errors.not_found(
450
+ message: "Invoice not found",
451
+ details: { model: "Invoice", id: 99 }
452
+ )
453
+
454
+ # Conflict
455
+ error = Railsmith::Errors.conflict(
456
+ message: "Email already taken",
457
+ details: { field: "email" }
458
+ )
459
+
460
+ # Unauthorized
461
+ error = Railsmith::Errors.unauthorized(
462
+ message: "Admin access required",
463
+ details: { required_role: "admin" }
464
+ )
465
+
466
+ # Unexpected
467
+ error = Railsmith::Errors.unexpected(
468
+ message: "Stripe API unavailable",
469
+ details: { exception_class: "Stripe::APIConnectionError" }
470
+ )
471
+
472
+ # Wrap in a Result
473
+ result = Railsmith::Result.failure(error: error)
474
+ ```
475
+
476
+ ---
477
+
478
+ ### Automatic exception mapping (built into default CRUD)
479
+
480
+ The default `create`, `update`, and `destroy` actions catch and map these exceptions automatically — you don't need to rescue them yourself:
481
+
482
+ | Exception | Mapped code |
483
+ |-----------|-------------|
484
+ | `ActiveRecord::RecordNotFound` | `not_found` |
485
+ | `ActiveRecord::RecordInvalid` | `validation_error` (with `record.errors`) |
486
+ | `ActiveRecord::RecordNotUnique` | `conflict` |
487
+ | `ActiveRecord::StaleObjectError` | `conflict` |
488
+ | Any other exception | `unexpected` |
489
+
490
+ ---
491
+
492
+ ### Consuming errors in a controller
493
+
494
+ ```ruby
495
+ class UsersController < ApplicationController
496
+ def create
497
+ result = Operations::UserService.call(
498
+ action: :create,
499
+ params: { attributes: user_params },
500
+ context: { current_domain: :identity }
501
+ )
502
+
503
+ if result.success?
504
+ render json: result.value, status: :created
505
+ else
506
+ status = case result.code
507
+ when "validation_error" then :unprocessable_entity
508
+ when "not_found" then :not_found
509
+ when "conflict" then :conflict
510
+ when "unauthorized" then :forbidden
511
+ else :internal_server_error
512
+ end
513
+ render json: result.error.to_h, status: status
514
+ end
515
+ end
516
+
517
+ private
518
+
519
+ def user_params
520
+ params.require(:user).permit(:name, :email)
521
+ end
522
+ end
523
+ ```
524
+
525
+ ---
526
+
527
+ ### Inline validation with required keys
528
+
529
+ ```ruby
530
+ def register
531
+ val = validate(params, required_keys: [:email, :password])
532
+ return val if val.failure?
533
+
534
+ # ... proceed
535
+ end
536
+ ```
537
+
538
+ `validate` returns `Result.failure` with a `validation_error` if any key is missing, or `Result.success` otherwise.
539
+
540
+ ---
541
+
542
+ ## Observability
543
+
544
+ ### Subscribe to service call events
545
+
546
+ ```ruby
547
+ Railsmith::Instrumentation.subscribe("service.call.railsmith") do |_event, payload|
548
+ Rails.logger.info(
549
+ "[railsmith] #{payload[:service]}##{payload[:action]} domain=#{payload[:domain]}"
550
+ )
551
+ end
552
+ ```
553
+
554
+ ### Subscribe to cross-domain warnings
555
+
556
+ ```ruby
557
+ Railsmith::Instrumentation.subscribe("cross_domain.warning.railsmith") do |_event, payload|
558
+ Rails.logger.warn("[railsmith:cross-domain] #{payload.inspect}")
559
+ end
560
+ ```
561
+
562
+ ---
563
+
564
+ ## Architecture Checks
565
+
566
+ Run static analysis to find controllers directly touching models (and actions missing service usage, per the bundled checkers):
567
+
568
+ ```bash
569
+ rake railsmith:arch_check
570
+ ```
571
+
572
+ With options:
573
+
574
+ ```bash
575
+ # JSON output
576
+ RAILSMITH_FORMAT=json rake railsmith:arch_check
577
+
578
+ # Check additional paths
579
+ RAILSMITH_PATHS=app/controllers,app/jobs rake railsmith:arch_check
580
+
581
+ # Fail CI on violations
582
+ RAILSMITH_FAIL_ON_ARCH_VIOLATIONS=true rake railsmith:arch_check
583
+ ```
584
+
585
+ The Rake task wraps `Railsmith::ArchChecks::Cli.run`. Use that when you need the same scan in Ruby (for example, to capture output or run with a custom env hash):
586
+
587
+ ```ruby
588
+ require "railsmith/arch_checks"
589
+ require "stringio"
590
+
591
+ out = StringIO.new
592
+ status = Railsmith::ArchChecks::Cli.run(
593
+ env: ENV.to_h.merge("RAILSMITH_PATHS" => "app/controllers", "RAILSMITH_FORMAT" => "json"),
594
+ output: out
595
+ )
596
+ # status is 0 or 1; report body is out.string
597
+ ```
598
+
599
+ Or set the default fail-on behaviour in the initializer:
600
+
601
+ ```ruby
602
+ Railsmith.configure do |config|
603
+ config.fail_on_arch_violations = true
604
+ end
605
+ ```