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,314 @@
|
|
|
1
|
+
# Extraction Signals
|
|
2
|
+
|
|
3
|
+
How to identify code that should be extracted to a different layer or abstraction.
|
|
4
|
+
|
|
5
|
+
## Contents
|
|
6
|
+
|
|
7
|
+
- Callback Scoring System
|
|
8
|
+
- God Object Identification
|
|
9
|
+
- Concern Health Check
|
|
10
|
+
- Service Object Signals
|
|
11
|
+
- Controller Fat Signals
|
|
12
|
+
- Quick Reference
|
|
13
|
+
- Static Analysis Tools
|
|
14
|
+
|
|
15
|
+
## Callback Scoring System
|
|
16
|
+
|
|
17
|
+
Rate model callbacks to identify extraction candidates:
|
|
18
|
+
|
|
19
|
+
| Score | Type | Description | Action |
|
|
20
|
+
|-------|------|-------------|--------|
|
|
21
|
+
| 5/5 | **Transformer** | Computes/defaults required values | Keep in model |
|
|
22
|
+
| 4/5 | **Normalizer** | Sanitizes input data | Keep (prefer `.normalizes` API) |
|
|
23
|
+
| 4/5 | **Utility** | Counter caches, cache busting | Keep in model |
|
|
24
|
+
| 2/5 | **Observer** | Side effects after commit | Review case-by-case |
|
|
25
|
+
| 1/5 | **Operation** | Business process steps | Extract immediately |
|
|
26
|
+
|
|
27
|
+
### Transformer Callbacks (Keep)
|
|
28
|
+
|
|
29
|
+
Compute or default required attribute values:
|
|
30
|
+
|
|
31
|
+
```ruby
|
|
32
|
+
class Post < ApplicationRecord
|
|
33
|
+
before_validation :compute_shortname, on: :create
|
|
34
|
+
before_save :set_word_count, if: :content_changed?
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def compute_shortname
|
|
39
|
+
self.short_name ||= title.parameterize
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def set_word_count
|
|
43
|
+
self.word_count = content.split(/\s+/).size
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### Normalizer Callbacks (Keep)
|
|
49
|
+
|
|
50
|
+
Sanitize user input. Prefer Rails 7.1+ `.normalizes` API:
|
|
51
|
+
|
|
52
|
+
```ruby
|
|
53
|
+
class Post < ApplicationRecord
|
|
54
|
+
normalizes :title, with: -> { _1.strip }
|
|
55
|
+
normalizes :content, with: -> { _1.squish }
|
|
56
|
+
end
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### Utility Callbacks (Keep)
|
|
60
|
+
|
|
61
|
+
Framework-level utilities like counter caches:
|
|
62
|
+
|
|
63
|
+
```ruby
|
|
64
|
+
class Comment < ApplicationRecord
|
|
65
|
+
belongs_to :post, touch: true, counter_cache: true
|
|
66
|
+
end
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### Operation Callbacks (Extract)
|
|
70
|
+
|
|
71
|
+
Signs of misplacement:
|
|
72
|
+
- Conditions (`unless: :admin?`)
|
|
73
|
+
- Collaboration with non-model objects (mailers, API clients)
|
|
74
|
+
- Remote peer communication
|
|
75
|
+
|
|
76
|
+
```ruby
|
|
77
|
+
# BAD - Extract these
|
|
78
|
+
class User < ApplicationRecord
|
|
79
|
+
after_create :generate_initial_project, unless: :admin?
|
|
80
|
+
after_commit :send_welcome_email, on: :create
|
|
81
|
+
after_commit :sync_with_crm
|
|
82
|
+
after_commit :track_signup_analytics
|
|
83
|
+
end
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
**Extraction options:**
|
|
87
|
+
1. Move to controller
|
|
88
|
+
2. Move to service object
|
|
89
|
+
3. Use event-driven approach
|
|
90
|
+
|
|
91
|
+
```ruby
|
|
92
|
+
# GOOD - Event-driven
|
|
93
|
+
class User < ApplicationRecord
|
|
94
|
+
after_commit on: :create do
|
|
95
|
+
UserCreatedEvent.publish(user: self)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Subscribers handle side effects
|
|
100
|
+
class WelcomeEmailSubscriber
|
|
101
|
+
def user_created(event)
|
|
102
|
+
UserMailer.welcome(event.user).deliver_later
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## God Object Identification
|
|
108
|
+
|
|
109
|
+
### Churn × Complexity Metric
|
|
110
|
+
|
|
111
|
+
**Churn** = how often a file changes (indicates ongoing modifications)
|
|
112
|
+
**Complexity** = code complexity score (use Flog)
|
|
113
|
+
|
|
114
|
+
Files high in both are prime refactoring candidates.
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
# Calculate churn
|
|
118
|
+
git log --format=oneline -- app/models/user.rb | wc -l
|
|
119
|
+
|
|
120
|
+
# Calculate complexity
|
|
121
|
+
flog -s app/models/user.rb
|
|
122
|
+
|
|
123
|
+
# Find intersection of top 10 by each
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
### Automated Tool
|
|
127
|
+
|
|
128
|
+
Use [attractor](https://github.com/julianrubisch/attractor):
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
attractor report -p app/models
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
### Common God Object Names
|
|
135
|
+
|
|
136
|
+
Watch for these accumulating responsibilities:
|
|
137
|
+
- `User` / `Account`
|
|
138
|
+
- `Order` / `Transaction`
|
|
139
|
+
- `Project` / `Workspace`
|
|
140
|
+
- `Post` / `Article`
|
|
141
|
+
|
|
142
|
+
### Decomposition Strategies
|
|
143
|
+
|
|
144
|
+
1. **Extract concerns** for shared behaviors
|
|
145
|
+
2. **Extract delegate objects** for complex operations
|
|
146
|
+
3. **Extract value objects** for groups of related attributes
|
|
147
|
+
4. **Create new models** for distinct concepts
|
|
148
|
+
|
|
149
|
+
```ruby
|
|
150
|
+
# Before: God User model
|
|
151
|
+
class User < ApplicationRecord
|
|
152
|
+
# Authentication (20 methods)
|
|
153
|
+
# Profile (15 methods)
|
|
154
|
+
# Notifications (10 methods)
|
|
155
|
+
# Analytics (10 methods)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# After: Decomposed
|
|
159
|
+
class User < ApplicationRecord
|
|
160
|
+
include User::Authentication
|
|
161
|
+
has_one :profile
|
|
162
|
+
has_one :notification_preferences
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
class User::Authentication
|
|
166
|
+
# Authentication behavior
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
class Profile < ApplicationRecord
|
|
170
|
+
belongs_to :user
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
class NotificationPreferences < ApplicationRecord
|
|
174
|
+
belongs_to :user
|
|
175
|
+
end
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
## Concern Health Check
|
|
179
|
+
|
|
180
|
+
### Good Concerns (Behavioral)
|
|
181
|
+
|
|
182
|
+
Can be tested in isolation, shared across models:
|
|
183
|
+
|
|
184
|
+
```ruby
|
|
185
|
+
module Publishable
|
|
186
|
+
extend ActiveSupport::Concern
|
|
187
|
+
|
|
188
|
+
included do
|
|
189
|
+
scope :published, -> { where.not(published_at: nil) }
|
|
190
|
+
scope :draft, -> { where(published_at: nil) }
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def published? = published_at.present?
|
|
194
|
+
def publish! = update!(published_at: Time.current)
|
|
195
|
+
end
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
**Test:** Can you write specs for this concern without instantiating the host model?
|
|
199
|
+
|
|
200
|
+
### Bad Concerns (Code-Slicing)
|
|
201
|
+
|
|
202
|
+
Groups code by Rails artifact type, not behavior:
|
|
203
|
+
|
|
204
|
+
```ruby
|
|
205
|
+
# BAD - Just groups contact-related code
|
|
206
|
+
module Contactable
|
|
207
|
+
extend ActiveSupport::Concern
|
|
208
|
+
|
|
209
|
+
included do
|
|
210
|
+
validates :email, presence: true
|
|
211
|
+
validates :phone, format: { with: PHONE_REGEX }
|
|
212
|
+
before_save :normalize_phone
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def full_contact_info
|
|
216
|
+
"#{email} / #{phone}"
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
**Test:** If removing this concern breaks unrelated tests, it's code-slicing.
|
|
222
|
+
|
|
223
|
+
### Overgrown Concerns
|
|
224
|
+
|
|
225
|
+
Signs a concern should be extracted:
|
|
226
|
+
- 50+ lines
|
|
227
|
+
- Multiple responsibilities
|
|
228
|
+
- Complex internal state
|
|
229
|
+
|
|
230
|
+
Extract to:
|
|
231
|
+
- **Delegate object** for operations
|
|
232
|
+
- **Value object** for attribute groups
|
|
233
|
+
- **Separate model** for distinct entity
|
|
234
|
+
|
|
235
|
+
## Service Object Signals
|
|
236
|
+
|
|
237
|
+
### When to Extract to Service
|
|
238
|
+
|
|
239
|
+
Extract from controller when you see:
|
|
240
|
+
- Multiple model operations in sequence
|
|
241
|
+
- Transaction spanning multiple models
|
|
242
|
+
- Complex error handling
|
|
243
|
+
- Reusable business operation
|
|
244
|
+
|
|
245
|
+
### When NOT to Use Services
|
|
246
|
+
|
|
247
|
+
Don't extract:
|
|
248
|
+
- Single model operations (keep in model)
|
|
249
|
+
- Simple CRUD (let controller handle)
|
|
250
|
+
- Domain logic (belongs in model, not service)
|
|
251
|
+
|
|
252
|
+
### Anemic Model Warning Signs
|
|
253
|
+
|
|
254
|
+
Your models might be anemic if:
|
|
255
|
+
- Services contain calculations that use only model data
|
|
256
|
+
- Models are pure data containers (associations + validations only)
|
|
257
|
+
- You have `CalculateXService` for model attributes
|
|
258
|
+
- Domain rules live in services, not models
|
|
259
|
+
|
|
260
|
+
```ruby
|
|
261
|
+
# BAD - Anemic
|
|
262
|
+
class Order < ApplicationRecord
|
|
263
|
+
# Just associations and validations
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
class CalculateOrderTotalService
|
|
267
|
+
def call(order)
|
|
268
|
+
order.items.sum { |i| i.price * i.quantity }
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# GOOD - Rich model
|
|
273
|
+
class Order < ApplicationRecord
|
|
274
|
+
def total
|
|
275
|
+
items.sum(&:subtotal)
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
## Controller Fat Signals
|
|
281
|
+
|
|
282
|
+
### Extract When You See
|
|
283
|
+
|
|
284
|
+
- Business calculations (pricing, discounts)
|
|
285
|
+
- Multiple model updates
|
|
286
|
+
- Complex conditionals based on business rules
|
|
287
|
+
- External API calls
|
|
288
|
+
- More than 10-15 lines per action
|
|
289
|
+
|
|
290
|
+
### Keep in Controller
|
|
291
|
+
|
|
292
|
+
- Parameter parsing
|
|
293
|
+
- Authentication/authorization
|
|
294
|
+
- Response formatting
|
|
295
|
+
- Simple model operations
|
|
296
|
+
|
|
297
|
+
## Quick Reference
|
|
298
|
+
|
|
299
|
+
| Signal | Threshold | Action |
|
|
300
|
+
|--------|-----------|--------|
|
|
301
|
+
| Callback score | ≤ 2/5 | Extract to service/event |
|
|
302
|
+
| Model complexity | Flog > 100 | Decompose |
|
|
303
|
+
| Model churn | > 30 changes/year | Review for extraction |
|
|
304
|
+
| Concern size | > 50 lines | Extract to delegate |
|
|
305
|
+
| Controller action | > 15 lines | Extract to service |
|
|
306
|
+
| Service with domain logic | Any calculations | Move to model |
|
|
307
|
+
|
|
308
|
+
## Static Analysis Tools
|
|
309
|
+
|
|
310
|
+
| Tool | Purpose | Command |
|
|
311
|
+
|------|---------|---------|
|
|
312
|
+
| [flog](https://github.com/seattlerb/flog) | Complexity scoring | `flog -s app/models/` |
|
|
313
|
+
| [attractor](https://github.com/julianrubisch/attractor) | Churn × complexity | `attractor report` |
|
|
314
|
+
| [callback_hell](https://github.com/evilmartians/callback_hell) | Callback audit | `bin/rails callback_hell:callbacks[User]` |
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
# The Specification Test
|
|
2
|
+
|
|
3
|
+
## Contents
|
|
4
|
+
|
|
5
|
+
- The Specification Principle
|
|
6
|
+
- Quick Test-Outline Diagnostic
|
|
7
|
+
- Layer Responsibilities
|
|
8
|
+
- Example: Controller Specification
|
|
9
|
+
- Example: Service Specification
|
|
10
|
+
- Example: Model Specification
|
|
11
|
+
- Cost Consideration
|
|
12
|
+
- Detailed Code Audit (3-Step Process)
|
|
13
|
+
- Quick Reference
|
|
14
|
+
|
|
15
|
+
## The Specification Principle
|
|
16
|
+
|
|
17
|
+
> If the specification of an object describes features beyond the primary responsibility of its abstraction layer, such features should be extracted into lower layers.
|
|
18
|
+
|
|
19
|
+
The specification test helps identify code that belongs in a different layer by examining what tests would verify.
|
|
20
|
+
|
|
21
|
+
## Quick Test-Outline Diagnostic
|
|
22
|
+
|
|
23
|
+
1. **Write test structure** (contexts/describes) without implementation
|
|
24
|
+
2. **Examine what the tests verify**
|
|
25
|
+
3. **Ask:** Does this test verify the object's primary responsibility?
|
|
26
|
+
|
|
27
|
+
If a test verifies something outside the layer's primary concern, that code should be extracted.
|
|
28
|
+
|
|
29
|
+
## Layer Responsibilities
|
|
30
|
+
|
|
31
|
+
| Layer | Primary Responsibility | Tests Should Verify |
|
|
32
|
+
|-------|------------------------|---------------------|
|
|
33
|
+
| Presentation (Controller) | HTTP handling | Authentication, authorization, response codes, redirects |
|
|
34
|
+
| Application (Service) | Use-case orchestration | Correct domain objects called, transaction boundaries |
|
|
35
|
+
| Domain (Model) | Business rules | Validations, state transitions, calculations |
|
|
36
|
+
| Infrastructure | Technical implementation | Data persistence, API calls |
|
|
37
|
+
|
|
38
|
+
## Example: Controller Specification
|
|
39
|
+
|
|
40
|
+
```ruby
|
|
41
|
+
describe "/callbacks/github" do
|
|
42
|
+
context "when signature is missing" # ✓ Authentication (controller responsibility)
|
|
43
|
+
context "when signature is invalid" # ✓ Authentication (controller responsibility)
|
|
44
|
+
context "when event is pull_request" # ✗ Business logic (extract to lower layer)
|
|
45
|
+
context "when event is issue" # ✗ Business logic (extract to lower layer)
|
|
46
|
+
context "when user is not found" # ✗ Business logic (extract to lower layer)
|
|
47
|
+
end
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
The business logic tests indicate code that should move to the application or domain layer.
|
|
51
|
+
|
|
52
|
+
**After extraction:**
|
|
53
|
+
|
|
54
|
+
```ruby
|
|
55
|
+
# Controller spec - only HTTP concerns
|
|
56
|
+
describe "/callbacks/github" do
|
|
57
|
+
context "when signature is missing"
|
|
58
|
+
context "when signature is invalid"
|
|
59
|
+
context "when signature is valid"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Service spec - business logic
|
|
63
|
+
describe HandleGithubEventService do
|
|
64
|
+
context "when event is pull_request"
|
|
65
|
+
context "when event is issue"
|
|
66
|
+
context "when user is not found"
|
|
67
|
+
end
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Example: Service Specification
|
|
71
|
+
|
|
72
|
+
```ruby
|
|
73
|
+
describe ProcessOrderService do
|
|
74
|
+
context "when order is valid" # ✓ Orchestration
|
|
75
|
+
context "when payment fails" # ✓ Error handling
|
|
76
|
+
context "when inventory check fails" # ✓ Error handling
|
|
77
|
+
context "when discount > 50%" # ✗ Business rule (move to model)
|
|
78
|
+
context "when order total < minimum" # ✗ Business rule (move to model)
|
|
79
|
+
end
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Discount and minimum order rules are domain logic—they belong in the Order model.
|
|
83
|
+
|
|
84
|
+
## Example: Model Specification
|
|
85
|
+
|
|
86
|
+
```ruby
|
|
87
|
+
describe Order do
|
|
88
|
+
context "validations" # ✓ Business rules
|
|
89
|
+
context "#calculate_total" # ✓ Domain calculation
|
|
90
|
+
context "#apply_discount" # ✓ Domain logic
|
|
91
|
+
context "when sending confirmation" # ✗ Presentation concern (extract)
|
|
92
|
+
context "when syncing to warehouse" # ✗ Infrastructure concern (extract)
|
|
93
|
+
end
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Notification and external sync are not domain responsibilities.
|
|
97
|
+
|
|
98
|
+
## Cost Consideration
|
|
99
|
+
|
|
100
|
+
Higher-layer tests are:
|
|
101
|
+
- **Harder to write** (more context setup)
|
|
102
|
+
- **Slower to execute** (HTTP requests, full stack)
|
|
103
|
+
- **More brittle** (depend on more components)
|
|
104
|
+
|
|
105
|
+
Moving logic to lower layers enables faster, simpler, more focused tests.
|
|
106
|
+
|
|
107
|
+
| Test Type | Speed | Setup Complexity | Brittleness |
|
|
108
|
+
|-----------|-------|------------------|-------------|
|
|
109
|
+
| Model/unit | Fast | Low | Low |
|
|
110
|
+
| Service | Medium | Medium | Medium |
|
|
111
|
+
| Controller/request | Slow | High | High |
|
|
112
|
+
| System/integration | Slowest | Highest | Highest |
|
|
113
|
+
|
|
114
|
+
## Detailed Code Audit (3-Step Process)
|
|
115
|
+
|
|
116
|
+
### Step 1: List Responsibilities
|
|
117
|
+
|
|
118
|
+
For the code you're examining, list every responsibility it handles.
|
|
119
|
+
|
|
120
|
+
Example for `OrdersController#create`:
|
|
121
|
+
- Parse order parameters
|
|
122
|
+
- Authenticate user
|
|
123
|
+
- Authorize order creation
|
|
124
|
+
- Validate inventory
|
|
125
|
+
- Calculate pricing
|
|
126
|
+
- Apply discounts
|
|
127
|
+
- Create order record
|
|
128
|
+
- Send confirmation email
|
|
129
|
+
- Sync to warehouse API
|
|
130
|
+
- Return JSON response
|
|
131
|
+
|
|
132
|
+
### Step 2: Categorize by Layer
|
|
133
|
+
|
|
134
|
+
| Responsibility | Layer | Belongs in Controller? |
|
|
135
|
+
|----------------|-------|------------------------|
|
|
136
|
+
| Parse parameters | Presentation | ✓ Yes |
|
|
137
|
+
| Authenticate user | Presentation | ✓ Yes |
|
|
138
|
+
| Authorize creation | Application | ✓ Yes (or policy) |
|
|
139
|
+
| Validate inventory | Domain | ✗ No |
|
|
140
|
+
| Calculate pricing | Domain | ✗ No |
|
|
141
|
+
| Apply discounts | Domain | ✗ No |
|
|
142
|
+
| Create record | Domain | ✗ No |
|
|
143
|
+
| Send email | Presentation | ✗ No (not controller's job) |
|
|
144
|
+
| Sync to API | Infrastructure | ✗ No |
|
|
145
|
+
| Return JSON | Presentation | ✓ Yes |
|
|
146
|
+
|
|
147
|
+
### Step 3: Extract
|
|
148
|
+
|
|
149
|
+
Move misplaced responsibilities to appropriate layers:
|
|
150
|
+
|
|
151
|
+
```ruby
|
|
152
|
+
# Controller - only HTTP concerns
|
|
153
|
+
class OrdersController < ApplicationController
|
|
154
|
+
def create
|
|
155
|
+
result = CreateOrderService.call(
|
|
156
|
+
params: order_params,
|
|
157
|
+
user: current_user
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
if result.success?
|
|
161
|
+
render json: OrderSerializer.new(result.order)
|
|
162
|
+
else
|
|
163
|
+
render json: { errors: result.errors }, status: :unprocessable_entity
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Service - orchestration
|
|
169
|
+
class CreateOrderService
|
|
170
|
+
def call
|
|
171
|
+
order = Order.new(params)
|
|
172
|
+
order.customer = user
|
|
173
|
+
|
|
174
|
+
return failure(order.errors) unless order.valid?
|
|
175
|
+
|
|
176
|
+
order.save!
|
|
177
|
+
OrderMailer.confirmation(order).deliver_later
|
|
178
|
+
WarehouseSyncJob.perform_later(order.id)
|
|
179
|
+
|
|
180
|
+
success(order)
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Model - domain logic
|
|
185
|
+
class Order < ApplicationRecord
|
|
186
|
+
def valid?
|
|
187
|
+
validate_inventory
|
|
188
|
+
calculate_pricing
|
|
189
|
+
apply_discounts
|
|
190
|
+
super
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
## Quick Reference
|
|
196
|
+
|
|
197
|
+
**Controller should test:**
|
|
198
|
+
- HTTP status codes
|
|
199
|
+
- Redirects
|
|
200
|
+
- Authentication/authorization
|
|
201
|
+
- Parameter handling
|
|
202
|
+
- Response format
|
|
203
|
+
|
|
204
|
+
**Controller should NOT test:**
|
|
205
|
+
- Business rules
|
|
206
|
+
- Calculations
|
|
207
|
+
- State transitions
|
|
208
|
+
- External service behavior
|
|
209
|
+
|
|
210
|
+
**Service should test:**
|
|
211
|
+
- Correct objects orchestrated
|
|
212
|
+
- Transaction success/failure
|
|
213
|
+
- Error handling
|
|
214
|
+
|
|
215
|
+
**Service should NOT test:**
|
|
216
|
+
- Domain validation rules
|
|
217
|
+
- Business calculations
|
|
218
|
+
- HTTP concerns
|
|
219
|
+
|
|
220
|
+
**Model should test:**
|
|
221
|
+
- Validations
|
|
222
|
+
- Business rules
|
|
223
|
+
- Calculations
|
|
224
|
+
- State transitions
|
|
225
|
+
|
|
226
|
+
**Model should NOT test:**
|
|
227
|
+
- HTTP concerns
|
|
228
|
+
- Notification delivery
|
|
229
|
+
- External API calls
|