ariadna 1.3.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/ariadna.gemspec +0 -1
- data/data/agents/ariadna-codebase-mapper.md +34 -722
- data/data/agents/ariadna-debugger.md +44 -1139
- data/data/agents/ariadna-executor.md +75 -396
- data/data/agents/ariadna-planner.md +78 -1215
- data/data/agents/ariadna-roadmapper.md +55 -582
- data/data/agents/ariadna-verifier.md +60 -702
- data/data/ariadna/templates/config.json +8 -33
- data/data/ariadna/workflows/debug.md +28 -0
- data/data/ariadna/workflows/execute-phase.md +31 -513
- data/data/ariadna/workflows/map-codebase.md +20 -319
- data/data/ariadna/workflows/new-milestone.md +20 -365
- data/data/ariadna/workflows/new-project.md +19 -880
- data/data/ariadna/workflows/plan-phase.md +24 -443
- data/data/ariadna/workflows/progress.md +20 -376
- data/data/ariadna/workflows/quick.md +19 -221
- data/data/ariadna/workflows/roadmap-ops.md +28 -0
- data/data/ariadna/workflows/verify-work.md +23 -560
- data/data/commands/ariadna/add-phase.md +11 -22
- data/data/commands/ariadna/debug.md +11 -143
- data/data/commands/ariadna/execute-phase.md +12 -30
- data/data/commands/ariadna/insert-phase.md +7 -14
- data/data/commands/ariadna/map-codebase.md +16 -49
- data/data/commands/ariadna/new-milestone.md +12 -25
- data/data/commands/ariadna/new-project.md +22 -26
- data/data/commands/ariadna/plan-phase.md +13 -22
- data/data/commands/ariadna/progress.md +16 -6
- data/data/commands/ariadna/quick.md +9 -11
- data/data/commands/ariadna/remove-phase.md +9 -12
- data/data/commands/ariadna/verify-work.md +14 -19
- data/data/skills/rails-backend/API.md +138 -0
- data/data/skills/rails-backend/CONTROLLERS.md +154 -0
- data/data/skills/rails-backend/JOBS.md +132 -0
- data/data/skills/rails-backend/MODELS.md +213 -0
- data/data/skills/rails-backend/SKILL.md +169 -0
- data/data/skills/rails-frontend/ASSETS.md +154 -0
- data/data/skills/rails-frontend/COMPONENTS.md +253 -0
- data/data/skills/rails-frontend/SKILL.md +187 -0
- data/data/skills/rails-frontend/VIEWS.md +168 -0
- data/data/skills/rails-performance/PROFILING.md +106 -0
- data/data/skills/rails-performance/SKILL.md +217 -0
- data/data/skills/rails-security/AUDIT.md +118 -0
- data/data/skills/rails-security/SKILL.md +422 -0
- data/data/skills/rails-testing/FIXTURES.md +78 -0
- data/data/skills/rails-testing/SKILL.md +160 -0
- data/data/skills/rails-testing/SYSTEM-TESTS.md +73 -0
- data/lib/ariadna/installer.rb +11 -15
- data/lib/ariadna/tools/cli.rb +0 -12
- data/lib/ariadna/tools/config_manager.rb +10 -72
- data/lib/ariadna/tools/frontmatter.rb +23 -1
- data/lib/ariadna/tools/init.rb +201 -401
- data/lib/ariadna/tools/model_profiles.rb +6 -14
- data/lib/ariadna/tools/phase_manager.rb +1 -10
- data/lib/ariadna/tools/state_manager.rb +170 -451
- data/lib/ariadna/tools/template_filler.rb +4 -12
- data/lib/ariadna/tools/verification.rb +21 -399
- data/lib/ariadna/uninstaller.rb +9 -0
- data/lib/ariadna/version.rb +1 -1
- data/lib/ariadna.rb +1 -0
- metadata +20 -91
- data/data/agents/ariadna-backend-executor.md +0 -261
- data/data/agents/ariadna-frontend-executor.md +0 -259
- data/data/agents/ariadna-integration-checker.md +0 -418
- data/data/agents/ariadna-phase-researcher.md +0 -469
- data/data/agents/ariadna-plan-checker.md +0 -622
- data/data/agents/ariadna-project-researcher.md +0 -618
- data/data/agents/ariadna-research-synthesizer.md +0 -236
- data/data/agents/ariadna-test-executor.md +0 -266
- data/data/ariadna/references/checkpoints.md +0 -772
- data/data/ariadna/references/continuation-format.md +0 -249
- data/data/ariadna/references/decimal-phase-calculation.md +0 -65
- data/data/ariadna/references/git-integration.md +0 -248
- data/data/ariadna/references/git-planning-commit.md +0 -38
- data/data/ariadna/references/model-profile-resolution.md +0 -32
- data/data/ariadna/references/model-profiles.md +0 -73
- data/data/ariadna/references/phase-argument-parsing.md +0 -61
- data/data/ariadna/references/planning-config.md +0 -194
- data/data/ariadna/references/questioning.md +0 -153
- data/data/ariadna/references/rails-conventions.md +0 -416
- data/data/ariadna/references/tdd.md +0 -267
- data/data/ariadna/references/ui-brand.md +0 -160
- data/data/ariadna/references/verification-patterns.md +0 -853
- data/data/ariadna/templates/codebase/architecture.md +0 -481
- data/data/ariadna/templates/codebase/concerns.md +0 -380
- data/data/ariadna/templates/codebase/conventions.md +0 -434
- data/data/ariadna/templates/codebase/integrations.md +0 -328
- data/data/ariadna/templates/codebase/stack.md +0 -189
- data/data/ariadna/templates/codebase/structure.md +0 -418
- data/data/ariadna/templates/codebase/testing.md +0 -606
- data/data/ariadna/templates/context.md +0 -283
- data/data/ariadna/templates/continue-here.md +0 -78
- data/data/ariadna/templates/debug-subagent-prompt.md +0 -91
- data/data/ariadna/templates/phase-prompt.md +0 -609
- data/data/ariadna/templates/planner-subagent-prompt.md +0 -117
- data/data/ariadna/templates/research-project/ARCHITECTURE.md +0 -439
- data/data/ariadna/templates/research-project/FEATURES.md +0 -168
- data/data/ariadna/templates/research-project/PITFALLS.md +0 -406
- data/data/ariadna/templates/research-project/STACK.md +0 -251
- data/data/ariadna/templates/research-project/SUMMARY.md +0 -247
- data/data/ariadna/templates/state.md +0 -176
- data/data/ariadna/templates/summary-complex.md +0 -59
- data/data/ariadna/templates/summary-minimal.md +0 -41
- data/data/ariadna/templates/summary-standard.md +0 -48
- data/data/ariadna/templates/user-setup.md +0 -310
- data/data/ariadna/workflows/add-phase.md +0 -111
- data/data/ariadna/workflows/add-todo.md +0 -157
- data/data/ariadna/workflows/audit-milestone.md +0 -241
- data/data/ariadna/workflows/check-todos.md +0 -176
- data/data/ariadna/workflows/complete-milestone.md +0 -644
- data/data/ariadna/workflows/diagnose-issues.md +0 -219
- data/data/ariadna/workflows/discovery-phase.md +0 -289
- data/data/ariadna/workflows/discuss-phase.md +0 -408
- data/data/ariadna/workflows/execute-plan.md +0 -448
- data/data/ariadna/workflows/help.md +0 -470
- data/data/ariadna/workflows/insert-phase.md +0 -129
- data/data/ariadna/workflows/list-phase-assumptions.md +0 -178
- data/data/ariadna/workflows/pause-work.md +0 -122
- data/data/ariadna/workflows/plan-milestone-gaps.md +0 -256
- data/data/ariadna/workflows/remove-phase.md +0 -154
- data/data/ariadna/workflows/research-phase.md +0 -74
- data/data/ariadna/workflows/resume-project.md +0 -306
- data/data/ariadna/workflows/set-profile.md +0 -80
- data/data/ariadna/workflows/settings.md +0 -145
- data/data/ariadna/workflows/transition.md +0 -493
- data/data/ariadna/workflows/update.md +0 -212
- data/data/ariadna/workflows/verify-phase.md +0 -226
- data/data/commands/ariadna/add-todo.md +0 -42
- data/data/commands/ariadna/audit-milestone.md +0 -42
- data/data/commands/ariadna/check-todos.md +0 -41
- data/data/commands/ariadna/complete-milestone.md +0 -136
- data/data/commands/ariadna/discuss-phase.md +0 -86
- data/data/commands/ariadna/help.md +0 -22
- data/data/commands/ariadna/list-phase-assumptions.md +0 -50
- data/data/commands/ariadna/pause-work.md +0 -35
- data/data/commands/ariadna/plan-milestone-gaps.md +0 -40
- data/data/commands/ariadna/reapply-patches.md +0 -110
- data/data/commands/ariadna/research-phase.md +0 -187
- data/data/commands/ariadna/resume-work.md +0 -40
- data/data/commands/ariadna/set-profile.md +0 -34
- data/data/commands/ariadna/settings.md +0 -36
- data/data/commands/ariadna/update.md +0 -37
- data/data/guides/backend.md +0 -3069
- data/data/guides/frontend.md +0 -1479
- data/data/guides/performance.md +0 -1193
- data/data/guides/security.md +0 -1522
- data/data/guides/style-guide.md +0 -1091
- data/data/guides/testing.md +0 -504
- data/data/templates.md +0 -94
data/data/guides/backend.md
DELETED
|
@@ -1,3069 +0,0 @@
|
|
|
1
|
-
# Backend Patterns & Architecture
|
|
2
|
-
|
|
3
|
-
**A Comprehensive Guide for Rails Developers**
|
|
4
|
-
|
|
5
|
-
This documentation explains the backend patterns, conventions, and best practices used in Rails application. It's designed for developers who already know Ruby on Rails and need to understand how to structure and organize code.
|
|
6
|
-
|
|
7
|
-
It has been heavily inspired by the architecture and patterns of [Fizzy](https://github.com/basecamp/fizzy)l(https://www.fizzy.do/), a [Kanban board](https://www.fizzy.do/) built with Ruby on Rails. Fizzy is an excellent example of a well-architected Rails application, and this guide distills the key patterns and practices that make it successful.
|
|
8
|
-
|
|
9
|
-
We stand on the shoulders of giants.
|
|
10
|
-
|
|
11
|
-
**Related guides:**
|
|
12
|
-
- [Frontend Patterns](frontend.md) — Presenter pattern, view layer conventions
|
|
13
|
-
- [Testing Patterns](testing.md) — Testing philosophy, model/controller/job test patterns
|
|
14
|
-
- [Security Guide](security.md) — Agent-oriented security checklist for code review
|
|
15
|
-
|
|
16
|
-
## Table of Contents
|
|
17
|
-
|
|
18
|
-
- [Part 1: Foundation & Architecture](#part-1-foundation--architecture)
|
|
19
|
-
- [1.0 The Vanilla Rails Philosophy](#10-the-vanilla-rails-philosophy)
|
|
20
|
-
- [1.1 Understanding Architecture](#11-understanding-fizzys-architecture)
|
|
21
|
-
- [Part 2: Model Layer Patterns](#part-2-model-layer-patterns)
|
|
22
|
-
- [2.1 Concern Architecture](#21-concern-architecture)
|
|
23
|
-
- [2.2 Intention-Revealing APIs](#22-intention-revealing-apis)
|
|
24
|
-
- [2.3 Smart Association Defaults](#23-smart-association-defaults)
|
|
25
|
-
- [2.4 Scopes That Tell Stories](#24-scopes-that-tell-stories)
|
|
26
|
-
- [2.5 Callbacks: When and How](#25-callbacks-when-and-how)
|
|
27
|
-
- [2.6 Why Not Service Objects](#26-why-not-service-objects)
|
|
28
|
-
- [Part 3: Domain-Specific Patterns](#part-3-domain-specific-patterns)
|
|
29
|
-
- [3.1 Event Tracking System](#31-event-tracking-system)
|
|
30
|
-
- [3.2 Storage Tracking Pattern](#32-storage-tracking-pattern)
|
|
31
|
-
- [3.3 Entropy System](#33-entropy-system)
|
|
32
|
-
- [Part 4: Controller & Job Patterns](#part-4-controller--job-patterns)
|
|
33
|
-
- [4.1 Thin Controllers with Rich Models](#41-thin-controllers-with-rich-models)
|
|
34
|
-
- [4.2 RESTful Resource Nesting](#42-restful-resource-nesting)
|
|
35
|
-
- [4.3 Controller Concerns](#43-controller-concerns)
|
|
36
|
-
- [4.4 Background Jobs: The _now/_later Pattern](#44-background-jobs-the-_now_later-pattern)
|
|
37
|
-
- [4.5 Multi-Tenancy in Background Jobs](#45-multi-tenancy-in-background-jobs)
|
|
38
|
-
- [4.6 Pagination with Geared Pagination](#46-pagination-with-geared-pagination)
|
|
39
|
-
- [Part 5: Coding Style Guide](#part-5-coding-style-guide)
|
|
40
|
-
- [5.1 Coding Conventions](#51-fizzy-coding-conventions)
|
|
41
|
-
- [Part 6: Common Tasks & Recipes](#part-6-common-tasks--recipes)
|
|
42
|
-
- [6.1 Recipe: Adding a New State to Cards](#61-recipe-adding-a-new-state-to-cards)
|
|
43
|
-
- [6.2 Recipe: Adding Event Tracking](#62-recipe-adding-event-tracking)
|
|
44
|
-
- [6.3 Recipe: Creating Background Jobs](#63-recipe-creating-background-jobs)
|
|
45
|
-
- [Part 7: Quick Reference](#part-7-quick-reference)
|
|
46
|
-
- [7.1 Concern Catalog](#71-concern-catalog)
|
|
47
|
-
- [7.2 Decision Trees](#72-decision-trees)
|
|
48
|
-
- [7.3 Common Gotchas](#73-common-gotchas)
|
|
49
|
-
|
|
50
|
-
---
|
|
51
|
-
|
|
52
|
-
# Part 1: Foundation & Architecture
|
|
53
|
-
|
|
54
|
-
Before diving into specific patterns, you need to understand the foundational architecture. These concepts underpin everything else in the application.
|
|
55
|
-
|
|
56
|
-
## 1.0 The Vanilla Rails Philosophy
|
|
57
|
-
|
|
58
|
-
Architecture is built on a single organizing principle: **place the domain model at the center of the application.** This idea comes from domain-driven design (Eric Evans, 2003) — the domain model is the heart of the system, and everything else exists to exercise it.
|
|
59
|
-
|
|
60
|
-
### Domain Model at the Center
|
|
61
|
-
|
|
62
|
-
Controllers, background jobs, and the Rails console are all boundaries — entry points that invoke domain model behavior. None of them contain business logic. They set up context and delegate:
|
|
63
|
-
|
|
64
|
-
```
|
|
65
|
-
┌─────────────┐
|
|
66
|
-
│ Controller │──┐
|
|
67
|
-
└─────────────┘ │
|
|
68
|
-
│
|
|
69
|
-
┌─────────────┐ ▼
|
|
70
|
-
│ Console │──────────────► ┌───────────┐
|
|
71
|
-
└─────────────┘ │ Domain │
|
|
72
|
-
│ Model │
|
|
73
|
-
┌─────────────┐ └───────────┘
|
|
74
|
-
│ Job │──────────────► ▲
|
|
75
|
-
└─────────────┘ │
|
|
76
|
-
│
|
|
77
|
-
┌─────────────┐ │
|
|
78
|
-
│ Script │──┘
|
|
79
|
-
└─────────────┘
|
|
80
|
-
```
|
|
81
|
-
|
|
82
|
-
This means `card.close` works the same whether called from a controller action, a background job, the Rails console, or a test. The domain model is the single source of truth for business behavior.
|
|
83
|
-
|
|
84
|
-
### No New Architectural Artifacts
|
|
85
|
-
|
|
86
|
-
It doesn't introduce architectural layers beyond what Rails and Ruby provide. No service objects, no form objects, no interactors, no command pattern libraries. The building blocks are:
|
|
87
|
-
|
|
88
|
-
- **Models** (ActiveRecord and plain Ruby classes) — domain entities and operations
|
|
89
|
-
- **Concerns** — organize model behavior into cohesive modules
|
|
90
|
-
- **Controllers** — HTTP boundary, orchestrate domain calls
|
|
91
|
-
- **Jobs** — async boundary, delegate to model methods
|
|
92
|
-
- **Views** — render domain state (templates, not view components)
|
|
93
|
-
|
|
94
|
-
When something doesn't fit in an entity, it becomes a plain Ruby object with a semantic name — not a new architectural pattern. A `Signup` class, not a `SignupService`. A `Notifier`, not a `NotificationInteractor`.
|
|
95
|
-
|
|
96
|
-
### Preference for Rails Defaults
|
|
97
|
-
|
|
98
|
-
It leans toward the tools Rails ships with rather than replacing them:
|
|
99
|
-
|
|
100
|
-
- **Minitest** over RSpec
|
|
101
|
-
- **View templates** over view components
|
|
102
|
-
- **ActiveRecord callbacks** over observer patterns
|
|
103
|
-
- **Concerns** over decorator libraries
|
|
104
|
-
- **`Current`** over dependency injection frameworks
|
|
105
|
-
|
|
106
|
-
This isn't about dogma — it's about reducing the number of concepts a developer needs to learn. When you open a controller, you see standard Rails. The patterns are Rails patterns. The only thing that's distinctive is how seriously the team takes the domain model.
|
|
107
|
-
|
|
108
|
-
## 1.1 Understanding Architecture
|
|
109
|
-
|
|
110
|
-
### Domain Model Overview
|
|
111
|
-
|
|
112
|
-
The domain model follows a clear hierarchy:
|
|
113
|
-
|
|
114
|
-
```
|
|
115
|
-
Account (tenant/organization)
|
|
116
|
-
└── Users (members with roles)
|
|
117
|
-
└── Boards (project spaces)
|
|
118
|
-
└── Columns (workflow stages)
|
|
119
|
-
└── Cards (tasks/issues)
|
|
120
|
-
└── Comments
|
|
121
|
-
└── Assignments
|
|
122
|
-
└── Tags
|
|
123
|
-
```
|
|
124
|
-
|
|
125
|
-
**Key relationships:**
|
|
126
|
-
- **Account**: The tenant root. All data belongs to an account.
|
|
127
|
-
- **Board**: Primary organizational unit where cards live.
|
|
128
|
-
- **Card**: Main work item with a sequential number per account.
|
|
129
|
-
- **User**: Account membership with role-based permissions.
|
|
130
|
-
|
|
131
|
-
### Multi-Tenancy Pattern
|
|
132
|
-
|
|
133
|
-
It uses **URL path-based multi-tenancy** rather than subdomains or separate databases:
|
|
134
|
-
|
|
135
|
-
```
|
|
136
|
-
https://localhost:3006/{account_id}/boards/{board_id}
|
|
137
|
-
└─────────┘
|
|
138
|
-
7+ digit ID extracted by middleware
|
|
139
|
-
```
|
|
140
|
-
|
|
141
|
-
**How it works:**
|
|
142
|
-
|
|
143
|
-
1. **Middleware extraction**: `AccountSlug::Extractor` pulls the account ID from the URL path
|
|
144
|
-
2. **Path manipulation**: The slug moves from `PATH_INFO` to `SCRIPT_NAME`
|
|
145
|
-
3. **Current context**: Sets `Current.account` for the request
|
|
146
|
-
4. **Automatic scoping**: All queries automatically scoped to the account
|
|
147
|
-
|
|
148
|
-
**Why this matters:**
|
|
149
|
-
- No subdomain configuration needed
|
|
150
|
-
- Simpler local development (no DNS tricks)
|
|
151
|
-
- Single database with account_id scoping
|
|
152
|
-
- Testing doesn't require per-tenant setup
|
|
153
|
-
|
|
154
|
-
**Reference**: See middleware in `config/initializers/tenanting/account_slug.rb`
|
|
155
|
-
|
|
156
|
-
### The Current Context Pattern
|
|
157
|
-
|
|
158
|
-
It uses `ActiveSupport::CurrentAttributes` to maintain thread-safe request state:
|
|
159
|
-
|
|
160
|
-
**File**: `app/models/current.rb`
|
|
161
|
-
|
|
162
|
-
```ruby
|
|
163
|
-
class Current < ActiveSupport::CurrentAttributes
|
|
164
|
-
attribute :session, :user, :identity, :account
|
|
165
|
-
attribute :http_method, :request_id, :user_agent, :ip_address, :referrer
|
|
166
|
-
|
|
167
|
-
def session=(value)
|
|
168
|
-
super(value)
|
|
169
|
-
|
|
170
|
-
if value.present?
|
|
171
|
-
self.identity = session.identity # Cascade to identity
|
|
172
|
-
end
|
|
173
|
-
end
|
|
174
|
-
|
|
175
|
-
def identity=(identity)
|
|
176
|
-
super(identity)
|
|
177
|
-
|
|
178
|
-
if identity.present?
|
|
179
|
-
self.user = identity.users.find_by(account: account) # Resolve user
|
|
180
|
-
end
|
|
181
|
-
end
|
|
182
|
-
|
|
183
|
-
def with_account(value, &)
|
|
184
|
-
with(account: value, &)
|
|
185
|
-
end
|
|
186
|
-
end
|
|
187
|
-
```
|
|
188
|
-
|
|
189
|
-
**The cascade**: `session` → `identity` → `user`
|
|
190
|
-
|
|
191
|
-
When you set `Current.session`, it automatically:
|
|
192
|
-
1. Extracts the `identity` from the session
|
|
193
|
-
2. Finds the `user` for that identity in the current `account`
|
|
194
|
-
|
|
195
|
-
**In models, always use:**
|
|
196
|
-
- `Current.user` instead of passing `@user` everywhere
|
|
197
|
-
- `Current.account` for tenant scoping
|
|
198
|
-
|
|
199
|
-
**In tests, always set:**
|
|
200
|
-
```ruby
|
|
201
|
-
setup do
|
|
202
|
-
Current.session = sessions(:david) # Sets up user and account context
|
|
203
|
-
end
|
|
204
|
-
```
|
|
205
|
-
|
|
206
|
-
---
|
|
207
|
-
|
|
208
|
-
# Part 2: Model Layer Patterns
|
|
209
|
-
|
|
210
|
-
This is where distinctive style shines. Models contain business logic and use concerns heavily to organize behavior.
|
|
211
|
-
|
|
212
|
-
## 2.1 Concern Architecture
|
|
213
|
-
|
|
214
|
-
It uses concerns extensively to compose model behavior. This is the most distinctive pattern in the codebase.
|
|
215
|
-
|
|
216
|
-
### Two Types of Concerns
|
|
217
|
-
|
|
218
|
-
**1. Shared Concerns** (`app/models/concerns/`)
|
|
219
|
-
- Reusable across multiple models
|
|
220
|
-
- Use adjective naming (typically ending in `-able` or `-ed`)
|
|
221
|
-
- Examples: `Eventable`, `Notifiable`, `Searchable`, `Attachments`
|
|
222
|
-
|
|
223
|
-
**2. Model-Specific Concerns** (`app/models/card/`, `app/models/board/`)
|
|
224
|
-
- Tightly coupled to a single model
|
|
225
|
-
- Use namespaced naming: `ModelName::Feature`
|
|
226
|
-
- Examples: `Card::Closeable`, `Card::Golden`, `Board::Accessible`
|
|
227
|
-
|
|
228
|
-
### Concern Composition in Models
|
|
229
|
-
|
|
230
|
-
**File**: `app/models/card.rb`
|
|
231
|
-
|
|
232
|
-
```ruby
|
|
233
|
-
class Card < ApplicationRecord
|
|
234
|
-
include Assignable, Attachments, Broadcastable, Closeable, Colored, Entropic, Eventable,
|
|
235
|
-
Exportable, Golden, Mentions, Multistep, Pinnable, Postponable, Promptable,
|
|
236
|
-
Readable, Searchable, Stallable, Statuses, Storage::Tracked, Taggable, Triageable, Watchable
|
|
237
|
-
|
|
238
|
-
belongs_to :account, default: -> { board.account }
|
|
239
|
-
belongs_to :board
|
|
240
|
-
belongs_to :creator, class_name: "User", default: -> { Current.user }
|
|
241
|
-
|
|
242
|
-
# ... rest of model
|
|
243
|
-
end
|
|
244
|
-
```
|
|
245
|
-
|
|
246
|
-
**How to read this**: Each concern adds a distinct capability. The Card model is composed of 18+ behavioral modules:
|
|
247
|
-
|
|
248
|
-
- `Closeable` → can be closed and reopened
|
|
249
|
-
- `Golden` → can be marked as golden (important)
|
|
250
|
-
- `Assignable` → can have assignees
|
|
251
|
-
- `Eventable` → tracks events
|
|
252
|
-
- `Postponable` → can be postponed to "not now"
|
|
253
|
-
- etc.
|
|
254
|
-
|
|
255
|
-
### Anatomy of a Concern
|
|
256
|
-
|
|
257
|
-
**File**: `app/models/card/closeable.rb`
|
|
258
|
-
|
|
259
|
-
```ruby
|
|
260
|
-
module Card::Closeable
|
|
261
|
-
extend ActiveSupport::Concern # Required for all concerns
|
|
262
|
-
|
|
263
|
-
included do
|
|
264
|
-
# Associations, scopes, callbacks added when concern is included
|
|
265
|
-
has_one :closure, dependent: :destroy
|
|
266
|
-
|
|
267
|
-
scope :closed, -> { joins(:closure) }
|
|
268
|
-
scope :open, -> { where.missing(:closure) }
|
|
269
|
-
|
|
270
|
-
scope :recently_closed_first, -> { closed.order(closures: { created_at: :desc }) }
|
|
271
|
-
scope :closed_at_window, ->(window) { closed.where(closures: { created_at: window }) }
|
|
272
|
-
scope :closed_by, ->(users) { closed.where(closures: { user_id: Array(users) }) }
|
|
273
|
-
end
|
|
274
|
-
|
|
275
|
-
# Instance methods (public)
|
|
276
|
-
def closed?
|
|
277
|
-
closure.present?
|
|
278
|
-
end
|
|
279
|
-
|
|
280
|
-
def open?
|
|
281
|
-
!closed?
|
|
282
|
-
end
|
|
283
|
-
|
|
284
|
-
def closed_by
|
|
285
|
-
closure&.user
|
|
286
|
-
end
|
|
287
|
-
|
|
288
|
-
def closed_at
|
|
289
|
-
closure&.created_at
|
|
290
|
-
end
|
|
291
|
-
|
|
292
|
-
def close(user: Current.user)
|
|
293
|
-
unless closed?
|
|
294
|
-
transaction do
|
|
295
|
-
create_closure! user: user
|
|
296
|
-
track_event :closed, creator: user
|
|
297
|
-
end
|
|
298
|
-
end
|
|
299
|
-
end
|
|
300
|
-
|
|
301
|
-
def reopen(user: Current.user)
|
|
302
|
-
if closed?
|
|
303
|
-
transaction do
|
|
304
|
-
closure&.destroy
|
|
305
|
-
track_event :reopened, creator: user
|
|
306
|
-
end
|
|
307
|
-
end
|
|
308
|
-
end
|
|
309
|
-
end
|
|
310
|
-
```
|
|
311
|
-
|
|
312
|
-
**Pattern breakdown:**
|
|
313
|
-
1. `extend ActiveSupport::Concern` - Required first line
|
|
314
|
-
2. `included do` block - Code run when the concern is included (associations, scopes, callbacks)
|
|
315
|
-
3. Public instance methods - The concern's API
|
|
316
|
-
4. Private methods (if needed) - Indented per style guide
|
|
317
|
-
|
|
318
|
-
### Naming Conventions
|
|
319
|
-
|
|
320
|
-
**Shared concerns** (adjectives):
|
|
321
|
-
- `Eventable` - can track events
|
|
322
|
-
- `Notifiable` - can send notifications
|
|
323
|
-
- `Searchable` - indexed for search
|
|
324
|
-
- `Attachments` - can have file attachments
|
|
325
|
-
- `Mentions` - can mention users
|
|
326
|
-
|
|
327
|
-
**Model-specific concerns** (namespaced):
|
|
328
|
-
- `Card::Closeable` - card closing/reopening logic
|
|
329
|
-
- `Card::Golden` - golden card functionality
|
|
330
|
-
- `Card::Postponable` - postponement logic
|
|
331
|
-
- `Board::Accessible` - board access control
|
|
332
|
-
- `Board::Storage` - storage calculation
|
|
333
|
-
|
|
334
|
-
### Template Method Pattern
|
|
335
|
-
|
|
336
|
-
Concerns can define override points for including models to customize behavior.
|
|
337
|
-
|
|
338
|
-
**Base concern** (`app/models/concerns/eventable.rb`):
|
|
339
|
-
|
|
340
|
-
```ruby
|
|
341
|
-
module Eventable
|
|
342
|
-
extend ActiveSupport::Concern
|
|
343
|
-
|
|
344
|
-
included do
|
|
345
|
-
has_many :events, as: :eventable, dependent: :destroy
|
|
346
|
-
end
|
|
347
|
-
|
|
348
|
-
def track_event(action, creator: Current.user, board: self.board, **particulars)
|
|
349
|
-
if should_track_event? # ← Template method
|
|
350
|
-
board.events.create!(
|
|
351
|
-
action: "#{eventable_prefix}_#{action}", # ← Template method
|
|
352
|
-
creator:,
|
|
353
|
-
board:,
|
|
354
|
-
eventable: self,
|
|
355
|
-
particulars:
|
|
356
|
-
)
|
|
357
|
-
end
|
|
358
|
-
end
|
|
359
|
-
|
|
360
|
-
def event_was_created(event) # ← Template method
|
|
361
|
-
# Override in specific concerns
|
|
362
|
-
end
|
|
363
|
-
|
|
364
|
-
private
|
|
365
|
-
def should_track_event? # ← Template method (default implementation)
|
|
366
|
-
true
|
|
367
|
-
end
|
|
368
|
-
|
|
369
|
-
def eventable_prefix # ← Template method (default implementation)
|
|
370
|
-
self.class.name.demodulize.underscore
|
|
371
|
-
end
|
|
372
|
-
end
|
|
373
|
-
```
|
|
374
|
-
|
|
375
|
-
**Card-specific override** (`app/models/card/eventable.rb`):
|
|
376
|
-
|
|
377
|
-
```ruby
|
|
378
|
-
module Card::Eventable
|
|
379
|
-
extend ActiveSupport::Concern
|
|
380
|
-
|
|
381
|
-
include ::Eventable # ← Includes the base concern
|
|
382
|
-
|
|
383
|
-
included do
|
|
384
|
-
before_create { self.last_active_at ||= created_at || Time.current }
|
|
385
|
-
after_save :track_title_change, if: :saved_change_to_title?
|
|
386
|
-
end
|
|
387
|
-
|
|
388
|
-
def event_was_created(event) # ← Overrides template method
|
|
389
|
-
transaction do
|
|
390
|
-
create_system_comment_for(event)
|
|
391
|
-
touch_last_active_at unless was_just_published?
|
|
392
|
-
end
|
|
393
|
-
end
|
|
394
|
-
|
|
395
|
-
private
|
|
396
|
-
def should_track_event? # ← Overrides template method
|
|
397
|
-
published? # Only track events for published cards
|
|
398
|
-
end
|
|
399
|
-
|
|
400
|
-
def track_title_change
|
|
401
|
-
if title_before_last_save.present?
|
|
402
|
-
track_event "title_changed",
|
|
403
|
-
particulars: { old_title: title_before_last_save, new_title: title }
|
|
404
|
-
end
|
|
405
|
-
end
|
|
406
|
-
end
|
|
407
|
-
```
|
|
408
|
-
|
|
409
|
-
**The pattern**: Base concern provides default behavior with override points. Model-specific concerns customize by overriding those methods.
|
|
410
|
-
|
|
411
|
-
### When to Create a Concern
|
|
412
|
-
|
|
413
|
-
**Create a shared concern when:**
|
|
414
|
-
- 3+ unrelated models need the same behavior
|
|
415
|
-
- The behavior is cross-cutting (events, search, notifications)
|
|
416
|
-
- The functionality is self-contained with clear boundaries
|
|
417
|
-
|
|
418
|
-
**Create a model-specific concern when:**
|
|
419
|
-
- A model has complex domain behavior
|
|
420
|
-
- You can extract 50+ lines into a cohesive module
|
|
421
|
-
- The feature has multiple methods working together
|
|
422
|
-
- Examples: state management, complex calculations, batch operations
|
|
423
|
-
|
|
424
|
-
**Don't create a concern when:**
|
|
425
|
-
- You have only 1-2 simple methods
|
|
426
|
-
- The code is unique to one place
|
|
427
|
-
- You're just grouping unrelated methods
|
|
428
|
-
|
|
429
|
-
### Concern Layering
|
|
430
|
-
|
|
431
|
-
Concerns can include other concerns:
|
|
432
|
-
|
|
433
|
-
```ruby
|
|
434
|
-
module Card::Eventable
|
|
435
|
-
extend ActiveSupport::Concern
|
|
436
|
-
|
|
437
|
-
include ::Eventable # ← Card::Eventable layers on top of Eventable
|
|
438
|
-
|
|
439
|
-
# Additional card-specific behavior
|
|
440
|
-
end
|
|
441
|
-
```
|
|
442
|
-
|
|
443
|
-
### Combining Concerns with Object Composition
|
|
444
|
-
|
|
445
|
-
Concerns are not a silver bullet. They organize large API surfaces into cohesive modules, but complex logic shouldn't live entirely inside concerns. The pattern: **concerns provide the entry point and framework integration; dedicated plain Ruby objects handle the complexity.**
|
|
446
|
-
|
|
447
|
-
**Example 1: Notifiable → Notifier hierarchy**
|
|
448
|
-
|
|
449
|
-
The `Notifiable` concern defines the interface, but delegates the actual work:
|
|
450
|
-
|
|
451
|
-
**File**: `app/models/concerns/notifiable.rb`
|
|
452
|
-
|
|
453
|
-
```ruby
|
|
454
|
-
module Notifiable
|
|
455
|
-
extend ActiveSupport::Concern
|
|
456
|
-
|
|
457
|
-
included do
|
|
458
|
-
has_many :notifications, as: :source, dependent: :destroy
|
|
459
|
-
after_create_commit :notify_recipients_later
|
|
460
|
-
end
|
|
461
|
-
|
|
462
|
-
def notify_recipients
|
|
463
|
-
Notifier.for(self)&.notify # ← Delegates to a dedicated object
|
|
464
|
-
end
|
|
465
|
-
end
|
|
466
|
-
```
|
|
467
|
-
|
|
468
|
-
The `Notifier` class uses a factory method and template method pattern — a hierarchy of objects where each subclass customizes recipient logic:
|
|
469
|
-
|
|
470
|
-
```ruby
|
|
471
|
-
# app/models/notifier.rb
|
|
472
|
-
class Notifier
|
|
473
|
-
def self.for(source)
|
|
474
|
-
# Returns the right notifier subclass based on source type
|
|
475
|
-
end
|
|
476
|
-
|
|
477
|
-
def notify
|
|
478
|
-
recipients.each { |user| create_notification_for(user) }
|
|
479
|
-
end
|
|
480
|
-
|
|
481
|
-
def recipients # ← Template method, overridden by subclasses
|
|
482
|
-
raise NotImplementedError
|
|
483
|
-
end
|
|
484
|
-
end
|
|
485
|
-
|
|
486
|
-
# app/models/mention/notifier.rb
|
|
487
|
-
class Mention::Notifier < Notifier
|
|
488
|
-
def recipients
|
|
489
|
-
# Mention-specific recipient logic
|
|
490
|
-
end
|
|
491
|
-
end
|
|
492
|
-
```
|
|
493
|
-
|
|
494
|
-
The concern organizes the API surface (`notify_recipients`, the callback, the association). The Notifier hierarchy handles the complexity.
|
|
495
|
-
|
|
496
|
-
**Example 2: Stallable → ActivitySpike::Detector**
|
|
497
|
-
|
|
498
|
-
The `Card::Stallable` concern detects when cards go cold after a burst of activity:
|
|
499
|
-
|
|
500
|
-
**File**: `app/models/card/stallable.rb`
|
|
501
|
-
|
|
502
|
-
```ruby
|
|
503
|
-
module Card::Stallable
|
|
504
|
-
extend ActiveSupport::Concern
|
|
505
|
-
|
|
506
|
-
included do
|
|
507
|
-
has_one :activity_spike, dependent: :destroy
|
|
508
|
-
after_update :detect_activity_spikes_later
|
|
509
|
-
end
|
|
510
|
-
|
|
511
|
-
def detect_activity_spikes
|
|
512
|
-
Card::ActivitySpike::Detector.new(self).detect # ← Delegates to dedicated object
|
|
513
|
-
end
|
|
514
|
-
end
|
|
515
|
-
```
|
|
516
|
-
|
|
517
|
-
The detection logic — "is this card entropic? Did multiple people comment? Was it just assigned?" — lives in `Card::ActivitySpike::Detector`, not in the concern itself.
|
|
518
|
-
|
|
519
|
-
**The pattern:**
|
|
520
|
-
|
|
521
|
-
```
|
|
522
|
-
Concern (framework integration) Plain Ruby Object (business logic)
|
|
523
|
-
───────────────────────────────── ──────────────────────────────────
|
|
524
|
-
• Associations, scopes, callbacks • Complex decision logic
|
|
525
|
-
• Public API methods • Hierarchies and composition
|
|
526
|
-
• Framework hooks • Algorithms and calculations
|
|
527
|
-
│ ▲
|
|
528
|
-
└── delegates to ──────────────────────┘
|
|
529
|
-
```
|
|
530
|
-
|
|
531
|
-
This combination gives you the best of both worlds: concerns keep your model's API organized and lightweight, while proper object-oriented design handles the complexity behind the scenes.
|
|
532
|
-
|
|
533
|
-
## 2.2 Intention-Revealing APIs
|
|
534
|
-
|
|
535
|
-
It emphasizes method names that read like business domain language. Models provide APIs that express intent clearly.
|
|
536
|
-
|
|
537
|
-
### Boolean Query Methods
|
|
538
|
-
|
|
539
|
-
Always provide both positive and negative checks:
|
|
540
|
-
|
|
541
|
-
**File**: `app/models/card/closeable.rb`
|
|
542
|
-
|
|
543
|
-
```ruby
|
|
544
|
-
def closed?
|
|
545
|
-
closure.present?
|
|
546
|
-
end
|
|
547
|
-
|
|
548
|
-
def open?
|
|
549
|
-
!closed?
|
|
550
|
-
end
|
|
551
|
-
```
|
|
552
|
-
|
|
553
|
-
**File**: `app/models/card/golden.rb`
|
|
554
|
-
|
|
555
|
-
```ruby
|
|
556
|
-
def golden?
|
|
557
|
-
goldness.present?
|
|
558
|
-
end
|
|
559
|
-
|
|
560
|
-
# Usage in code:
|
|
561
|
-
if card.closed?
|
|
562
|
-
# ...
|
|
563
|
-
end
|
|
564
|
-
|
|
565
|
-
if card.open? && card.golden?
|
|
566
|
-
# ...
|
|
567
|
-
end
|
|
568
|
-
```
|
|
569
|
-
|
|
570
|
-
**Pattern**: Boolean methods end with `?` and read naturally in conditions.
|
|
571
|
-
|
|
572
|
-
### Action Methods (Imperative Verbs)
|
|
573
|
-
|
|
574
|
-
Use clear imperative verbs for actions that change state:
|
|
575
|
-
|
|
576
|
-
**File**: `app/models/card/golden.rb`
|
|
577
|
-
|
|
578
|
-
```ruby
|
|
579
|
-
def gild
|
|
580
|
-
create_goldness! unless golden?
|
|
581
|
-
end
|
|
582
|
-
|
|
583
|
-
def ungild
|
|
584
|
-
goldness&.destroy
|
|
585
|
-
end
|
|
586
|
-
```
|
|
587
|
-
|
|
588
|
-
**File**: `app/models/card/closeable.rb`
|
|
589
|
-
|
|
590
|
-
```ruby
|
|
591
|
-
def close(user: Current.user)
|
|
592
|
-
unless closed?
|
|
593
|
-
transaction do
|
|
594
|
-
create_closure! user: user
|
|
595
|
-
track_event :closed, creator: user
|
|
596
|
-
end
|
|
597
|
-
end
|
|
598
|
-
end
|
|
599
|
-
|
|
600
|
-
def reopen(user: Current.user)
|
|
601
|
-
if closed?
|
|
602
|
-
transaction do
|
|
603
|
-
closure&.destroy
|
|
604
|
-
track_event :reopened, creator: user
|
|
605
|
-
end
|
|
606
|
-
end
|
|
607
|
-
end
|
|
608
|
-
```
|
|
609
|
-
|
|
610
|
-
**Notice the pattern:**
|
|
611
|
-
- `close` / `reopen` (not `closed` / `unclosed`)
|
|
612
|
-
- `gild` / `ungild` (not `make_golden` / `remove_golden`)
|
|
613
|
-
- Clear opposites that read naturally
|
|
614
|
-
|
|
615
|
-
### Delegation for Readability
|
|
616
|
-
|
|
617
|
-
Use delegation to create more readable APIs:
|
|
618
|
-
|
|
619
|
-
**File**: `app/models/card/closeable.rb`
|
|
620
|
-
|
|
621
|
-
```ruby
|
|
622
|
-
def closed_by
|
|
623
|
-
closure&.user
|
|
624
|
-
end
|
|
625
|
-
|
|
626
|
-
def closed_at
|
|
627
|
-
closure&.created_at
|
|
628
|
-
end
|
|
629
|
-
|
|
630
|
-
# Usage:
|
|
631
|
-
card.closed_by # ← Reads nicely
|
|
632
|
-
# vs
|
|
633
|
-
card.closure&.user # ← Leaks implementation details
|
|
634
|
-
```
|
|
635
|
-
|
|
636
|
-
### Complex Actions with Transactions
|
|
637
|
-
|
|
638
|
-
Multi-step operations wrap everything in transactions and track events:
|
|
639
|
-
|
|
640
|
-
**File**: `app/models/card.rb`
|
|
641
|
-
|
|
642
|
-
```ruby
|
|
643
|
-
def handle_board_change
|
|
644
|
-
old_board = account.boards.find_by(id: board_id_before_last_save)
|
|
645
|
-
|
|
646
|
-
transaction do
|
|
647
|
-
update! column: nil # 1. Clear column
|
|
648
|
-
track_board_change_event(old_board.name) # 2. Track event
|
|
649
|
-
grant_access_to_assignees unless board.all_access? # 3. Grant access
|
|
650
|
-
end
|
|
651
|
-
|
|
652
|
-
remove_inaccessible_notifications_later # 4. Async cleanup
|
|
653
|
-
end
|
|
654
|
-
|
|
655
|
-
private
|
|
656
|
-
def track_board_change_event(old_board_name)
|
|
657
|
-
track_event "board_changed",
|
|
658
|
-
particulars: { old_board: old_board_name, new_board: board.name }
|
|
659
|
-
end
|
|
660
|
-
|
|
661
|
-
def grant_access_to_assignees
|
|
662
|
-
board.accesses.grant_to(assignees)
|
|
663
|
-
end
|
|
664
|
-
```
|
|
665
|
-
|
|
666
|
-
**Pattern**:
|
|
667
|
-
1. **Transaction boundary** - All or nothing
|
|
668
|
-
2. **Multiple state changes** - Related updates together
|
|
669
|
-
3. **Event tracking** - Always inside the transaction
|
|
670
|
-
4. **Async operations** - Outside the transaction (can fail separately)
|
|
671
|
-
|
|
672
|
-
### Anti-Patterns to Avoid
|
|
673
|
-
|
|
674
|
-
```ruby
|
|
675
|
-
# ✗ Bad: Generic method names
|
|
676
|
-
def process
|
|
677
|
-
def handle
|
|
678
|
-
def do_something
|
|
679
|
-
|
|
680
|
-
# ✓ Good: Intention-revealing names
|
|
681
|
-
def close
|
|
682
|
-
def postpone
|
|
683
|
-
def assign_to(user)
|
|
684
|
-
|
|
685
|
-
# ✗ Bad: Leaking implementation details
|
|
686
|
-
def create_closure_record
|
|
687
|
-
def set_golden_flag
|
|
688
|
-
|
|
689
|
-
# ✓ Good: Domain concepts
|
|
690
|
-
def close
|
|
691
|
-
def gild
|
|
692
|
-
|
|
693
|
-
# ✗ Bad: Unclear what happens
|
|
694
|
-
def update_state(value)
|
|
695
|
-
|
|
696
|
-
# ✓ Good: Explicit actions
|
|
697
|
-
def close
|
|
698
|
-
def reopen
|
|
699
|
-
```
|
|
700
|
-
|
|
701
|
-
## 2.3 Smart Association Defaults
|
|
702
|
-
|
|
703
|
-
It uses lambda defaults on `belongs_to` associations to automatically propagate context. This reduces boilerplate and enforces multi-tenancy.
|
|
704
|
-
|
|
705
|
-
### Basic Pattern
|
|
706
|
-
|
|
707
|
-
**File**: `app/models/card.rb`
|
|
708
|
-
|
|
709
|
-
```ruby
|
|
710
|
-
class Card < ApplicationRecord
|
|
711
|
-
belongs_to :account, default: -> { board.account } # ← Get account from board
|
|
712
|
-
belongs_to :board # ← Must declare before use
|
|
713
|
-
belongs_to :creator, class_name: "User", default: -> { Current.user }
|
|
714
|
-
|
|
715
|
-
# Now when creating a card:
|
|
716
|
-
# Card.create!(board: some_board, title: "...")
|
|
717
|
-
#
|
|
718
|
-
# Automatically sets:
|
|
719
|
-
# - account (from board.account)
|
|
720
|
-
# - creator (from Current.user)
|
|
721
|
-
end
|
|
722
|
-
```
|
|
723
|
-
|
|
724
|
-
**Why this matters:**
|
|
725
|
-
|
|
726
|
-
```ruby
|
|
727
|
-
# Without lambda defaults:
|
|
728
|
-
Card.create!(
|
|
729
|
-
board: board,
|
|
730
|
-
account: board.account, # ← Repetitive
|
|
731
|
-
creator: Current.user, # ← Easy to forget
|
|
732
|
-
title: "New card"
|
|
733
|
-
)
|
|
734
|
-
|
|
735
|
-
# With lambda defaults:
|
|
736
|
-
Card.create!(
|
|
737
|
-
board: board,
|
|
738
|
-
title: "New card"
|
|
739
|
-
)
|
|
740
|
-
# account and creator set automatically!
|
|
741
|
-
```
|
|
742
|
-
|
|
743
|
-
### Declaration Order Matters
|
|
744
|
-
|
|
745
|
-
You must declare associations before using them in defaults:
|
|
746
|
-
|
|
747
|
-
```ruby
|
|
748
|
-
# ✓ Correct order
|
|
749
|
-
belongs_to :board # Declare first
|
|
750
|
-
belongs_to :account, default: -> { board.account } # Use after
|
|
751
|
-
|
|
752
|
-
# ✗ Wrong order
|
|
753
|
-
belongs_to :account, default: -> { board.account } # Error! board not declared yet
|
|
754
|
-
belongs_to :board
|
|
755
|
-
```
|
|
756
|
-
|
|
757
|
-
### Current Context Integration
|
|
758
|
-
|
|
759
|
-
Lambda defaults commonly use `Current.user` for automatic creator tracking:
|
|
760
|
-
|
|
761
|
-
**File**: `app/models/event.rb`
|
|
762
|
-
|
|
763
|
-
```ruby
|
|
764
|
-
class Event < ApplicationRecord
|
|
765
|
-
belongs_to :board
|
|
766
|
-
belongs_to :account, default: -> { board.account }
|
|
767
|
-
belongs_to :creator, class_name: "User", default: -> { Current.user }
|
|
768
|
-
belongs_to :eventable, polymorphic: true
|
|
769
|
-
|
|
770
|
-
# Usage in models:
|
|
771
|
-
# board.events.create!(action: "card_closed", eventable: card)
|
|
772
|
-
# Automatically sets account and creator!
|
|
773
|
-
end
|
|
774
|
-
```
|
|
775
|
-
|
|
776
|
-
### Multi-Tenancy Enforcement
|
|
777
|
-
|
|
778
|
-
Lambda defaults help enforce multi-tenancy automatically:
|
|
779
|
-
|
|
780
|
-
**File**: `app/models/access.rb`
|
|
781
|
-
|
|
782
|
-
```ruby
|
|
783
|
-
class Access < ApplicationRecord
|
|
784
|
-
belongs_to :account, default: -> { user.account } # ← Account from user
|
|
785
|
-
belongs_to :board, touch: true
|
|
786
|
-
belongs_to :user, touch: true
|
|
787
|
-
end
|
|
788
|
-
```
|
|
789
|
-
|
|
790
|
-
Every record automatically gets its `account_id` set, maintaining tenant isolation.
|
|
791
|
-
|
|
792
|
-
### When to Use Lambda Defaults
|
|
793
|
-
|
|
794
|
-
**Always use for:**
|
|
795
|
-
- `account` - Multi-tenancy (get from parent association)
|
|
796
|
-
- `creator`/`user` - User tracking (from `Current.user`)
|
|
797
|
-
|
|
798
|
-
**Consider for:**
|
|
799
|
-
- Other contextual defaults that come from parent objects
|
|
800
|
-
- Values that are always derived from other associations
|
|
801
|
-
|
|
802
|
-
**Don't use for:**
|
|
803
|
-
- Complex business logic (use callbacks or explicit methods instead)
|
|
804
|
-
- Values that need validation or can fail
|
|
805
|
-
- Defaults that depend on instance state
|
|
806
|
-
|
|
807
|
-
## 2.4 Scopes That Tell Stories
|
|
808
|
-
|
|
809
|
-
Scopes have descriptive names that express business concepts, not SQL operations. They're composable and chainable.
|
|
810
|
-
|
|
811
|
-
### Naming Conventions
|
|
812
|
-
|
|
813
|
-
**State filters** (adjectives):
|
|
814
|
-
|
|
815
|
-
**File**: `app/models/card/closeable.rb`
|
|
816
|
-
|
|
817
|
-
```ruby
|
|
818
|
-
scope :closed, -> { joins(:closure) }
|
|
819
|
-
scope :open, -> { where.missing(:closure) }
|
|
820
|
-
```
|
|
821
|
-
|
|
822
|
-
**Ordering** (adverbs):
|
|
823
|
-
|
|
824
|
-
**File**: `app/models/card.rb`
|
|
825
|
-
|
|
826
|
-
```ruby
|
|
827
|
-
scope :reverse_chronologically, -> { order created_at: :desc, id: :desc }
|
|
828
|
-
scope :chronologically, -> { order created_at: :asc, id: :asc }
|
|
829
|
-
scope :latest, -> { order last_active_at: :desc, id: :desc }
|
|
830
|
-
```
|
|
831
|
-
|
|
832
|
-
**Time-based** (gerunds or descriptions):
|
|
833
|
-
|
|
834
|
-
```ruby
|
|
835
|
-
scope :postponing_soon, -> { ... }
|
|
836
|
-
scope :due_to_be_postponed, -> { ... }
|
|
837
|
-
```
|
|
838
|
-
|
|
839
|
-
### Composable Scopes
|
|
840
|
-
|
|
841
|
-
Scopes return `ActiveRecord::Relation` so they can be chained:
|
|
842
|
-
|
|
843
|
-
```ruby
|
|
844
|
-
# All of these work:
|
|
845
|
-
Card.open
|
|
846
|
-
Card.open.latest
|
|
847
|
-
Card.open.golden.latest
|
|
848
|
-
Card.closed.closed_by([user1, user2])
|
|
849
|
-
```
|
|
850
|
-
|
|
851
|
-
**File**: `app/models/card/closeable.rb`
|
|
852
|
-
|
|
853
|
-
```ruby
|
|
854
|
-
scope :closed, -> { joins(:closure) }
|
|
855
|
-
scope :open, -> { where.missing(:closure) }
|
|
856
|
-
|
|
857
|
-
scope :recently_closed_first, -> { closed.order(closures: { created_at: :desc }) }
|
|
858
|
-
scope :closed_at_window, ->(window) { closed.where(closures: { created_at: window }) }
|
|
859
|
-
scope :closed_by, ->(users) { closed.where(closures: { user_id: Array(users) }) }
|
|
860
|
-
```
|
|
861
|
-
|
|
862
|
-
### Conditional Scopes for UI
|
|
863
|
-
|
|
864
|
-
Use case statements in scopes to map UI concepts to queries:
|
|
865
|
-
|
|
866
|
-
**File**: `app/models/card.rb`
|
|
867
|
-
|
|
868
|
-
```ruby
|
|
869
|
-
scope :indexed_by, ->(index) do
|
|
870
|
-
case index
|
|
871
|
-
when "stalled" then stalled
|
|
872
|
-
when "postponing_soon" then postponing_soon
|
|
873
|
-
when "closed" then closed
|
|
874
|
-
when "not_now" then postponed.latest
|
|
875
|
-
when "golden" then golden
|
|
876
|
-
when "draft" then drafted
|
|
877
|
-
else all
|
|
878
|
-
end
|
|
879
|
-
end
|
|
880
|
-
|
|
881
|
-
scope :sorted_by, ->(sort) do
|
|
882
|
-
case sort
|
|
883
|
-
when "newest" then reverse_chronologically
|
|
884
|
-
when "oldest" then chronologically
|
|
885
|
-
when "latest" then latest
|
|
886
|
-
else latest
|
|
887
|
-
end
|
|
888
|
-
end
|
|
889
|
-
|
|
890
|
-
# Usage in controllers:
|
|
891
|
-
@cards = board.cards.indexed_by(params[:index]).sorted_by(params[:sort])
|
|
892
|
-
```
|
|
893
|
-
|
|
894
|
-
This keeps conditional logic out of controllers.
|
|
895
|
-
|
|
896
|
-
### Preloading Scopes
|
|
897
|
-
|
|
898
|
-
Define scopes for eager loading to prevent N+1 queries:
|
|
899
|
-
|
|
900
|
-
**File**: `app/models/card.rb`
|
|
901
|
-
|
|
902
|
-
```ruby
|
|
903
|
-
scope :with_users, -> {
|
|
904
|
-
preload(
|
|
905
|
-
creator: [ :avatar_attachment, :account ],
|
|
906
|
-
assignees: [ :avatar_attachment, :account ]
|
|
907
|
-
)
|
|
908
|
-
}
|
|
909
|
-
|
|
910
|
-
scope :preloaded, -> {
|
|
911
|
-
with_users
|
|
912
|
-
.preload(:column, :tags, :steps, :closure, :goldness, :activity_spike,
|
|
913
|
-
:image_attachment, board: [ :entropy, :columns ], not_now: [ :user ])
|
|
914
|
-
.with_rich_text_description_and_embeds
|
|
915
|
-
}
|
|
916
|
-
|
|
917
|
-
# Usage:
|
|
918
|
-
@cards = board.cards.preloaded # ← Single query loads everything
|
|
919
|
-
```
|
|
920
|
-
|
|
921
|
-
### Best Practices
|
|
922
|
-
|
|
923
|
-
```ruby
|
|
924
|
-
# ✓ Good: Descriptive business name
|
|
925
|
-
scope :closed, -> { joins(:closure) }
|
|
926
|
-
|
|
927
|
-
# ✗ Bad: SQL operation name
|
|
928
|
-
scope :with_closures, -> { joins(:closure) }
|
|
929
|
-
|
|
930
|
-
# ✓ Good: Chainable
|
|
931
|
-
scope :closed, -> { joins(:closure) }
|
|
932
|
-
scope :closed_by, ->(users) { closed.where(closures: { user_id: users }) }
|
|
933
|
-
|
|
934
|
-
# ✗ Bad: Not chainable (returns array)
|
|
935
|
-
scope :closed_list, -> { closed.to_a }
|
|
936
|
-
|
|
937
|
-
# ✓ Good: Use lambda for stability
|
|
938
|
-
scope :active, -> { where(status: 'active') }
|
|
939
|
-
|
|
940
|
-
# ✗ Bad: Direct block (evaluated at load time)
|
|
941
|
-
scope :active, where(status: 'active')
|
|
942
|
-
```
|
|
943
|
-
|
|
944
|
-
## 2.5 Callbacks: When and How
|
|
945
|
-
|
|
946
|
-
### The "Whenever X Happens" Test
|
|
947
|
-
|
|
948
|
-
Callbacks are controversial in Rails, but the controversy comes from misuse, not from callbacks themselves. The right mental model: **if you can naturally say "whenever X happens, do Y" as a human describing the system, a callback is the right tool.**
|
|
949
|
-
|
|
950
|
-
Examples where callbacks fit naturally:
|
|
951
|
-
- "Whenever a card changes, detect activity spikes" → `after_update :detect_activity_spikes_later`
|
|
952
|
-
- "Whenever a notifiable object is created, notify recipients" → `after_create_commit :notify_recipients_later`
|
|
953
|
-
- "Whenever a card's title changes, track the change" → `after_save :track_title_change, if: :saved_change_to_title?`
|
|
954
|
-
|
|
955
|
-
The key insight: callbacks express **reactive, cross-cutting behavior** that should fire regardless of *how* the change happens. If you introduce a new way of commenting on a card, the activity spike detection system keeps working automatically — that's good design.
|
|
956
|
-
|
|
957
|
-
But having callbacks available doesn't mean using them everywhere. **Explicit invocation is the default.** For example, in `Card::Triageable`, `track_event` is called explicitly inside `send_back_to_triage` — not via a callback — because event tracking there is part of the specific operation, not a cross-cutting reactive concern.
|
|
958
|
-
|
|
959
|
-
### Minimal Callback Guidelines
|
|
960
|
-
|
|
961
|
-
**Use callbacks for:**
|
|
962
|
-
- Data consistency (setting required fields)
|
|
963
|
-
- Triggering async operations
|
|
964
|
-
- Touching associations
|
|
965
|
-
|
|
966
|
-
**Don't use callbacks for:**
|
|
967
|
-
- Business logic (use explicit methods instead)
|
|
968
|
-
- Complex orchestration
|
|
969
|
-
- Anything users might want to skip
|
|
970
|
-
|
|
971
|
-
### Common Patterns
|
|
972
|
-
|
|
973
|
-
#### `before_create` - Set Required Data
|
|
974
|
-
|
|
975
|
-
**File**: `app/models/card.rb`
|
|
976
|
-
|
|
977
|
-
```ruby
|
|
978
|
-
before_create :assign_number
|
|
979
|
-
|
|
980
|
-
private
|
|
981
|
-
def assign_number
|
|
982
|
-
self.number ||= account.increment!(:cards_count).cards_count
|
|
983
|
-
end
|
|
984
|
-
```
|
|
985
|
-
|
|
986
|
-
Use `before_create` for data that must be set before saving:
|
|
987
|
-
- Sequential numbers
|
|
988
|
-
- Default values based on other records
|
|
989
|
-
- ID generation
|
|
990
|
-
|
|
991
|
-
#### `after_create_commit` - Async Operations
|
|
992
|
-
|
|
993
|
-
**File**: `app/models/concerns/notifiable.rb`
|
|
994
|
-
|
|
995
|
-
```ruby
|
|
996
|
-
module Notifiable
|
|
997
|
-
extend ActiveSupport::Concern
|
|
998
|
-
|
|
999
|
-
included do
|
|
1000
|
-
has_many :notifications, as: :source, dependent: :destroy
|
|
1001
|
-
|
|
1002
|
-
after_create_commit :notify_recipients_later # ← Note the _commit
|
|
1003
|
-
end
|
|
1004
|
-
|
|
1005
|
-
private
|
|
1006
|
-
def notify_recipients_later
|
|
1007
|
-
NotifyRecipientsJob.perform_later self
|
|
1008
|
-
end
|
|
1009
|
-
end
|
|
1010
|
-
```
|
|
1011
|
-
|
|
1012
|
-
**Why `_commit`?**
|
|
1013
|
-
- Only runs after transaction successfully commits
|
|
1014
|
-
- Prevents jobs from running for rolled-back records
|
|
1015
|
-
- Standard pattern for background jobs
|
|
1016
|
-
|
|
1017
|
-
#### `after_save` - Touch Associations
|
|
1018
|
-
|
|
1019
|
-
**File**: `app/models/card.rb`
|
|
1020
|
-
|
|
1021
|
-
```ruby
|
|
1022
|
-
after_save -> { board.touch }, if: :published?
|
|
1023
|
-
after_touch -> { board.touch }, if: :published?
|
|
1024
|
-
```
|
|
1025
|
-
|
|
1026
|
-
Cascade touch events to parent associations for cache invalidation.
|
|
1027
|
-
|
|
1028
|
-
#### Conditional Callbacks
|
|
1029
|
-
|
|
1030
|
-
Use `if:`/`unless:` to run callbacks conditionally:
|
|
1031
|
-
|
|
1032
|
-
**File**: `app/models/card.rb`
|
|
1033
|
-
|
|
1034
|
-
```ruby
|
|
1035
|
-
after_update :handle_board_change, if: :saved_change_to_board_id?
|
|
1036
|
-
|
|
1037
|
-
# Only runs when board_id changes
|
|
1038
|
-
```
|
|
1039
|
-
|
|
1040
|
-
**File**: `app/models/card/eventable.rb`
|
|
1041
|
-
|
|
1042
|
-
```ruby
|
|
1043
|
-
after_save :track_title_change, if: :saved_change_to_title?
|
|
1044
|
-
|
|
1045
|
-
private
|
|
1046
|
-
def track_title_change
|
|
1047
|
-
if title_before_last_save.present?
|
|
1048
|
-
track_event "title_changed",
|
|
1049
|
-
particulars: { old_title: title_before_last_save, new_title: title }
|
|
1050
|
-
end
|
|
1051
|
-
end
|
|
1052
|
-
```
|
|
1053
|
-
|
|
1054
|
-
### Lambda Callbacks for Simple Operations
|
|
1055
|
-
|
|
1056
|
-
For single-line operations, use lambda callbacks:
|
|
1057
|
-
|
|
1058
|
-
```ruby
|
|
1059
|
-
# ✓ Good: Simple one-liner
|
|
1060
|
-
after_save -> { board.touch }, if: :published?
|
|
1061
|
-
|
|
1062
|
-
# ✗ Overkill: Method for one line
|
|
1063
|
-
after_save :touch_board, if: :published?
|
|
1064
|
-
|
|
1065
|
-
private
|
|
1066
|
-
def touch_board
|
|
1067
|
-
board.touch
|
|
1068
|
-
end
|
|
1069
|
-
```
|
|
1070
|
-
|
|
1071
|
-
### Anti-Patterns
|
|
1072
|
-
|
|
1073
|
-
```ruby
|
|
1074
|
-
# ✗ Bad: Complex business logic in callback
|
|
1075
|
-
after_create :send_notifications_and_update_metrics
|
|
1076
|
-
|
|
1077
|
-
private
|
|
1078
|
-
def send_notifications_and_update_metrics
|
|
1079
|
-
# 50 lines of logic
|
|
1080
|
-
end
|
|
1081
|
-
|
|
1082
|
-
# ✓ Good: Explicit method called from controller
|
|
1083
|
-
def publish
|
|
1084
|
-
transaction do
|
|
1085
|
-
update!(status: 'published')
|
|
1086
|
-
send_notifications
|
|
1087
|
-
update_metrics
|
|
1088
|
-
end
|
|
1089
|
-
end
|
|
1090
|
-
|
|
1091
|
-
# ✗ Bad: Callbacks that prevent standard operations
|
|
1092
|
-
before_destroy :prevent_if_has_comments
|
|
1093
|
-
|
|
1094
|
-
# ✓ Good: Explicit methods
|
|
1095
|
-
def can_destroy?
|
|
1096
|
-
comments.none?
|
|
1097
|
-
end
|
|
1098
|
-
```
|
|
1099
|
-
|
|
1100
|
-
## 2.6 Why Not Service Objects
|
|
1101
|
-
|
|
1102
|
-
It deliberately avoids service objects. This isn't a minor style preference — it's a core architectural decision rooted in domain-driven design principles.
|
|
1103
|
-
|
|
1104
|
-
### Controllers Already Fill This Role
|
|
1105
|
-
|
|
1106
|
-
In DDD, the "application service" layer connects the external world with the domain model. It orchestrates domain entities to satisfy business needs. Rails controllers already do exactly this:
|
|
1107
|
-
|
|
1108
|
-
**File**: `app/controllers/cards/closures_controller.rb`
|
|
1109
|
-
|
|
1110
|
-
```ruby
|
|
1111
|
-
class Cards::ClosuresController < ApplicationController
|
|
1112
|
-
include CardScoped
|
|
1113
|
-
|
|
1114
|
-
def create
|
|
1115
|
-
@card.close # ← Orchestrating domain logic from the boundary
|
|
1116
|
-
|
|
1117
|
-
respond_to do |format|
|
|
1118
|
-
format.turbo_stream { render_card_replacement }
|
|
1119
|
-
end
|
|
1120
|
-
end
|
|
1121
|
-
end
|
|
1122
|
-
```
|
|
1123
|
-
|
|
1124
|
-
DDD proposed service objects before Rails existed. Rails controllers fulfill that role — they sit at the boundary and orchestrate domain entities. Adding service objects on top creates a redundant layer.
|
|
1125
|
-
|
|
1126
|
-
### The Boilerplate Problem
|
|
1127
|
-
|
|
1128
|
-
If you use service objects the way DDD intends (for orchestration, not business logic), they become one-line wrappers:
|
|
1129
|
-
|
|
1130
|
-
```ruby
|
|
1131
|
-
# ✗ Service object that just wraps a domain model call
|
|
1132
|
-
class SendBackToTriage
|
|
1133
|
-
def initialize(card)
|
|
1134
|
-
@card = card
|
|
1135
|
-
end
|
|
1136
|
-
|
|
1137
|
-
def call
|
|
1138
|
-
@card.send_back_to_triage # ← Just wrapping one line
|
|
1139
|
-
end
|
|
1140
|
-
end
|
|
1141
|
-
|
|
1142
|
-
@card.send_back_to_triage
|
|
1143
|
-
```
|
|
1144
|
-
|
|
1145
|
-
You've replaced a single line of code with an entire class for no benefit. Controllers already provide the orchestration context.
|
|
1146
|
-
|
|
1147
|
-
### The Real Danger: Anemic Domain Models
|
|
1148
|
-
|
|
1149
|
-
The bigger problem comes when developers put business logic *inside* service objects. This is a well-known anti-pattern identified by Eric Evans (DDD, 2003) and Martin Fowler ("Anemic Domain Model," 2003):
|
|
1150
|
-
|
|
1151
|
-
```ruby
|
|
1152
|
-
# ✗ Dangerous: business logic lives in the service object
|
|
1153
|
-
class SendBackToTriageService
|
|
1154
|
-
def initialize(card)
|
|
1155
|
-
@card = card
|
|
1156
|
-
end
|
|
1157
|
-
|
|
1158
|
-
def call
|
|
1159
|
-
@card.update!(column: nil)
|
|
1160
|
-
@card.resume # But wait — this calls another service object...
|
|
1161
|
-
ResumeService.new(@card).call
|
|
1162
|
-
@card.track_event(:sent_back_to_triage)
|
|
1163
|
-
end
|
|
1164
|
-
end
|
|
1165
|
-
```
|
|
1166
|
-
|
|
1167
|
-
When business logic lives in service objects instead of domain entities:
|
|
1168
|
-
- **Domain models become empty data holders** — they have attributes but no behavior
|
|
1169
|
-
- **Logic scatters across a flat list of service classes** — no hierarchy, no composition
|
|
1170
|
-
- **Code reuse requires coupling between services** — service A calls service B calls service C
|
|
1171
|
-
- **You lose the benefits of object-oriented design** — encapsulation, polymorphism, cohesion
|
|
1172
|
-
|
|
1173
|
-
Compare the approach where the domain model owns its behavior:
|
|
1174
|
-
|
|
1175
|
-
**File**: `app/models/card/triageable.rb`
|
|
1176
|
-
|
|
1177
|
-
```ruby
|
|
1178
|
-
module Card::Triageable
|
|
1179
|
-
def send_back_to_triage(user: Current.user, skip_event: false)
|
|
1180
|
-
if triaged?
|
|
1181
|
-
transaction do
|
|
1182
|
-
update! column: nil
|
|
1183
|
-
track_event :sent_back_to_triage, creator: user unless skip_event
|
|
1184
|
-
end
|
|
1185
|
-
end
|
|
1186
|
-
end
|
|
1187
|
-
end
|
|
1188
|
-
```
|
|
1189
|
-
|
|
1190
|
-
The logic lives where it belongs — in the domain entity that knows about its own state and invariants.
|
|
1191
|
-
|
|
1192
|
-
### Domain Operations as Plain Ruby Objects
|
|
1193
|
-
|
|
1194
|
-
When an operation genuinely doesn't fit in a single entity, create a plain Ruby object with a semantic name:
|
|
1195
|
-
|
|
1196
|
-
```ruby
|
|
1197
|
-
# ✓ Good: semantic name, domain operation
|
|
1198
|
-
class Signup
|
|
1199
|
-
def self.create_identity(email:, name:)
|
|
1200
|
-
# Creates an identity in the system
|
|
1201
|
-
# This isn't an entity — it's a domain operation
|
|
1202
|
-
end
|
|
1203
|
-
end
|
|
1204
|
-
|
|
1205
|
-
# Usage in controller:
|
|
1206
|
-
Signup.create_identity(email: params[:email], name: params[:name])
|
|
1207
|
-
```
|
|
1208
|
-
|
|
1209
|
-
Note the naming: `Signup.create_identity`, not `SignupService.call`. The name describes what the object represents in the domain, not its architectural role.
|
|
1210
|
-
|
|
1211
|
-
### Decision Tree: Where Does This Logic Belong?
|
|
1212
|
-
|
|
1213
|
-
```
|
|
1214
|
-
Does the operation act on a single entity's state?
|
|
1215
|
-
│
|
|
1216
|
-
├─ YES → Put it in the entity (model or concern)
|
|
1217
|
-
│ Example: card.close, card.send_back_to_triage
|
|
1218
|
-
│
|
|
1219
|
-
└─ NO
|
|
1220
|
-
│
|
|
1221
|
-
└─ Does it coordinate 2-3 entities at a high level?
|
|
1222
|
-
│
|
|
1223
|
-
├─ YES, and it's triggered by HTTP → Controller handles it
|
|
1224
|
-
│ Example: @board.update!(params) then @board.accesses.revise(user_ids)
|
|
1225
|
-
│
|
|
1226
|
-
├─ YES, and it's a domain concept → Plain Ruby object
|
|
1227
|
-
│ Example: Signup.create_identity (not tied to any single entity)
|
|
1228
|
-
│
|
|
1229
|
-
└─ YES, and it runs async → Job delegates to model method
|
|
1230
|
-
Example: NotifyRecipientsJob → notifiable.notify_recipients
|
|
1231
|
-
```
|
|
1232
|
-
|
|
1233
|
-
---
|
|
1234
|
-
|
|
1235
|
-
# Part 3: Domain-Specific Patterns
|
|
1236
|
-
|
|
1237
|
-
It has several unique domain-specific features that follow consistent patterns.
|
|
1238
|
-
|
|
1239
|
-
> **Note:** For the Presenter Pattern, see [Frontend Patterns](frontend.md).
|
|
1240
|
-
|
|
1241
|
-
## 3.1 Event Tracking System
|
|
1242
|
-
|
|
1243
|
-
Events audit trail. Every significant action creates an event record that drives activity timelines, notifications, and webhooks.
|
|
1244
|
-
|
|
1245
|
-
### Why Events Matter
|
|
1246
|
-
|
|
1247
|
-
Events serve multiple purposes:
|
|
1248
|
-
1. **Activity timeline** - Show users what happened
|
|
1249
|
-
2. **Webhook payloads** - External integrations get event data
|
|
1250
|
-
3. **Notification triggers** - Events create notifications
|
|
1251
|
-
4. **Audit trail** - Historical record of changes
|
|
1252
|
-
|
|
1253
|
-
### The Eventable Concern
|
|
1254
|
-
|
|
1255
|
-
**File**: `app/models/concerns/eventable.rb`
|
|
1256
|
-
|
|
1257
|
-
```ruby
|
|
1258
|
-
module Eventable
|
|
1259
|
-
extend ActiveSupport::Concern
|
|
1260
|
-
|
|
1261
|
-
included do
|
|
1262
|
-
has_many :events, as: :eventable, dependent: :destroy
|
|
1263
|
-
end
|
|
1264
|
-
|
|
1265
|
-
def track_event(action, creator: Current.user, board: self.board, **particulars)
|
|
1266
|
-
if should_track_event?
|
|
1267
|
-
board.events.create!(
|
|
1268
|
-
action: "#{eventable_prefix}_#{action}",
|
|
1269
|
-
creator:,
|
|
1270
|
-
board:,
|
|
1271
|
-
eventable: self,
|
|
1272
|
-
particulars:
|
|
1273
|
-
)
|
|
1274
|
-
end
|
|
1275
|
-
end
|
|
1276
|
-
|
|
1277
|
-
def event_was_created(event)
|
|
1278
|
-
# Override in specific models to react to event creation
|
|
1279
|
-
end
|
|
1280
|
-
|
|
1281
|
-
private
|
|
1282
|
-
def should_track_event?
|
|
1283
|
-
true # Override to conditionally track
|
|
1284
|
-
end
|
|
1285
|
-
|
|
1286
|
-
def eventable_prefix
|
|
1287
|
-
self.class.name.demodulize.underscore # "Card" → "card"
|
|
1288
|
-
end
|
|
1289
|
-
end
|
|
1290
|
-
```
|
|
1291
|
-
|
|
1292
|
-
**The API**: `track_event(action, creator:, board:, **particulars)`
|
|
1293
|
-
|
|
1294
|
-
### Using Track Event
|
|
1295
|
-
|
|
1296
|
-
**File**: `app/models/card/closeable.rb`
|
|
1297
|
-
|
|
1298
|
-
```ruby
|
|
1299
|
-
def close(user: Current.user)
|
|
1300
|
-
unless closed?
|
|
1301
|
-
transaction do
|
|
1302
|
-
create_closure! user: user
|
|
1303
|
-
track_event :closed, creator: user # ← Tracks event
|
|
1304
|
-
end
|
|
1305
|
-
end
|
|
1306
|
-
end
|
|
1307
|
-
```
|
|
1308
|
-
|
|
1309
|
-
**File**: `app/models/card/assignable.rb`
|
|
1310
|
-
|
|
1311
|
-
```ruby
|
|
1312
|
-
def assign(user, assigner: Current.user)
|
|
1313
|
-
unless assigned_to?(user)
|
|
1314
|
-
transaction do
|
|
1315
|
-
assignments.create!(assignee: user, assigner: assigner)
|
|
1316
|
-
track_event :assigned, assignee_ids: [ user.id ] # ← With particulars
|
|
1317
|
-
end
|
|
1318
|
-
end
|
|
1319
|
-
end
|
|
1320
|
-
```
|
|
1321
|
-
|
|
1322
|
-
### Model-Specific Customization
|
|
1323
|
-
|
|
1324
|
-
Models customize event behavior by overriding template methods:
|
|
1325
|
-
|
|
1326
|
-
**File**: `app/models/card/eventable.rb`
|
|
1327
|
-
|
|
1328
|
-
```ruby
|
|
1329
|
-
module Card::Eventable
|
|
1330
|
-
extend ActiveSupport::Concern
|
|
1331
|
-
|
|
1332
|
-
include ::Eventable # ← Include base concern
|
|
1333
|
-
|
|
1334
|
-
included do
|
|
1335
|
-
before_create { self.last_active_at ||= created_at || Time.current }
|
|
1336
|
-
after_save :track_title_change, if: :saved_change_to_title?
|
|
1337
|
-
end
|
|
1338
|
-
|
|
1339
|
-
def event_was_created(event) # ← Override hook
|
|
1340
|
-
transaction do
|
|
1341
|
-
create_system_comment_for(event)
|
|
1342
|
-
touch_last_active_at unless was_just_published?
|
|
1343
|
-
end
|
|
1344
|
-
end
|
|
1345
|
-
|
|
1346
|
-
private
|
|
1347
|
-
def should_track_event? # ← Override conditional
|
|
1348
|
-
published? # Only track events for published cards
|
|
1349
|
-
end
|
|
1350
|
-
|
|
1351
|
-
def track_title_change
|
|
1352
|
-
if title_before_last_save.present?
|
|
1353
|
-
track_event "title_changed",
|
|
1354
|
-
particulars: { old_title: title_before_last_save, new_title: title }
|
|
1355
|
-
end
|
|
1356
|
-
end
|
|
1357
|
-
|
|
1358
|
-
def create_system_comment_for(event)
|
|
1359
|
-
SystemCommenter.new(self, event).comment
|
|
1360
|
-
end
|
|
1361
|
-
end
|
|
1362
|
-
```
|
|
1363
|
-
|
|
1364
|
-
### Event Lifecycle
|
|
1365
|
-
|
|
1366
|
-
1. **Action occurs**: `card.close`
|
|
1367
|
-
2. **Track event** (inside transaction): `track_event :closed`
|
|
1368
|
-
3. **Event created**: Database INSERT
|
|
1369
|
-
4. **`after_create` callback**: `eventable.event_was_created(event)` ← Synchronous
|
|
1370
|
-
5. **`after_create_commit`**: Dispatch webhooks ← Async
|
|
1371
|
-
|
|
1372
|
-
### Particulars Hash
|
|
1373
|
-
|
|
1374
|
-
The `particulars` hash stores action-specific data as JSON:
|
|
1375
|
-
|
|
1376
|
-
```ruby
|
|
1377
|
-
# Board change event
|
|
1378
|
-
track_event "board_changed",
|
|
1379
|
-
particulars: { old_board: "Project A", new_board: "Project B" }
|
|
1380
|
-
|
|
1381
|
-
# Title change event
|
|
1382
|
-
track_event "title_changed",
|
|
1383
|
-
particulars: { old_title: "Old Title", new_title: "New Title" }
|
|
1384
|
-
|
|
1385
|
-
# Assignment event
|
|
1386
|
-
track_event :assigned,
|
|
1387
|
-
assignee_ids: [ user.id ]
|
|
1388
|
-
|
|
1389
|
-
# Access to particulars:
|
|
1390
|
-
event.particulars["old_board"] # => "Project A"
|
|
1391
|
-
```
|
|
1392
|
-
|
|
1393
|
-
### Adding Events to New Actions
|
|
1394
|
-
|
|
1395
|
-
**Step-by-step:**
|
|
1396
|
-
|
|
1397
|
-
1. Include `Eventable` in your model (if not already included)
|
|
1398
|
-
2. Call `track_event` in your action method (inside transaction)
|
|
1399
|
-
3. Pass relevant data in `particulars` hash
|
|
1400
|
-
4. Test that events are created (see [Testing Patterns](testing.md))
|
|
1401
|
-
|
|
1402
|
-
**Example**: Adding event to a hypothetical `archive` method:
|
|
1403
|
-
|
|
1404
|
-
```ruby
|
|
1405
|
-
def archive(user: Current.user)
|
|
1406
|
-
unless archived?
|
|
1407
|
-
transaction do
|
|
1408
|
-
update!(archived_at: Time.current)
|
|
1409
|
-
track_event :archived, creator: user # ← Add event tracking
|
|
1410
|
-
end
|
|
1411
|
-
end
|
|
1412
|
-
end
|
|
1413
|
-
```
|
|
1414
|
-
|
|
1415
|
-
## 3.2 Storage Tracking Pattern
|
|
1416
|
-
|
|
1417
|
-
It tracks file storage usage at the board and account level to enforce quotas. This pattern shows how to handle complex accounting across hierarchies.
|
|
1418
|
-
|
|
1419
|
-
### Business Context
|
|
1420
|
-
|
|
1421
|
-
- Each account has a storage quota
|
|
1422
|
-
- Boards show their storage usage
|
|
1423
|
-
- Count only original uploads (not image variants)
|
|
1424
|
-
- When cards move between boards, storage moves too
|
|
1425
|
-
|
|
1426
|
-
### The Storage::Tracked Concern
|
|
1427
|
-
|
|
1428
|
-
**File**: `app/models/concerns/storage/tracked.rb` (simplified)
|
|
1429
|
-
|
|
1430
|
-
```ruby
|
|
1431
|
-
module Storage::Tracked
|
|
1432
|
-
extend ActiveSupport::Concern
|
|
1433
|
-
|
|
1434
|
-
TRACKED_RECORD_TYPES = %w[ Card Comment ].freeze
|
|
1435
|
-
|
|
1436
|
-
included do
|
|
1437
|
-
after_create_commit :track_storage_later
|
|
1438
|
-
after_update_commit :track_storage_transfer_later, if: :saved_change_to_board_id?
|
|
1439
|
-
end
|
|
1440
|
-
|
|
1441
|
-
private
|
|
1442
|
-
def track_storage_later
|
|
1443
|
-
Storage::TrackJob.perform_later(self)
|
|
1444
|
-
end
|
|
1445
|
-
|
|
1446
|
-
def track_storage_transfer_later
|
|
1447
|
-
Storage::TransferJob.perform_later(
|
|
1448
|
-
records: storage_transfer_records,
|
|
1449
|
-
from_board_id: board_id_before_last_save,
|
|
1450
|
-
to_board_id: board_id
|
|
1451
|
-
)
|
|
1452
|
-
end
|
|
1453
|
-
|
|
1454
|
-
def storage_transfer_records
|
|
1455
|
-
[ self ] # Override in models to include related records
|
|
1456
|
-
end
|
|
1457
|
-
end
|
|
1458
|
-
```
|
|
1459
|
-
|
|
1460
|
-
### Board Transfer Logic
|
|
1461
|
-
|
|
1462
|
-
When a card moves boards, all its storage must transfer:
|
|
1463
|
-
|
|
1464
|
-
**File**: `app/models/card.rb`
|
|
1465
|
-
|
|
1466
|
-
```ruby
|
|
1467
|
-
private
|
|
1468
|
-
STORAGE_BATCH_SIZE = 1000
|
|
1469
|
-
|
|
1470
|
-
# Override to include comments, but only load comments that have attachments.
|
|
1471
|
-
# Cards can have thousands of comments; most won't have attachments.
|
|
1472
|
-
# Streams in batches to avoid loading all IDs into memory at once.
|
|
1473
|
-
def storage_transfer_records
|
|
1474
|
-
comment_ids_with_attachments = storage_comment_ids_with_attachments
|
|
1475
|
-
|
|
1476
|
-
if comment_ids_with_attachments.any?
|
|
1477
|
-
[ self, *comments.where(id: comment_ids_with_attachments).to_a ]
|
|
1478
|
-
else
|
|
1479
|
-
[ self ]
|
|
1480
|
-
end
|
|
1481
|
-
end
|
|
1482
|
-
|
|
1483
|
-
def storage_comment_ids_with_attachments
|
|
1484
|
-
direct = []
|
|
1485
|
-
rich_text_map = {}
|
|
1486
|
-
|
|
1487
|
-
# Stream comment IDs in batches to avoid loading all into memory
|
|
1488
|
-
comments.in_batches(of: STORAGE_BATCH_SIZE) do |batch|
|
|
1489
|
-
batch_ids = batch.pluck(:id)
|
|
1490
|
-
|
|
1491
|
-
# Find comments with direct attachments
|
|
1492
|
-
direct.concat \
|
|
1493
|
-
ActiveStorage::Attachment
|
|
1494
|
-
.where(record_type: "Comment", record_id: batch_ids)
|
|
1495
|
-
.distinct
|
|
1496
|
-
.pluck(:record_id)
|
|
1497
|
-
|
|
1498
|
-
# Build map of rich text records to comments
|
|
1499
|
-
ActionText::RichText
|
|
1500
|
-
.where(record_type: "Comment", record_id: batch_ids)
|
|
1501
|
-
.pluck(:id, :record_id)
|
|
1502
|
-
.each { |rt_id, comment_id| rich_text_map[rt_id] = comment_id }
|
|
1503
|
-
end
|
|
1504
|
-
|
|
1505
|
-
# Find comments with rich text embeds
|
|
1506
|
-
embed_comment_ids = if rich_text_map.any?
|
|
1507
|
-
rich_text_map.keys.each_slice(STORAGE_BATCH_SIZE).flat_map do |batch_ids|
|
|
1508
|
-
ActiveStorage::Attachment
|
|
1509
|
-
.where(record_type: "ActionText::RichText", record_id: batch_ids)
|
|
1510
|
-
.distinct
|
|
1511
|
-
.pluck(:record_id)
|
|
1512
|
-
end.filter_map { |rt_id| rich_text_map[rt_id] }
|
|
1513
|
-
else
|
|
1514
|
-
[]
|
|
1515
|
-
end
|
|
1516
|
-
|
|
1517
|
-
(direct + embed_comment_ids).uniq
|
|
1518
|
-
end
|
|
1519
|
-
```
|
|
1520
|
-
|
|
1521
|
-
**Pattern highlights:**
|
|
1522
|
-
- **Batch processing**: Process in chunks to avoid memory issues
|
|
1523
|
-
- **Selective loading**: Only load comments with attachments
|
|
1524
|
-
- **Rich text resolution**: Handle embedded images in rich text
|
|
1525
|
-
- **Performance**: No N+1 queries, streams data
|
|
1526
|
-
|
|
1527
|
-
### When to Modify Storage Tracking
|
|
1528
|
-
|
|
1529
|
-
**Adding attachments to a new model:**
|
|
1530
|
-
|
|
1531
|
-
1. Add model to `TRACKED_RECORD_TYPES`
|
|
1532
|
-
2. Include `Storage::Tracked` concern
|
|
1533
|
-
3. Implement `storage_transfer_records` if hierarchical
|
|
1534
|
-
|
|
1535
|
-
**Adding storage features:**
|
|
1536
|
-
|
|
1537
|
-
Follow the pattern:
|
|
1538
|
-
- Track on creation
|
|
1539
|
-
- Transfer on board changes
|
|
1540
|
-
- Handle rich text embeds
|
|
1541
|
-
- Batch process large datasets
|
|
1542
|
-
|
|
1543
|
-
## 3.3 Entropy System
|
|
1544
|
-
|
|
1545
|
-
The unique "entropy" feature automatically postpones stale cards to prevent infinite todo lists. This shows how to implement complex time-based business rules.
|
|
1546
|
-
|
|
1547
|
-
### Business Philosophy
|
|
1548
|
-
|
|
1549
|
-
- Cards that go untouched eventually "decay"
|
|
1550
|
-
- After an inactivity period, cards auto-postpone to "not now"
|
|
1551
|
-
- Period is configurable per account/board
|
|
1552
|
-
- Prevents todo lists from growing forever
|
|
1553
|
-
|
|
1554
|
-
### The Entropic Concern
|
|
1555
|
-
|
|
1556
|
-
**File**: `app/models/card/entropic.rb` (simplified)
|
|
1557
|
-
|
|
1558
|
-
```ruby
|
|
1559
|
-
module Card::Entropic
|
|
1560
|
-
extend ActiveSupport::Concern
|
|
1561
|
-
|
|
1562
|
-
included do
|
|
1563
|
-
scope :due_to_be_postponed, -> do
|
|
1564
|
-
active
|
|
1565
|
-
.joins(board: :account)
|
|
1566
|
-
.left_outer_joins(board: :entropy)
|
|
1567
|
-
.joins("LEFT OUTER JOIN entropies AS account_entropies
|
|
1568
|
-
ON account_entropies.account_id = accounts.id")
|
|
1569
|
-
.where("last_active_at <= #{connection.date_subtract('?',
|
|
1570
|
-
'COALESCE(entropies.auto_postpone_period,
|
|
1571
|
-
account_entropies.auto_postpone_period)')}",
|
|
1572
|
-
Time.now)
|
|
1573
|
-
end
|
|
1574
|
-
|
|
1575
|
-
delegate :auto_postpone_period, to: :board
|
|
1576
|
-
end
|
|
1577
|
-
|
|
1578
|
-
class_methods do
|
|
1579
|
-
def auto_postpone_all_due
|
|
1580
|
-
due_to_be_postponed.find_each do |card|
|
|
1581
|
-
card.auto_postpone(user: card.account.system_user)
|
|
1582
|
-
end
|
|
1583
|
-
end
|
|
1584
|
-
end
|
|
1585
|
-
|
|
1586
|
-
def entropy
|
|
1587
|
-
Card::Entropy.for(self)
|
|
1588
|
-
end
|
|
1589
|
-
|
|
1590
|
-
def entropic?
|
|
1591
|
-
entropy.present?
|
|
1592
|
-
end
|
|
1593
|
-
end
|
|
1594
|
-
```
|
|
1595
|
-
|
|
1596
|
-
**Key points:**
|
|
1597
|
-
- Complex SQL with COALESCE for fallback
|
|
1598
|
-
- Board-level period overrides account-level
|
|
1599
|
-
- Class method for batch processing
|
|
1600
|
-
- Used by recurring job
|
|
1601
|
-
|
|
1602
|
-
### The Postponable Concern
|
|
1603
|
-
|
|
1604
|
-
**File**: `app/models/card/postponable.rb` (simplified)
|
|
1605
|
-
|
|
1606
|
-
```ruby
|
|
1607
|
-
module Card::Postponable
|
|
1608
|
-
extend ActiveSupport::Concern
|
|
1609
|
-
|
|
1610
|
-
included do
|
|
1611
|
-
has_one :not_now, dependent: :destroy
|
|
1612
|
-
scope :postponed, -> { joins(:not_now) }
|
|
1613
|
-
end
|
|
1614
|
-
|
|
1615
|
-
def postponed?
|
|
1616
|
-
not_now.present?
|
|
1617
|
-
end
|
|
1618
|
-
|
|
1619
|
-
def postpone(user: Current.user, event_name: :postponed)
|
|
1620
|
-
transaction do
|
|
1621
|
-
send_back_to_triage(skip_event: true)
|
|
1622
|
-
reopen
|
|
1623
|
-
activity_spike&.destroy
|
|
1624
|
-
create_not_now!(user: user) unless postponed?
|
|
1625
|
-
track_event event_name, creator: user
|
|
1626
|
-
end
|
|
1627
|
-
end
|
|
1628
|
-
|
|
1629
|
-
def auto_postpone(user: Current.user)
|
|
1630
|
-
postpone(user: user, event_name: :auto_postponed)
|
|
1631
|
-
end
|
|
1632
|
-
|
|
1633
|
-
def resume(user: Current.user)
|
|
1634
|
-
if postponed?
|
|
1635
|
-
transaction do
|
|
1636
|
-
not_now.destroy
|
|
1637
|
-
track_event :resumed, creator: user
|
|
1638
|
-
end
|
|
1639
|
-
end
|
|
1640
|
-
end
|
|
1641
|
-
end
|
|
1642
|
-
```
|
|
1643
|
-
|
|
1644
|
-
**Note**: `auto_postpone` uses different event name than manual `postpone`.
|
|
1645
|
-
|
|
1646
|
-
### Configuration Inheritance
|
|
1647
|
-
|
|
1648
|
-
```sql
|
|
1649
|
-
-- SQL shows board entropy overrides account entropy
|
|
1650
|
-
COALESCE(
|
|
1651
|
-
board_entropies.auto_postpone_period, -- Board-specific first
|
|
1652
|
-
account_entropies.auto_postpone_period -- Account default fallback
|
|
1653
|
-
)
|
|
1654
|
-
```
|
|
1655
|
-
|
|
1656
|
-
### Recurring Job Integration
|
|
1657
|
-
|
|
1658
|
-
**File**: `config/recurring.yml`
|
|
1659
|
-
|
|
1660
|
-
```yaml
|
|
1661
|
-
auto_postpone_cards:
|
|
1662
|
-
schedule: "50 * * * *" # Every hour at :50
|
|
1663
|
-
command: "Card.auto_postpone_all_due"
|
|
1664
|
-
```
|
|
1665
|
-
|
|
1666
|
-
The class method `Card.auto_postpone_all_due` is called hourly to process all eligible cards.
|
|
1667
|
-
|
|
1668
|
-
---
|
|
1669
|
-
|
|
1670
|
-
# Part 4: Controller & Job Patterns
|
|
1671
|
-
|
|
1672
|
-
Controllers and background jobs are extremely thin. They orchestrate, but delegate all business logic to models.
|
|
1673
|
-
|
|
1674
|
-
## 4.1 Thin Controllers with Rich Models
|
|
1675
|
-
|
|
1676
|
-
It strictly follows the "thin controller, fat model" philosophy. Controllers have 3 responsibilities: setup, call model, respond.
|
|
1677
|
-
|
|
1678
|
-
### The Pattern
|
|
1679
|
-
|
|
1680
|
-
**File**: `app/controllers/cards/goldnesses_controller.rb`
|
|
1681
|
-
|
|
1682
|
-
```ruby
|
|
1683
|
-
class Cards::GoldnessesController < ApplicationController
|
|
1684
|
-
include CardScoped # ← Sets @card and @board
|
|
1685
|
-
|
|
1686
|
-
def create
|
|
1687
|
-
@card.gild # ← Single line of business logic
|
|
1688
|
-
|
|
1689
|
-
respond_to do |format|
|
|
1690
|
-
format.turbo_stream { render_card_replacement }
|
|
1691
|
-
format.json { head :no_content }
|
|
1692
|
-
end
|
|
1693
|
-
end
|
|
1694
|
-
|
|
1695
|
-
def destroy
|
|
1696
|
-
@card.ungild # ← Single line of business logic
|
|
1697
|
-
|
|
1698
|
-
respond_to do |format|
|
|
1699
|
-
format.turbo_stream { render_card_replacement }
|
|
1700
|
-
format.json { head :no_content }
|
|
1701
|
-
end
|
|
1702
|
-
end
|
|
1703
|
-
end
|
|
1704
|
-
```
|
|
1705
|
-
|
|
1706
|
-
**That's the entire controller!**
|
|
1707
|
-
|
|
1708
|
-
### Controller Responsibilities
|
|
1709
|
-
|
|
1710
|
-
**Controllers should:**
|
|
1711
|
-
1. Set instance variables from params (via concerns or before_action)
|
|
1712
|
-
2. Call one model method
|
|
1713
|
-
3. Render or redirect
|
|
1714
|
-
|
|
1715
|
-
**Controllers should NOT:**
|
|
1716
|
-
- Contain business logic
|
|
1717
|
-
- Build complex queries
|
|
1718
|
-
- Orchestrate multi-step operations
|
|
1719
|
-
- Directly manipulate multiple models
|
|
1720
|
-
|
|
1721
|
-
### Comparison: Before and After
|
|
1722
|
-
|
|
1723
|
-
**Anti-pattern (business logic in controller):**
|
|
1724
|
-
|
|
1725
|
-
```ruby
|
|
1726
|
-
def create
|
|
1727
|
-
@card = board.cards.find_by!(number: params[:card_id])
|
|
1728
|
-
|
|
1729
|
-
unless @card.goldness.present?
|
|
1730
|
-
@goldness = @card.create_goldness!
|
|
1731
|
-
|
|
1732
|
-
Event.create!(
|
|
1733
|
-
action: "card_gilded",
|
|
1734
|
-
eventable: @card,
|
|
1735
|
-
board: @card.board,
|
|
1736
|
-
account: @card.account,
|
|
1737
|
-
creator: Current.user
|
|
1738
|
-
)
|
|
1739
|
-
end
|
|
1740
|
-
|
|
1741
|
-
respond_to do |format|
|
|
1742
|
-
format.turbo_stream { render_card_replacement }
|
|
1743
|
-
end
|
|
1744
|
-
end
|
|
1745
|
-
```
|
|
1746
|
-
|
|
1747
|
-
**Correct (model handles logic):**
|
|
1748
|
-
|
|
1749
|
-
```ruby
|
|
1750
|
-
def create
|
|
1751
|
-
@card.gild # Model method encapsulates all logic
|
|
1752
|
-
|
|
1753
|
-
respond_to do |format|
|
|
1754
|
-
format.turbo_stream { render_card_replacement }
|
|
1755
|
-
end
|
|
1756
|
-
end
|
|
1757
|
-
```
|
|
1758
|
-
|
|
1759
|
-
**The model** (`app/models/card/golden.rb`):
|
|
1760
|
-
|
|
1761
|
-
```ruby
|
|
1762
|
-
def gild
|
|
1763
|
-
create_goldness! unless golden?
|
|
1764
|
-
# Event tracking happens automatically via another concern
|
|
1765
|
-
end
|
|
1766
|
-
```
|
|
1767
|
-
|
|
1768
|
-
### Benefits
|
|
1769
|
-
|
|
1770
|
-
- **Testable**: Business logic tested at model level (fast)
|
|
1771
|
-
- **Reusable**: Can call `card.gild` from console, jobs, tests
|
|
1772
|
-
- **Maintainable**: Logic lives in one place
|
|
1773
|
-
- **Readable**: Controller action is self-documenting
|
|
1774
|
-
|
|
1775
|
-
## 4.2 RESTful Resource Nesting
|
|
1776
|
-
|
|
1777
|
-
In models all actions as RESTful resources, not custom routes. This is a core pattern from the style guide.
|
|
1778
|
-
|
|
1779
|
-
### The Pattern
|
|
1780
|
-
|
|
1781
|
-
Instead of adding custom action methods, create a new resource:
|
|
1782
|
-
|
|
1783
|
-
**Anti-pattern:**
|
|
1784
|
-
|
|
1785
|
-
```ruby
|
|
1786
|
-
# routes.rb
|
|
1787
|
-
resources :cards do
|
|
1788
|
-
post :close # ← Custom action
|
|
1789
|
-
post :reopen # ← Custom action
|
|
1790
|
-
post :gild # ← Custom action
|
|
1791
|
-
post :ungild # ← Custom action
|
|
1792
|
-
end
|
|
1793
|
-
```
|
|
1794
|
-
|
|
1795
|
-
**Correct pattern:**
|
|
1796
|
-
|
|
1797
|
-
```ruby
|
|
1798
|
-
# routes.rb
|
|
1799
|
-
resources :cards do
|
|
1800
|
-
scope module: :cards do
|
|
1801
|
-
resource :closure # ← RESTful resource (singular!)
|
|
1802
|
-
resource :goldness # ← RESTful resource
|
|
1803
|
-
# ...
|
|
1804
|
-
end
|
|
1805
|
-
end
|
|
1806
|
-
```
|
|
1807
|
-
|
|
1808
|
-
### Controller Implementation
|
|
1809
|
-
|
|
1810
|
-
**File**: `app/controllers/cards/closures_controller.rb`
|
|
1811
|
-
|
|
1812
|
-
```ruby
|
|
1813
|
-
class Cards::ClosuresController < ApplicationController
|
|
1814
|
-
include CardScoped
|
|
1815
|
-
|
|
1816
|
-
def create # ← POST /cards/:card_id/closure
|
|
1817
|
-
@card.close
|
|
1818
|
-
respond_to do |format|
|
|
1819
|
-
format.turbo_stream { render_card_replacement }
|
|
1820
|
-
end
|
|
1821
|
-
end
|
|
1822
|
-
|
|
1823
|
-
def destroy # ← DELETE /cards/:card_id/closure
|
|
1824
|
-
@card.reopen
|
|
1825
|
-
respond_to do |format|
|
|
1826
|
-
format.turbo_stream { render_card_replacement }
|
|
1827
|
-
end
|
|
1828
|
-
end
|
|
1829
|
-
end
|
|
1830
|
-
```
|
|
1831
|
-
|
|
1832
|
-
**Routes generated:**
|
|
1833
|
-
- `POST /cards/:card_id/closure` → close card
|
|
1834
|
-
- `DELETE /cards/:card_id/closure` → reopen card
|
|
1835
|
-
|
|
1836
|
-
### More Examples
|
|
1837
|
-
|
|
1838
|
-
**Gilding cards:**
|
|
1839
|
-
```ruby
|
|
1840
|
-
resource :goldness # Cards::GoldnessesController
|
|
1841
|
-
# POST /cards/:card_id/goldness → gild
|
|
1842
|
-
# DELETE /cards/:card_id/goldness → ungild
|
|
1843
|
-
```
|
|
1844
|
-
|
|
1845
|
-
**Pinning cards:**
|
|
1846
|
-
```ruby
|
|
1847
|
-
resource :pin # Cards::PinsController
|
|
1848
|
-
# POST /cards/:card_id/pin → pin
|
|
1849
|
-
# DELETE /cards/:card_id/pin → unpin
|
|
1850
|
-
```
|
|
1851
|
-
|
|
1852
|
-
**Watching cards:**
|
|
1853
|
-
```ruby
|
|
1854
|
-
resource :watch # Cards::WatchesController
|
|
1855
|
-
# POST /cards/:card_id/watch → watch
|
|
1856
|
-
# DELETE /cards/:card_id/watch → unwatch
|
|
1857
|
-
```
|
|
1858
|
-
|
|
1859
|
-
### Benefits
|
|
1860
|
-
|
|
1861
|
-
- **RESTful conventions**: Standard HTTP verbs
|
|
1862
|
-
- **Clear intent**: URL describes the resource
|
|
1863
|
-
- **Testable**: Use standard REST helpers in tests
|
|
1864
|
-
- **Framework alignment**: Follows Rails conventions
|
|
1865
|
-
|
|
1866
|
-
### When to Create a New Resource
|
|
1867
|
-
|
|
1868
|
-
**Ask**: "Is this action creating, updating, or destroying something?"
|
|
1869
|
-
|
|
1870
|
-
If yes, that "something" is probably a resource:
|
|
1871
|
-
- Closing a card → Creates a `Closure`
|
|
1872
|
-
- Gilding a card → Creates a `Goldness`
|
|
1873
|
-
- Assigning a user → Creates an `Assignment`
|
|
1874
|
-
- Posting a comment → Creates a `Comment`
|
|
1875
|
-
|
|
1876
|
-
## 4.3 Controller Concerns
|
|
1877
|
-
|
|
1878
|
-
Controller concerns extract common before_action patterns and resource loading.
|
|
1879
|
-
|
|
1880
|
-
### CardScoped Concern
|
|
1881
|
-
|
|
1882
|
-
**File**: `app/controllers/concerns/card_scoped.rb`
|
|
1883
|
-
|
|
1884
|
-
```ruby
|
|
1885
|
-
module CardScoped
|
|
1886
|
-
extend ActiveSupport::Concern
|
|
1887
|
-
|
|
1888
|
-
included do
|
|
1889
|
-
before_action :set_card, :set_board
|
|
1890
|
-
end
|
|
1891
|
-
|
|
1892
|
-
private
|
|
1893
|
-
def set_card
|
|
1894
|
-
@card = Current.user.accessible_cards.find_by!(number: params[:card_id])
|
|
1895
|
-
end
|
|
1896
|
-
|
|
1897
|
-
def set_board
|
|
1898
|
-
@board = @card.board
|
|
1899
|
-
end
|
|
1900
|
-
|
|
1901
|
-
def render_card_replacement
|
|
1902
|
-
render turbo_stream: turbo_stream.replace(
|
|
1903
|
-
[ @card, :card_container ],
|
|
1904
|
-
partial: "cards/container",
|
|
1905
|
-
method: :morph,
|
|
1906
|
-
locals: { card: @card.reload }
|
|
1907
|
-
)
|
|
1908
|
-
end
|
|
1909
|
-
end
|
|
1910
|
-
```
|
|
1911
|
-
|
|
1912
|
-
**Used by all nested card controllers:**
|
|
1913
|
-
- `Cards::ClosuresController`
|
|
1914
|
-
- `Cards::GoldnessesController`
|
|
1915
|
-
- `Cards::AssignmentsController`
|
|
1916
|
-
- `Cards::CommentsController`
|
|
1917
|
-
- etc.
|
|
1918
|
-
|
|
1919
|
-
**Each controller just:**
|
|
1920
|
-
```ruby
|
|
1921
|
-
class Cards::GoldnessesController < ApplicationController
|
|
1922
|
-
include CardScoped # ← Sets @card and @board automatically
|
|
1923
|
-
|
|
1924
|
-
def create
|
|
1925
|
-
@card.gild
|
|
1926
|
-
respond_to do |format|
|
|
1927
|
-
format.turbo_stream { render_card_replacement }
|
|
1928
|
-
end
|
|
1929
|
-
end
|
|
1930
|
-
end
|
|
1931
|
-
```
|
|
1932
|
-
|
|
1933
|
-
### BoardScoped Concern
|
|
1934
|
-
|
|
1935
|
-
**File**: `app/controllers/concerns/board_scoped.rb`
|
|
1936
|
-
|
|
1937
|
-
```ruby
|
|
1938
|
-
module BoardScoped
|
|
1939
|
-
extend ActiveSupport::Concern
|
|
1940
|
-
|
|
1941
|
-
included do
|
|
1942
|
-
before_action :set_board
|
|
1943
|
-
end
|
|
1944
|
-
|
|
1945
|
-
private
|
|
1946
|
-
def set_board
|
|
1947
|
-
@board = Current.user.boards.find(params[:board_id])
|
|
1948
|
-
end
|
|
1949
|
-
|
|
1950
|
-
def ensure_permission_to_admin_board
|
|
1951
|
-
unless Current.user.can_administer_board?(@board)
|
|
1952
|
-
head :forbidden
|
|
1953
|
-
end
|
|
1954
|
-
end
|
|
1955
|
-
end
|
|
1956
|
-
```
|
|
1957
|
-
|
|
1958
|
-
### When to Create Controller Concerns
|
|
1959
|
-
|
|
1960
|
-
**Create a controller concern when:**
|
|
1961
|
-
- 3+ controllers need the same before_action
|
|
1962
|
-
- Resource loading logic is repeated
|
|
1963
|
-
- Authorization checks are duplicated
|
|
1964
|
-
|
|
1965
|
-
**Don't create when:**
|
|
1966
|
-
- Logic is unique to one controller
|
|
1967
|
-
- It's just one simple before_action
|
|
1968
|
-
- The abstraction doesn't simplify code
|
|
1969
|
-
|
|
1970
|
-
## 4.4 Background Jobs: The _now/_later Pattern
|
|
1971
|
-
|
|
1972
|
-
It has a strict naming convention for asynchronous operations. Jobs are ultra-thin and delegate everything to models.
|
|
1973
|
-
|
|
1974
|
-
### The Pattern
|
|
1975
|
-
|
|
1976
|
-
For every async operation:
|
|
1977
|
-
1. **Synchronous method**: `method_name` (or `method_now` if ambiguous)
|
|
1978
|
-
2. **Async wrapper**: `method_later`
|
|
1979
|
-
3. **Job class**: Calls the synchronous method
|
|
1980
|
-
|
|
1981
|
-
### Complete Example
|
|
1982
|
-
|
|
1983
|
-
**File**: `app/models/concerns/notifiable.rb`
|
|
1984
|
-
|
|
1985
|
-
```ruby
|
|
1986
|
-
module Notifiable
|
|
1987
|
-
extend ActiveSupport::Concern
|
|
1988
|
-
|
|
1989
|
-
included do
|
|
1990
|
-
has_many :notifications, as: :source, dependent: :destroy
|
|
1991
|
-
after_create_commit :notify_recipients_later # ← Trigger async
|
|
1992
|
-
end
|
|
1993
|
-
|
|
1994
|
-
def notify_recipients # ← Synchronous version
|
|
1995
|
-
Notifier.for(self)&.notify
|
|
1996
|
-
end
|
|
1997
|
-
|
|
1998
|
-
private
|
|
1999
|
-
def notify_recipients_later # ← Async wrapper
|
|
2000
|
-
NotifyRecipientsJob.perform_later self
|
|
2001
|
-
end
|
|
2002
|
-
end
|
|
2003
|
-
```
|
|
2004
|
-
|
|
2005
|
-
**File**: `app/jobs/notify_recipients_job.rb`
|
|
2006
|
-
|
|
2007
|
-
```ruby
|
|
2008
|
-
class NotifyRecipientsJob < ApplicationJob
|
|
2009
|
-
def perform(notifiable)
|
|
2010
|
-
notifiable.notify_recipients # ← Calls synchronous method
|
|
2011
|
-
end
|
|
2012
|
-
end
|
|
2013
|
-
```
|
|
2014
|
-
|
|
2015
|
-
**Flow:**
|
|
2016
|
-
1. Record created
|
|
2017
|
-
2. `after_create_commit` → `notify_recipients_later`
|
|
2018
|
-
3. Enqueues `NotifyRecipientsJob`
|
|
2019
|
-
4. Job executes → calls `notify_recipients`
|
|
2020
|
-
5. All logic in model method
|
|
2021
|
-
|
|
2022
|
-
### Why This Pattern?
|
|
2023
|
-
|
|
2024
|
-
**Clear which version is async:**
|
|
2025
|
-
```ruby
|
|
2026
|
-
record.notify_recipients # Synchronous
|
|
2027
|
-
record.notify_recipients_later # Async (enqueues job)
|
|
2028
|
-
```
|
|
2029
|
-
|
|
2030
|
-
**Can call from different contexts:**
|
|
2031
|
-
```ruby
|
|
2032
|
-
# In a callback:
|
|
2033
|
-
after_create_commit :notify_recipients_later
|
|
2034
|
-
|
|
2035
|
-
# Synchronously in console:
|
|
2036
|
-
comment.notify_recipients
|
|
2037
|
-
|
|
2038
|
-
# Manually queue a job:
|
|
2039
|
-
comment.notify_recipients_later
|
|
2040
|
-
```
|
|
2041
|
-
|
|
2042
|
-
> For testing patterns related to the _now/_later pattern, see [Testing Patterns](testing.md).
|
|
2043
|
-
|
|
2044
|
-
### Ultra-Thin Jobs
|
|
2045
|
-
|
|
2046
|
-
Jobs should be 3-6 lines:
|
|
2047
|
-
|
|
2048
|
-
**File**: `app/jobs/webhook/delivery_job.rb`
|
|
2049
|
-
|
|
2050
|
-
```ruby
|
|
2051
|
-
class Webhook::DeliveryJob < ApplicationJob
|
|
2052
|
-
queue_as :webhooks
|
|
2053
|
-
|
|
2054
|
-
def perform(delivery)
|
|
2055
|
-
delivery.deliver # ← That's it!
|
|
2056
|
-
end
|
|
2057
|
-
end
|
|
2058
|
-
```
|
|
2059
|
-
|
|
2060
|
-
**File**: `app/jobs/export_account_data_job.rb`
|
|
2061
|
-
|
|
2062
|
-
```ruby
|
|
2063
|
-
class ExportAccountDataJob < ApplicationJob
|
|
2064
|
-
queue_as :backend
|
|
2065
|
-
|
|
2066
|
-
def perform(export)
|
|
2067
|
-
export.build # ← All logic in model
|
|
2068
|
-
end
|
|
2069
|
-
end
|
|
2070
|
-
```
|
|
2071
|
-
|
|
2072
|
-
### Common Mistake
|
|
2073
|
-
|
|
2074
|
-
**Don't put logic in jobs:**
|
|
2075
|
-
|
|
2076
|
-
```ruby
|
|
2077
|
-
class NotifyRecipientsJob < ApplicationJob
|
|
2078
|
-
def perform(comment)
|
|
2079
|
-
# ← 50 lines of notification logic here
|
|
2080
|
-
# ← Hard to test
|
|
2081
|
-
# ← Can't call synchronously
|
|
2082
|
-
end
|
|
2083
|
-
end
|
|
2084
|
-
```
|
|
2085
|
-
|
|
2086
|
-
**Put logic in models:**
|
|
2087
|
-
|
|
2088
|
-
```ruby
|
|
2089
|
-
class NotifyRecipientsJob < ApplicationJob
|
|
2090
|
-
def perform(comment)
|
|
2091
|
-
comment.notify_recipients # ← Logic in model
|
|
2092
|
-
end
|
|
2093
|
-
end
|
|
2094
|
-
```
|
|
2095
|
-
|
|
2096
|
-
## 4.5 Multi-Tenancy in Background Jobs
|
|
2097
|
-
|
|
2098
|
-
Background jobs automatically capture and restore the current account context. You never need to manually pass `account` to jobs.
|
|
2099
|
-
|
|
2100
|
-
### The Problem
|
|
2101
|
-
|
|
2102
|
-
Jobs run outside HTTP requests:
|
|
2103
|
-
- No `Current.account` set automatically
|
|
2104
|
-
- Need to restore tenant context for queries
|
|
2105
|
-
- Easy to forget and cause bugs
|
|
2106
|
-
|
|
2107
|
-
### Solution
|
|
2108
|
-
|
|
2109
|
-
**File**: `config/initializers/active_job.rb`
|
|
2110
|
-
|
|
2111
|
-
```ruby
|
|
2112
|
-
module AppActiveJobExtensions
|
|
2113
|
-
extend ActiveSupport::Concern
|
|
2114
|
-
|
|
2115
|
-
prepended do
|
|
2116
|
-
attr_reader :account
|
|
2117
|
-
self.enqueue_after_transaction_commit = true
|
|
2118
|
-
end
|
|
2119
|
-
|
|
2120
|
-
def initialize(...)
|
|
2121
|
-
super
|
|
2122
|
-
@account = Current.account # ← Capture on creation
|
|
2123
|
-
end
|
|
2124
|
-
|
|
2125
|
-
def serialize
|
|
2126
|
-
super.merge({ "account" => @account&.to_gid }) # ← Serialize
|
|
2127
|
-
end
|
|
2128
|
-
|
|
2129
|
-
def deserialize(job_data)
|
|
2130
|
-
super
|
|
2131
|
-
if _account = job_data.fetch("account", nil)
|
|
2132
|
-
@account = GlobalID::Locator.locate(_account) # ← Deserialize
|
|
2133
|
-
end
|
|
2134
|
-
end
|
|
2135
|
-
|
|
2136
|
-
def perform_now
|
|
2137
|
-
if account.present?
|
|
2138
|
-
Current.with_account(account) { super } # ← Restore context
|
|
2139
|
-
else
|
|
2140
|
-
super
|
|
2141
|
-
end
|
|
2142
|
-
end
|
|
2143
|
-
end
|
|
2144
|
-
|
|
2145
|
-
ActiveSupport.on_load(:active_job) do
|
|
2146
|
-
prepend AppActiveJobExtensions # ← Applied to ALL jobs
|
|
2147
|
-
end
|
|
2148
|
-
```
|
|
2149
|
-
|
|
2150
|
-
### How It Works
|
|
2151
|
-
|
|
2152
|
-
**1. Job Creation (in request):**
|
|
2153
|
-
```ruby
|
|
2154
|
-
# Current.account is set (from URL)
|
|
2155
|
-
NotifyRecipientsJob.perform_later(comment)
|
|
2156
|
-
|
|
2157
|
-
# Behind the scenes:
|
|
2158
|
-
# job = NotifyRecipientsJob.new(comment)
|
|
2159
|
-
# job.initialize → @account = Current.account (captures it!)
|
|
2160
|
-
```
|
|
2161
|
-
|
|
2162
|
-
**2. Job Serialization:**
|
|
2163
|
-
```ruby
|
|
2164
|
-
# Job serialized to queue:
|
|
2165
|
-
{
|
|
2166
|
-
"job_class" => "NotifyRecipientsJob",
|
|
2167
|
-
"arguments" => [...],
|
|
2168
|
-
"account" => "gid://fizzy/Account/abc123..." # ← Account included
|
|
2169
|
-
}
|
|
2170
|
-
```
|
|
2171
|
-
|
|
2172
|
-
**3. Job Execution (in worker):**
|
|
2173
|
-
```ruby
|
|
2174
|
-
# Job deserialized:
|
|
2175
|
-
# @account = GlobalID::Locator.locate("gid://fizzy/Account/abc123...")
|
|
2176
|
-
|
|
2177
|
-
# Job runs:
|
|
2178
|
-
# Current.with_account(@account) do
|
|
2179
|
-
# perform(comment) # ← Current.account is set!
|
|
2180
|
-
# end
|
|
2181
|
-
```
|
|
2182
|
-
|
|
2183
|
-
### Practical Implications
|
|
2184
|
-
|
|
2185
|
-
**You never pass account manually:**
|
|
2186
|
-
|
|
2187
|
-
```ruby
|
|
2188
|
-
# Don't do this:
|
|
2189
|
-
SomeJob.perform_later(record, account: Current.account)
|
|
2190
|
-
|
|
2191
|
-
# Do this:
|
|
2192
|
-
SomeJob.perform_later(record)
|
|
2193
|
-
# Account captured automatically!
|
|
2194
|
-
```
|
|
2195
|
-
|
|
2196
|
-
**Queries in jobs just work:**
|
|
2197
|
-
|
|
2198
|
-
```ruby
|
|
2199
|
-
class NotifyRecipientsJob < ApplicationJob
|
|
2200
|
-
def perform(comment)
|
|
2201
|
-
# This works because Current.account is set:
|
|
2202
|
-
comment.card.watchers.each do |user|
|
|
2203
|
-
# All queries properly scoped to account
|
|
2204
|
-
Notification.create!(user: user, source: comment)
|
|
2205
|
-
end
|
|
2206
|
-
end
|
|
2207
|
-
end
|
|
2208
|
-
```
|
|
2209
|
-
|
|
2210
|
-
### Key Insight
|
|
2211
|
-
|
|
2212
|
-
Multi-tenancy "just works" in jobs without thinking about it. This is one of the most powerful patterns.
|
|
2213
|
-
|
|
2214
|
-
---
|
|
2215
|
-
|
|
2216
|
-
## 4.6 Pagination with Geared Pagination
|
|
2217
|
-
|
|
2218
|
-
We use Basecamp's [geared_pagination](https://github.com/basecamp/geared_pagination) gem for all pagination. It fits the vanilla Rails philosophy: a single method call in the controller, no DSL to learn, and sensible defaults that handle the common case.
|
|
2219
|
-
|
|
2220
|
-
### Why Geared Pagination
|
|
2221
|
-
|
|
2222
|
-
Traditional pagination uses a fixed page size (e.g., 25 per page). Geared pagination uses **variable-speed page sizes** that grow as the user goes deeper:
|
|
2223
|
-
|
|
2224
|
-
- **Page 1:** 15 records — fast initial load
|
|
2225
|
-
- **Page 2:** 30 records
|
|
2226
|
-
- **Page 3:** 50 records
|
|
2227
|
-
- **Page 4+:** 100 records
|
|
2228
|
-
|
|
2229
|
-
This is optimized for how people actually browse: most users only see the first page, so it loads fast. The few who paginate deeper get larger batches to reduce round trips. Works naturally with infinite scroll and Turbo Frames.
|
|
2230
|
-
|
|
2231
|
-
### Basic Controller Usage
|
|
2232
|
-
|
|
2233
|
-
One line in the controller — consistent with the thin-controller philosophy from [section 4.1](#41-thin-controllers-with-rich-models):
|
|
2234
|
-
|
|
2235
|
-
```ruby
|
|
2236
|
-
class MessagesController < ApplicationController
|
|
2237
|
-
def index
|
|
2238
|
-
set_page_and_extract_portion_from Message.order(created_at: :desc) # ← one line does it all
|
|
2239
|
-
end
|
|
2240
|
-
end
|
|
2241
|
-
```
|
|
2242
|
-
|
|
2243
|
-
This sets `@page` as an instance variable available in the view. The relation must be ordered — geared_pagination needs a deterministic sort.
|
|
2244
|
-
|
|
2245
|
-
**With scopes:**
|
|
2246
|
-
|
|
2247
|
-
```ruby
|
|
2248
|
-
class CardsController < ApplicationController
|
|
2249
|
-
def index
|
|
2250
|
-
set_page_and_extract_portion_from Current.account.cards.active.ordered # ← chain scopes naturally
|
|
2251
|
-
end
|
|
2252
|
-
end
|
|
2253
|
-
```
|
|
2254
|
-
|
|
2255
|
-
### The `@page` Object
|
|
2256
|
-
|
|
2257
|
-
After calling `set_page_and_extract_portion_from`, the `@page` object provides everything you need:
|
|
2258
|
-
|
|
2259
|
-
| Method | Returns | Purpose |
|
|
2260
|
-
|--------|---------|---------|
|
|
2261
|
-
| `@page.records` | Collection | Current page's records, ready to render |
|
|
2262
|
-
| `@page.number` | Integer | Current page number |
|
|
2263
|
-
| `@page.last?` | Boolean | Whether this is the final page |
|
|
2264
|
-
| `@page.next_param` | String | Parameter value for the next page link |
|
|
2265
|
-
| `@page.recordset.page_count` | Integer | Total number of pages |
|
|
2266
|
-
| `@page.recordset.records_count` | Integer | Total records across all pages |
|
|
2267
|
-
|
|
2268
|
-
### View Integration
|
|
2269
|
-
|
|
2270
|
-
**Basic template with next-page link:**
|
|
2271
|
-
|
|
2272
|
-
```erb
|
|
2273
|
-
<%# app/views/messages/index.html.erb %>
|
|
2274
|
-
|
|
2275
|
-
<%= render @page.records %> <%# ← renders the partial for each record %>
|
|
2276
|
-
|
|
2277
|
-
<% unless @page.last? %>
|
|
2278
|
-
<%= link_to "Next page", messages_path(page: @page.next_param) %>
|
|
2279
|
-
<% end %>
|
|
2280
|
-
```
|
|
2281
|
-
|
|
2282
|
-
**With Turbo Frames for infinite scroll:**
|
|
2283
|
-
|
|
2284
|
-
```erb
|
|
2285
|
-
<%# app/views/messages/index.html.erb %>
|
|
2286
|
-
|
|
2287
|
-
<%= turbo_frame_tag "messages_page_#{@page.number}" do %>
|
|
2288
|
-
<%= render @page.records %>
|
|
2289
|
-
|
|
2290
|
-
<% unless @page.last? %>
|
|
2291
|
-
<%= turbo_frame_tag "messages_page_#{@page.number + 1}",
|
|
2292
|
-
src: messages_path(page: @page.next_param),
|
|
2293
|
-
loading: :lazy %> <%# ← loads next page when scrolled into view %>
|
|
2294
|
-
<% end %>
|
|
2295
|
-
<% end %>
|
|
2296
|
-
```
|
|
2297
|
-
|
|
2298
|
-
**Previous and next navigation:**
|
|
2299
|
-
|
|
2300
|
-
```erb
|
|
2301
|
-
<nav class="pagination">
|
|
2302
|
-
<% if @page.number > 1 %>
|
|
2303
|
-
<%= link_to "Previous", messages_path(page: @page.number - 1) %>
|
|
2304
|
-
<% end %>
|
|
2305
|
-
|
|
2306
|
-
<span>Page <%= @page.number %> of <%= @page.recordset.page_count %></span>
|
|
2307
|
-
|
|
2308
|
-
<% unless @page.last? %>
|
|
2309
|
-
<%= link_to "Next", messages_path(page: @page.next_param) %>
|
|
2310
|
-
<% end %>
|
|
2311
|
-
</nav>
|
|
2312
|
-
```
|
|
2313
|
-
|
|
2314
|
-
### Cursor-Based Pagination
|
|
2315
|
-
|
|
2316
|
-
For large datasets, offset-based pagination degrades because the database still scans skipped rows. Use `ordered_by:` to switch to cursor-based pagination:
|
|
2317
|
-
|
|
2318
|
-
```ruby
|
|
2319
|
-
class EventsController < ApplicationController
|
|
2320
|
-
def index
|
|
2321
|
-
set_page_and_extract_portion_from Event.all,
|
|
2322
|
-
ordered_by: { created_at: :desc, id: :desc } # ← cursor-based, no OFFSET
|
|
2323
|
-
end
|
|
2324
|
-
end
|
|
2325
|
-
```
|
|
2326
|
-
|
|
2327
|
-
With cursor-based pagination, the `page` parameter becomes an encoded cursor instead of a number. The database seeks directly to the right position using an index — O(1) instead of O(n).
|
|
2328
|
-
|
|
2329
|
-
**When to use cursor-based:**
|
|
2330
|
-
- Large tables (100k+ rows) where users may paginate deep
|
|
2331
|
-
- Tables with appropriate indexes on the sort columns
|
|
2332
|
-
- Infinite-scroll UIs where page numbers don't matter
|
|
2333
|
-
|
|
2334
|
-
**When to stick with offset-based:**
|
|
2335
|
-
- Small bounded datasets
|
|
2336
|
-
- UIs that show "Page 3 of 12" navigation
|
|
2337
|
-
- Complex relations where cursor encoding isn't straightforward
|
|
2338
|
-
|
|
2339
|
-
### Caching with Pagination
|
|
2340
|
-
|
|
2341
|
-
Include `@page` in cache keys so different pages don't serve stale content:
|
|
2342
|
-
|
|
2343
|
-
```erb
|
|
2344
|
-
<% cache [@page, Current.account] do %>
|
|
2345
|
-
<%= render @page.records %>
|
|
2346
|
-
<% end %>
|
|
2347
|
-
```
|
|
2348
|
-
|
|
2349
|
-
ETags automatically incorporate the current page and gear ratios, so conditional GET responses work correctly across pages.
|
|
2350
|
-
|
|
2351
|
-
### Anti-Patterns
|
|
2352
|
-
|
|
2353
|
-
**Don't use manual `limit`/`offset`:**
|
|
2354
|
-
|
|
2355
|
-
```ruby
|
|
2356
|
-
# Bad — reimplements pagination poorly
|
|
2357
|
-
def index
|
|
2358
|
-
@messages = Message.order(created_at: :desc)
|
|
2359
|
-
.offset(params[:page].to_i * 25)
|
|
2360
|
-
.limit(25)
|
|
2361
|
-
end
|
|
2362
|
-
|
|
2363
|
-
# Good — let geared_pagination handle it
|
|
2364
|
-
def index
|
|
2365
|
-
set_page_and_extract_portion_from Message.order(created_at: :desc)
|
|
2366
|
-
end
|
|
2367
|
-
```
|
|
2368
|
-
|
|
2369
|
-
**Don't build custom pagination when geared_pagination handles it:**
|
|
2370
|
-
|
|
2371
|
-
```ruby
|
|
2372
|
-
# Bad — unnecessary abstraction
|
|
2373
|
-
class PaginationService
|
|
2374
|
-
def initialize(scope, page:, per_page:)
|
|
2375
|
-
# ...50 lines of pagination logic...
|
|
2376
|
-
end
|
|
2377
|
-
end
|
|
2378
|
-
|
|
2379
|
-
# Good — one line, no service object needed
|
|
2380
|
-
set_page_and_extract_portion_from scope
|
|
2381
|
-
```
|
|
2382
|
-
|
|
2383
|
-
**Don't ignore the gear ratios by forcing a fixed page size.** The variable-speed sizing is the whole point — it optimizes both initial load time and deep pagination efficiency.
|
|
2384
|
-
|
|
2385
|
-
---
|
|
2386
|
-
|
|
2387
|
-
# Part 5: Coding Style Guide
|
|
2388
|
-
|
|
2389
|
-
It has specific code style conventions beyond standard Ruby/Rails style.
|
|
2390
|
-
|
|
2391
|
-
## 5.1 Coding Conventions
|
|
2392
|
-
|
|
2393
|
-
### Conditional Returns (Expanded Conditionals Preferred)
|
|
2394
|
-
|
|
2395
|
-
It prefers expanded conditionals over guard clauses:
|
|
2396
|
-
|
|
2397
|
-
**Avoid:**
|
|
2398
|
-
|
|
2399
|
-
```ruby
|
|
2400
|
-
def todos_for_new_group
|
|
2401
|
-
ids = params.require(:todolist)[:todo_ids]
|
|
2402
|
-
return [] unless ids # ← Guard clause
|
|
2403
|
-
@bucket.recordings.todos.find(ids.split(","))
|
|
2404
|
-
end
|
|
2405
|
-
```
|
|
2406
|
-
|
|
2407
|
-
**Prefer:**
|
|
2408
|
-
|
|
2409
|
-
```ruby
|
|
2410
|
-
def todos_for_new_group
|
|
2411
|
-
if ids = params.require(:todolist)[:todo_ids]
|
|
2412
|
-
@bucket.recordings.todos.find(ids.split(","))
|
|
2413
|
-
else
|
|
2414
|
-
[]
|
|
2415
|
-
end
|
|
2416
|
-
end
|
|
2417
|
-
```
|
|
2418
|
-
|
|
2419
|
-
**Why?** Guard clauses can be hard to read, especially when nested.
|
|
2420
|
-
|
|
2421
|
-
**Exception**: Use guard clauses at the beginning of methods when:
|
|
2422
|
-
- The return is right at the start
|
|
2423
|
-
- The main body is non-trivial (many lines)
|
|
2424
|
-
- It improves readability
|
|
2425
|
-
|
|
2426
|
-
```ruby
|
|
2427
|
-
def after_recorded_as_commit(recording)
|
|
2428
|
-
return if recording.parent.was_created? # ← OK: at start, non-trivial body
|
|
2429
|
-
|
|
2430
|
-
if recording.was_created?
|
|
2431
|
-
broadcast_new_column(recording)
|
|
2432
|
-
else
|
|
2433
|
-
broadcast_column_change(recording)
|
|
2434
|
-
end
|
|
2435
|
-
end
|
|
2436
|
-
```
|
|
2437
|
-
|
|
2438
|
-
### Method Ordering
|
|
2439
|
-
|
|
2440
|
-
Methods are ordered in classes:
|
|
2441
|
-
|
|
2442
|
-
1. `class` methods (at top)
|
|
2443
|
-
2. `public` methods (`initialize` first if present)
|
|
2444
|
-
3. `private` methods
|
|
2445
|
-
|
|
2446
|
-
```ruby
|
|
2447
|
-
class SomeClass
|
|
2448
|
-
# 1. Class methods
|
|
2449
|
-
class << self
|
|
2450
|
-
def create_with_owner(attrs)
|
|
2451
|
-
# ...
|
|
2452
|
-
end
|
|
2453
|
-
end
|
|
2454
|
-
|
|
2455
|
-
# 2. Public methods
|
|
2456
|
-
def initialize(attrs)
|
|
2457
|
-
# ...
|
|
2458
|
-
end
|
|
2459
|
-
|
|
2460
|
-
def some_public_method
|
|
2461
|
-
# ...
|
|
2462
|
-
end
|
|
2463
|
-
|
|
2464
|
-
# 3. Private methods
|
|
2465
|
-
private
|
|
2466
|
-
def some_private_method
|
|
2467
|
-
# ...
|
|
2468
|
-
end
|
|
2469
|
-
end
|
|
2470
|
-
```
|
|
2471
|
-
|
|
2472
|
-
### Invocation Order (Private Methods)
|
|
2473
|
-
|
|
2474
|
-
Private methods are ordered vertically by their invocation order:
|
|
2475
|
-
|
|
2476
|
-
```ruby
|
|
2477
|
-
class SomeClass
|
|
2478
|
-
def some_method
|
|
2479
|
-
method_1
|
|
2480
|
-
method_2
|
|
2481
|
-
end
|
|
2482
|
-
|
|
2483
|
-
private
|
|
2484
|
-
def method_1
|
|
2485
|
-
method_1_1
|
|
2486
|
-
method_1_2
|
|
2487
|
-
end
|
|
2488
|
-
|
|
2489
|
-
def method_1_1 # ← Called from method_1
|
|
2490
|
-
# ...
|
|
2491
|
-
end
|
|
2492
|
-
|
|
2493
|
-
def method_1_2 # ← Called from method_1
|
|
2494
|
-
# ...
|
|
2495
|
-
end
|
|
2496
|
-
|
|
2497
|
-
def method_2 # ← Called from some_method (after method_1)
|
|
2498
|
-
method_2_1
|
|
2499
|
-
method_2_2
|
|
2500
|
-
end
|
|
2501
|
-
|
|
2502
|
-
def method_2_1 # ← Called from method_2
|
|
2503
|
-
# ...
|
|
2504
|
-
end
|
|
2505
|
-
|
|
2506
|
-
def method_2_2 # ← Called from method_2
|
|
2507
|
-
# ...
|
|
2508
|
-
end
|
|
2509
|
-
end
|
|
2510
|
-
```
|
|
2511
|
-
|
|
2512
|
-
This makes it easy to read code top-to-bottom following the execution flow.
|
|
2513
|
-
|
|
2514
|
-
### Visibility Modifiers
|
|
2515
|
-
|
|
2516
|
-
Don't add a newline under visibility modifiers. Indent content under them:
|
|
2517
|
-
|
|
2518
|
-
```ruby
|
|
2519
|
-
class SomeClass
|
|
2520
|
-
def some_method
|
|
2521
|
-
# ...
|
|
2522
|
-
end
|
|
2523
|
-
|
|
2524
|
-
private # ← No newline after
|
|
2525
|
-
def some_private_method_1 # ← Indented
|
|
2526
|
-
# ...
|
|
2527
|
-
end
|
|
2528
|
-
|
|
2529
|
-
def some_private_method_2 # ← Indented
|
|
2530
|
-
# ...
|
|
2531
|
-
end
|
|
2532
|
-
end
|
|
2533
|
-
```
|
|
2534
|
-
|
|
2535
|
-
**Exception**: For modules with only private methods, mark `private` at top with extra newline, but don't indent:
|
|
2536
|
-
|
|
2537
|
-
```ruby
|
|
2538
|
-
module SomeModule
|
|
2539
|
-
private # ← At top
|
|
2540
|
-
# ← Extra newline
|
|
2541
|
-
def some_private_method # ← Not indented
|
|
2542
|
-
# ...
|
|
2543
|
-
end
|
|
2544
|
-
end
|
|
2545
|
-
```
|
|
2546
|
-
|
|
2547
|
-
### Bang Method Naming
|
|
2548
|
-
|
|
2549
|
-
Only use `!` for methods that have a non-bang counterpart:
|
|
2550
|
-
|
|
2551
|
-
**Good:**
|
|
2552
|
-
|
|
2553
|
-
```ruby
|
|
2554
|
-
def save # ← Returns false on failure
|
|
2555
|
-
def save! # ← Raises on failure (has counterpart)
|
|
2556
|
-
|
|
2557
|
-
def update(attrs)
|
|
2558
|
-
def update!(attrs)
|
|
2559
|
-
```
|
|
2560
|
-
|
|
2561
|
-
**Avoid:**
|
|
2562
|
-
|
|
2563
|
-
```ruby
|
|
2564
|
-
def destroy! # ← No non-bang counterpart? Don't use !
|
|
2565
|
-
def process! # ← Not signaling danger vs non-bang version
|
|
2566
|
-
```
|
|
2567
|
-
|
|
2568
|
-
**Don't use `!` to flag destructive actions**. Many destructive methods in Ruby/Rails don't have `!`.
|
|
2569
|
-
|
|
2570
|
-
---
|
|
2571
|
-
|
|
2572
|
-
# Part 6: Common Tasks & Recipes
|
|
2573
|
-
|
|
2574
|
-
Step-by-step guides for common development tasks. For test examples associated with each recipe, see [Testing Patterns](testing.md).
|
|
2575
|
-
|
|
2576
|
-
## 6.1 Recipe: Adding a New State to Cards
|
|
2577
|
-
|
|
2578
|
-
Let's say you want to add an "archived" state to cards. Here's the full pattern:
|
|
2579
|
-
|
|
2580
|
-
### Step 1: Create the State Model
|
|
2581
|
-
|
|
2582
|
-
```ruby
|
|
2583
|
-
# app/models/card/archive.rb
|
|
2584
|
-
class Card::Archive < ApplicationRecord
|
|
2585
|
-
self.table_name = "card_archives"
|
|
2586
|
-
|
|
2587
|
-
belongs_to :card
|
|
2588
|
-
belongs_to :user
|
|
2589
|
-
belongs_to :account, default: -> { card.account }
|
|
2590
|
-
end
|
|
2591
|
-
```
|
|
2592
|
-
|
|
2593
|
-
**Migration:**
|
|
2594
|
-
|
|
2595
|
-
```ruby
|
|
2596
|
-
class CreateCardArchives < ActiveRecord::Migration[7.1]
|
|
2597
|
-
def change
|
|
2598
|
-
create_table :card_archives do |t|
|
|
2599
|
-
t.references :card, null: false, foreign_key: true
|
|
2600
|
-
t.references :user, null: false, foreign_key: true
|
|
2601
|
-
t.references :account, null: false, foreign_key: true
|
|
2602
|
-
t.timestamps
|
|
2603
|
-
end
|
|
2604
|
-
end
|
|
2605
|
-
end
|
|
2606
|
-
```
|
|
2607
|
-
|
|
2608
|
-
### Step 2: Create the Concern
|
|
2609
|
-
|
|
2610
|
-
```ruby
|
|
2611
|
-
# app/models/card/archivable.rb
|
|
2612
|
-
module Card::Archivable
|
|
2613
|
-
extend ActiveSupport::Concern
|
|
2614
|
-
|
|
2615
|
-
included do
|
|
2616
|
-
has_one :archive, dependent: :destroy
|
|
2617
|
-
|
|
2618
|
-
scope :archived, -> { joins(:archive) }
|
|
2619
|
-
scope :unarchived, -> { where.missing(:archive) }
|
|
2620
|
-
end
|
|
2621
|
-
|
|
2622
|
-
def archived?
|
|
2623
|
-
archive.present?
|
|
2624
|
-
end
|
|
2625
|
-
|
|
2626
|
-
def unarchived?
|
|
2627
|
-
!archived?
|
|
2628
|
-
end
|
|
2629
|
-
|
|
2630
|
-
def archived_by
|
|
2631
|
-
archive&.user
|
|
2632
|
-
end
|
|
2633
|
-
|
|
2634
|
-
def archived_at
|
|
2635
|
-
archive&.created_at
|
|
2636
|
-
end
|
|
2637
|
-
|
|
2638
|
-
def archive(user: Current.user)
|
|
2639
|
-
unless archived?
|
|
2640
|
-
transaction do
|
|
2641
|
-
create_archive! user: user
|
|
2642
|
-
track_event :archived, creator: user
|
|
2643
|
-
end
|
|
2644
|
-
end
|
|
2645
|
-
end
|
|
2646
|
-
|
|
2647
|
-
def unarchive(user: Current.user)
|
|
2648
|
-
if archived?
|
|
2649
|
-
transaction do
|
|
2650
|
-
archive.destroy
|
|
2651
|
-
track_event :unarchived, creator: user
|
|
2652
|
-
end
|
|
2653
|
-
end
|
|
2654
|
-
end
|
|
2655
|
-
end
|
|
2656
|
-
```
|
|
2657
|
-
|
|
2658
|
-
### Step 3: Include in Card Model
|
|
2659
|
-
|
|
2660
|
-
```ruby
|
|
2661
|
-
# app/models/card.rb
|
|
2662
|
-
class Card < ApplicationRecord
|
|
2663
|
-
include Assignable, Archivable, Attachments, Broadcastable, Closeable, ...
|
|
2664
|
-
# └─ Add here
|
|
2665
|
-
```
|
|
2666
|
-
|
|
2667
|
-
### Step 4: Create the Controller
|
|
2668
|
-
|
|
2669
|
-
```ruby
|
|
2670
|
-
# app/controllers/cards/archives_controller.rb
|
|
2671
|
-
class Cards::ArchivesController < ApplicationController
|
|
2672
|
-
include CardScoped
|
|
2673
|
-
|
|
2674
|
-
def create
|
|
2675
|
-
@card.archive
|
|
2676
|
-
|
|
2677
|
-
respond_to do |format|
|
|
2678
|
-
format.turbo_stream { render_card_replacement }
|
|
2679
|
-
format.json { head :no_content }
|
|
2680
|
-
end
|
|
2681
|
-
end
|
|
2682
|
-
|
|
2683
|
-
def destroy
|
|
2684
|
-
@card.unarchive
|
|
2685
|
-
|
|
2686
|
-
respond_to do |format|
|
|
2687
|
-
format.turbo_stream { render_card_replacement }
|
|
2688
|
-
format.json { head :no_content }
|
|
2689
|
-
end
|
|
2690
|
-
end
|
|
2691
|
-
end
|
|
2692
|
-
```
|
|
2693
|
-
|
|
2694
|
-
### Step 5: Add Routes
|
|
2695
|
-
|
|
2696
|
-
```ruby
|
|
2697
|
-
# config/routes.rb
|
|
2698
|
-
resources :cards do
|
|
2699
|
-
scope module: :cards do
|
|
2700
|
-
resource :archive # ← Add this
|
|
2701
|
-
# ...
|
|
2702
|
-
end
|
|
2703
|
-
end
|
|
2704
|
-
```
|
|
2705
|
-
|
|
2706
|
-
### Step 6: Add Tests
|
|
2707
|
-
|
|
2708
|
-
See [Testing Patterns — Recipe: Testing New Card States](testing.md#recipe-testing-new-card-states) for the complete test examples.
|
|
2709
|
-
|
|
2710
|
-
### Step 7: Update Views
|
|
2711
|
-
|
|
2712
|
-
Add UI elements to trigger archive/unarchive.
|
|
2713
|
-
|
|
2714
|
-
## 6.2 Recipe: Adding Event Tracking
|
|
2715
|
-
|
|
2716
|
-
Add event tracking to any model action:
|
|
2717
|
-
|
|
2718
|
-
### Step 1: Ensure Eventable is Included
|
|
2719
|
-
|
|
2720
|
-
```ruby
|
|
2721
|
-
class Card < ApplicationRecord
|
|
2722
|
-
include Eventable, ... # ← Already included
|
|
2723
|
-
```
|
|
2724
|
-
|
|
2725
|
-
### Step 2: Add track_event Call
|
|
2726
|
-
|
|
2727
|
-
```ruby
|
|
2728
|
-
def some_action(user: Current.user)
|
|
2729
|
-
transaction do
|
|
2730
|
-
# ... state changes ...
|
|
2731
|
-
|
|
2732
|
-
track_event :action_name, creator: user # ← Add this
|
|
2733
|
-
end
|
|
2734
|
-
end
|
|
2735
|
-
```
|
|
2736
|
-
|
|
2737
|
-
### Step 3: Add Particulars for Context
|
|
2738
|
-
|
|
2739
|
-
If you need to store additional data:
|
|
2740
|
-
|
|
2741
|
-
```ruby
|
|
2742
|
-
def move_to(new_board)
|
|
2743
|
-
old_board_name = board.name
|
|
2744
|
-
|
|
2745
|
-
transaction do
|
|
2746
|
-
update!(board: new_board)
|
|
2747
|
-
track_event :board_changed,
|
|
2748
|
-
particulars: {
|
|
2749
|
-
old_board: old_board_name,
|
|
2750
|
-
new_board: new_board.name
|
|
2751
|
-
}
|
|
2752
|
-
end
|
|
2753
|
-
end
|
|
2754
|
-
```
|
|
2755
|
-
|
|
2756
|
-
### Step 4: Test Event Creation
|
|
2757
|
-
|
|
2758
|
-
See [Testing Patterns — Recipe: Testing Event Creation](testing.md#recipe-testing-event-creation) for the complete test examples.
|
|
2759
|
-
|
|
2760
|
-
## 6.3 Recipe: Creating Background Jobs
|
|
2761
|
-
|
|
2762
|
-
Follow the _now/_later pattern:
|
|
2763
|
-
|
|
2764
|
-
### Step 1: Create Synchronous Method
|
|
2765
|
-
|
|
2766
|
-
```ruby
|
|
2767
|
-
# app/models/some_model.rb
|
|
2768
|
-
def process_data
|
|
2769
|
-
# All the logic here
|
|
2770
|
-
some_complex_operation
|
|
2771
|
-
update_related_records
|
|
2772
|
-
send_notifications
|
|
2773
|
-
end
|
|
2774
|
-
```
|
|
2775
|
-
|
|
2776
|
-
### Step 2: Create Async Wrapper
|
|
2777
|
-
|
|
2778
|
-
```ruby
|
|
2779
|
-
def process_data_later
|
|
2780
|
-
ProcessDataJob.perform_later(self)
|
|
2781
|
-
end
|
|
2782
|
-
```
|
|
2783
|
-
|
|
2784
|
-
### Step 3: Create Job
|
|
2785
|
-
|
|
2786
|
-
```ruby
|
|
2787
|
-
# app/jobs/process_data_job.rb
|
|
2788
|
-
class ProcessDataJob < ApplicationJob
|
|
2789
|
-
queue_as :backend
|
|
2790
|
-
|
|
2791
|
-
def perform(record)
|
|
2792
|
-
record.process_data # ← Calls synchronous method
|
|
2793
|
-
end
|
|
2794
|
-
end
|
|
2795
|
-
```
|
|
2796
|
-
|
|
2797
|
-
### Step 4: Add Callback (if needed)
|
|
2798
|
-
|
|
2799
|
-
```ruby
|
|
2800
|
-
after_create_commit :process_data_later
|
|
2801
|
-
```
|
|
2802
|
-
|
|
2803
|
-
### Step 5: Test Both Versions
|
|
2804
|
-
|
|
2805
|
-
See [Testing Patterns — Recipe: Testing Background Jobs](testing.md#recipe-testing-background-jobs) for the complete test examples.
|
|
2806
|
-
|
|
2807
|
-
---
|
|
2808
|
-
|
|
2809
|
-
# Part 7: Quick Reference
|
|
2810
|
-
|
|
2811
|
-
## 7.1 Concern Catalog
|
|
2812
|
-
|
|
2813
|
-
### Shared Concerns (`app/models/concerns/`)
|
|
2814
|
-
|
|
2815
|
-
| Concern | Purpose | Key Methods |
|
|
2816
|
-
|---------|---------|-------------|
|
|
2817
|
-
| `Eventable` | Track events for actions | `track_event(action, **particulars)` |
|
|
2818
|
-
| `Notifiable` | Send notifications | `notify_recipients`, `notify_recipients_later` |
|
|
2819
|
-
| `Searchable` | Index for full-text search | `reindex`, creates search records on save |
|
|
2820
|
-
| `Attachments` | Support for file attachments | ActiveStorage integration |
|
|
2821
|
-
| `Mentions` | Scan and create @mentions | `create_mentions`, scans on save |
|
|
2822
|
-
| `Storage::Tracked` | Track storage usage | Automatic tracking on attachment changes |
|
|
2823
|
-
| `Storage::Totaled` | Materialized storage totals | `bytes_used`, `bytes_used_exact` |
|
|
2824
|
-
|
|
2825
|
-
### Card-Specific Concerns (`app/models/card/`)
|
|
2826
|
-
|
|
2827
|
-
| Concern | Purpose | Key Methods |
|
|
2828
|
-
|---------|---------|-------------|
|
|
2829
|
-
| `Assignable` | Assign users to cards | `assign(user)`, `unassign(user)` |
|
|
2830
|
-
| `Closeable` | Close/reopen cards | `close`, `reopen`, `closed?`, `open?` |
|
|
2831
|
-
| `Golden` | Mark cards as golden | `gild`, `ungild`, `golden?` |
|
|
2832
|
-
| `Postponable` | Postpone to "not now" | `postpone`, `resume`, `auto_postpone` |
|
|
2833
|
-
| `Entropic` | Auto-postpone stale cards | `entropic?`, scope: `due_to_be_postponed` |
|
|
2834
|
-
| `Triageable` | Move through triage workflow | `triage_into(column)`, `send_back_to_triage` |
|
|
2835
|
-
| `Watchable` | Watch/unwatch cards | `watch`, `unwatch`, `watched_by?(user)` |
|
|
2836
|
-
| `Eventable` | Card-specific event logic | Overrides from base `Eventable` |
|
|
2837
|
-
|
|
2838
|
-
### Board-Specific Concerns (`app/models/board/`)
|
|
2839
|
-
|
|
2840
|
-
| Concern | Purpose | Key Methods |
|
|
2841
|
-
|---------|---------|-------------|
|
|
2842
|
-
| `Accessible` | Board access control | `accessible_to?(user)`, `accesses.grant_to(users)` |
|
|
2843
|
-
| `Storage` | Calculate board storage | `storage_used`, `storage_limit` |
|
|
2844
|
-
|
|
2845
|
-
## 7.2 Decision Trees
|
|
2846
|
-
|
|
2847
|
-
### "Where Does This Code Belong?"
|
|
2848
|
-
|
|
2849
|
-
```
|
|
2850
|
-
Is it business logic that changes data or makes decisions?
|
|
2851
|
-
│
|
|
2852
|
-
├─ YES
|
|
2853
|
-
│ │
|
|
2854
|
-
│ └─ Is it used by multiple controllers OR console OR jobs?
|
|
2855
|
-
│ │
|
|
2856
|
-
│ ├─ YES → Put in MODEL
|
|
2857
|
-
│ │
|
|
2858
|
-
│ └─ NO → Still put in MODEL (keeps controller thin)
|
|
2859
|
-
│
|
|
2860
|
-
└─ NO
|
|
2861
|
-
│
|
|
2862
|
-
└─ Is it about HTTP (params, rendering, redirects)?
|
|
2863
|
-
│
|
|
2864
|
-
├─ YES → Put in CONTROLLER
|
|
2865
|
-
│
|
|
2866
|
-
└─ NO → Is it about background execution?
|
|
2867
|
-
│
|
|
2868
|
-
├─ YES → Create JOB (but logic goes in MODEL)
|
|
2869
|
-
│
|
|
2870
|
-
└─ NO → Is it a utility/helper?
|
|
2871
|
-
│
|
|
2872
|
-
└─ YES → Put in lib/ or helper/
|
|
2873
|
-
```
|
|
2874
|
-
|
|
2875
|
-
### "Should I Create a Concern?"
|
|
2876
|
-
|
|
2877
|
-
```
|
|
2878
|
-
Is the behavior needed by multiple models?
|
|
2879
|
-
│
|
|
2880
|
-
├─ YES
|
|
2881
|
-
│ │
|
|
2882
|
-
│ └─ Are they related models (same hierarchy)?
|
|
2883
|
-
│ │
|
|
2884
|
-
│ ├─ YES → Model-specific concern (Card::Something)
|
|
2885
|
-
│ │
|
|
2886
|
-
│ └─ NO → Shared concern (Somethingable)
|
|
2887
|
-
│
|
|
2888
|
-
└─ NO
|
|
2889
|
-
│
|
|
2890
|
-
└─ Is it 50+ lines of cohesive behavior?
|
|
2891
|
-
│
|
|
2892
|
-
├─ YES → Model-specific concern (Card::Something)
|
|
2893
|
-
│
|
|
2894
|
-
└─ NO → Keep in model (don't over-extract)
|
|
2895
|
-
```
|
|
2896
|
-
|
|
2897
|
-
### "Is This a New Resource or Custom Action?"
|
|
2898
|
-
|
|
2899
|
-
```
|
|
2900
|
-
Does this action create, update, or destroy something?
|
|
2901
|
-
│
|
|
2902
|
-
├─ YES
|
|
2903
|
-
│ │
|
|
2904
|
-
│ └─ What is that "something"?
|
|
2905
|
-
│ │
|
|
2906
|
-
│ └─ That's your resource!
|
|
2907
|
-
│ │
|
|
2908
|
-
│ Examples:
|
|
2909
|
-
│ - Closing card → Creates "Closure" resource
|
|
2910
|
-
│ - Gilding card → Creates "Goldness" resource
|
|
2911
|
-
│ - Assigning user → Creates "Assignment" resource
|
|
2912
|
-
│
|
|
2913
|
-
└─ NO → Maybe it's actually creating/destroying something?
|
|
2914
|
-
Look harder. Most actions fit the resource pattern.
|
|
2915
|
-
```
|
|
2916
|
-
|
|
2917
|
-
## 7.3 Common Gotchas
|
|
2918
|
-
|
|
2919
|
-
### 1. Adding Business Logic to Controllers
|
|
2920
|
-
|
|
2921
|
-
**Problem:**
|
|
2922
|
-
|
|
2923
|
-
```ruby
|
|
2924
|
-
def create
|
|
2925
|
-
@card = board.cards.create!(...)
|
|
2926
|
-
@card.events.create!(...)
|
|
2927
|
-
@card.comments.create!(...)
|
|
2928
|
-
# ... 10 more lines
|
|
2929
|
-
end
|
|
2930
|
-
```
|
|
2931
|
-
|
|
2932
|
-
**Solution:**
|
|
2933
|
-
|
|
2934
|
-
```ruby
|
|
2935
|
-
def create
|
|
2936
|
-
@card = board.cards.create_with_initial_comment!(...) # ← Model method
|
|
2937
|
-
end
|
|
2938
|
-
```
|
|
2939
|
-
|
|
2940
|
-
Put logic in models, not controllers.
|
|
2941
|
-
|
|
2942
|
-
### 2. Creating Custom Actions Instead of Resources
|
|
2943
|
-
|
|
2944
|
-
**Problem:**
|
|
2945
|
-
|
|
2946
|
-
```ruby
|
|
2947
|
-
resources :cards do
|
|
2948
|
-
post :close # ← Anti-pattern
|
|
2949
|
-
end
|
|
2950
|
-
```
|
|
2951
|
-
|
|
2952
|
-
**Solution:**
|
|
2953
|
-
|
|
2954
|
-
```ruby
|
|
2955
|
-
resources :cards do
|
|
2956
|
-
resource :closure
|
|
2957
|
-
end
|
|
2958
|
-
```
|
|
2959
|
-
|
|
2960
|
-
Model actions as resources.
|
|
2961
|
-
|
|
2962
|
-
### 3. Putting Logic in Jobs Instead of Models
|
|
2963
|
-
|
|
2964
|
-
**Problem:**
|
|
2965
|
-
|
|
2966
|
-
```ruby
|
|
2967
|
-
class ProcessJob < ApplicationJob
|
|
2968
|
-
def perform(record)
|
|
2969
|
-
# 50 lines of business logic here
|
|
2970
|
-
end
|
|
2971
|
-
end
|
|
2972
|
-
```
|
|
2973
|
-
|
|
2974
|
-
**Solution:**
|
|
2975
|
-
|
|
2976
|
-
```ruby
|
|
2977
|
-
class ProcessJob < ApplicationJob
|
|
2978
|
-
def perform(record)
|
|
2979
|
-
record.process # ← Logic in model
|
|
2980
|
-
end
|
|
2981
|
-
end
|
|
2982
|
-
```
|
|
2983
|
-
|
|
2984
|
-
Jobs should be thin wrappers.
|
|
2985
|
-
|
|
2986
|
-
### 4. Breaking Association Declaration Order
|
|
2987
|
-
|
|
2988
|
-
**Problem:**
|
|
2989
|
-
|
|
2990
|
-
```ruby
|
|
2991
|
-
belongs_to :account, default: -> { board.account } # ← Error!
|
|
2992
|
-
belongs_to :board # ← board not declared yet above
|
|
2993
|
-
```
|
|
2994
|
-
|
|
2995
|
-
**Solution:**
|
|
2996
|
-
|
|
2997
|
-
```ruby
|
|
2998
|
-
belongs_to :board # ← Declare first
|
|
2999
|
-
belongs_to :account, default: -> { board.account } # ← Use after
|
|
3000
|
-
```
|
|
3001
|
-
|
|
3002
|
-
Declare associations before using them in defaults.
|
|
3003
|
-
|
|
3004
|
-
### 5. Using .find for Cards in Controllers
|
|
3005
|
-
|
|
3006
|
-
**Problem:**
|
|
3007
|
-
|
|
3008
|
-
```ruby
|
|
3009
|
-
@card = Card.find(params[:id]) # ← Wrong! Cards use :number
|
|
3010
|
-
```
|
|
3011
|
-
|
|
3012
|
-
**Solution:**
|
|
3013
|
-
|
|
3014
|
-
```ruby
|
|
3015
|
-
@card = Current.user.accessible_cards.find_by!(number: params[:id])
|
|
3016
|
-
```
|
|
3017
|
-
|
|
3018
|
-
Cards use `number` for user-facing IDs, not `id`.
|
|
3019
|
-
|
|
3020
|
-
### 6. Not Using Transactions for Multi-Step Operations
|
|
3021
|
-
|
|
3022
|
-
**Problem:**
|
|
3023
|
-
|
|
3024
|
-
```ruby
|
|
3025
|
-
def close
|
|
3026
|
-
create_closure!
|
|
3027
|
-
track_event :closed # ← If this fails, closure exists but no event
|
|
3028
|
-
end
|
|
3029
|
-
```
|
|
3030
|
-
|
|
3031
|
-
**Solution:**
|
|
3032
|
-
|
|
3033
|
-
```ruby
|
|
3034
|
-
def close
|
|
3035
|
-
transaction do
|
|
3036
|
-
create_closure!
|
|
3037
|
-
track_event :closed
|
|
3038
|
-
end
|
|
3039
|
-
end
|
|
3040
|
-
```
|
|
3041
|
-
|
|
3042
|
-
Wrap related operations in transactions.
|
|
3043
|
-
|
|
3044
|
-
---
|
|
3045
|
-
|
|
3046
|
-
# Conclusion
|
|
3047
|
-
|
|
3048
|
-
This documentation covers the core backend patterns and practices used throughout the Rails application:
|
|
3049
|
-
|
|
3050
|
-
- **Foundation**: Multi-tenancy via Current context
|
|
3051
|
-
- **Models**: Concern-driven architecture, intention-revealing APIs, smart defaults
|
|
3052
|
-
- **Controllers**: Thin controllers that delegate to rich models
|
|
3053
|
-
- **Jobs**: Ultra-thin jobs following _now/_later pattern
|
|
3054
|
-
- **Style**: specific conventions for readable code
|
|
3055
|
-
|
|
3056
|
-
The key principle underlying all patterns: **business logic belongs in models, and everything else orchestrates that logic as simply as possible.**
|
|
3057
|
-
|
|
3058
|
-
For more details, explore the actual code files referenced throughout this document. The patterns are consistent, so once you understand them, you can navigate the entire codebase confidently.
|
|
3059
|
-
|
|
3060
|
-
**Related guides:**
|
|
3061
|
-
- [Frontend Patterns](frontend.md) — Presenter pattern, view layer conventions
|
|
3062
|
-
- [Testing Patterns](testing.md) — Testing philosophy, model/controller/job test patterns
|
|
3063
|
-
- [Security Guide](security.md) — Agent-oriented security checklist for code review
|
|
3064
|
-
|
|
3065
|
-
---
|
|
3066
|
-
|
|
3067
|
-
**Document Version**: 1.1
|
|
3068
|
-
**Last Updated**: 2026-02-15
|
|
3069
|
-
**Maintainer**: Development Team
|