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
data/lib/rails-hyperdrive-layered-rails/hyperdrive/skills/layered-rails/references/gems/workflow.md
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
# Workflow
|
|
2
|
+
|
|
3
|
+
Finite state machine implementation for Ruby.
|
|
4
|
+
|
|
5
|
+
**GitHub**: https://github.com/geekq/workflow
|
|
6
|
+
**Layer**: Domain
|
|
7
|
+
|
|
8
|
+
## Contents
|
|
9
|
+
|
|
10
|
+
- Installation
|
|
11
|
+
- Basic Usage
|
|
12
|
+
- Per-Event Callback Methods
|
|
13
|
+
- Transition Guards
|
|
14
|
+
- State-Dependent Behavior
|
|
15
|
+
- Standalone Workflow
|
|
16
|
+
- Generic `on_transition` Hook
|
|
17
|
+
- Querying by State
|
|
18
|
+
- Testing
|
|
19
|
+
|
|
20
|
+
## Installation
|
|
21
|
+
|
|
22
|
+
```ruby
|
|
23
|
+
# Gemfile
|
|
24
|
+
gem "workflow"
|
|
25
|
+
gem "workflow-activerecord" # For Active Record integration
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Basic Usage
|
|
29
|
+
|
|
30
|
+
### Define State Machine
|
|
31
|
+
|
|
32
|
+
```ruby
|
|
33
|
+
class Post < ApplicationRecord
|
|
34
|
+
include WorkflowActiverecord
|
|
35
|
+
|
|
36
|
+
workflow_column :status
|
|
37
|
+
|
|
38
|
+
workflow do
|
|
39
|
+
state :draft do
|
|
40
|
+
event :submit, transitions_to: :pending_review
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
state :pending_review do
|
|
44
|
+
event :approve, transitions_to: :approved
|
|
45
|
+
event :reject, transitions_to: :rejected
|
|
46
|
+
event :request_changes, transitions_to: :draft
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
state :approved do
|
|
50
|
+
event :publish, transitions_to: :published
|
|
51
|
+
event :archive, transitions_to: :archived
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
state :rejected do
|
|
55
|
+
event :revise, transitions_to: :draft
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
state :published do
|
|
59
|
+
event :archive, transitions_to: :archived
|
|
60
|
+
event :unpublish, transitions_to: :draft
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
state :archived do
|
|
64
|
+
event :restore, transitions_to: :draft
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### Trigger Transitions
|
|
71
|
+
|
|
72
|
+
```ruby
|
|
73
|
+
post = Post.create!(title: "My Post")
|
|
74
|
+
post.draft? #=> true
|
|
75
|
+
|
|
76
|
+
post.submit!
|
|
77
|
+
post.pending_review? #=> true
|
|
78
|
+
|
|
79
|
+
post.approve!
|
|
80
|
+
post.approved? #=> true
|
|
81
|
+
|
|
82
|
+
post.publish!
|
|
83
|
+
post.published? #=> true
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### Check Available Events
|
|
87
|
+
|
|
88
|
+
```ruby
|
|
89
|
+
post.current_state.events
|
|
90
|
+
#=> [:submit]
|
|
91
|
+
|
|
92
|
+
post.can_submit? #=> true
|
|
93
|
+
post.can_approve? #=> false
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## Per-Event Callback Methods
|
|
97
|
+
|
|
98
|
+
```ruby
|
|
99
|
+
class Post < ApplicationRecord
|
|
100
|
+
include WorkflowActiverecord
|
|
101
|
+
|
|
102
|
+
workflow do
|
|
103
|
+
state :draft do
|
|
104
|
+
event :submit, transitions_to: :pending_review
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
state :pending_review do
|
|
108
|
+
event :approve, transitions_to: :approved
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
state :approved do
|
|
112
|
+
event :publish, transitions_to: :published
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Called when event fires
|
|
117
|
+
def submit
|
|
118
|
+
self.submitted_at = Time.current
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def approve
|
|
122
|
+
self.approved_at = Time.current
|
|
123
|
+
self.approved_by = Current.user
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def publish
|
|
127
|
+
self.published_at = Time.current
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
## Transition Guards
|
|
133
|
+
|
|
134
|
+
```ruby
|
|
135
|
+
class Post < ApplicationRecord
|
|
136
|
+
include WorkflowActiverecord
|
|
137
|
+
|
|
138
|
+
workflow do
|
|
139
|
+
state :draft do
|
|
140
|
+
event :submit, transitions_to: :pending_review,
|
|
141
|
+
if: :submittable?
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
state :pending_review do
|
|
145
|
+
event :approve, transitions_to: :approved,
|
|
146
|
+
if: proc { Current.user&.admin? }
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
private
|
|
151
|
+
|
|
152
|
+
def submittable?
|
|
153
|
+
title.present? && body.present?
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
## State-Dependent Behavior
|
|
159
|
+
|
|
160
|
+
```ruby
|
|
161
|
+
class Post < ApplicationRecord
|
|
162
|
+
include WorkflowActiverecord
|
|
163
|
+
|
|
164
|
+
workflow do
|
|
165
|
+
state :draft
|
|
166
|
+
state :published
|
|
167
|
+
state :archived
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def editable?
|
|
171
|
+
draft?
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def visible?
|
|
175
|
+
published?
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def restorable?
|
|
179
|
+
archived?
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
## Standalone Workflow
|
|
185
|
+
|
|
186
|
+
When state machine isn't central to the model:
|
|
187
|
+
|
|
188
|
+
```ruby
|
|
189
|
+
class Post::PublicationWorkflow
|
|
190
|
+
include Workflow
|
|
191
|
+
|
|
192
|
+
private attr_reader :post
|
|
193
|
+
|
|
194
|
+
def initialize(post)
|
|
195
|
+
@post = post
|
|
196
|
+
@state = post.publication_status
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
workflow do
|
|
200
|
+
state :draft do
|
|
201
|
+
event :submit, transitions_to: :submitted
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
state :submitted do
|
|
205
|
+
event :approve, transitions_to: :approved
|
|
206
|
+
event :reject, transitions_to: :rejected
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
state :approved do
|
|
210
|
+
event :publish, transitions_to: :published
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
state :published do
|
|
214
|
+
event :archive, transitions_to: :archived
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def persist_workflow_state(new_state)
|
|
219
|
+
post.update!(publication_status: new_state)
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def publish
|
|
223
|
+
post.touch(:published_at)
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# Usage
|
|
228
|
+
class Post < ApplicationRecord
|
|
229
|
+
def publication_workflow
|
|
230
|
+
@publication_workflow ||= PublicationWorkflow.new(self)
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
delegate :submit!, :approve!, :publish!, to: :publication_workflow
|
|
234
|
+
end
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
## Generic `on_transition` Hook
|
|
238
|
+
|
|
239
|
+
```ruby
|
|
240
|
+
class Order < ApplicationRecord
|
|
241
|
+
include WorkflowActiverecord
|
|
242
|
+
|
|
243
|
+
workflow do
|
|
244
|
+
state :pending do
|
|
245
|
+
event :confirm, transitions_to: :confirmed
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
state :confirmed do
|
|
249
|
+
event :ship, transitions_to: :shipped
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
state :shipped do
|
|
253
|
+
event :deliver, transitions_to: :delivered
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# Runs after any transition
|
|
258
|
+
def on_transition(from, to, event, *args)
|
|
259
|
+
AuditLog.create!(
|
|
260
|
+
record: self,
|
|
261
|
+
from_state: from,
|
|
262
|
+
to_state: to,
|
|
263
|
+
event: event
|
|
264
|
+
)
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
## Querying by State
|
|
270
|
+
|
|
271
|
+
```ruby
|
|
272
|
+
# Workflow adds scopes for each state
|
|
273
|
+
Post.with_draft_status
|
|
274
|
+
Post.with_published_status
|
|
275
|
+
|
|
276
|
+
# Or use enum-style queries if using enum column
|
|
277
|
+
Post.where(status: :draft)
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
## Testing
|
|
281
|
+
|
|
282
|
+
```ruby
|
|
283
|
+
RSpec.describe Post do
|
|
284
|
+
describe "workflow" do
|
|
285
|
+
it "starts in draft state" do
|
|
286
|
+
post = Post.new
|
|
287
|
+
expect(post).to be_draft
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
it "transitions from draft to pending_review" do
|
|
291
|
+
post = create(:post, :draft)
|
|
292
|
+
|
|
293
|
+
expect { post.submit! }
|
|
294
|
+
.to change { post.status }
|
|
295
|
+
.from("draft").to("pending_review")
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
it "guards against invalid submissions" do
|
|
299
|
+
post = create(:post, title: nil)
|
|
300
|
+
|
|
301
|
+
expect { post.submit! }.to raise_error(Workflow::NoTransitionAllowed)
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
```
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
# Collaborator Objects
|
|
2
|
+
|
|
3
|
+
## Contents
|
|
4
|
+
|
|
5
|
+
- Summary
|
|
6
|
+
- When to Use
|
|
7
|
+
- When NOT to Use
|
|
8
|
+
- Key Principles
|
|
9
|
+
- Implementation
|
|
10
|
+
- Comparison With Other Patterns
|
|
11
|
+
- Anti-Patterns
|
|
12
|
+
|
|
13
|
+
## Summary
|
|
14
|
+
|
|
15
|
+
A collaborator object is a small, focused class that owns a slice of behavior tightly coupled to a primary model. It lives alongside the model, takes the model (or a piece of its data) as input, and exposes a narrow interface. The classic shape is a **delegate object**: a separate class — sometimes a separate Active Record table — that the primary model delegates a coherent group of attributes and methods to.
|
|
16
|
+
|
|
17
|
+
A collaborator is **not** a service object. Services orchestrate operations across the application. Collaborators stay inside the Domain layer; they exist to keep the primary model from accumulating unrelated responsibilities.
|
|
18
|
+
|
|
19
|
+
## When to Use
|
|
20
|
+
|
|
21
|
+
- A model has a coherent slice of behavior that doesn't justify a full sibling model but bloats the primary class (contact information, profile metadata, billing details).
|
|
22
|
+
- Multiple models share the slice (polymorphic delegates work well here).
|
|
23
|
+
- The slice has its own validations, normalizations, or formatting rules that don't belong on the parent.
|
|
24
|
+
- A behavioral concern has grown beyond a single mixin's reasonable size and would be clearer as a typed object.
|
|
25
|
+
|
|
26
|
+
## When NOT to Use
|
|
27
|
+
|
|
28
|
+
- The slice is just data with no behavior — use a value object instead.
|
|
29
|
+
- The slice is a unit of work (creating, sending, syncing) — use a service object.
|
|
30
|
+
- The slice is reusable across heterogeneous models with no per-instance state — a concern is fine.
|
|
31
|
+
- The slice has its own identity and lifecycle — promote it to a sibling model.
|
|
32
|
+
|
|
33
|
+
## Key Principles
|
|
34
|
+
|
|
35
|
+
- **One primary association.** A collaborator is created for and owned by a single model (or polymorphic owner). It does not stand alone.
|
|
36
|
+
- **Narrow interface.** The collaborator exposes a focused set of methods — what its slice of behavior provides — not the full Active Record API.
|
|
37
|
+
- **Domain-layer only.** A collaborator stays inside the Domain layer. It does not call mailers, jobs, services, or external APIs. If it needs to, it's a service in disguise.
|
|
38
|
+
- **Delegate from the parent.** The parent model uses `delegate :method, to: :collaborator` (or a concern that does the same) so callers can keep using the parent's existing API while the implementation moves.
|
|
39
|
+
|
|
40
|
+
## Implementation
|
|
41
|
+
|
|
42
|
+
### Polymorphic Delegate (Active Record)
|
|
43
|
+
|
|
44
|
+
The contact-information example: a `User` had grown phone-number, country-code, social-account, and visibility validations and normalizations that all clustered around contacting a person. Extracting them into `ContactInformation` keeps the user model focused on identity and authentication.
|
|
45
|
+
|
|
46
|
+
```ruby
|
|
47
|
+
class ContactInformation < ApplicationRecord
|
|
48
|
+
belongs_to :contactable, polymorphic: true
|
|
49
|
+
|
|
50
|
+
SOCIAL_ACCOUNTS = %i[facebook twitter tiktok].freeze
|
|
51
|
+
store_accessor :social_accounts, *SOCIAL_ACCOUNTS, suffix: :social_id
|
|
52
|
+
|
|
53
|
+
validates :phone_number, allow_blank: true, phone: {types: :mobile}
|
|
54
|
+
validates :country_code, inclusion: Country.codes
|
|
55
|
+
|
|
56
|
+
normalizes :phone_number, with: -> { Phonelib.parse(it).e164 }
|
|
57
|
+
|
|
58
|
+
def region
|
|
59
|
+
Country[country_code].region
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def phone_number_visible?
|
|
63
|
+
contact_info_visible && phone_number_visible
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
module Contactable
|
|
68
|
+
extend ActiveSupport::Concern
|
|
69
|
+
|
|
70
|
+
included do
|
|
71
|
+
has_one :contact_information, as: :contactable, dependent: :destroy
|
|
72
|
+
|
|
73
|
+
delegate :phone_number, :region, to: :contact_information
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
class User < ApplicationRecord
|
|
78
|
+
include Contactable
|
|
79
|
+
end
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Callers keep using `user.phone_number` and `user.region` — the delegate is invisible at the call site.
|
|
83
|
+
|
|
84
|
+
### Plain Ruby Delegate
|
|
85
|
+
|
|
86
|
+
A collaborator does not have to be an Active Record model. When the slice does not need persistence, a plain Ruby object that takes the parent in its constructor is enough.
|
|
87
|
+
|
|
88
|
+
```ruby
|
|
89
|
+
class User
|
|
90
|
+
class Greeter
|
|
91
|
+
def initialize(user)
|
|
92
|
+
@user = user
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def hello
|
|
96
|
+
"Hello, #{@user.name}!"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def goodbye
|
|
100
|
+
"See you later, #{@user.name}."
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def greeter
|
|
105
|
+
@greeter ||= Greeter.new(self)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
delegate :hello, :goodbye, to: :greeter
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
user.hello #=> "Hello, Alice!"
|
|
112
|
+
user.goodbye #=> "See you later, Alice."
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
### Delegate Replacing a Mixed Concern
|
|
116
|
+
|
|
117
|
+
When a concern accumulates unrelated behavior — validation rules, query helpers, formatting — splitting it into a typed delegate (or several) makes ownership clear.
|
|
118
|
+
|
|
119
|
+
```ruby
|
|
120
|
+
# Before: User concern is a junk drawer
|
|
121
|
+
module Contactable
|
|
122
|
+
extend ActiveSupport::Concern
|
|
123
|
+
|
|
124
|
+
included do
|
|
125
|
+
validates :phone_number, ...
|
|
126
|
+
normalizes :phone_number, ...
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def region = ...
|
|
130
|
+
def phone_number_visible? = ...
|
|
131
|
+
def social_links = ...
|
|
132
|
+
def primary_channel = ...
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# After: contact information owns its own type
|
|
136
|
+
class ContactInformation < ApplicationRecord
|
|
137
|
+
validates :phone_number, ...
|
|
138
|
+
normalizes :phone_number, ...
|
|
139
|
+
|
|
140
|
+
def region = ...
|
|
141
|
+
def phone_number_visible? = ...
|
|
142
|
+
def social_links = ...
|
|
143
|
+
def primary_channel = ...
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
module Contactable
|
|
147
|
+
extend ActiveSupport::Concern
|
|
148
|
+
|
|
149
|
+
included do
|
|
150
|
+
has_one :contact_information, as: :contactable
|
|
151
|
+
delegate :region, :phone_number_visible?, :social_links, :primary_channel,
|
|
152
|
+
to: :contact_information
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
The `Contactable` concern shrinks to a one-liner association + delegate. The behavior moves to a class that names what it represents.
|
|
158
|
+
|
|
159
|
+
## Comparison With Other Patterns
|
|
160
|
+
|
|
161
|
+
| Pattern | Owns | Has behavior? | Has identity? | Layer |
|
|
162
|
+
|---|---|---|---|---|
|
|
163
|
+
| **Value object** | A bundle of attributes | Yes (pure) | No | Domain |
|
|
164
|
+
| **Collaborator / delegate object** | A slice of one model's behavior | Yes (and possibly state) | Optional (polymorphic AR has its own row) | Domain |
|
|
165
|
+
| **Service object** | A unit of work | Yes (orchestration) | No (transient) | Application |
|
|
166
|
+
| **Sibling model** | A first-class concept | Yes | Yes (own primary key) | Domain |
|
|
167
|
+
| **Concern** | A reusable behavior across models | Yes (mixed in) | No | Domain |
|
|
168
|
+
|
|
169
|
+
The collaborator/delegate sits between value objects and sibling models: it has more behavior than a value object and less independent identity than a sibling model. It is the right tool when a slice of a model wants its own type but does not want to be its own entity.
|
|
170
|
+
|
|
171
|
+
## Anti-Patterns
|
|
172
|
+
|
|
173
|
+
### Cross-Layer Delegate
|
|
174
|
+
|
|
175
|
+
```ruby
|
|
176
|
+
# BAD — crosses into Infrastructure
|
|
177
|
+
class User::Notifier
|
|
178
|
+
def initialize(user) = @user = user
|
|
179
|
+
|
|
180
|
+
def notify(event)
|
|
181
|
+
NotifierMailer.send(@user, event).deliver_later
|
|
182
|
+
SlackClient.post(@user.slack_id, event)
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
class User < ApplicationRecord
|
|
187
|
+
delegate :notify, to: :notifier
|
|
188
|
+
def notifier = @notifier ||= Notifier.new(self)
|
|
189
|
+
end
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
Calling mailers, jobs, or external APIs from a delegate makes it an application service in domain clothing. Move it to `app/services/` or to the dedicated notification layer.
|
|
193
|
+
|
|
194
|
+
### Anemic Delegate
|
|
195
|
+
|
|
196
|
+
```ruby
|
|
197
|
+
# BAD — just a struct of attribute readers
|
|
198
|
+
class User::Profile
|
|
199
|
+
def initialize(user) = @user = user
|
|
200
|
+
def first_name = @user.first_name
|
|
201
|
+
def last_name = @user.last_name
|
|
202
|
+
end
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
If the delegate has no behavior of its own, it is paperwork. Either inline the methods on the parent, or promote to a value object if the attributes deserve their own type.
|
|
206
|
+
|
|
207
|
+
### Delegate That Knows About Callers
|
|
208
|
+
|
|
209
|
+
A collaborator should not branch on who called it (`if controller_context.user.admin?`). It owns a slice of one model's behavior; cross-cutting context belongs in the calling layer.
|