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.
- checksums.yaml +7 -0
- data/.tool-versions +1 -0
- data/CHANGELOG.md +64 -0
- data/LICENSE.txt +21 -0
- data/MIGRATION.md +156 -0
- data/README.md +249 -0
- data/Rakefile +14 -0
- data/docs/cookbook.md +605 -0
- data/docs/legacy-adoption.md +283 -0
- data/docs/quickstart.md +110 -0
- data/lib/generators/railsmith/domain/domain_generator.rb +57 -0
- data/lib/generators/railsmith/domain/templates/domain.rb.tt +14 -0
- data/lib/generators/railsmith/install/install_generator.rb +21 -0
- data/lib/generators/railsmith/install/templates/railsmith.rb +10 -0
- data/lib/generators/railsmith/model_service/model_service_generator.rb +121 -0
- data/lib/generators/railsmith/model_service/templates/model_service.rb.tt +28 -0
- data/lib/generators/railsmith/operation/operation_generator.rb +88 -0
- data/lib/generators/railsmith/operation/templates/operation.rb.tt +27 -0
- data/lib/railsmith/arch_checks/cli.rb +79 -0
- data/lib/railsmith/arch_checks/direct_model_access_checker.rb +94 -0
- data/lib/railsmith/arch_checks/missing_service_usage_checker.rb +206 -0
- data/lib/railsmith/arch_checks/violation.rb +14 -0
- data/lib/railsmith/arch_checks.rb +7 -0
- data/lib/railsmith/arch_report.rb +96 -0
- data/lib/railsmith/base_service/bulk_actions.rb +77 -0
- data/lib/railsmith/base_service/bulk_contract.rb +56 -0
- data/lib/railsmith/base_service/bulk_execution.rb +68 -0
- data/lib/railsmith/base_service/bulk_params.rb +56 -0
- data/lib/railsmith/base_service/crud_actions.rb +63 -0
- data/lib/railsmith/base_service/crud_error_mapping.rb +78 -0
- data/lib/railsmith/base_service/crud_model_resolution.rb +36 -0
- data/lib/railsmith/base_service/crud_record_helpers.rb +60 -0
- data/lib/railsmith/base_service/crud_transactions.rb +31 -0
- data/lib/railsmith/base_service/domain_context_propagation.rb +29 -0
- data/lib/railsmith/base_service/dup_helpers.rb +15 -0
- data/lib/railsmith/base_service/validation.rb +67 -0
- data/lib/railsmith/base_service.rb +96 -0
- data/lib/railsmith/configuration.rb +18 -0
- data/lib/railsmith/cross_domain_guard.rb +90 -0
- data/lib/railsmith/cross_domain_warning_formatter.rb +66 -0
- data/lib/railsmith/deep_dup.rb +20 -0
- data/lib/railsmith/domain_context.rb +44 -0
- data/lib/railsmith/errors.rb +50 -0
- data/lib/railsmith/instrumentation.rb +64 -0
- data/lib/railsmith/railtie.rb +10 -0
- data/lib/railsmith/result.rb +60 -0
- data/lib/railsmith/version.rb +5 -0
- data/lib/railsmith.rb +31 -0
- data/lib/tasks/railsmith.rake +24 -0
- data/sig/railsmith.rbs +4 -0
- 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.
|
data/docs/quickstart.md
ADDED
|
@@ -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,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
|
+
|