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.
Files changed (149) hide show
  1. checksums.yaml +4 -4
  2. data/ariadna.gemspec +0 -1
  3. data/data/agents/ariadna-codebase-mapper.md +34 -722
  4. data/data/agents/ariadna-debugger.md +44 -1139
  5. data/data/agents/ariadna-executor.md +75 -396
  6. data/data/agents/ariadna-planner.md +78 -1215
  7. data/data/agents/ariadna-roadmapper.md +55 -582
  8. data/data/agents/ariadna-verifier.md +60 -702
  9. data/data/ariadna/templates/config.json +8 -33
  10. data/data/ariadna/workflows/debug.md +28 -0
  11. data/data/ariadna/workflows/execute-phase.md +31 -513
  12. data/data/ariadna/workflows/map-codebase.md +20 -319
  13. data/data/ariadna/workflows/new-milestone.md +20 -365
  14. data/data/ariadna/workflows/new-project.md +19 -880
  15. data/data/ariadna/workflows/plan-phase.md +24 -443
  16. data/data/ariadna/workflows/progress.md +20 -376
  17. data/data/ariadna/workflows/quick.md +19 -221
  18. data/data/ariadna/workflows/roadmap-ops.md +28 -0
  19. data/data/ariadna/workflows/verify-work.md +23 -560
  20. data/data/commands/ariadna/add-phase.md +11 -22
  21. data/data/commands/ariadna/debug.md +11 -143
  22. data/data/commands/ariadna/execute-phase.md +12 -30
  23. data/data/commands/ariadna/insert-phase.md +7 -14
  24. data/data/commands/ariadna/map-codebase.md +16 -49
  25. data/data/commands/ariadna/new-milestone.md +12 -25
  26. data/data/commands/ariadna/new-project.md +22 -26
  27. data/data/commands/ariadna/plan-phase.md +13 -22
  28. data/data/commands/ariadna/progress.md +16 -6
  29. data/data/commands/ariadna/quick.md +9 -11
  30. data/data/commands/ariadna/remove-phase.md +9 -12
  31. data/data/commands/ariadna/verify-work.md +14 -19
  32. data/data/skills/rails-backend/API.md +138 -0
  33. data/data/skills/rails-backend/CONTROLLERS.md +154 -0
  34. data/data/skills/rails-backend/JOBS.md +132 -0
  35. data/data/skills/rails-backend/MODELS.md +213 -0
  36. data/data/skills/rails-backend/SKILL.md +169 -0
  37. data/data/skills/rails-frontend/ASSETS.md +154 -0
  38. data/data/skills/rails-frontend/COMPONENTS.md +253 -0
  39. data/data/skills/rails-frontend/SKILL.md +187 -0
  40. data/data/skills/rails-frontend/VIEWS.md +168 -0
  41. data/data/skills/rails-performance/PROFILING.md +106 -0
  42. data/data/skills/rails-performance/SKILL.md +217 -0
  43. data/data/skills/rails-security/AUDIT.md +118 -0
  44. data/data/skills/rails-security/SKILL.md +422 -0
  45. data/data/skills/rails-testing/FIXTURES.md +78 -0
  46. data/data/skills/rails-testing/SKILL.md +160 -0
  47. data/data/skills/rails-testing/SYSTEM-TESTS.md +73 -0
  48. data/lib/ariadna/installer.rb +11 -15
  49. data/lib/ariadna/tools/cli.rb +0 -12
  50. data/lib/ariadna/tools/config_manager.rb +10 -72
  51. data/lib/ariadna/tools/frontmatter.rb +23 -1
  52. data/lib/ariadna/tools/init.rb +201 -401
  53. data/lib/ariadna/tools/model_profiles.rb +6 -14
  54. data/lib/ariadna/tools/phase_manager.rb +1 -10
  55. data/lib/ariadna/tools/state_manager.rb +170 -451
  56. data/lib/ariadna/tools/template_filler.rb +4 -12
  57. data/lib/ariadna/tools/verification.rb +21 -399
  58. data/lib/ariadna/uninstaller.rb +9 -0
  59. data/lib/ariadna/version.rb +1 -1
  60. data/lib/ariadna.rb +1 -0
  61. metadata +20 -91
  62. data/data/agents/ariadna-backend-executor.md +0 -261
  63. data/data/agents/ariadna-frontend-executor.md +0 -259
  64. data/data/agents/ariadna-integration-checker.md +0 -418
  65. data/data/agents/ariadna-phase-researcher.md +0 -469
  66. data/data/agents/ariadna-plan-checker.md +0 -622
  67. data/data/agents/ariadna-project-researcher.md +0 -618
  68. data/data/agents/ariadna-research-synthesizer.md +0 -236
  69. data/data/agents/ariadna-test-executor.md +0 -266
  70. data/data/ariadna/references/checkpoints.md +0 -772
  71. data/data/ariadna/references/continuation-format.md +0 -249
  72. data/data/ariadna/references/decimal-phase-calculation.md +0 -65
  73. data/data/ariadna/references/git-integration.md +0 -248
  74. data/data/ariadna/references/git-planning-commit.md +0 -38
  75. data/data/ariadna/references/model-profile-resolution.md +0 -32
  76. data/data/ariadna/references/model-profiles.md +0 -73
  77. data/data/ariadna/references/phase-argument-parsing.md +0 -61
  78. data/data/ariadna/references/planning-config.md +0 -194
  79. data/data/ariadna/references/questioning.md +0 -153
  80. data/data/ariadna/references/rails-conventions.md +0 -416
  81. data/data/ariadna/references/tdd.md +0 -267
  82. data/data/ariadna/references/ui-brand.md +0 -160
  83. data/data/ariadna/references/verification-patterns.md +0 -853
  84. data/data/ariadna/templates/codebase/architecture.md +0 -481
  85. data/data/ariadna/templates/codebase/concerns.md +0 -380
  86. data/data/ariadna/templates/codebase/conventions.md +0 -434
  87. data/data/ariadna/templates/codebase/integrations.md +0 -328
  88. data/data/ariadna/templates/codebase/stack.md +0 -189
  89. data/data/ariadna/templates/codebase/structure.md +0 -418
  90. data/data/ariadna/templates/codebase/testing.md +0 -606
  91. data/data/ariadna/templates/context.md +0 -283
  92. data/data/ariadna/templates/continue-here.md +0 -78
  93. data/data/ariadna/templates/debug-subagent-prompt.md +0 -91
  94. data/data/ariadna/templates/phase-prompt.md +0 -609
  95. data/data/ariadna/templates/planner-subagent-prompt.md +0 -117
  96. data/data/ariadna/templates/research-project/ARCHITECTURE.md +0 -439
  97. data/data/ariadna/templates/research-project/FEATURES.md +0 -168
  98. data/data/ariadna/templates/research-project/PITFALLS.md +0 -406
  99. data/data/ariadna/templates/research-project/STACK.md +0 -251
  100. data/data/ariadna/templates/research-project/SUMMARY.md +0 -247
  101. data/data/ariadna/templates/state.md +0 -176
  102. data/data/ariadna/templates/summary-complex.md +0 -59
  103. data/data/ariadna/templates/summary-minimal.md +0 -41
  104. data/data/ariadna/templates/summary-standard.md +0 -48
  105. data/data/ariadna/templates/user-setup.md +0 -310
  106. data/data/ariadna/workflows/add-phase.md +0 -111
  107. data/data/ariadna/workflows/add-todo.md +0 -157
  108. data/data/ariadna/workflows/audit-milestone.md +0 -241
  109. data/data/ariadna/workflows/check-todos.md +0 -176
  110. data/data/ariadna/workflows/complete-milestone.md +0 -644
  111. data/data/ariadna/workflows/diagnose-issues.md +0 -219
  112. data/data/ariadna/workflows/discovery-phase.md +0 -289
  113. data/data/ariadna/workflows/discuss-phase.md +0 -408
  114. data/data/ariadna/workflows/execute-plan.md +0 -448
  115. data/data/ariadna/workflows/help.md +0 -470
  116. data/data/ariadna/workflows/insert-phase.md +0 -129
  117. data/data/ariadna/workflows/list-phase-assumptions.md +0 -178
  118. data/data/ariadna/workflows/pause-work.md +0 -122
  119. data/data/ariadna/workflows/plan-milestone-gaps.md +0 -256
  120. data/data/ariadna/workflows/remove-phase.md +0 -154
  121. data/data/ariadna/workflows/research-phase.md +0 -74
  122. data/data/ariadna/workflows/resume-project.md +0 -306
  123. data/data/ariadna/workflows/set-profile.md +0 -80
  124. data/data/ariadna/workflows/settings.md +0 -145
  125. data/data/ariadna/workflows/transition.md +0 -493
  126. data/data/ariadna/workflows/update.md +0 -212
  127. data/data/ariadna/workflows/verify-phase.md +0 -226
  128. data/data/commands/ariadna/add-todo.md +0 -42
  129. data/data/commands/ariadna/audit-milestone.md +0 -42
  130. data/data/commands/ariadna/check-todos.md +0 -41
  131. data/data/commands/ariadna/complete-milestone.md +0 -136
  132. data/data/commands/ariadna/discuss-phase.md +0 -86
  133. data/data/commands/ariadna/help.md +0 -22
  134. data/data/commands/ariadna/list-phase-assumptions.md +0 -50
  135. data/data/commands/ariadna/pause-work.md +0 -35
  136. data/data/commands/ariadna/plan-milestone-gaps.md +0 -40
  137. data/data/commands/ariadna/reapply-patches.md +0 -110
  138. data/data/commands/ariadna/research-phase.md +0 -187
  139. data/data/commands/ariadna/resume-work.md +0 -40
  140. data/data/commands/ariadna/set-profile.md +0 -34
  141. data/data/commands/ariadna/settings.md +0 -36
  142. data/data/commands/ariadna/update.md +0 -37
  143. data/data/guides/backend.md +0 -3069
  144. data/data/guides/frontend.md +0 -1479
  145. data/data/guides/performance.md +0 -1193
  146. data/data/guides/security.md +0 -1522
  147. data/data/guides/style-guide.md +0 -1091
  148. data/data/guides/testing.md +0 -504
  149. data/data/templates.md +0 -94
@@ -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