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
@@ -0,0 +1,283 @@
1
+ # Legacy Adoption Guide
2
+
3
+ How to incrementally introduce Railsmith into an existing Rails application without a big-bang rewrite.
4
+
5
+ ---
6
+
7
+ ## Principles
8
+
9
+ - **No forced migration.** Old code keeps working. Railsmith services live alongside existing code.
10
+ - **Strangler fig.** Wrap old logic inside services one model at a time, then delete the old paths.
11
+ - **Controller is the seam.** A controller action is the safest place to switch from inline model calls to a service call — the rest of the app stays unchanged.
12
+ - **Ship continuously.** Each phase produces a releasable diff. Never accumulate more than one phase of unreleased work.
13
+
14
+ ---
15
+
16
+ ## Phase 0 — Install without touching existing code
17
+
18
+ ```bash
19
+ bundle add railsmith
20
+ rails generate railsmith:install
21
+ ```
22
+
23
+ Commit only the initializer and empty directories. No behavior changes.
24
+
25
+ ```ruby
26
+ # config/initializers/railsmith.rb
27
+ Railsmith.configure do |config|
28
+ config.warn_on_cross_domain_calls = true
29
+ config.strict_mode = false
30
+ config.fail_on_arch_violations = false # keep off until Phase 4
31
+ end
32
+ ```
33
+
34
+ Verify nothing is broken:
35
+
36
+ ```bash
37
+ bundle exec rspec
38
+ ```
39
+
40
+ ---
41
+
42
+ ## Phase 1 — Audit what you have
43
+
44
+ Run the architecture checker in warn-only mode to see where models are accessed directly from controllers:
45
+
46
+ ```bash
47
+ rake railsmith:arch_check
48
+ ```
49
+
50
+ Save the output as a baseline. This list is your migration backlog. Prioritize by risk:
51
+ - High-write models (frequently mutated, complex validations) → migrate first.
52
+ - Read-only models → migrate last or skip.
53
+
54
+ ---
55
+
56
+ ## Phase 2 — Wrap one model at a time
57
+
58
+ Pick the simplest model from your backlog (few validations, no callbacks). Generate its service:
59
+
60
+ ```bash
61
+ rails generate railsmith:model_service Post
62
+ ```
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.
65
+
66
+ Find the controller that creates posts. Replace the inline model call:
67
+
68
+ **Before:**
69
+
70
+ ```ruby
71
+ # app/controllers/posts_controller.rb
72
+ def create
73
+ @post = Post.new(post_params)
74
+ if @post.save
75
+ redirect_to @post
76
+ else
77
+ render :new
78
+ end
79
+ end
80
+ ```
81
+
82
+ **After:**
83
+
84
+ ```ruby
85
+ def create
86
+ result = Operations::PostService.call(
87
+ action: :create,
88
+ params: { attributes: post_params.to_h },
89
+ context: {}
90
+ )
91
+
92
+ if result.success?
93
+ redirect_to result.value
94
+ else
95
+ @post = Post.new(post_params) # re-build for form re-render
96
+ @post.errors.merge!(ActiveModel::Errors.new(@post)) # optional: surface errors
97
+ flash.now[:alert] = result.error.message
98
+ render :new
99
+ end
100
+ end
101
+ ```
102
+
103
+ Test the controller thoroughly. Merge and deploy.
104
+
105
+ Repeat for `update` and `destroy` on the same model before moving to the next.
106
+
107
+ ---
108
+
109
+ ## Phase 3 — Move business logic into services
110
+
111
+ Once a controller is routing through a service, move inline business logic (currently in the controller or model callbacks) into the service as custom actions.
112
+
113
+ **Example — promoting to paid plan:**
114
+
115
+ Old controller:
116
+
117
+ ```ruby
118
+ def upgrade
119
+ @user = User.find(params[:id])
120
+ @user.update!(plan: "paid", upgraded_at: Time.current)
121
+ BillingMailer.upgraded(@user).deliver_later
122
+ redirect_to @user
123
+ end
124
+ ```
125
+
126
+ New service action:
127
+
128
+ ```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
143
+ end
144
+ end
145
+ ```
146
+
147
+ Controller:
148
+
149
+ ```ruby
150
+ def upgrade
151
+ result = Operations::UserService.call(
152
+ action: :upgrade,
153
+ params: { id: params[:id] },
154
+ context: {}
155
+ )
156
+ result.success? ? redirect_to result.value : redirect_back(fallback_location: root_path)
157
+ end
158
+ ```
159
+
160
+ ---
161
+
162
+ ## Phase 4 — Introduce domain boundaries (optional)
163
+
164
+ Once several related models are wrapped, group them into a domain.
165
+
166
+ ```bash
167
+ rails generate railsmith:domain Billing
168
+ rails generate railsmith:model_service Billing::Invoice --domain=Billing
169
+ rails generate railsmith:model_service Billing::Payment --domain=Billing
170
+ ```
171
+
172
+ Move the service files from `app/services/operations/` to `app/domains/billing/services/` and add `service_domain`:
173
+
174
+ ```ruby
175
+ module Billing
176
+ module Services
177
+ class InvoiceService < Railsmith::BaseService
178
+ model(Billing::Invoice)
179
+ service_domain :billing
180
+ end
181
+ end
182
+ end
183
+ ```
184
+
185
+ Update all callers to use the new namespace. The `warn_on_cross_domain_calls` flag will surface any places that call billing services from non-billing contexts, without breaking them.
186
+
187
+ ---
188
+
189
+ ## Phase 5 — Enable architecture enforcement in CI
190
+
191
+ Once the migration is sufficiently complete, turn on the arch check in CI:
192
+
193
+ ```ruby
194
+ # config/initializers/railsmith.rb
195
+ Railsmith.configure do |config|
196
+ config.fail_on_arch_violations = true
197
+ end
198
+ ```
199
+
200
+ Or via environment variable in CI only:
201
+
202
+ ```yaml
203
+ # .github/workflows/ci.yml
204
+ - name: Architecture check
205
+ run: RAILSMITH_FAIL_ON_ARCH_VIOLATIONS=true bundle exec rake railsmith:arch_check
206
+ ```
207
+
208
+ This prevents new direct model access from being introduced into controllers.
209
+
210
+ ---
211
+
212
+ ## Migration checklist
213
+
214
+ Use this checklist per model:
215
+
216
+ - [ ] Generated service with `rails g railsmith:model_service`
217
+ - [ ] Controller `create` replaced with service call
218
+ - [ ] Controller `update` replaced with service call
219
+ - [ ] Controller `destroy` replaced with service call
220
+ - [ ] Custom controller actions moved to named service methods
221
+ - [ ] Old model callbacks reviewed — move to service if business logic
222
+ - [ ] Tests updated: service spec added, controller spec uses service double or real service
223
+ - [ ] `rake railsmith:arch_check` shows no violations for this model
224
+
225
+ ---
226
+
227
+ ## Testing strategy during migration
228
+
229
+ **For controllers under migration**, prefer integration tests that call the real service rather than stubbing it. This catches regressions where the controller and service disagree on the interface.
230
+
231
+ **For services**, write isolated unit specs:
232
+
233
+ ```ruby
234
+ # spec/services/operations/post_service_spec.rb
235
+ RSpec.describe Operations::PostService do
236
+ describe "#create" do
237
+ it "creates a post" do
238
+ result = described_class.call(
239
+ action: :create,
240
+ params: { attributes: { title: "Hello", body: "World" } },
241
+ context: {}
242
+ )
243
+ expect(result).to be_success
244
+ expect(result.value).to be_a(Post)
245
+ expect(result.value.title).to eq("Hello")
246
+ end
247
+
248
+ it "returns validation_error when title is blank" do
249
+ result = described_class.call(
250
+ action: :create,
251
+ params: { attributes: { title: "", body: "World" } },
252
+ context: {}
253
+ )
254
+ expect(result).to be_failure
255
+ expect(result.code).to eq("validation_error")
256
+ end
257
+ end
258
+ end
259
+ ```
260
+
261
+ ---
262
+
263
+ ## Common pitfalls
264
+
265
+ **Returning the AR error object in failure results.**
266
+ The default CRUD actions handle this automatically. In custom actions, build the error explicitly:
267
+
268
+ ```ruby
269
+ # Correct
270
+ return Result.failure(error: Errors.validation_error(
271
+ message: record.errors.full_messages.to_sentence,
272
+ details: { errors: record.errors.to_h }
273
+ ))
274
+ ```
275
+
276
+ **Forgetting to forward `context:`.**
277
+ When a service calls another service, always pass `context: context` so domain tracking propagates.
278
+
279
+ **Wrapping service calls in rescue.**
280
+ Don't. Services return `Result.failure` for all expected error conditions. Rescuing exceptions at the caller layer bypasses error mapping and loses structure.
281
+
282
+ **Moving too much at once.**
283
+ One model, one PR. Smaller diffs are easier to review and easier to revert.
@@ -0,0 +1,110 @@
1
+ # Railsmith Quickstart
2
+
3
+ ## 1. Install
4
+
5
+ Add to your `Gemfile`:
6
+
7
+ ```ruby
8
+ gem "railsmith"
9
+ ```
10
+
11
+ Then:
12
+
13
+ ```bash
14
+ bundle install
15
+ ```
16
+
17
+ **Requirements**: Ruby >= 3.2.0, Rails 7.0–8.x.
18
+
19
+ ---
20
+
21
+ ## 2. Generate the Initializer
22
+
23
+ ```bash
24
+ rails generate railsmith:install
25
+ ```
26
+
27
+ This creates:
28
+
29
+ - `config/initializers/railsmith.rb` — global configuration
30
+ - `app/services/` and `app/services/operations/` — service directories
31
+
32
+ The generated initializer:
33
+
34
+ ```ruby
35
+ # config/initializers/railsmith.rb
36
+ Railsmith.configure do |config|
37
+ config.warn_on_cross_domain_calls = true
38
+ config.strict_mode = false
39
+ config.fail_on_arch_violations = false
40
+ end
41
+ ```
42
+
43
+ ---
44
+
45
+ ## 3. Generate Your First Service
46
+
47
+ ```bash
48
+ rails generate railsmith:model_service User
49
+ ```
50
+
51
+ Creates `app/services/operations/user_service.rb`:
52
+
53
+ ```ruby
54
+ module Operations
55
+ class UserService < Railsmith::BaseService
56
+ model(User)
57
+ end
58
+ end
59
+ ```
60
+
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.
62
+
63
+ ---
64
+
65
+ ## 4. Make Your First Call
66
+
67
+ ```ruby
68
+ result = Operations::UserService.call(
69
+ action: :create,
70
+ params: { attributes: { name: "Alice", email: "alice@example.com" } },
71
+ context: {}
72
+ )
73
+
74
+ if result.success?
75
+ puts "Created user #{result.value.id}"
76
+ else
77
+ puts "Failed: #{result.error.message}"
78
+ puts result.error.details.inspect
79
+ end
80
+ ```
81
+
82
+ Every service call returns a `Railsmith::Result`. You never rescue exceptions from service calls — failures surface as structured `Result` objects.
83
+
84
+ ---
85
+
86
+ ## 5. Result Contract at a Glance
87
+
88
+ ```ruby
89
+ # Success
90
+ result.success? # => true
91
+ result.value # => the returned object (e.g., an ActiveRecord instance)
92
+ result.meta # => optional hash of metadata
93
+ result.to_h
94
+ # => { success: true, value: ..., meta: ... }
95
+
96
+ # Failure
97
+ result.failure? # => true
98
+ result.code # => "not_found" | "validation_error" | "conflict" | "unauthorized" | "unexpected"
99
+ result.error # => Railsmith::Errors::ErrorPayload
100
+ result.error.message # => human-readable string
101
+ result.error.details # => structured hash (model errors, missing keys, etc.)
102
+ result.error.to_h # => { code: ..., message: ..., details: ... }
103
+ ```
104
+
105
+ ---
106
+
107
+ ## 6. Next Steps
108
+
109
+ - **[Cookbook](cookbook.md)** — CRUD customization, bulk operations, domain context, error mapping, custom actions.
110
+ - **[Legacy Adoption Guide](legacy-adoption.md)** — Incrementally migrate an existing Rails app to Railsmith.
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+
5
+ module Railsmith
6
+ module Generators
7
+ # Scaffolds a domain module skeleton under `app/domains`.
8
+ class DomainGenerator < Rails::Generators::NamedBase
9
+ source_root File.expand_path("templates", __dir__)
10
+
11
+ class_option :output_path,
12
+ type: :string,
13
+ default: "app/domains",
14
+ desc: "Base path where domains are generated"
15
+
16
+ def create_domain_module
17
+ return if skip_existing_file?(target_file)
18
+
19
+ create_domain_directories
20
+ template "domain.rb.tt", target_file
21
+ end
22
+
23
+ private
24
+
25
+ def skip_existing_file?(relative_path)
26
+ absolute = File.join(destination_root, relative_path)
27
+ return false unless File.exist?(absolute)
28
+ return false if options[:force]
29
+
30
+ say_status(
31
+ :skip,
32
+ "#{relative_path} already exists (use --force to overwrite)",
33
+ :yellow
34
+ )
35
+ true
36
+ end
37
+
38
+ def create_domain_directories
39
+ empty_directory domain_directory
40
+ empty_directory File.join(domain_directory, "operations")
41
+ empty_directory File.join(domain_directory, "services")
42
+ end
43
+
44
+ def target_file
45
+ File.join(options.fetch(:output_path), "#{file_path}.rb")
46
+ end
47
+
48
+ def domain_directory
49
+ File.join(options.fetch(:output_path), file_path)
50
+ end
51
+
52
+ def domain_modules
53
+ class_name.split("::")
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ <% domain_modules.each do |mod| -%>
4
+ module <%= mod %>
5
+ <% end -%>
6
+ module Operations
7
+ end
8
+
9
+ module Services
10
+ end
11
+ <% (domain_modules.length - 1).downto(0) do -%>
12
+ end
13
+ <% end -%>
14
+
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+
5
+ module Railsmith
6
+ module Generators
7
+ # Installs initializer and base service directories in host app.
8
+ class InstallGenerator < Rails::Generators::Base
9
+ source_root File.expand_path("templates", __dir__)
10
+
11
+ def create_initializer
12
+ template "railsmith.rb", "config/initializers/railsmith.rb"
13
+ end
14
+
15
+ def create_service_directories
16
+ empty_directory "app/services"
17
+ empty_directory "app/services/operations"
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ Railsmith.configure do |config|
4
+ config.warn_on_cross_domain_calls = true
5
+ config.strict_mode = false
6
+ config.fail_on_arch_violations = false # set true (or use RAILSMITH_FAIL_ON_ARCH_VIOLATIONS) to fail CI on arch checks
7
+ # Approved context_domain → service_domain pairs, e.g.:
8
+ # config.cross_domain_allowlist = [{ from: :billing, to: :catalog }]
9
+ config.on_cross_domain_violation = nil # optional Proc, called on each violation when strict_mode is true
10
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "active_support/core_ext/string/inflections"
5
+
6
+ module Railsmith
7
+ module Generators
8
+ # Scaffolds a service class for a given model constant.
9
+ #
10
+ # Default mode:
11
+ # - Generates into `app/services/operations`
12
+ # - Namespace becomes `Operations::<ModelNamespace>::<Model>Service`
13
+ #
14
+ # Domain mode (when --domain is provided):
15
+ # - Generates into `app/domains/<domain>/services`
16
+ # - Namespace becomes `<Domain>::Services::<ModelNamespaceWithoutDomain>::<Model>Service`
17
+ class ModelServiceGenerator < Rails::Generators::NamedBase
18
+ source_root File.expand_path("templates", __dir__)
19
+
20
+ class_option :output_path,
21
+ type: :string,
22
+ default: "app/services/operations",
23
+ desc: "Base path where model services are generated"
24
+
25
+ class_option :domains_path,
26
+ type: :string,
27
+ default: "app/domains",
28
+ desc: "Base path where domains live (used with --domain)"
29
+
30
+ class_option :domain,
31
+ type: :string,
32
+ default: nil,
33
+ desc: "Domain module for domain-mode output (e.g. Billing or Admin::Billing)"
34
+
35
+ class_option :actions,
36
+ type: :array,
37
+ default: [],
38
+ desc: "Optional action stubs to include (e.g. create update destroy)"
39
+
40
+ def create_model_service
41
+ if File.exist?(File.join(destination_root, target_file)) && !options[:force]
42
+ say_status(
43
+ :skip,
44
+ "#{target_file} already exists (use --force to overwrite)",
45
+ :yellow
46
+ )
47
+ return
48
+ end
49
+
50
+ template "model_service.rb.tt", target_file
51
+ end
52
+
53
+ private
54
+
55
+ def target_file
56
+ return File.join(options[:output_path], "#{file_path}_service.rb") unless domain_mode?
57
+
58
+ File.join(
59
+ options[:domains_path],
60
+ domain_file_path,
61
+ "services",
62
+ "#{model_file_path}_service.rb"
63
+ )
64
+ end
65
+
66
+ def service_class_name
67
+ "#{class_name}Service"
68
+ end
69
+
70
+ def enclosing_modules
71
+ return default_modules unless domain_mode?
72
+
73
+ domain_modules + ["Services"] + model_modules_without_domain
74
+ end
75
+
76
+ def default_modules
77
+ ["Operations", *model_modules]
78
+ end
79
+
80
+ def model_modules
81
+ class_name.split("::")[0...-1]
82
+ end
83
+
84
+ def domain_modules
85
+ options[:domain].to_s.strip.split("::")
86
+ end
87
+
88
+ def model_modules_without_domain
89
+ class_parts = class_name.split("::")
90
+ remaining = class_parts.drop(domain_modules.length)
91
+ remaining[0...-1]
92
+ end
93
+
94
+ def domain_file_path
95
+ domain_modules.map(&:underscore).join("/")
96
+ end
97
+
98
+ def model_file_path
99
+ class_parts = class_name.split("::")
100
+ remaining = class_parts.drop(domain_modules.length)
101
+ remaining.map(&:underscore).join("/")
102
+ end
103
+
104
+ def domain_mode?
105
+ !options[:domain].to_s.strip.empty?
106
+ end
107
+
108
+ def declared_actions
109
+ options
110
+ .fetch(:actions)
111
+ .map { |a| a.to_s.strip }
112
+ .reject(&:empty?)
113
+ .uniq
114
+ end
115
+
116
+ def stub_action?(action_name)
117
+ declared_actions.include?(action_name.to_s)
118
+ end
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ <% enclosing_modules.each do |mod| -%>
4
+ module <%= mod %>
5
+ <% end -%>
6
+ class <%= service_class_name.split("::").last %> < Railsmith::BaseService
7
+ model(<%= class_name %>)
8
+
9
+ <% if stub_action?("create") -%>
10
+ def create
11
+ super
12
+ end
13
+ <% end -%>
14
+ <% if stub_action?("update") -%>
15
+ def update
16
+ super
17
+ end
18
+ <% end -%>
19
+ <% if stub_action?("destroy") -%>
20
+ def destroy
21
+ super
22
+ end
23
+ <% end -%>
24
+ end
25
+ <% (enclosing_modules.length - 1).downto(0) do -%>
26
+ end
27
+ <% end -%>
28
+