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,119 @@
|
|
|
1
|
+
# Form Object for Complex Input
|
|
2
|
+
|
|
3
|
+
Replace a controller orchestrating multiple models with a form object that owns the registration workflow.
|
|
4
|
+
|
|
5
|
+
## Contents
|
|
6
|
+
|
|
7
|
+
- Before (fat controller)
|
|
8
|
+
- After (form object)
|
|
9
|
+
|
|
10
|
+
## Before
|
|
11
|
+
|
|
12
|
+
```ruby
|
|
13
|
+
class RegistrationsController < ApplicationController
|
|
14
|
+
def create
|
|
15
|
+
@user = User.new(user_params)
|
|
16
|
+
@user.profile = Profile.new(profile_params)
|
|
17
|
+
|
|
18
|
+
if @user.email.end_with?("@company.com")
|
|
19
|
+
@user.role = :employee
|
|
20
|
+
@user.team = Team.find_by(department: profile_params[:department])
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
if @user.save
|
|
24
|
+
UserMailer.welcome(@user).deliver_later
|
|
25
|
+
redirect_to dashboard_path
|
|
26
|
+
else
|
|
27
|
+
render :new, status: :unprocessable_entity
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def user_params
|
|
34
|
+
params.require(:user).permit(:email, :password, :name)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def profile_params
|
|
38
|
+
params.require(:profile).permit(:bio, :department, :avatar)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## After
|
|
44
|
+
|
|
45
|
+
```ruby
|
|
46
|
+
# app/forms/registration_form.rb
|
|
47
|
+
class RegistrationForm < ApplicationForm
|
|
48
|
+
attribute :email, :string
|
|
49
|
+
attribute :password, :string
|
|
50
|
+
attribute :name, :string
|
|
51
|
+
attribute :bio, :string
|
|
52
|
+
attribute :department, :string
|
|
53
|
+
attribute :avatar
|
|
54
|
+
|
|
55
|
+
validates :email, :password, :name, presence: true
|
|
56
|
+
validates :email, format: { with: URI::MailTo::EMAIL_REGEXP }
|
|
57
|
+
validates :password, length: { minimum: 8 }
|
|
58
|
+
|
|
59
|
+
def save
|
|
60
|
+
return false unless valid?
|
|
61
|
+
|
|
62
|
+
ApplicationRecord.transaction do
|
|
63
|
+
create_user
|
|
64
|
+
create_profile
|
|
65
|
+
assign_team if company_email?
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
true
|
|
69
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
70
|
+
errors.merge!(e.record.errors)
|
|
71
|
+
false
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
attr_reader :user
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
def create_user
|
|
79
|
+
@user = User.create!(
|
|
80
|
+
email: email,
|
|
81
|
+
password: password,
|
|
82
|
+
name: name,
|
|
83
|
+
role: company_email? ? :employee : :member
|
|
84
|
+
)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def create_profile
|
|
88
|
+
@user.create_profile!(bio: bio, department: department, avatar: avatar)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def assign_team
|
|
92
|
+
@user.update!(team: Team.find_by(department: department))
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def company_email?
|
|
96
|
+
email.end_with?("@company.com")
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# app/controllers/registrations_controller.rb
|
|
101
|
+
class RegistrationsController < ApplicationController
|
|
102
|
+
def create
|
|
103
|
+
@form = RegistrationForm.new(registration_params)
|
|
104
|
+
|
|
105
|
+
if @form.save
|
|
106
|
+
UserMailer.welcome(@form.user).deliver_later
|
|
107
|
+
redirect_to dashboard_path
|
|
108
|
+
else
|
|
109
|
+
render :new, status: :unprocessable_entity
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
private
|
|
114
|
+
|
|
115
|
+
def registration_params
|
|
116
|
+
params.require(:registration).permit(:email, :password, :name, :bio, :department, :avatar)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
```
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# Extract Current from Model
|
|
2
|
+
|
|
3
|
+
Remove `Current.user` access from a domain model by moving authorization to a policy and ownership to the controller.
|
|
4
|
+
|
|
5
|
+
## Before
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
class Post < ApplicationRecord
|
|
9
|
+
belongs_to :author, class_name: "User"
|
|
10
|
+
|
|
11
|
+
before_validation :set_author, on: :create
|
|
12
|
+
|
|
13
|
+
def can_edit?
|
|
14
|
+
author == Current.user || Current.user&.admin?
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def set_author
|
|
20
|
+
self.author = Current.user
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## After
|
|
26
|
+
|
|
27
|
+
```ruby
|
|
28
|
+
# app/models/post.rb
|
|
29
|
+
class Post < ApplicationRecord
|
|
30
|
+
belongs_to :author, class_name: "User"
|
|
31
|
+
# No Current access - domain is context-agnostic
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# app/policies/post_policy.rb
|
|
35
|
+
class PostPolicy < ApplicationPolicy
|
|
36
|
+
def edit?
|
|
37
|
+
owner? || admin?
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def owner?
|
|
43
|
+
record.author_id == user.id
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def admin?
|
|
47
|
+
user.admin?
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# app/controllers/posts_controller.rb
|
|
52
|
+
class PostsController < ApplicationController
|
|
53
|
+
def create
|
|
54
|
+
@post = current_user.posts.build(post_params)
|
|
55
|
+
|
|
56
|
+
if @post.save
|
|
57
|
+
redirect_to @post
|
|
58
|
+
else
|
|
59
|
+
render :new, status: :unprocessable_entity
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
```
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# Extract God Object with Associated Objects
|
|
2
|
+
|
|
3
|
+
Decompose a sprawling `User` model into focused collaborators using the associated-objects pattern.
|
|
4
|
+
|
|
5
|
+
## Before
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
class User < ApplicationRecord
|
|
9
|
+
# 500+ lines with multiple responsibilities
|
|
10
|
+
|
|
11
|
+
# Authentication
|
|
12
|
+
has_secure_password
|
|
13
|
+
def generate_token; end
|
|
14
|
+
def verify_token; end
|
|
15
|
+
def reset_password!; end
|
|
16
|
+
|
|
17
|
+
# Billing
|
|
18
|
+
def subscribe!(plan); end
|
|
19
|
+
def cancel_subscription!; end
|
|
20
|
+
def update_payment_method(token); end
|
|
21
|
+
def invoice_history; end
|
|
22
|
+
|
|
23
|
+
# Notifications
|
|
24
|
+
def notify!(message); end
|
|
25
|
+
def notification_preferences; end
|
|
26
|
+
def unread_notifications_count; end
|
|
27
|
+
end
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## After
|
|
31
|
+
|
|
32
|
+
```ruby
|
|
33
|
+
# app/models/user.rb
|
|
34
|
+
class User < ApplicationRecord
|
|
35
|
+
has_secure_password
|
|
36
|
+
|
|
37
|
+
has_object :billing
|
|
38
|
+
has_object :notification_settings
|
|
39
|
+
|
|
40
|
+
# Only core user identity logic
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# app/models/user/billing.rb
|
|
44
|
+
class User::Billing
|
|
45
|
+
attr_reader :user
|
|
46
|
+
|
|
47
|
+
def initialize(user)
|
|
48
|
+
@user = user
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def subscribe!(plan)
|
|
52
|
+
Stripe::Subscription.create(customer: stripe_customer_id, items: [{ price: plan.stripe_price_id }])
|
|
53
|
+
user.update!(plan: plan, subscribed_at: Time.current)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def cancel_subscription!
|
|
57
|
+
Stripe::Subscription.cancel(subscription_id)
|
|
58
|
+
user.update!(plan: nil, subscription_cancelled_at: Time.current)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def invoice_history
|
|
62
|
+
Stripe::Invoice.list(customer: stripe_customer_id)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def stripe_customer_id
|
|
68
|
+
user.stripe_customer_id
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# app/models/user/notification_settings.rb
|
|
73
|
+
class User::NotificationSettings
|
|
74
|
+
include ActiveModel::Model
|
|
75
|
+
include ActiveModel::Attributes
|
|
76
|
+
|
|
77
|
+
attribute :email_enabled, :boolean, default: true
|
|
78
|
+
attribute :push_enabled, :boolean, default: true
|
|
79
|
+
attribute :digest_frequency, :string, default: "daily"
|
|
80
|
+
end
|
|
81
|
+
```
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# Replace Implicit State Machine
|
|
2
|
+
|
|
3
|
+
Replace ad-hoc timestamp checks with an explicit state machine using the `workflow` gem.
|
|
4
|
+
|
|
5
|
+
## Before
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
class Order < ApplicationRecord
|
|
9
|
+
def status
|
|
10
|
+
return :cancelled if cancelled_at?
|
|
11
|
+
return :delivered if delivered_at?
|
|
12
|
+
return :shipped if shipped_at?
|
|
13
|
+
return :paid if paid_at?
|
|
14
|
+
:pending
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def can_ship?
|
|
18
|
+
paid_at? && !shipped_at? && !cancelled_at?
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def ship!
|
|
22
|
+
return false unless can_ship?
|
|
23
|
+
update!(shipped_at: Time.current)
|
|
24
|
+
OrderMailer.shipped(self).deliver_later
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## After
|
|
30
|
+
|
|
31
|
+
```ruby
|
|
32
|
+
# app/models/order.rb
|
|
33
|
+
class Order < ApplicationRecord
|
|
34
|
+
include WorkflowActiverecord
|
|
35
|
+
|
|
36
|
+
workflow_column :status
|
|
37
|
+
|
|
38
|
+
workflow do
|
|
39
|
+
state :pending do
|
|
40
|
+
event :pay, transitions_to: :paid
|
|
41
|
+
event :cancel, transitions_to: :cancelled
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
state :paid do
|
|
45
|
+
event :ship, transitions_to: :shipped
|
|
46
|
+
event :cancel, transitions_to: :cancelled
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
state :shipped do
|
|
50
|
+
event :deliver, transitions_to: :delivered
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
state :delivered
|
|
54
|
+
state :cancelled
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def ship
|
|
58
|
+
self.shipped_at = Time.current
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def deliver
|
|
62
|
+
self.delivered_at = Time.current
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def cancel
|
|
66
|
+
self.cancelled_at = Time.current
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Notifications in service
|
|
71
|
+
class Orders::Ship < ApplicationService
|
|
72
|
+
def call(order)
|
|
73
|
+
order.ship!
|
|
74
|
+
OrderMailer.shipped(order).deliver_later
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
```
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# Extract Query Logic to Query Object
|
|
2
|
+
|
|
3
|
+
Pull a long Active Record chain out of a controller into a composable query object.
|
|
4
|
+
|
|
5
|
+
## Before
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
class ReportsController < ApplicationController
|
|
9
|
+
def sales
|
|
10
|
+
@orders = Order
|
|
11
|
+
.joins(:customer, :line_items)
|
|
12
|
+
.where(status: :completed)
|
|
13
|
+
.where(created_at: params[:start_date]..params[:end_date])
|
|
14
|
+
.where(customers: { region: params[:region] }) if params[:region].present?
|
|
15
|
+
.group("DATE(orders.created_at)")
|
|
16
|
+
.select(
|
|
17
|
+
"DATE(orders.created_at) as date",
|
|
18
|
+
"COUNT(DISTINCT orders.id) as order_count",
|
|
19
|
+
"SUM(line_items.quantity * line_items.price) as revenue"
|
|
20
|
+
)
|
|
21
|
+
.order("date DESC")
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## After
|
|
27
|
+
|
|
28
|
+
```ruby
|
|
29
|
+
# app/queries/sales_report_query.rb
|
|
30
|
+
class SalesReportQuery < ApplicationQuery
|
|
31
|
+
relation { Order.joins(:customer, :line_items).where(status: :completed) }
|
|
32
|
+
|
|
33
|
+
def by_date_range(start_date, end_date)
|
|
34
|
+
relation.where(created_at: start_date..end_date)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def by_region(region)
|
|
38
|
+
return relation if region.blank?
|
|
39
|
+
relation.where(customers: { region: region })
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def daily_summary
|
|
43
|
+
relation
|
|
44
|
+
.group("DATE(orders.created_at)")
|
|
45
|
+
.select(
|
|
46
|
+
"DATE(orders.created_at) as date",
|
|
47
|
+
"COUNT(DISTINCT orders.id) as order_count",
|
|
48
|
+
"SUM(line_items.quantity * line_items.price) as revenue"
|
|
49
|
+
)
|
|
50
|
+
.order("date DESC")
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# app/controllers/reports_controller.rb
|
|
55
|
+
class ReportsController < ApplicationController
|
|
56
|
+
def sales
|
|
57
|
+
@orders = SalesReportQuery.new
|
|
58
|
+
.by_date_range(params[:start_date], params[:end_date])
|
|
59
|
+
.by_region(params[:region])
|
|
60
|
+
.daily_summary
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
```
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# Extract View Logic to Presenter
|
|
2
|
+
|
|
3
|
+
Move complex view computations out of templates into a presenter wrapping the model.
|
|
4
|
+
|
|
5
|
+
## Before
|
|
6
|
+
|
|
7
|
+
```erb
|
|
8
|
+
<%# app/views/users/show.html.erb %>
|
|
9
|
+
<div class="profile">
|
|
10
|
+
<span class="badge <%= user.status == 'active' ? 'badge-success' : user.status == 'pending' ? 'badge-warning' : 'badge-secondary' %>">
|
|
11
|
+
<%= user.status.titleize %>
|
|
12
|
+
</span>
|
|
13
|
+
|
|
14
|
+
<h1>
|
|
15
|
+
<%= user.name.squish.split(/\s/).then { |parts| parts[0..-2].map { _1[0] + "." }.join + parts.last } %>
|
|
16
|
+
</h1>
|
|
17
|
+
|
|
18
|
+
<p>Member since <%= user.created_at.strftime("%B %Y") %></p>
|
|
19
|
+
</div>
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## After
|
|
23
|
+
|
|
24
|
+
```ruby
|
|
25
|
+
# app/presenters/user_presenter.rb
|
|
26
|
+
class UserPresenter < SimpleDelegator
|
|
27
|
+
def status_badge_class
|
|
28
|
+
case status
|
|
29
|
+
when "active" then "badge-success"
|
|
30
|
+
when "pending" then "badge-warning"
|
|
31
|
+
else "badge-secondary"
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def short_name
|
|
36
|
+
name.squish.split(/\s/).then do |parts|
|
|
37
|
+
parts[0..-2].map { _1[0] + "." }.join + parts.last
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def member_since
|
|
42
|
+
created_at.strftime("%B %Y")
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
```erb
|
|
48
|
+
<%# app/views/users/show.html.erb %>
|
|
49
|
+
<% present(@user) do |user| %>
|
|
50
|
+
<div class="profile">
|
|
51
|
+
<span class="badge <%= user.status_badge_class %>">
|
|
52
|
+
<%= user.status.titleize %>
|
|
53
|
+
</span>
|
|
54
|
+
|
|
55
|
+
<h1><%= user.short_name %></h1>
|
|
56
|
+
|
|
57
|
+
<p>Member since <%= user.member_since %></p>
|
|
58
|
+
</div>
|
|
59
|
+
<% end %>
|
|
60
|
+
```
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# Callback Anti-Patterns
|
|
2
|
+
|
|
3
|
+
Misuses of Active Record callbacks that signal a layering problem.
|
|
4
|
+
|
|
5
|
+
## Contents
|
|
6
|
+
|
|
7
|
+
- Operation Callbacks
|
|
8
|
+
- Skip Callback Anti-Pattern
|
|
9
|
+
- Callback Control Flags
|
|
10
|
+
|
|
11
|
+
## Operation Callbacks
|
|
12
|
+
|
|
13
|
+
**Problem:** Business process steps disguised as callbacks.
|
|
14
|
+
|
|
15
|
+
```ruby
|
|
16
|
+
# BAD
|
|
17
|
+
class User < ApplicationRecord
|
|
18
|
+
after_create :generate_initial_project, unless: :admin?
|
|
19
|
+
after_commit :send_welcome_email, on: :create
|
|
20
|
+
after_commit :sync_with_crm
|
|
21
|
+
after_commit :track_signup_analytics
|
|
22
|
+
end
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
**Fix:** Extract to controller, service, or events.
|
|
26
|
+
|
|
27
|
+
```ruby
|
|
28
|
+
# GOOD - Controller handles side effects
|
|
29
|
+
class UsersController < ApplicationController
|
|
30
|
+
def create
|
|
31
|
+
@user = User.create!(user_params)
|
|
32
|
+
UserMailer.welcome(@user).deliver_later
|
|
33
|
+
CrmSyncJob.perform_later(@user.id)
|
|
34
|
+
AnalyticsService.track_signup(@user)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# OR use events
|
|
39
|
+
class User < ApplicationRecord
|
|
40
|
+
after_commit on: :create do
|
|
41
|
+
UserCreatedEvent.publish(user: self)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Skip Callback Anti-Pattern
|
|
47
|
+
|
|
48
|
+
**Problem:** `skip_before_action` creates hidden dependencies.
|
|
49
|
+
|
|
50
|
+
```ruby
|
|
51
|
+
# BAD
|
|
52
|
+
class ApplicationController < ActionController::Base
|
|
53
|
+
before_action :authenticate_user!
|
|
54
|
+
before_action :set_current_tenant
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
class PublicController < ApplicationController
|
|
58
|
+
skip_before_action :authenticate_user!
|
|
59
|
+
# Now depends on parent's internal callback order
|
|
60
|
+
end
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
**Fix:** Use explicit inheritance or composition.
|
|
64
|
+
|
|
65
|
+
```ruby
|
|
66
|
+
# GOOD
|
|
67
|
+
class AuthenticatedController < ApplicationController
|
|
68
|
+
before_action :authenticate_user!
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
class PublicController < ApplicationController
|
|
72
|
+
# No authentication needed
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
class DashboardController < AuthenticatedController
|
|
76
|
+
# Inherits authentication
|
|
77
|
+
end
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Callback Control Flags
|
|
81
|
+
|
|
82
|
+
**Problem:** Virtual attributes to skip callbacks.
|
|
83
|
+
|
|
84
|
+
```ruby
|
|
85
|
+
# BAD
|
|
86
|
+
class User < ApplicationRecord
|
|
87
|
+
attr_accessor :skip_welcome_email, :skip_crm_sync
|
|
88
|
+
|
|
89
|
+
after_commit :send_welcome_email, unless: :skip_welcome_email
|
|
90
|
+
after_commit :sync_with_crm, unless: :skip_crm_sync
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Usage
|
|
94
|
+
user = User.new(params)
|
|
95
|
+
user.skip_welcome_email = true
|
|
96
|
+
user.save!
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
**Fix:** Extract callbacks, call explicitly when needed.
|
|
100
|
+
|
|
101
|
+
```ruby
|
|
102
|
+
# GOOD
|
|
103
|
+
class User < ApplicationRecord
|
|
104
|
+
# No operation callbacks
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
class RegisterUserService
|
|
108
|
+
def call(user_params, send_welcome: true)
|
|
109
|
+
user = User.create!(user_params)
|
|
110
|
+
UserMailer.welcome(user).deliver_later if send_welcome
|
|
111
|
+
user
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
```
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# Concern Anti-Patterns
|
|
2
|
+
|
|
3
|
+
Misuses of `ActiveSupport::Concern` that hide structural problems.
|
|
4
|
+
|
|
5
|
+
## Code-Slicing Concerns
|
|
6
|
+
|
|
7
|
+
**Problem:** Splitting model by Rails artifact type, not behavior.
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
# BAD - Just groups related code, not a behavior
|
|
11
|
+
module Contactable
|
|
12
|
+
extend ActiveSupport::Concern
|
|
13
|
+
|
|
14
|
+
included do
|
|
15
|
+
validates :email, presence: true
|
|
16
|
+
validates :phone, format: { with: PHONE_REGEX }
|
|
17
|
+
before_save :normalize_phone
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def full_contact_info
|
|
21
|
+
"#{email} / #{phone}"
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
**Test:** If removing this concern breaks unrelated tests, it's code-slicing.
|
|
27
|
+
|
|
28
|
+
**Fix:** Keep in model or extract to value object.
|
|
29
|
+
|
|
30
|
+
```ruby
|
|
31
|
+
# GOOD - Behavioral concern (can be tested in isolation)
|
|
32
|
+
module Publishable
|
|
33
|
+
extend ActiveSupport::Concern
|
|
34
|
+
|
|
35
|
+
included do
|
|
36
|
+
scope :published, -> { where.not(published_at: nil) }
|
|
37
|
+
scope :draft, -> { where(published_at: nil) }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def published? = published_at.present?
|
|
41
|
+
|
|
42
|
+
def publish!
|
|
43
|
+
update!(published_at: Time.current)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Overgrown Concerns
|
|
49
|
+
|
|
50
|
+
**Problem:** Concern has too many responsibilities.
|
|
51
|
+
|
|
52
|
+
```ruby
|
|
53
|
+
# BAD - Too much in one concern
|
|
54
|
+
module WithMedia
|
|
55
|
+
extend ActiveSupport::Concern
|
|
56
|
+
|
|
57
|
+
included do
|
|
58
|
+
has_many_attached :images
|
|
59
|
+
has_many_attached :videos
|
|
60
|
+
has_one_attached :thumbnail
|
|
61
|
+
|
|
62
|
+
after_commit :process_images
|
|
63
|
+
after_commit :generate_thumbnail
|
|
64
|
+
after_commit :transcode_videos
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def images_ready? = # ...
|
|
68
|
+
def videos_ready? = # ...
|
|
69
|
+
def thumbnail_url = # ...
|
|
70
|
+
def processing_status = # ...
|
|
71
|
+
# 50 more methods...
|
|
72
|
+
end
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
**Fix:** Extract to delegate object or separate concerns.
|
|
76
|
+
|
|
77
|
+
```ruby
|
|
78
|
+
# GOOD
|
|
79
|
+
class Post::MediaProcessor
|
|
80
|
+
def initialize(post)
|
|
81
|
+
@post = post
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def process_all
|
|
85
|
+
process_images
|
|
86
|
+
generate_thumbnail
|
|
87
|
+
transcode_videos
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# ...
|
|
91
|
+
end
|
|
92
|
+
```
|