rails-hyperdrive-layered-rails 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 +7 -0
- data/LICENSE +30 -0
- data/README.md +89 -0
- data/UPSTREAM +4 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/SKILL.md.erb +315 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/examples/authorization-to-policy.md +79 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/examples/callbacks-to-service.md +72 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/examples/complex-input-to-form-object.md +119 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/examples/current-from-model.md +63 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/examples/god-object-decomposition.md +81 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/examples/implicit-to-explicit-state-machine.md +77 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/examples/query-to-query-object.md +63 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/examples/view-logic-to-presenter.md +60 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/references/anti-patterns/callbacks.md +114 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/references/anti-patterns/concerns.md +92 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/references/anti-patterns/helpers.md +66 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/references/anti-patterns/jobs.md +71 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/references/anti-patterns/layer-violations.md +176 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/references/anti-patterns/service-objects.md +131 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/references/anti-patterns/testing.md +37 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/references/core/architecture-layers.md +204 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/references/core/extraction-signals.md +314 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/references/core/specification-test.md +229 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/references/gems/action-policy.md +437 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/references/gems/active-agent.md +275 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/references/gems/active-delivery.md +221 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/references/gems/active-job-performs.md +171 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/references/gems/alba.md +257 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/references/gems/anyway-config.md +212 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/references/gems/rubanok.md +246 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/references/gems/view-component.md +223 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/references/gems/workflow.md +305 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/references/patterns/collaborator-objects.md +209 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/references/patterns/concerns.md +381 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/references/patterns/filter-objects.md +222 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/references/patterns/form-objects.md +267 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/references/patterns/policy-objects.md +298 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/references/patterns/presenters.md +257 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/references/patterns/query-objects.md +208 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/references/patterns/repositories.md +382 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/references/patterns/serializers.md +256 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/references/patterns/service-objects.md +170 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/references/patterns/state-machines.md +368 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/references/patterns/value-objects.md +267 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/references/topics/ai-integration.md +372 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/references/topics/authorization.md +398 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/references/topics/callbacks.md +336 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/references/topics/configuration.md +383 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/references/topics/current-attributes.md +307 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/references/topics/instrumentation.md +386 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/references/topics/notifications.md +373 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/references/topics/view-components.md +454 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/workflows/analyze-callbacks.md +255 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/workflows/analyze-gods.md +279 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/workflows/analyze-services.md +1444 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/workflows/analyze.md +376 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/workflows/plan.md +249 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/workflows/review.md +473 -0
- data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/workflows/spec-test.md +282 -0
- data/lib/rails-hyperdrive-layered-rails/version.rb +3 -0
- data/lib/rails-hyperdrive-layered-rails.rb +7 -0
- metadata +106 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# Helper Anti-Patterns
|
|
2
|
+
|
|
3
|
+
Helpers doing work that belongs in templates or components.
|
|
4
|
+
|
|
5
|
+
## HTML Construction in Helpers
|
|
6
|
+
|
|
7
|
+
**Problem:** Helpers building HTML programmatically instead of providing logic for templates.
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
# BAD: Helper constructs HTML
|
|
11
|
+
module MessagesHelper
|
|
12
|
+
def message_tag(message, &)
|
|
13
|
+
tag.div id: dom_id(message),
|
|
14
|
+
class: "message #{"message--emoji" if message.plain_text_body.all_emoji?}",
|
|
15
|
+
data: {
|
|
16
|
+
controller: "reply",
|
|
17
|
+
user_id: message.creator_id,
|
|
18
|
+
message_id: message.id,
|
|
19
|
+
# ... many more data attributes
|
|
20
|
+
}, &
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
**Issues:**
|
|
26
|
+
- HTML structure hidden in Ruby code, harder to read and modify
|
|
27
|
+
- No template preview, harder to collaborate with designers
|
|
28
|
+
- Logic and markup tightly coupled
|
|
29
|
+
- Testing requires rendering, not unit testable
|
|
30
|
+
- Misses ViewComponent benefits (sidecar assets, previews, slots)
|
|
31
|
+
|
|
32
|
+
**Signal:** Heavy use of `tag.div`, `tag.button`, `tag.span` or complex `content_tag` chains.
|
|
33
|
+
|
|
34
|
+
**Fix:** Extract to ViewComponent with template.
|
|
35
|
+
|
|
36
|
+
```ruby
|
|
37
|
+
# GOOD: ViewComponent with template
|
|
38
|
+
class MessageComponent < ViewComponent::Base
|
|
39
|
+
def initialize(message:)
|
|
40
|
+
@message = message
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def emoji_only?
|
|
44
|
+
@message.plain_text_body.all_emoji?
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def stimulus_data
|
|
48
|
+
{
|
|
49
|
+
controller: "reply",
|
|
50
|
+
user_id: @message.creator_id,
|
|
51
|
+
message_id: @message.id
|
|
52
|
+
}
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
```erb
|
|
58
|
+
<%# app/components/message_component.html.erb %>
|
|
59
|
+
<div id="<%= dom_id(@message) %>"
|
|
60
|
+
class="message <%= "message--emoji" if emoji_only? %>"
|
|
61
|
+
data="<%= stimulus_data.to_json %>">
|
|
62
|
+
<%= content %>
|
|
63
|
+
</div>
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
**Rule of thumb:** If a helper method has more than 2-3 `tag.*` calls or builds nested HTML structure, extract to ViewComponent.
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# Job Anti-Patterns
|
|
2
|
+
|
|
3
|
+
Background-job classes that add boilerplate without value.
|
|
4
|
+
|
|
5
|
+
## Anemic Jobs
|
|
6
|
+
|
|
7
|
+
**Problem:** Job classes that just delegate to a single model method.
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
# BAD: Job is just a wrapper
|
|
11
|
+
class NotifyRecipientsJob < ApplicationJob
|
|
12
|
+
discard_on ActiveJob::DeserializationError
|
|
13
|
+
|
|
14
|
+
def perform(notifiable)
|
|
15
|
+
notifiable.notify_recipients
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# BAD: Model method just to enqueue job
|
|
20
|
+
class Post < ApplicationRecord
|
|
21
|
+
def notify_recipients_later
|
|
22
|
+
NotifyRecipientsJob.perform_later(self)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
**Issues:**
|
|
28
|
+
- Boilerplate job files cluttering `app/jobs`
|
|
29
|
+
- Two places to maintain (job + model method)
|
|
30
|
+
- Job class adds no value beyond async execution
|
|
31
|
+
- Makes the jobs folder noisy, hiding complex jobs that need attention
|
|
32
|
+
|
|
33
|
+
**Signal:** Job's `perform` method is a single line calling a method on the argument.
|
|
34
|
+
|
|
35
|
+
**Fix:** Use the [active_job-performs](https://github.com/kaspth/active_job-performs) gem.
|
|
36
|
+
|
|
37
|
+
```ruby
|
|
38
|
+
# GOOD: Short form (no job options needed)
|
|
39
|
+
class Post < ApplicationRecord
|
|
40
|
+
performs def notify_recipients
|
|
41
|
+
# Notification logic
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Usage
|
|
46
|
+
post.notify_recipients_later
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
This generates:
|
|
50
|
+
- `Post::NotifyRecipientsJob` automatically
|
|
51
|
+
- `notify_recipients_later` instance method
|
|
52
|
+
- `notify_recipients_later_bulk` class method (Rails 7.1+)
|
|
53
|
+
|
|
54
|
+
**With options:**
|
|
55
|
+
```ruby
|
|
56
|
+
class Post < ApplicationRecord
|
|
57
|
+
performs :notify_recipients,
|
|
58
|
+
queue_as: :notifications,
|
|
59
|
+
discard_on: ActiveRecord::RecordNotFound
|
|
60
|
+
|
|
61
|
+
def notify_recipients
|
|
62
|
+
# ...
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
**When to keep separate job class:**
|
|
68
|
+
- Job has complex logic beyond single method call
|
|
69
|
+
- Job processes multiple records with custom batching
|
|
70
|
+
- Job needs extensive retry/error handling configuration
|
|
71
|
+
- Job is triggered from multiple unrelated models
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# Layer Violations
|
|
2
|
+
|
|
3
|
+
Cross-layer dependencies that break unidirectional data flow.
|
|
4
|
+
|
|
5
|
+
## Contents
|
|
6
|
+
|
|
7
|
+
- Current Attributes in Models
|
|
8
|
+
- Request Objects in Services
|
|
9
|
+
- Notifications in Models
|
|
10
|
+
- Business Logic in Controllers
|
|
11
|
+
|
|
12
|
+
## Current Attributes in Models
|
|
13
|
+
|
|
14
|
+
**Problem:** Models depend on presentation-layer context.
|
|
15
|
+
|
|
16
|
+
```ruby
|
|
17
|
+
# BAD
|
|
18
|
+
class Post < ApplicationRecord
|
|
19
|
+
def destroy
|
|
20
|
+
self.deleted_by = Current.user # Hidden dependency!
|
|
21
|
+
super
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
**Issues:**
|
|
27
|
+
- Background jobs lose Current context (silent bugs)
|
|
28
|
+
- Callbacks can overwrite Current mid-iteration
|
|
29
|
+
- Hidden dependency makes testing harder
|
|
30
|
+
- Violates no-reverse-dependencies rule
|
|
31
|
+
|
|
32
|
+
**Fix:** Use explicit parameters.
|
|
33
|
+
|
|
34
|
+
```ruby
|
|
35
|
+
# GOOD
|
|
36
|
+
class Post < ApplicationRecord
|
|
37
|
+
def destroy_by(user:)
|
|
38
|
+
self.deleted_by = user
|
|
39
|
+
destroy
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Request Objects in Services
|
|
45
|
+
|
|
46
|
+
**Problem:** Application layer depends on presentation layer.
|
|
47
|
+
|
|
48
|
+
```ruby
|
|
49
|
+
# BAD
|
|
50
|
+
class HandleEventService
|
|
51
|
+
param :request
|
|
52
|
+
|
|
53
|
+
def call
|
|
54
|
+
event_type = request.headers["X-Event-Type"]
|
|
55
|
+
payload = JSON.parse(request.body.read)
|
|
56
|
+
# ...
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
**Fix:** Extract value object in controller, pass to service.
|
|
62
|
+
|
|
63
|
+
```ruby
|
|
64
|
+
# GOOD
|
|
65
|
+
class GithubCallbacksController < ApplicationController
|
|
66
|
+
def create
|
|
67
|
+
event = GithubEvent.from_request(request)
|
|
68
|
+
HandleEventService.call(event:)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
class HandleEventService
|
|
73
|
+
param :event # Value object, not request
|
|
74
|
+
|
|
75
|
+
def call
|
|
76
|
+
# Work with clean domain object
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Notifications in Models
|
|
82
|
+
|
|
83
|
+
**Problem:** Model triggers notifications, crossing into application layer.
|
|
84
|
+
|
|
85
|
+
```ruby
|
|
86
|
+
# BAD
|
|
87
|
+
class License < ApplicationRecord
|
|
88
|
+
def prolong
|
|
89
|
+
update!(status: :active, expires_at: 1.year.from_now)
|
|
90
|
+
LicenseDelivery.with(license: self).purchased.deliver_later
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
**Issues:**
|
|
96
|
+
- Domain layer depends on application layer (reverse dependency)
|
|
97
|
+
- Model has side effects beyond state management
|
|
98
|
+
- Harder to test model in isolation
|
|
99
|
+
- Notification may fire unexpectedly from different call sites
|
|
100
|
+
|
|
101
|
+
**Fix:** Trace the call chain, move notification to existing orchestrator.
|
|
102
|
+
|
|
103
|
+
```ruby
|
|
104
|
+
# GOOD: Service handles side effects
|
|
105
|
+
class StripeEventManager
|
|
106
|
+
def handle_invoice_paid(invoice)
|
|
107
|
+
# ... find license, create payment record ...
|
|
108
|
+
license.prolong
|
|
109
|
+
LicenseDelivery.with(license:).purchased.deliver_later
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
class License < ApplicationRecord
|
|
114
|
+
def prolong
|
|
115
|
+
update!(status: :active, expires_at: 1.year.from_now)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
**Resolution process:**
|
|
121
|
+
1. Find the caller (controller, service, job)
|
|
122
|
+
2. If orchestrator exists → move notification there
|
|
123
|
+
3. If no orchestrator → suggest service/form/controller based on context
|
|
124
|
+
|
|
125
|
+
## Business Logic in Controllers
|
|
126
|
+
|
|
127
|
+
**Problem:** Presentation layer doing domain work.
|
|
128
|
+
|
|
129
|
+
```ruby
|
|
130
|
+
# BAD
|
|
131
|
+
class OrdersController < ApplicationController
|
|
132
|
+
def create
|
|
133
|
+
@order = Order.new(order_params)
|
|
134
|
+
@order.total = @order.items.sum { |i| i.price * i.quantity }
|
|
135
|
+
@order.total *= 0.9 if @order.customer.vip?
|
|
136
|
+
@order.total += calculate_shipping(@order)
|
|
137
|
+
|
|
138
|
+
if @order.save
|
|
139
|
+
OrderMailer.confirmation(@order).deliver_later
|
|
140
|
+
redirect_to @order
|
|
141
|
+
else
|
|
142
|
+
render :new
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
**Fix:** Move domain logic to model, orchestration to service if needed.
|
|
149
|
+
|
|
150
|
+
```ruby
|
|
151
|
+
# GOOD
|
|
152
|
+
class Order < ApplicationRecord
|
|
153
|
+
before_validation :calculate_total
|
|
154
|
+
|
|
155
|
+
private
|
|
156
|
+
|
|
157
|
+
def calculate_total
|
|
158
|
+
self.total = items.sum(&:subtotal)
|
|
159
|
+
self.total *= 0.9 if customer.vip?
|
|
160
|
+
self.total += shipping_cost
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
class OrdersController < ApplicationController
|
|
165
|
+
def create
|
|
166
|
+
@order = Order.new(order_params)
|
|
167
|
+
|
|
168
|
+
if @order.save
|
|
169
|
+
OrderMailer.confirmation(@order).deliver_later
|
|
170
|
+
redirect_to @order
|
|
171
|
+
else
|
|
172
|
+
render :new
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
```
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# Service Object Anti-Patterns
|
|
2
|
+
|
|
3
|
+
Common mistakes when introducing service objects.
|
|
4
|
+
|
|
5
|
+
## Contents
|
|
6
|
+
|
|
7
|
+
- Anemic Models
|
|
8
|
+
- Bag of Random Objects
|
|
9
|
+
- Premature Abstraction
|
|
10
|
+
|
|
11
|
+
## Anemic Models
|
|
12
|
+
|
|
13
|
+
**Problem:** All logic moved to services, models become data containers.
|
|
14
|
+
|
|
15
|
+
```ruby
|
|
16
|
+
# BAD - Anemic model
|
|
17
|
+
class Order < ApplicationRecord
|
|
18
|
+
# Just associations and validations, no behavior
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
class CalculateOrderTotalService
|
|
22
|
+
def call(order)
|
|
23
|
+
total = order.items.sum { |i| i.price * i.quantity }
|
|
24
|
+
total *= 0.9 if order.customer.vip?
|
|
25
|
+
order.update!(total:)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
class ApplyDiscountService
|
|
30
|
+
def call(order, code)
|
|
31
|
+
discount = Discount.find_by(code:)
|
|
32
|
+
order.update!(discount_amount: discount.amount)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
**Fix:** Keep domain logic in models. Services orchestrate, models know their business rules.
|
|
38
|
+
|
|
39
|
+
```ruby
|
|
40
|
+
# GOOD
|
|
41
|
+
class Order < ApplicationRecord
|
|
42
|
+
def calculate_total
|
|
43
|
+
self.total = items.sum(&:subtotal)
|
|
44
|
+
apply_vip_discount if customer.vip?
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def apply_discount(code)
|
|
48
|
+
discount = Discount.find_by(code:)
|
|
49
|
+
self.discount_amount = discount.amount
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Bag of Random Objects
|
|
55
|
+
|
|
56
|
+
**Problem:** No conventions, each service is unique.
|
|
57
|
+
|
|
58
|
+
```ruby
|
|
59
|
+
# BAD - No consistency
|
|
60
|
+
class UserRegistration
|
|
61
|
+
def perform(attrs)
|
|
62
|
+
# returns user or nil
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
class OrderProcessor
|
|
67
|
+
def self.process!(order_id)
|
|
68
|
+
# raises on failure
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
class SendNewsletterJob
|
|
73
|
+
def run(newsletter, subscribers)
|
|
74
|
+
# returns count
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
**Fix:** Establish conventions.
|
|
80
|
+
|
|
81
|
+
```ruby
|
|
82
|
+
# GOOD - Consistent interface
|
|
83
|
+
class ApplicationService
|
|
84
|
+
extend Dry::Initializer
|
|
85
|
+
def self.call(...) = new(...).call
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
class RegisterUserService < ApplicationService
|
|
89
|
+
param :attrs
|
|
90
|
+
def call
|
|
91
|
+
# Returns result object
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
class ProcessOrderService < ApplicationService
|
|
96
|
+
param :order_id
|
|
97
|
+
def call
|
|
98
|
+
# Returns result object
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Premature Abstraction
|
|
104
|
+
|
|
105
|
+
**Problem:** Creating abstractions before patterns emerge.
|
|
106
|
+
|
|
107
|
+
```ruby
|
|
108
|
+
# BAD - Over-engineered from day one
|
|
109
|
+
class BaseCommand
|
|
110
|
+
include CommandPattern
|
|
111
|
+
include ResultMonad
|
|
112
|
+
include TransactionWrapper
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
class CreateUserCommand < BaseCommand
|
|
116
|
+
# Complex infrastructure for simple operation
|
|
117
|
+
end
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
**Fix:** Wait for patterns to emerge. Start simple.
|
|
121
|
+
|
|
122
|
+
```ruby
|
|
123
|
+
# GOOD - Simple first
|
|
124
|
+
class CreateUserService
|
|
125
|
+
def self.call(params)
|
|
126
|
+
User.create!(params)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Extract patterns AFTER you see repetition
|
|
131
|
+
```
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# Testing Anti-Patterns
|
|
2
|
+
|
|
3
|
+
Tests that verify the wrong layer's responsibilities.
|
|
4
|
+
|
|
5
|
+
## Testing Wrong Layer
|
|
6
|
+
|
|
7
|
+
**Problem:** Controller tests verify business logic.
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
# BAD
|
|
11
|
+
describe OrdersController do
|
|
12
|
+
it "applies VIP discount" do
|
|
13
|
+
post :create, params: { items: [...] }
|
|
14
|
+
expect(Order.last.total).to eq(90) # Testing domain logic!
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
**Fix:** Test business logic in model specs.
|
|
20
|
+
|
|
21
|
+
```ruby
|
|
22
|
+
# GOOD
|
|
23
|
+
describe Order do
|
|
24
|
+
it "applies VIP discount" do
|
|
25
|
+
order = build(:order, customer: vip_customer)
|
|
26
|
+
order.calculate_total
|
|
27
|
+
expect(order.total).to eq(90)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
describe OrdersController do
|
|
32
|
+
it "creates order and redirects" do
|
|
33
|
+
post :create, params: { items: [...] }
|
|
34
|
+
expect(response).to redirect_to(Order.last)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
```
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
# Architecture Layers
|
|
2
|
+
|
|
3
|
+
## Contents
|
|
4
|
+
|
|
5
|
+
- The Four Layers (Presentation, Application, Domain, Infrastructure)
|
|
6
|
+
- The Four Layering Rules
|
|
7
|
+
- Mapping Rails Components to Layers
|
|
8
|
+
- Applying Layered Architecture in Practice
|
|
9
|
+
- Common Layering Mistakes
|
|
10
|
+
|
|
11
|
+
## The Four Layers (Presentation, Application, Domain, Infrastructure)
|
|
12
|
+
|
|
13
|
+
Rails applications are organized into four architecture layers with unidirectional data flow:
|
|
14
|
+
|
|
15
|
+
| Layer | Responsibility | Rails Examples |
|
|
16
|
+
|-------|----------------|----------------|
|
|
17
|
+
| **Presentation** | Handle user interactions, present information | Controllers, Views, Channels, Mailers |
|
|
18
|
+
| **Application** | Organize domain objects for use cases | Service objects, Form objects, Policy objects |
|
|
19
|
+
| **Domain** | Entities, rules, invariants, application state | Models, Value objects, Domain events |
|
|
20
|
+
| **Infrastructure** | Supporting technologies | Active Record, API clients, File storage |
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
Presentation → Application → Domain → Infrastructure
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## The Four Layering Rules
|
|
27
|
+
|
|
28
|
+
### Rule 1: Unidirectional Data Flow
|
|
29
|
+
|
|
30
|
+
Data flows from top to bottom only. Arrows in the architecture always point downward.
|
|
31
|
+
|
|
32
|
+
### Rule 2: No Reverse Dependencies
|
|
33
|
+
|
|
34
|
+
Lower layers must not depend on higher layers. A domain object should never depend on a controller or request object.
|
|
35
|
+
|
|
36
|
+
**Violations:**
|
|
37
|
+
|
|
38
|
+
```ruby
|
|
39
|
+
# BAD: Service (Application) depends on request (Presentation)
|
|
40
|
+
class HandleEventService
|
|
41
|
+
param :request # Reverse dependency!
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# BAD: Model (Domain) depends on Current (Presentation context)
|
|
45
|
+
class Post < ApplicationRecord
|
|
46
|
+
def destroy
|
|
47
|
+
self.deleted_by = Current.user # Hidden dependency!
|
|
48
|
+
super
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
**Correct:**
|
|
54
|
+
|
|
55
|
+
```ruby
|
|
56
|
+
# GOOD: Service accepts domain objects only
|
|
57
|
+
class HandleEventService
|
|
58
|
+
param :event # Value object, not request
|
|
59
|
+
|
|
60
|
+
def call
|
|
61
|
+
user = User.find_by(gh_id: event.user_id)
|
|
62
|
+
# ...
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# GOOD: Model method accepts explicit parameters
|
|
67
|
+
class Post < ApplicationRecord
|
|
68
|
+
def destroy_by(user:)
|
|
69
|
+
self.deleted_by = user
|
|
70
|
+
destroy
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### Rule 3: Abstraction Boundaries
|
|
76
|
+
|
|
77
|
+
Every abstraction layer must belong to a single architecture layer. An abstraction cannot span multiple architecture layers.
|
|
78
|
+
|
|
79
|
+
**Evaluating abstractions:**
|
|
80
|
+
- Does this object depend on objects from a higher layer? → Extract or refactor
|
|
81
|
+
- Does this object's responsibility match its architecture layer? → Move if not
|
|
82
|
+
|
|
83
|
+
### Rule 4: Minimize Inter-Layer Connections
|
|
84
|
+
|
|
85
|
+
Fewer connections = looser coupling = better testability and reusability.
|
|
86
|
+
|
|
87
|
+
**Good layering:**
|
|
88
|
+
```ruby
|
|
89
|
+
# Controller (Presentation) → Service (Application) → Model (Domain)
|
|
90
|
+
class PostsController
|
|
91
|
+
def publish
|
|
92
|
+
PublishPostService.call(post_id: params[:id], user: current_user)
|
|
93
|
+
redirect_to posts_path
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
class PublishPostService
|
|
98
|
+
def call
|
|
99
|
+
post = Post.find(post_id)
|
|
100
|
+
post.publish!(by: user) # Domain logic stays in model
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
**Caveat — Architecture Sinkhole:** If you reduce connections to minimum (each layer only talks to adjacent layer), you may create objects that just proxy data through layers with no modification.
|
|
106
|
+
|
|
107
|
+
```ruby
|
|
108
|
+
# BAD: Service does nothing but proxy
|
|
109
|
+
class FindPostService
|
|
110
|
+
def call(id)
|
|
111
|
+
Post.find(id) # No value added, just pass-through
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## Mapping Rails Components to Layers
|
|
117
|
+
|
|
118
|
+
### Presentation Layer
|
|
119
|
+
|
|
120
|
+
**Purpose:** Handle user interactions, present information
|
|
121
|
+
|
|
122
|
+
**Includes:**
|
|
123
|
+
- Controllers (HTTP request/response)
|
|
124
|
+
- Views (HTML rendering)
|
|
125
|
+
- Channels (WebSocket connections)
|
|
126
|
+
- Mailers (email composition)
|
|
127
|
+
- API serializers
|
|
128
|
+
- Form objects (user input handling)
|
|
129
|
+
- Filter objects (request parameter transformation)
|
|
130
|
+
- Presenters (view-specific logic)
|
|
131
|
+
|
|
132
|
+
**Primary concerns:**
|
|
133
|
+
- Request parsing and validation
|
|
134
|
+
- Authentication
|
|
135
|
+
- Response formatting
|
|
136
|
+
- User interface logic
|
|
137
|
+
|
|
138
|
+
### Application Layer
|
|
139
|
+
|
|
140
|
+
**Purpose:** Organize domain objects for specific use cases
|
|
141
|
+
|
|
142
|
+
**Includes:**
|
|
143
|
+
- Service objects (business operations)
|
|
144
|
+
- Policy objects (authorization)
|
|
145
|
+
- Interactors/Commands
|
|
146
|
+
|
|
147
|
+
**Primary concerns:**
|
|
148
|
+
- Orchestrating domain objects
|
|
149
|
+
- Transaction boundaries
|
|
150
|
+
- Use-case specific logic
|
|
151
|
+
|
|
152
|
+
**Warning:** This layer is often overused. Don't strip all logic from models into services (anemic models anti-pattern).
|
|
153
|
+
|
|
154
|
+
### Domain Layer
|
|
155
|
+
|
|
156
|
+
**Purpose:** Entities, rules, invariants, application state
|
|
157
|
+
|
|
158
|
+
**Includes:**
|
|
159
|
+
- Models (business entities)
|
|
160
|
+
- Value objects (immutable concepts)
|
|
161
|
+
- Domain events
|
|
162
|
+
- Query objects (data retrieval logic)
|
|
163
|
+
- Concerns (shared behaviors)
|
|
164
|
+
|
|
165
|
+
**Primary concerns:**
|
|
166
|
+
- Business rules and invariants
|
|
167
|
+
- Entity relationships
|
|
168
|
+
- Data transformations
|
|
169
|
+
- Domain-specific calculations
|
|
170
|
+
|
|
171
|
+
### Infrastructure Layer
|
|
172
|
+
|
|
173
|
+
**Purpose:** Supporting technologies
|
|
174
|
+
|
|
175
|
+
**Includes:**
|
|
176
|
+
- Active Record (database access)
|
|
177
|
+
- API clients (external services)
|
|
178
|
+
- File storage adapters
|
|
179
|
+
- Message queue adapters
|
|
180
|
+
- Cache implementations
|
|
181
|
+
|
|
182
|
+
**Primary concerns:**
|
|
183
|
+
- Persistence
|
|
184
|
+
- External communication
|
|
185
|
+
- Technical implementations
|
|
186
|
+
|
|
187
|
+
## Applying Layered Architecture in Practice
|
|
188
|
+
|
|
189
|
+
When designing or refactoring code:
|
|
190
|
+
|
|
191
|
+
1. **Identify the architecture layer** the code belongs to
|
|
192
|
+
2. **Check dependencies** — does it depend on higher layers?
|
|
193
|
+
3. **Apply specification test** — do tests verify appropriate responsibilities?
|
|
194
|
+
4. **Extract if needed** — move code to the correct layer
|
|
195
|
+
|
|
196
|
+
## Common Layering Mistakes
|
|
197
|
+
|
|
198
|
+
| Mistake | Problem | Solution |
|
|
199
|
+
|---------|---------|----------|
|
|
200
|
+
| Current in models | Hidden dependency on presentation context | Pass as explicit parameter |
|
|
201
|
+
| Request in services | Service depends on HTTP layer | Extract value object from request |
|
|
202
|
+
| Mailer in callbacks | Model triggers presentation-layer code | Use events or move to controller |
|
|
203
|
+
| SQL in controllers | Presentation doing infrastructure work | Use model scopes or query objects |
|
|
204
|
+
| Business logic in views | Presentation doing domain work | Use presenters or model methods |
|