├── .gitignore ├── CLAUDE.md ├── observability.md ├── performance.md ├── active-storage.md ├── security-checklist.md ├── what-they-avoid.md ├── database.md ├── testing.md ├── README.md ├── caching.md ├── background-jobs.md ├── views.md ├── routing.md ├── multi-tenancy.md ├── development-philosophy.md ├── css.md ├── configuration.md ├── models.md ├── authentication.md ├── stimulus.md ├── ai-llm.md ├── jason-zimdars.md ├── workflows.md ├── controllers.md ├── actioncable.md └── mobile.md /.gitignore: -------------------------------------------------------------------------------- 1 | source/ 2 | -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | # CLAUDE.md 2 | 3 | This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. 4 | 5 | ## Repository Purpose 6 | 7 | This is a documentation-only repository containing transferable Rails patterns and development philosophy extracted from analyzing 37signals' Fizzy codebase. It consists entirely of markdown files - there is no application code, build system, or tests. 8 | 9 | ## Structure 10 | 11 | - `README.md` - Main entry point with table of contents 12 | - Topic files (e.g., `controllers.md`, `models.md`, `stimulus.md`) - Each covers a specific pattern area 13 | - All content is LLM-generated from PR analysis and should be verified against actual implementations 14 | 15 | ## Content Guidelines 16 | 17 | When editing or adding content: 18 | - Focus on transferable patterns, not Fizzy-specific business logic 19 | - Include code examples licensed under the O'Saasy License 20 | - Link PR references to actual GitHub PRs when available 21 | - Maintain the existing markdown structure and style 22 | -------------------------------------------------------------------------------- /observability.md: -------------------------------------------------------------------------------- 1 | # Observability & Logging 2 | 3 | > Patterns from 37signals' Fizzy codebase. 4 | 5 | --- 6 | 7 | ## Structured JSON Logging ([#285](https://github.com/basecamp/fizzy/pull/285)) 8 | 9 | ```ruby 10 | # config/environments/production.rb 11 | config.log_level = :fatal # Suppress unstructured logs 12 | config.structured_logging.logger = ActiveSupport::Logger.new(STDOUT) 13 | ``` 14 | 15 | Use `rails_structured_logging` gem. 16 | 17 | ## Multi-Tenant Context ([#301](https://github.com/basecamp/fizzy/pull/301)) 18 | 19 | Inject tenant into every log: 20 | 21 | ```ruby 22 | before_action { logger.struct tenant: ApplicationRecord.current_tenant } 23 | ``` 24 | 25 | ## User Context ([#472](https://github.com/basecamp/fizzy/pull/472)) 26 | 27 | Log authenticated user: 28 | 29 | ```ruby 30 | def set_current_session(session) 31 | logger.struct "Authorized User##{session.user.id}", 32 | authentication: { user: { id: session.user.id } } 33 | end 34 | ``` 35 | 36 | ## Yabeda Metrics Stack ([#1112](https://github.com/basecamp/fizzy/pull/1112)) 37 | 38 | ```ruby 39 | # Gemfile 40 | gem "yabeda" 41 | gem "yabeda-rails" 42 | gem "yabeda-puma-plugin" 43 | gem "yabeda-prometheus-mmap" 44 | 45 | # config/puma.rb 46 | plugin :yabeda 47 | plugin :yabeda_prometheus 48 | 49 | on_worker_boot do 50 | Yabeda::ActiveRecord.start_timed_metric_collection_task 51 | end 52 | ``` 53 | 54 | ## Additional Yabeda Modules ([#1165](https://github.com/basecamp/fizzy/pull/1165)) 55 | 56 | ```ruby 57 | gem "yabeda-activejob" 58 | gem "yabeda-gc" 59 | gem "yabeda-http_requests" 60 | 61 | # In initializer 62 | Yabeda::ActiveJob.install! 63 | ``` 64 | 65 | ## Log Configuration ([#1602](https://github.com/basecamp/fizzy/pull/1602)) 66 | 67 | ```ruby 68 | # Silence health checks 69 | config.silence_healthcheck_path = "/up" 70 | 71 | # Separate logger for jobs 72 | config.solid_queue.logger = ActiveSupport::Logger.new(STDOUT, level: :info) 73 | ``` 74 | 75 | ## Console Auditing ([#1834](https://github.com/basecamp/fizzy/pull/1834)) 76 | 77 | For compliance, use `console1984` + `audits1984`: 78 | 79 | ```ruby 80 | config.console1984.protected_environments = %i[production staging] 81 | config.audits1984.base_controller_class = "AdminController" 82 | ``` 83 | 84 | Requires Active Record Encryption keys in protected environments. 85 | 86 | ## OpenTelemetry Collector ([#1118](https://github.com/basecamp/fizzy/pull/1118)) 87 | 88 | Deploy as sidecar for container metrics: 89 | 90 | ```yaml 91 | # config/otel_collector.yml 92 | receivers: 93 | prometheus: 94 | config: 95 | scrape_configs: 96 | - job_name: "kamal-containers" 97 | docker_sd_configs: 98 | - host: unix:///var/run/docker.sock 99 | ``` 100 | -------------------------------------------------------------------------------- /performance.md: -------------------------------------------------------------------------------- 1 | # Performance Patterns 2 | 3 | > Database, CSS, and rendering optimizations from 37signals. 4 | 5 | --- 6 | 7 | ## CSS Performance 8 | 9 | ### Avoid Complex `:has()` Selectors 10 | Safari freezes on complex nested `:has()` selectors ([#1089](https://github.com/basecamp/fizzy/pull/1089)). 11 | Prefer simpler selectors over clever CSS. 12 | 13 | ### View Transitions 14 | Remove unnecessary `view-transition-name` causing navigation jank. 15 | 16 | ## Database Performance 17 | 18 | ### N+1 → JOINs 19 | Replace `find_each` loops with JOINs for bulk operations ([#1129](https://github.com/basecamp/fizzy/pull/1129)). 20 | 21 | Accept "unmaintainable" SQL when performance requires it: 22 | > "Way way way faster but feels unmaintainable" 23 | 24 | ### Counter Caches 25 | Fast reads, but callbacks are bypassed. Consider manual approach if you need side effects. 26 | 27 | ## Pagination 28 | 29 | - Start with reasonable page sizes (25-50) 30 | - Reduce if initial render is slow ([#1089](https://github.com/basecamp/fizzy/pull/1089): 50 → 25) 31 | - Use "Load more" buttons or intersection observer 32 | - Separate pagination per column/section 33 | 34 | ## Active Storage 35 | 36 | ### Read Replicas 37 | Use `preprocessed: true` - lazy generation fails on read-only replicas ([#767](https://github.com/basecamp/fizzy/pull/767)) 38 | 39 | ### Slow Uploads 40 | Extend signed URL expiry from default 5 min to 48 hours ([#773](https://github.com/basecamp/fizzy/pull/773)). 41 | Cloudflare buffering can exceed default timeout. 42 | 43 | ### Large Files 44 | Skip previews above size threshold (e.g., 16MB) to avoid timeouts ([#941](https://github.com/basecamp/fizzy/pull/941)) 45 | 46 | ### Avatars 47 | - Redirect to blob URL instead of streaming through Rails 48 | - Define thumbnail variants for consistent sizing 49 | - Faster than proxying through the app 50 | 51 | ## Rendering Performance 52 | 53 | ### Lazy Loading 54 | - Convert expensive menus to turbo frames 55 | - Load on interaction, not page load 56 | - Reduces initial render time significantly 57 | 58 | ### Debouncing 59 | 100ms debounce on filter search feels responsive ([#567](https://github.com/basecamp/fizzy/pull/567)) 60 | 61 | ## Puma/Ruby Tuning ([#1283](https://github.com/basecamp/fizzy/pull/1283)) 62 | 63 | ```ruby 64 | # config/puma.rb 65 | workers Concurrent.physical_processor_count 66 | threads 1, 1 67 | 68 | before_fork do 69 | Process.warmup # GC, compact, malloc_trim for CoW 70 | end 71 | ``` 72 | 73 | Use `autotuner` gem to collect data and suggest tuning. 74 | 75 | ## N+1 Prevention ([#1747](https://github.com/basecamp/fizzy/pull/1747)) 76 | 77 | Use `prosopite` gem for detection. Replace: 78 | ```ruby 79 | # Bad - extra query 80 | assignments.exists? assignee: user 81 | 82 | # Good - in-memory 83 | assignments.any? { |a| a.assignee_id == user.id } 84 | ``` 85 | 86 | Create `preloaded` scopes: 87 | ```ruby 88 | scope :preloaded, -> { 89 | includes(:column, :tags, board: [:entropy, :columns]) 90 | } 91 | ``` 92 | 93 | ## Optimistic UI for D&D ([#1927](https://github.com/basecamp/fizzy/pull/1927)) 94 | 95 | Insert immediately, request async: 96 | ```javascript 97 | #insertDraggedItem(container, item) { 98 | // Insert at correct position respecting priority 99 | const topItems = container.querySelectorAll("[data-drag-and-drop-top]") 100 | // ... insert logic 101 | } 102 | 103 | await this.#submitDropRequest(item, container) 104 | ``` 105 | 106 | ## Batch SQL Over N+1 Loops ([#1129](https://github.com/basecamp/fizzy/pull/1129)) 107 | 108 | Replace `find_each` with JOINs: 109 | ```ruby 110 | # Single query with JOINs instead of N queries 111 | user.mentions 112 | .joins("LEFT JOIN cards ON ...") 113 | .joins("LEFT JOIN comments ON ...") 114 | .where("cards.collection_id = ?", id) 115 | .destroy_all 116 | ``` 117 | -------------------------------------------------------------------------------- /active-storage.md: -------------------------------------------------------------------------------- 1 | # Active Storage Patterns 2 | 3 | > Lessons from 37signals' Fizzy codebase. 4 | 5 | --- 6 | 7 | ## Variant Preprocessing ([#767](https://github.com/basecamp/fizzy/pull/767)) 8 | 9 | Use `preprocessed: true` to prevent on-the-fly transformations failing on read replicas: 10 | 11 | ```ruby 12 | has_many_attached :embeds do |attachable| 13 | attachable.variant :small, 14 | resize_to_limit: [800, 600], 15 | preprocessed: true 16 | end 17 | ``` 18 | 19 | Centralize variant definitions in a module. 20 | 21 | ## Direct Upload Expiry ([#773](https://github.com/basecamp/fizzy/pull/773)) 22 | 23 | **Problem**: When using Cloudflare (or similar CDN/proxy), large file uploads can fail with signature expiration errors. Cloudflare buffers the entire request before forwarding it to your origin server. For large files on slow connections, this buffering can take longer than Rails' default signed URL expiry (5 minutes), causing the upload to fail even though the user is still actively uploading. 24 | 25 | **Solution**: Extend the direct upload URL expiry to accommodate slow uploads: 26 | 27 | ```ruby 28 | # config/initializers/active_storage.rb 29 | module ActiveStorage 30 | mattr_accessor :service_urls_for_direct_uploads_expire_in, 31 | default: 48.hours 32 | end 33 | 34 | # Prepend to ActiveStorage::Blob 35 | def service_url_for_direct_upload(expires_in: ActiveStorage.service_urls_for_direct_uploads_expire_in) 36 | super 37 | end 38 | ``` 39 | 40 | **Why 48 hours?** This provides ample time for even the slowest uploads while still expiring unused URLs. The signed URL is single-use anyway, so the security impact is minimal. 41 | 42 | ## Large File Preview Limits ([#941](https://github.com/basecamp/fizzy/pull/941)) 43 | 44 | Skip previews above size threshold: 45 | 46 | ```ruby 47 | module ActiveStorageBlobPreviewable 48 | MAX_PREVIEWABLE_SIZE = 16.megabytes 49 | 50 | def previewable? 51 | super && byte_size <= MAX_PREVIEWABLE_SIZE 52 | end 53 | end 54 | ``` 55 | 56 | ## Preview vs Variant ([#770](https://github.com/basecamp/fizzy/pull/770)) 57 | 58 | - **Variable** (images): `blob.variant(options)` 59 | - **Previewable** (PDFs, videos): `blob.preview(options)` 60 | 61 | Don't conflate them - different operations. 62 | 63 | ## Avatar Optimization ([#1689](https://github.com/basecamp/fizzy/pull/1689)) 64 | 65 | **Problem**: Streaming avatar images through your Rails app ties up web workers and adds latency. Every avatar request occupies a Puma thread while bytes flow through. 66 | 67 | **Solution**: Redirect to the blob URL and let your storage service (S3, GCS, etc.) serve the file directly: 68 | 69 | ```ruby 70 | def show 71 | if @user.avatar.attached? 72 | redirect_to rails_blob_url(@user.avatar.variant(:thumb)) 73 | else 74 | render_initials if stale?(@user) 75 | end 76 | end 77 | ``` 78 | 79 | **Key details**: 80 | - Use a preprocessed `:thumb` variant to avoid on-the-fly transformations 81 | - Only apply `stale?` to the initials fallback, not the redirect—otherwise browsers will show broken images after an avatar change until the cache expires 82 | - The redirect is fast (just sends a 302), offloading the heavy lifting to your CDN/storage service 83 | 84 | ## Mirror Configuration ([#557](https://github.com/basecamp/fizzy/pull/557)) 85 | 86 | **Pattern**: Use Active Storage's mirror service to write to multiple backends simultaneously while reading from a fast local primary. 87 | 88 | **Use cases**: 89 | - Local NVMe/SSD primary for speed, cloud backup for durability 90 | - Gradual migration between storage providers 91 | - Disaster recovery without impacting read performance 92 | 93 | ```yaml 94 | # config/storage.yml 95 | mirror: 96 | service: Mirror 97 | primary: local 98 | mirrors: [s3_backup] 99 | 100 | local: 101 | service: Disk 102 | root: <%= Rails.root.join("storage") %> 103 | 104 | s3_backup: 105 | service: S3 106 | bucket: myapp-backups 107 | force_path_style: true # Required for MinIO, Pure Storage, etc. 108 | request_checksum_calculation: when_required # For non-AWS S3-compatible services 109 | ``` 110 | 111 | **How it works**: Uploads write to both primary and mirrors. Downloads always read from primary only. This gives you local-speed reads with cloud redundancy. 112 | -------------------------------------------------------------------------------- /security-checklist.md: -------------------------------------------------------------------------------- 1 | # Security Checklist 2 | 3 | > Security patterns and gotchas from 37signals. 4 | 5 | --- 6 | 7 | ## XSS Prevention 8 | 9 | ### Always Escape Before `html_safe` 10 | ```ruby 11 | # Bad 12 | "#{user_input}".html_safe 13 | 14 | # Good 15 | "#{h(user_input)}".html_safe 16 | ``` 17 | 18 | Escape in helpers, not views ([#1114](https://github.com/basecamp/fizzy/pull/1114)). 19 | 20 | ## CSRF Protection 21 | 22 | ### Don't HTTP Cache Pages With Forms 23 | CSRF tokens get stale → 422 errors on form submit ([#1607](https://github.com/basecamp/fizzy/pull/1607)) 24 | 25 | ### Sec-Fetch-Site Header 26 | Additional CSRF check using browser's `Sec-Fetch-Site` header: 27 | 1. Report mode first to observe ([#1721](https://github.com/basecamp/fizzy/pull/1721)) 28 | 2. Enforce after validation ([#1751](https://github.com/basecamp/fizzy/pull/1751)) 29 | 30 | Defense in depth - use alongside traditional tokens. 31 | 32 | ## SSRF (Server-Side Request Forgery) 33 | 34 | For webhooks and any user-provided URLs: 35 | 36 | ### DNS Rebinding Protection ([#1903](https://github.com/basecamp/fizzy/pull/1903)) 37 | ```ruby 38 | # Resolve DNS once, pin the IP 39 | resolved_ip = resolve_dns(url) 40 | # Use pinned IP for request 41 | Net::HTTP.new(host, port, ipaddr: resolved_ip) 42 | ``` 43 | 44 | ### Block Private Networks ([#1905](https://github.com/basecamp/fizzy/pull/1905)) 45 | - Loopback (127.0.0.0/8) 46 | - Private (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16) 47 | - Link-local (169.254.0.0/16) - AWS metadata! 48 | - IPv4-mapped IPv6 49 | 50 | ### Validate Twice 51 | Check at creation time AND request time. 52 | 53 | ## ActionText / Rich Text 54 | 55 | ### Sanitizer Config ([#873](https://github.com/basecamp/fizzy/pull/873)) 56 | ```ruby 57 | # In after_initialize - eager loading bypasses config otherwise 58 | ActionText::ContentHelper.allowed_tags = ... 59 | ActionText::ContentHelper.allowed_attributes = ... 60 | ``` 61 | 62 | ### Remote Images ([#1859](https://github.com/basecamp/fizzy/pull/1859)) 63 | ```ruby 64 | # Use skip_pipeline for external URLs 65 | image_tag url, skip_pipeline: true 66 | ``` 67 | Asset pipeline can't process arbitrary external URLs. 68 | 69 | ## Multi-tenancy 70 | 71 | ### Scope Broadcasts ([#1800](https://github.com/basecamp/fizzy/pull/1800)) 72 | ```ruby 73 | # Bad - leaks across tenants 74 | broadcast_to :all_boards 75 | 76 | # Good - scoped by account 77 | broadcast_to [account, :all_boards] 78 | ``` 79 | 80 | ### Disconnect Deactivated Users ([#1810](https://github.com/basecamp/fizzy/pull/1810)) 81 | ```ruby 82 | ActionCable.server.remote_connections 83 | .where(current_user: self) 84 | .disconnect(reconnect: false) 85 | ``` 86 | 87 | ## Content Security Policy ([#1964](https://github.com/basecamp/fizzy/pull/1964)) 88 | 89 | ```ruby 90 | # config/initializers/content_security_policy.rb 91 | config.content_security_policy do |policy| 92 | policy.script_src :self 93 | policy.style_src :self, :unsafe_inline 94 | policy.base_uri :none 95 | policy.form_action :self 96 | policy.frame_ancestors :self 97 | policy.report_uri ENV["CSP_REPORT_URI"] if ENV["CSP_REPORT_URI"] 98 | end 99 | 100 | config.content_security_policy_report_only = ENV["CSP_REPORT_ONLY"] == "true" 101 | ``` 102 | 103 | Use nonce-based script loading for importmap support. 104 | 105 | ## Sec-Fetch-Site as CSRF Fallback ([#1721](https://github.com/basecamp/fizzy/pull/1721), [#1751](https://github.com/basecamp/fizzy/pull/1751)) 106 | 107 | ```ruby 108 | def verified_request? 109 | super || safe_fetch_site? 110 | end 111 | 112 | def safe_fetch_site? 113 | %w[same-origin same-site].include?( 114 | request.headers["Sec-Fetch-Site"]&.downcase 115 | ) 116 | end 117 | ``` 118 | 119 | Add `Sec-Fetch-Site` to Vary header for proper caching. 120 | 121 | ## Rate Limiting ([#1304](https://github.com/basecamp/fizzy/pull/1304)) 122 | 123 | Use Rails 7.2+ built-in rate limiting for auth endpoints: 124 | 125 | ```ruby 126 | class Sessions::MagicLinksController < ApplicationController 127 | rate_limit to: 10, within: 15.minutes, only: :create, 128 | with: -> { redirect_to session_magic_link_path, 129 | alert: "Try again in 15 minutes." } 130 | end 131 | 132 | class Memberships::EmailAddressesController < ApplicationController 133 | rate_limit to: 5, within: 1.hour, only: :create 134 | end 135 | ``` 136 | 137 | **When to rate limit**: 138 | - Authentication actions (login, magic links, password resets) 139 | - Email sending endpoints 140 | - External API calls 141 | - Resource creation endpoints 142 | 143 | ## Authorization Patterns ([#1083](https://github.com/basecamp/fizzy/pull/1083)) 144 | 145 | Use controller concerns for consistent authorization: 146 | 147 | ```ruby 148 | module Authorization 149 | extend ActiveSupport::Concern 150 | 151 | included do 152 | before_action :ensure_can_access_account, if: -> { 153 | ApplicationRecord.current_tenant && Current.session 154 | } 155 | end 156 | 157 | private 158 | def ensure_can_administer 159 | head :forbidden unless Current.user.admin? 160 | end 161 | 162 | def ensure_is_staff_member 163 | head :forbidden unless Current.user.staff? 164 | end 165 | end 166 | 167 | # In controllers 168 | class WebhooksController < ApplicationController 169 | before_action :ensure_can_administer 170 | end 171 | ``` 172 | 173 | Simple, readable, follows existing conventions. 174 | -------------------------------------------------------------------------------- /what-they-avoid.md: -------------------------------------------------------------------------------- 1 | # What They Deliberately Avoid 2 | 3 | > Patterns and gems 37signals chooses NOT to use. 4 | 5 | --- 6 | 7 | ## Notable Absences 8 | 9 | The Fizzy codebase is interesting as much for what's missing as what's present. 10 | 11 | ## Authentication: No Devise 12 | 13 | **Instead**: ~150 lines of custom passwordless magic link code. 14 | 15 | **Why avoid Devise**: 16 | - Too heavyweight for passwordless auth 17 | - Comes with password complexity they don't need 18 | - Custom code is simpler to understand and modify 19 | 20 | See [authentication.md](authentication.md) for the pattern. 21 | 22 | ## Authorization: No Pundit/CanCanCan 23 | 24 | **Instead**: Simple predicate methods on models. 25 | 26 | ```ruby 27 | # No policy objects - just model methods 28 | class Card < ApplicationRecord 29 | def editable_by?(user) 30 | !closed? && (creator == user || user.admin?) 31 | end 32 | 33 | def deletable_by?(user) 34 | user.admin? || creator == user 35 | end 36 | end 37 | 38 | # In controller 39 | def edit 40 | head :forbidden unless @card.editable_by?(Current.user) 41 | end 42 | ``` 43 | 44 | **Why avoid authorization gems**: 45 | - Simple predicates are easier to understand 46 | - No separate policy files to maintain 47 | - Logic lives with the model it protects 48 | 49 | ## Service Objects 50 | 51 | **Instead**: Rich domain models with focused methods. 52 | 53 | ```ruby 54 | # Bad - service object 55 | class CardCloser 56 | def initialize(card, user) 57 | @card = card 58 | @user = user 59 | end 60 | 61 | def call 62 | @card.update!(closed: true, closed_by: @user) 63 | NotifyWatchersJob.perform_later(@card) 64 | @card 65 | end 66 | end 67 | 68 | # Good - model method 69 | class Card < ApplicationRecord 70 | def close(by:) 71 | transaction do 72 | create_closure!(creator: by) 73 | notify_watchers_later 74 | end 75 | end 76 | end 77 | ``` 78 | 79 | **Why avoid service objects**: 80 | - They fragment domain logic across files 81 | - Models become anemic (just data, no behavior) 82 | - Simple operations don't need coordination objects 83 | 84 | ## Form Objects 85 | 86 | **Instead**: Strong parameters and model validations. 87 | 88 | ```ruby 89 | # No form objects - just params.expect 90 | def create 91 | @card = @board.cards.create!(card_params) 92 | end 93 | 94 | private 95 | def card_params 96 | params.expect(card: [:title, :description, { tag_ids: [] }]) 97 | end 98 | ``` 99 | 100 | **When form objects might be justified**: Complex multi-model forms. But even then, consider if nested attributes suffice. 101 | 102 | ## Decorators/Presenters 103 | 104 | **Instead**: View helpers and partials. 105 | 106 | ```ruby 107 | # No decorator gems 108 | # Just helpers for view logic 109 | module CardsHelper 110 | def card_status_badge(card) 111 | if card.closed? 112 | tag.span "Closed", class: "badge badge--closed" 113 | elsif card.overdue? 114 | tag.span "Overdue", class: "badge badge--warning" 115 | end 116 | end 117 | end 118 | ``` 119 | 120 | ## ViewComponent 121 | 122 | **Instead**: ERB partials with locals. 123 | 124 | ```erb 125 | <%# No ViewComponent - just partials %> 126 | <%= render "cards/preview", card: @card, draggable: true %> 127 | ``` 128 | 129 | **Why partials are enough**: 130 | - Simpler mental model 131 | - No component class overhead 132 | - Rails has good partial caching built-in 133 | 134 | ## GraphQL 135 | 136 | **Instead**: REST endpoints with Turbo. 137 | 138 | **Why avoid GraphQL**: 139 | - Adds complexity for uncertain benefit 140 | - REST + Turbo handles their needs 141 | - No mobile app requiring flexible queries 142 | 143 | ## Sidekiq 144 | 145 | **Instead**: Solid Queue (database-backed). 146 | 147 | **Why avoid Sidekiq**: 148 | - Removes Redis dependency 149 | - Database is already managed 150 | - Good enough for their scale 151 | 152 | ## React/Vue/Frontend Framework 153 | 154 | **Instead**: Turbo + Stimulus + server-rendered HTML. 155 | 156 | **Why avoid SPAs**: 157 | - Server rendering is simpler 158 | - Less JavaScript to maintain 159 | - Turbo provides SPA-like feel 160 | - Stimulus handles interactions 161 | 162 | ## Tailwind CSS 163 | 164 | **Instead**: Native CSS with cascade layers. 165 | 166 | **Why avoid Tailwind**: 167 | - Native CSS has nesting, variables, layers now 168 | - No build step complexity 169 | - Semantic class names preferred 170 | 171 | ## RSpec 172 | 173 | **Instead**: Minitest (ships with Rails). 174 | 175 | **Why avoid RSpec**: 176 | - Minitest is simpler, less DSL 177 | - Faster boot time 178 | - Good enough assertions 179 | 180 | ## FactoryBot 181 | 182 | **Instead**: Fixtures. 183 | 184 | **Why avoid factories**: 185 | - Fixtures are faster (loaded once) 186 | - Relationships are explicit in YAML 187 | - Deterministic test data 188 | 189 | ## The Philosophy 190 | 191 | > "We reach for gems when Rails doesn't provide a solution. But Rails provides most solutions." 192 | 193 | Before adding a dependency, ask: 194 | 1. Can vanilla Rails do this? 195 | 2. Is the complexity worth the benefit? 196 | 3. Will we need to maintain this dependency? 197 | 4. Does it make the codebase harder to understand? 198 | 199 | ## What They DO Use 200 | 201 | Some gems that made the cut: 202 | 203 | - `solid_queue`, `solid_cache`, `solid_cable` - Database-backed infrastructure 204 | - `turbo-rails`, `stimulus-rails` - Hotwire 205 | - `propshaft` - Simple asset pipeline 206 | - `kamal` - Deployment 207 | - `bcrypt` - Password hashing (for magic link tokens) 208 | - `image_processing` - Active Storage variants 209 | 210 | The bar is high. Each gem must clearly earn its place. 211 | -------------------------------------------------------------------------------- /database.md: -------------------------------------------------------------------------------- 1 | # Database Patterns 2 | 3 | > UUIDs, state as records, and database-backed everything. 4 | 5 | --- 6 | 7 | ## UUIDs as Primary Keys 8 | 9 | All tables use UUIDs instead of auto-incrementing integers: 10 | 11 | ```ruby 12 | # In migration 13 | create_table :cards, id: :uuid do |t| 14 | t.references :board, type: :uuid, foreign_key: true 15 | t.string :title 16 | t.timestamps 17 | end 18 | ``` 19 | 20 | **Why UUIDs**: 21 | - No ID guessing/enumeration attacks 22 | - Safe for distributed systems 23 | - Client can generate IDs before insert 24 | - Merge-friendly across databases 25 | 26 | ### UUIDv7 Format 27 | 28 | Fizzy uses time-sortable UUIDv7 (base36-encoded as 25-char strings): 29 | 30 | ```ruby 31 | # Fixtures generate deterministic UUIDs 32 | # Runtime records are always "newer" than fixture data 33 | # .first/.last work correctly in tests 34 | ``` 35 | 36 | ## State as Records, Not Booleans 37 | 38 | Instead of boolean flags, create records to represent state: 39 | 40 | ```ruby 41 | # Bad - boolean flag 42 | class Card < ApplicationRecord 43 | # closed: boolean 44 | 45 | def close 46 | update!(closed: true) 47 | end 48 | end 49 | 50 | # Good - state record with attribution 51 | class Card < ApplicationRecord 52 | has_one :closure, dependent: :destroy 53 | 54 | def closed? 55 | closure.present? 56 | end 57 | 58 | def close(by:) 59 | create_closure!(creator: by) 60 | end 61 | 62 | def reopen 63 | closure.destroy! 64 | end 65 | end 66 | 67 | class Closure < ApplicationRecord 68 | belongs_to :card 69 | belongs_to :creator, class_name: "User" 70 | 71 | # Timestamps tell you when it was closed 72 | # creator tells you who closed it 73 | end 74 | ``` 75 | 76 | **Why records over booleans**: 77 | - Know WHO made the change 78 | - Know WHEN it happened 79 | - Query history easily 80 | - Add metadata (reason, notes) 81 | 82 | ## Database-Backed Infrastructure 83 | 84 | No Redis - everything uses the database: 85 | 86 | ### Solid Queue (Jobs) 87 | 88 | ```ruby 89 | # Gemfile 90 | gem "solid_queue" 91 | 92 | # config/database.yml 93 | production: 94 | queue: 95 | <<: *default 96 | database: fizzy_queue 97 | ``` 98 | 99 | ### Solid Cache 100 | 101 | ```ruby 102 | # Gemfile 103 | gem "solid_cache" 104 | 105 | # config/environments/production.rb 106 | config.cache_store = :solid_cache_store 107 | ``` 108 | 109 | ### Solid Cable (WebSockets) 110 | 111 | ```ruby 112 | # Gemfile 113 | gem "solid_cable" 114 | 115 | # config/cable.yml 116 | production: 117 | adapter: solid_cable 118 | ``` 119 | 120 | **Why database over Redis**: 121 | - One less dependency to manage 122 | - Same backup/restore process 123 | - Simpler ops for small-medium scale 124 | - SQLite works in development 125 | 126 | ## Account ID Everywhere 127 | 128 | Multi-tenancy via `account_id` foreign key: 129 | 130 | ```ruby 131 | class Card < ApplicationRecord 132 | belongs_to :account 133 | belongs_to :board 134 | 135 | # Scoped uniqueness 136 | validates :number, uniqueness: { scope: :account_id } 137 | end 138 | 139 | # Default scope (optional, use carefully) 140 | class ApplicationRecord < ActiveRecord::Base 141 | def self.inherited(subclass) 142 | super 143 | subclass.default_scope { where(account_id: Current.account&.id) } 144 | end 145 | end 146 | ``` 147 | 148 | ## No Soft Deletes 149 | 150 | Records are deleted, not marked as deleted: 151 | 152 | ```ruby 153 | # Bad 154 | class Card < ApplicationRecord 155 | scope :active, -> { where(deleted_at: nil) } 156 | end 157 | 158 | # Good - just delete it 159 | card.destroy 160 | ``` 161 | 162 | **Why hard deletes**: 163 | - Simpler queries (no `where(deleted: false)` everywhere) 164 | - No data retention complexity 165 | - If you need history, use events/audit logs 166 | 167 | ## Counter Caches 168 | 169 | Denormalize counts for performance: 170 | 171 | ```ruby 172 | class Board < ApplicationRecord 173 | has_many :cards, counter_cache: true 174 | end 175 | 176 | # Migration 177 | add_column :boards, :cards_count, :integer, default: 0 178 | ``` 179 | 180 | ## Minimal Foreign Keys 181 | 182 | Fizzy uses `belongs_to` without database-level foreign keys in many places: 183 | 184 | ```ruby 185 | # No FK constraint - application handles integrity 186 | t.references :board, foreign_key: false 187 | 188 | # With FK - database enforces 189 | t.references :account, foreign_key: true 190 | ``` 191 | 192 | **Trade-off**: Flexibility vs. data integrity guarantees. 193 | 194 | ## Index Strategy 195 | 196 | ```ruby 197 | # Always index foreign keys 198 | add_index :cards, :board_id 199 | add_index :cards, :account_id 200 | 201 | # Index columns you filter/sort by 202 | add_index :cards, :created_at 203 | add_index :cards, :status 204 | 205 | # Composite indexes for common queries 206 | add_index :cards, [:account_id, :board_id, :created_at] 207 | ``` 208 | 209 | ## Sharded Search 210 | 211 | Full-text search uses 16 MySQL shards: 212 | 213 | ```ruby 214 | class Search::Record < ApplicationRecord 215 | connects_to shards: { 216 | shard_0: { writing: :search_0, reading: :search_0 }, 217 | shard_1: { writing: :search_1, reading: :search_1 }, 218 | # ... 219 | } 220 | 221 | def self.shard_for(account) 222 | :"shard_#{Zlib.crc32(account.id.to_s) % 16}" 223 | end 224 | end 225 | ``` 226 | 227 | **Why sharding over Elasticsearch**: 228 | - Simpler ops (just MySQL) 229 | - No separate search cluster 230 | - Good enough for most scales 231 | 232 | ## Key Principles 233 | 234 | 1. **UUIDs over integers** - Security, distribution, client generation 235 | 2. **State records over booleans** - Who, when, why 236 | 3. **Database-backed infra** - Solid Queue/Cache/Cable over Redis 237 | 4. **Hard deletes** - Simpler queries, use audit logs for history 238 | 5. **Counter caches** - Denormalize common counts 239 | 6. **Index what you query** - But don't over-index 240 | -------------------------------------------------------------------------------- /testing.md: -------------------------------------------------------------------------------- 1 | # Testing Patterns 2 | 3 | > Minitest with fixtures - simple, fast, deterministic. 4 | 5 | --- 6 | 7 | ## Minitest Over RSpec 8 | 9 | 37signals uses Minitest, not RSpec: 10 | - Simpler, less DSL magic 11 | - Ships with Rails 12 | - Faster boot time 13 | - Plain Ruby assertions 14 | 15 | ## Fixtures Over Factories 16 | 17 | Fixtures provide deterministic, preloaded test data: 18 | 19 | ```yaml 20 | # test/fixtures/users.yml 21 | david: 22 | identity: david 23 | account: basecamp 24 | role: admin 25 | 26 | jason: 27 | identity: jason 28 | account: basecamp 29 | role: member 30 | ``` 31 | 32 | ```ruby 33 | # In tests 34 | test "admin can delete cards" do 35 | user = users(:david) 36 | card = cards(:urgent_bug) 37 | 38 | assert user.can_delete?(card) 39 | end 40 | ``` 41 | 42 | **Why fixtures over factories**: 43 | - Loaded once, reused across tests 44 | - No runtime object creation overhead 45 | - Relationships are explicit and visible 46 | - Deterministic IDs for debugging 47 | 48 | ## Fixture Relationships 49 | 50 | Use labels, not IDs: 51 | 52 | ```yaml 53 | # test/fixtures/cards.yml 54 | urgent_bug: 55 | board: engineering 56 | creator: david 57 | title: "Fix login bug" 58 | created_at: <%= 2.days.ago %> 59 | 60 | # test/fixtures/comments.yml 61 | first_comment: 62 | card: urgent_bug 63 | creator: jason 64 | body: "I'll take this one" 65 | ``` 66 | 67 | ## ERB in Fixtures 68 | 69 | Use ERB for dynamic values: 70 | 71 | ```yaml 72 | recent_card: 73 | board: engineering 74 | creator: david 75 | created_at: <%= 1.hour.ago %> 76 | 77 | old_card: 78 | board: engineering 79 | creator: david 80 | created_at: <%= 6.months.ago %> 81 | ``` 82 | 83 | ## Test Structure 84 | 85 | ```ruby 86 | class CardTest < ActiveSupport::TestCase 87 | setup do 88 | @card = cards(:urgent_bug) 89 | @user = users(:david) 90 | end 91 | 92 | test "closing a card creates an event" do 93 | assert_difference "Event.count", 1 do 94 | @card.close(by: @user) 95 | end 96 | 97 | assert @card.closed? 98 | assert_equal "closed", Event.last.action 99 | end 100 | 101 | test "closed cards cannot be edited" do 102 | @card.close(by: @user) 103 | 104 | assert_not @card.editable_by?(@user) 105 | end 106 | end 107 | ``` 108 | 109 | ## Integration Tests 110 | 111 | Test full request/response cycles: 112 | 113 | ```ruby 114 | class CardsControllerTest < ActionDispatch::IntegrationTest 115 | setup do 116 | @user = users(:david) 117 | sign_in_as @user 118 | end 119 | 120 | test "creating a card" do 121 | assert_difference "Card.count", 1 do 122 | post board_cards_path(boards(:engineering)), 123 | params: { card: { title: "New feature" } } 124 | end 125 | 126 | assert_redirected_to card_path(Card.last) 127 | end 128 | 129 | test "unauthorized users cannot delete" do 130 | sign_in_as users(:guest) 131 | 132 | assert_no_difference "Card.count" do 133 | delete card_path(cards(:urgent_bug)) 134 | end 135 | 136 | assert_response :forbidden 137 | end 138 | end 139 | ``` 140 | 141 | ## System Tests 142 | 143 | Use Capybara for browser testing: 144 | 145 | ```ruby 146 | class CardSystemTest < ApplicationSystemTestCase 147 | setup do 148 | sign_in_as users(:david) 149 | end 150 | 151 | test "dragging card between columns" do 152 | visit board_path(boards(:engineering)) 153 | 154 | card = find("[data-card-id='#{cards(:urgent_bug).id}']") 155 | target = find("[data-column='doing']") 156 | 157 | card.drag_to(target) 158 | 159 | assert_selector "[data-column='doing'] [data-card-id='#{cards(:urgent_bug).id}']" 160 | end 161 | end 162 | ``` 163 | 164 | ## Test Helpers 165 | 166 | ```ruby 167 | # test/test_helper.rb 168 | class ActiveSupport::TestCase 169 | include SignInHelper 170 | 171 | parallelize(workers: :number_of_processors) 172 | fixtures :all 173 | end 174 | 175 | module SignInHelper 176 | def sign_in_as(user) 177 | post session_path, params: { 178 | email: user.identity.email 179 | } 180 | # Follow magic link in test mode 181 | follow_redirect! 182 | end 183 | end 184 | ``` 185 | 186 | ## Testing Time 187 | 188 | Use `travel_to` for time-dependent tests: 189 | 190 | ```ruby 191 | test "cards auto-close after 30 days of inactivity" do 192 | card = cards(:stale_card) 193 | 194 | travel_to 31.days.from_now do 195 | Card.auto_close_stale! 196 | 197 | assert card.reload.closed? 198 | end 199 | end 200 | ``` 201 | 202 | ## VCR for External APIs 203 | 204 | Record and replay HTTP interactions: 205 | 206 | ```ruby 207 | test "fetching weather data" do 208 | VCR.use_cassette("weather/new_york") do 209 | weather = WeatherService.fetch("New York") 210 | 211 | assert_equal "Sunny", weather.condition 212 | end 213 | end 214 | ``` 215 | 216 | ## Testing Jobs 217 | 218 | ```ruby 219 | test "closing card enqueues notification job" do 220 | assert_enqueued_with(job: NotifyWatchersJob) do 221 | cards(:urgent_bug).close(by: users(:david)) 222 | end 223 | end 224 | 225 | test "notification job sends emails" do 226 | perform_enqueued_jobs do 227 | cards(:urgent_bug).close(by: users(:david)) 228 | end 229 | 230 | assert_emails 3 # 3 watchers 231 | end 232 | ``` 233 | 234 | ## When Tests Ship 235 | 236 | Tests ship **with** features in the same commit: 237 | - Not beforehand (not strict TDD) 238 | - Not afterward (not "I'll add tests later") 239 | - Security fixes always include regression tests 240 | 241 | ## Key Principles 242 | 243 | 1. **Minitest is enough** - No need for RSpec's DSL 244 | 2. **Fixtures over factories** - Faster, deterministic, visible relationships 245 | 3. **Test behavior, not implementation** - What it does, not how 246 | 4. **Integration tests for flows** - Cover the full stack 247 | 5. **Ship tests with features** - Same commit, same PR 248 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Unofficial 37signals Coding Style Guide 2 | 3 | Transferable Rails patterns and development philosophy extracted from analyzing 265 pull requests in 37signals' Fizzy codebase. 4 | 5 | ## What This Is 6 | 7 | Fizzy is a kanban-style project management app built by 37signals. We analyzed both the final source code AND the process that led to it—pull requests, git commits, code review discussions, and iterations. This dual approach captures not just *what* 37signals builds, but *how* they build it: their decision-making, trade-offs, and evolution of ideas. 8 | 9 | ## What This Is Not 10 | 11 | These are not Fizzy-specific implementation details. We deliberately skipped business logic unique to Fizzy and focused only on patterns you can apply to your own projects. 12 | 13 | ## Important Caveats 14 | 15 | **LLM-Generated Content**: This guide was largely produced by an LLM (Claude) analyzing PR descriptions and code snippets. Given the volume of text and code involved, there may be hallucinations or inaccuracies. Take everything with a grain of salt and verify against actual implementations. That said, I've found it useful as a reference in my own projects. 16 | 17 | **Code License**: The code examples extracted from Fizzy are licensed under the [O'Saasy License](https://osaasy.dev). Please review that license before using code snippets in your projects. 18 | 19 | --- 20 | 21 | ## Table of Contents 22 | 23 | ### Core Rails 24 | - [Routing](routing.md) - Everything is CRUD, resource-based patterns, resolve helpers 25 | - [Controllers](controllers.md) - Thin controllers, rich models, composable concerns catalog 26 | - [Models](models.md) - Concerns, state as records, Current context, PORO patterns 27 | - [Views](views.md) - Turbo Streams, partials, fragment caching, view helpers 28 | 29 | ### Frontend 30 | - [Stimulus](stimulus.md) - Reusable controller catalog with copy-paste code 31 | - [CSS](css.md) - Native CSS, cascade layers, OKLCH colors, modern features 32 | - [Hotwire](hotwire.md) - Turbo Frames/Streams, morphing, drag & drop 33 | - [Accessibility](accessibility.md) - ARIA patterns, keyboard navigation, screen readers 34 | - [Mobile](mobile.md) - Responsive CSS, safe area insets, touch optimization 35 | 36 | ### Backend 37 | - [Authentication](authentication.md) - Passwordless magic links without Devise 38 | - [Multi-Tenancy](multi-tenancy.md) - Path-based tenancy, middleware, ActiveJob extensions 39 | - [Database](database.md) - UUIDs, state as records, database-backed everything 40 | - [Background Jobs](background-jobs.md) - Solid Queue patterns, tenant preservation, continuable jobs 41 | - [Caching](caching.md) - HTTP caching, fragment caching, invalidation 42 | - [Performance](performance.md) - Preloading, N+1 prevention, memoization 43 | 44 | ### Real-time & Communication 45 | - [ActionCable](actioncable.md) - Multi-tenant WebSockets, broadcast scoping, Solid Cable 46 | - [Notifications](notifications.md) - Time window bundling, user preferences, real-time 47 | - [Email](email.md) - Multi-tenant mailers, timezone handling 48 | 49 | ### Features 50 | - [Webhooks](webhooks.md) - SSRF protection, delinquency tracking, state machines 51 | - [Workflows](workflows.md) - Event-driven state, undoable commands 52 | - [Watching](watching.md) - Subscription patterns, toggle UI 53 | - [Filtering](filtering.md) - Filter objects, URL-based state 54 | - [AI/LLM Integration](ai-llm.md) - Command pattern, cost tracking, tool patterns 55 | 56 | ### Rails Components 57 | - [Active Storage](active-storage.md) - Attachment patterns, variants 58 | - [Action Text](action-text.md) - Sanitizer config, remote images 59 | 60 | ### Infrastructure & Testing 61 | - [Security Checklist](security-checklist.md) - XSS, CSRF, SSRF, rate limiting, authorization 62 | - [Testing](testing.md) - Minitest, fixtures over factories, integration tests 63 | - [Configuration](configuration.md) - Environment config, Kamal deployment 64 | - [Observability](observability.md) - Structured logging, Yabeda metrics 65 | 66 | ### Philosophy 67 | - [Development Philosophy](development-philosophy.md) - Ship/Validate/Refine, vanilla Rails, DHH's review patterns 68 | - [What They Avoid](what-they-avoid.md) - Gems and patterns 37signals deliberately doesn't use 69 | 70 | ### Contributors 71 | - [Jason Fried](jason-fried.md) - Product-oriented development, perceived performance 72 | - [Jorge Manrubia](jorge-manrubia.md) - Code review style, architecture decisions 73 | 74 | --- 75 | 76 | ## Quick Start: The 37signals Way 77 | 78 | 1. **Rich domain models** over service objects 79 | 2. **CRUD controllers** over custom actions 80 | 3. **Concerns** for horizontal code sharing 81 | 4. **Records as state** over boolean columns 82 | 5. **Database-backed everything** (no Redis) 83 | 6. **Build it yourself** before reaching for gems 84 | 7. **Ship to learn** - prototype quality is valid 85 | 8. **Vanilla Rails is plenty** - maximize what Rails gives you 86 | 87 | --- 88 | 89 | ## Acknowledgements 90 | 91 | - **[37signals](https://37signals.com)** for open-sourcing their codebase and giving the community a window into how they build software 92 | - **[Mateusz Zolkos](https://www.zolkos.com/2025/12/10/fizzys-pull-requests)** for his work identifying and cataloging the most educational PRs 93 | - **[Claude Code](https://claude.com/claude-code)** for creating this guide through iterative deep-dive research sessions 94 | 95 | ## Further Reading 96 | 97 | - [Fizzy Source Code](https://github.com/basecamp/fizzy) - The official Fizzy repository with full git history 98 | - [Campfire Source Code](https://github.com/basecamp/once-campfire) - 37signals' open-source chat application, another reference implementation 99 | - [Fizzy's Pull Requests: Who Built What and How](https://www.zolkos.com/2025/12/10/fizzys-pull-requests) - Curated PR sequences organized by topic 100 | - [Fizzy JS Patterns](https://www.driftingruby.com/episodes/fizzy-js-patterns) - Drifting Ruby episode analyzing JavaScript patterns in Fizzy 101 | - [Read The Friendly Source Code](https://beautifulruby.com/code/fizzy) - Beautiful Ruby code review covering architecture and security observations 102 | - [Writebook by ONCE](https://igor.works/blog/writebook-by-once) - Code walkthrough of another 37signals open-source project 103 | - [On Writing Software Well](https://www.youtube.com/playlist?list=PL9wALaIpe0Py6E_oHCgTrD6FvFETwJLlx) - DHH's screencast series on software development practices 104 | 105 | ## Disclaimer 106 | 107 | This is an unofficial guide created by analyzing publicly discussed code patterns. It is not affiliated with or endorsed by 37signals. 108 | 109 | ## License 110 | 111 | Code examples extracted from Fizzy are licensed under the [O'Saasy License](https://osaasy.dev). All analysis, commentary, and original content in this guide is licensed under MIT. 112 | -------------------------------------------------------------------------------- /caching.md: -------------------------------------------------------------------------------- 1 | # Caching Patterns 2 | 3 | > HTTP caching and fragment caching lessons from 37signals. 4 | 5 | --- 6 | 7 | ## HTTP Caching (ETags) 8 | 9 | ### How ETags Work 10 | 11 | ETags let the browser avoid re-downloading unchanged content. Here's the flow: 12 | 13 | 1. **First request**: Server responds with content + ETag header (a fingerprint of the data) 14 | 2. **Subsequent requests**: Browser sends `If-None-Match` header with the ETag 15 | 3. **Server checks**: If content unchanged, responds with `304 Not Modified` (no body) 16 | 4. **Browser uses cache**: Displays cached content without re-downloading 17 | 18 | In Rails, `fresh_when` computes an ETag from your objects and halts rendering if the browser's cache is still valid: 19 | 20 | ```ruby 21 | def show 22 | fresh_when etag: @card # Uses @card.cache_key_with_version 23 | end 24 | ``` 25 | 26 | For multiple objects, pass an array—Rails combines them into a single ETag: 27 | 28 | ```ruby 29 | def show 30 | @tags = Current.account.tags.alphabetically 31 | @boards = Current.user.boards.ordered_by_recently_accessed 32 | 33 | fresh_when etag: [@tags, @boards] 34 | end 35 | ``` 36 | 37 | The ETag is computed from each object's `cache_key_with_version` (which includes `updated_at`), so any change to any object invalidates the cache. 38 | 39 | ### Don't HTTP Cache Forms 40 | 41 | CSRF tokens get stale → 422 errors on submit ([#1607](https://github.com/basecamp/fizzy/pull/1607)) 42 | 43 | Remove `fresh_when` from pages with forms. 44 | 45 | ### Public Caching 46 | 47 | - Safe for read-only public pages 48 | - 30 seconds is reasonable ([#1377](https://github.com/basecamp/fizzy/pull/1377)) 49 | - Use concern to DRY up cache headers 50 | 51 | ## Fragment Caching 52 | 53 | ### Basic Pattern 54 | 55 | ```ruby 56 | # Bad - same cache for different contexts 57 | cache card 58 | 59 | # Good - includes rendering context 60 | cache [card, previewing_card?] 61 | cache [card, Current.user.id] # if user-specific 62 | ``` 63 | 64 | ### Include What Affects Output 65 | - Timezone affects rendered times 66 | - User ID affects personalized content 67 | - Filter state affects what's shown 68 | 69 | ### Touch Chains for Dependencies ([#566](https://github.com/basecamp/fizzy/pull/566)) 70 | 71 | ```ruby 72 | class Workflow::Stage < ApplicationRecord 73 | belongs_to :workflow, touch: true 74 | end 75 | ``` 76 | 77 | Changes to children automatically update parent timestamps: 78 | 79 | ```ruby 80 | # View - workflow changes when any stage changes 81 | cache [card, card.collection.workflow] 82 | ``` 83 | 84 | ### Domain Models for Cache Keys ([#1132](https://github.com/basecamp/fizzy/pull/1132)) 85 | 86 | For complex views, create dedicated cache key objects: 87 | 88 | ```ruby 89 | class Cards::Columns 90 | def cache_key 91 | ActiveSupport::Cache.expand_cache_key([ 92 | considering, on_deck, doing, closed, 93 | Workflow.all, user_filtering 94 | ]) 95 | end 96 | end 97 | ``` 98 | 99 | ## Lazy-Loaded Content with Turbo Frames ([#1089](https://github.com/basecamp/fizzy/pull/1089)) 100 | 101 | Expensive menus (with multiple database queries) can slow down every page load. Convert them to lazy-loaded turbo frames that only load when needed: 102 | 103 | ```erb 104 | <%# app/views/my/_menu.html.erb %> 105 | 119 | ``` 120 | 121 | The controller loads the expensive data only when requested: 122 | 123 | ```ruby 124 | # app/controllers/my/menus_controller.rb 125 | class My::MenusController < ApplicationController 126 | def show 127 | @filters = Current.user.filters.all 128 | @boards = Current.user.boards.ordered_by_recently_accessed 129 | @tags = Current.account.tags.alphabetically 130 | @users = Current.account.users.active.alphabetically 131 | 132 | fresh_when etag: [@filters, @boards, @tags, @users] 133 | end 134 | end 135 | ``` 136 | 137 | **Key points:** 138 | - `loading: :lazy` defers the request until the frame is visible 139 | - The frame only loads when the dialog opens (triggered by `mouseenter` or click) 140 | - `fresh_when` with ETags prevents re-rendering if data hasn't changed 141 | - Initial page load is faster since the menu queries are deferred 142 | 143 | ## User-Specific Content in Cached Fragments 144 | 145 | When caching breaks because of user-specific elements, move the personalization to client-side JavaScript: 146 | 147 | ```erb 148 | <%# Instead of breaking the cache with conditionals: %> 149 | <% cache card do %> 150 |
Loading notifications...
202 | <% end %> 203 | ``` 204 | 205 | ### Frame for Inline Editing 206 | 207 | ```erb 208 | <%= turbo_frame_tag dom_id(card, :title) do %> 209 |