├── .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 |
153 | 155 |
156 | <% end %> 157 | ``` 158 | 159 | ```javascript 160 | // app/javascript/controllers/ownership_controller.js 161 | export default class extends Controller { 162 | static targets = ["ownerOnly"] 163 | static values = { currentUser: Number } 164 | 165 | connect() { 166 | const creatorId = parseInt(this.element.dataset.creatorId) 167 | if (creatorId === this.currentUserValue) { 168 | this.ownerOnlyTargets.forEach(el => el.classList.remove("hidden")) 169 | } 170 | } 171 | } 172 | ``` 173 | 174 | **Common patterns:** 175 | - "You commented..." indicators → check creator ID via JS 176 | - Delete/edit buttons → show/hide based on ownership 177 | - "New" badges → compare timestamps client-side 178 | 179 | See also: [Stimulus for Cached Fragment Personalization](hotwire.md#stimulus-for-cached-fragment-personalization-124) for the full pattern using a global `Current` object. 180 | 181 | ## Extract Dynamic Content to Turbo Frames ([#317](https://github.com/basecamp/fizzy/pull/317)) 182 | 183 | When part of a cached fragment needs frequent updates, extract it to a turbo frame: 184 | 185 | ```erb 186 | <% cache [card, board] do %> 187 |
188 |

<%= card.title %>

189 | 190 | <%# Assignment changes often - don't let it bust the cache %> 191 | <%= turbo_frame_tag card, :assignment, 192 | src: card_assignment_path(card), 193 | loading: :lazy, 194 | refresh: :morph do %> 195 | <%# Placeholder %> 196 | <% end %> 197 |
198 | <% end %> 199 | ``` 200 | 201 | The assignment dropdown loads independently and can update without invalidating the card cache. 202 | -------------------------------------------------------------------------------- /background-jobs.md: -------------------------------------------------------------------------------- 1 | # Background Jobs 2 | 3 | > Solid Queue patterns from 37signals. 4 | 5 | --- 6 | 7 | ## Configuration 8 | 9 | ### Development 10 | ```bash 11 | # Run jobs in Puma process 12 | export SOLID_QUEUE_IN_PUMA=1 13 | ``` 14 | Simplifies dev - no separate worker process ([#469](https://github.com/basecamp/fizzy/pull/469)). 15 | 16 | ### Production 17 | - Match workers to CPU cores ([#1290](https://github.com/basecamp/fizzy/pull/1290)) 18 | - 3 threads for I/O-bound jobs ([#1329](https://github.com/basecamp/fizzy/pull/1329)) 19 | 20 | ## Stagger Recurring Jobs 21 | 22 | Prevent resource spikes by offsetting schedules ([#1329](https://github.com/basecamp/fizzy/pull/1329)): 23 | 24 | ```yaml 25 | # Bad - all at :00 26 | job_a: every hour at minute 0 27 | job_b: every hour at minute 0 28 | 29 | # Good - staggered 30 | job_a: every hour at minute 12 31 | job_b: every hour at minute 50 32 | ``` 33 | 34 | ## Transaction Safety 35 | 36 | ### Enqueue After Commit ([#1664](https://github.com/basecamp/fizzy/pull/1664)) 37 | ```ruby 38 | # In initializer 39 | ActiveJob::Base.enqueue_after_transaction_commit = true 40 | ``` 41 | 42 | Prevents jobs from running before the data they need exists. 43 | Fixes `ActiveStorage::FileNotFoundError` on uploads. 44 | 45 | ## Error Handling 46 | 47 | ### Transient Errors ([#1924](https://github.com/basecamp/fizzy/pull/1924)) 48 | 49 | Retry network and temporary SMTP errors with polynomial backoff: 50 | 51 | ```ruby 52 | module SmtpDeliveryErrorHandling 53 | extend ActiveSupport::Concern 54 | 55 | included do 56 | # Retry delivery to possibly-unavailable remote mailservers 57 | retry_on Net::OpenTimeout, Net::ReadTimeout, Socket::ResolutionError, 58 | wait: :polynomially_longer 59 | 60 | # Net::SMTPServerBusy is SMTP error code 4xx, a temporary error. 61 | # Common one: 452 4.3.1 Insufficient system storage. 62 | retry_on Net::SMTPServerBusy, wait: :polynomially_longer 63 | end 64 | end 65 | ``` 66 | 67 | ### Permanent Failures 68 | 69 | Swallow gracefully—don't fail the job for unrecoverable errors. Log at info level, not error: 70 | 71 | ```ruby 72 | module SmtpDeliveryErrorHandling 73 | extend ActiveSupport::Concern 74 | 75 | included do 76 | # SMTP error 50x 77 | rescue_from Net::SMTPSyntaxError do |error| 78 | case error.message 79 | when /\A501 5\.1\.3/ # Bad email address format 80 | Sentry.capture_exception error, level: :info 81 | else 82 | raise 83 | end 84 | end 85 | 86 | # SMTP error 5xx except 50x and 53x 87 | rescue_from Net::SMTPFatalError do |error| 88 | case error.message 89 | when /\A550 5\.1\.1/ # Unknown user 90 | Sentry.capture_exception error, level: :info 91 | when /\A552 5\.6\.0/ # Message too large 92 | Sentry.capture_exception error, level: :info 93 | when /\A555 5\.5\.4/ # Bad headers 94 | Sentry.capture_exception error, level: :info 95 | else 96 | raise 97 | end 98 | end 99 | end 100 | end 101 | ``` 102 | 103 | Apply to ActionMailer's delivery job via initializer: 104 | 105 | ```ruby 106 | # lib/rails_ext/action_mailer_mail_delivery_job.rb 107 | Rails.application.config.to_prepare do 108 | ActionMailer::MailDeliveryJob.include SmtpDeliveryErrorHandling 109 | end 110 | ``` 111 | 112 | **Why swallow instead of retry?** These errors are permanent—retrying won't help. The user has a bad email address or their mailbox is full. Log it for visibility but don't waste job queue resources. 113 | 114 | ## Maintenance Jobs 115 | 116 | ### Clean Finished Jobs ([#943](https://github.com/basecamp/fizzy/pull/943)) 117 | ```yaml 118 | clear_finished_jobs: 119 | command: "SolidQueue::Job.clear_finished_in_batches" 120 | schedule: every hour at minute 12 121 | ``` 122 | 123 | ### Clean Orphaned Data ([#494](https://github.com/basecamp/fizzy/pull/494)) 124 | - Unused tags (daily) 125 | - Old webhook deliveries (every 4 hours) 126 | - Expired magic links 127 | 128 | ## Job Patterns 129 | 130 | ### Shallow Jobs 131 | Jobs just call model methods: 132 | ```ruby 133 | class NotifyRecipientsJob < ApplicationJob 134 | def perform(notifiable) 135 | notifiable.notify_recipients 136 | end 137 | end 138 | ``` 139 | 140 | ### `_later` and `_now` Convention 141 | 142 | When a model method enqueues a job that invokes another method on that same class, use the `_later` suffix for the async version. The synchronous method can use `_now` or just the plain name: 143 | 144 | ```ruby 145 | module Notifiable 146 | extend ActiveSupport::Concern 147 | 148 | included do 149 | after_create_commit :notify_recipients_later 150 | end 151 | 152 | # Called by the job - the actual work 153 | def notify_recipients 154 | Notifier.for(self)&.notify 155 | end 156 | 157 | private 158 | # Enqueues the job 159 | def notify_recipients_later 160 | NotifyRecipientsJob.perform_later(self) 161 | end 162 | end 163 | 164 | class NotifyRecipientsJob < ApplicationJob 165 | def perform(notifiable) 166 | notifiable.notify_recipients 167 | end 168 | end 169 | ``` 170 | 171 | Another example with class methods: 172 | 173 | ```ruby 174 | class Notification::Bundle < ApplicationRecord 175 | class << self 176 | # Synchronous - does the work 177 | def deliver_all 178 | due.in_batches do |batch| 179 | jobs = batch.collect { DeliverJob.new(it) } 180 | ActiveJob.perform_all_later jobs 181 | end 182 | end 183 | 184 | # Async - enqueues job 185 | def deliver_all_later 186 | DeliverAllJob.perform_later 187 | end 188 | end 189 | 190 | # Instance-level pattern 191 | def deliver 192 | processing! 193 | Notification::BundleMailer.notification(self).deliver if deliverable? 194 | delivered! 195 | end 196 | 197 | def deliver_later 198 | DeliverJob.perform_later(self) 199 | end 200 | end 201 | ``` 202 | 203 | **Key insight**: The `_later` method is usually private and called from callbacks. The plain method name (`deliver`, `notify_recipients`) is the public API that the job invokes. This keeps the job class shallow—it just calls the model method. 204 | 205 | ## Continuable Jobs for Resilient Iteration ([#1083](https://github.com/basecamp/fizzy/pull/1083)) 206 | 207 | Use `ActiveJob::Continuable` to resume from where you left off after crashes: 208 | 209 | ```ruby 210 | require "active_job/continuable" 211 | 212 | class Event::WebhookDispatchJob < ApplicationJob 213 | include ActiveJob::Continuable 214 | queue_as :webhooks 215 | 216 | def perform(event) 217 | step :dispatch do |step| 218 | Webhook.active.triggered_by(event).find_each(start: step.cursor) do |webhook| 219 | webhook.trigger(event) 220 | step.advance! from: webhook.id 221 | end 222 | end 223 | end 224 | end 225 | ``` 226 | 227 | **Why it matters**: If the job crashes midway through iteration, it resumes from where it left off rather than reprocessing everything. Essential for jobs processing large batches that might timeout. 228 | 229 | **Use cases**: Webhooks dispatching, email broadcasts, bulk updates, data migrations. 230 | -------------------------------------------------------------------------------- /views.md: -------------------------------------------------------------------------------- 1 | # Views 2 | 3 | > Turbo Streams, partials over components, and server-rendered HTML. 4 | 5 | --- 6 | 7 | ## Turbo Streams for Partial Updates 8 | 9 | ```erb 10 | <%# app/views/cards/comments/create.turbo_stream.erb %> 11 | <%= turbo_stream.before [@card, :new_comment], 12 | partial: "cards/comments/comment", 13 | locals: { comment: @comment } %> 14 | 15 | <%= turbo_stream.update [@card, :new_comment], 16 | partial: "cards/comments/new", 17 | locals: { card: @card } %> 18 | ``` 19 | 20 | ## Morphing for Complex Updates 21 | 22 | ```erb 23 | <%# app/views/cards/update.turbo_stream.erb %> 24 | <%= turbo_stream.replace dom_id(@card, :card_container), 25 | partial: "cards/container", 26 | method: :morph, 27 | locals: { card: @card.reload } %> 28 | ``` 29 | 30 | ## Turbo Stream Subscriptions in Views 31 | 32 | ```erb 33 | <%# app/views/cards/show.html.erb %> 34 | <%= turbo_stream_from @card %> 35 | <%= turbo_stream_from @card, :activity %> 36 | 37 |
38 | <%= render "cards/container", card: @card %> 39 | <%= render "cards/messages", card: @card %> 40 |
41 | ``` 42 | 43 | --- 44 | 45 | ## Partials Over ViewComponents 46 | 47 | ```erb 48 | <%# Use standard partials %> 49 | <%= render "cards/container", card: @card %> 50 | <%= render "cards/display/perma/meta", card: @card %> 51 | 52 | <%# With caching %> 53 | <% cache card do %> 54 |
55 | <%= render "cards/container/content", card: card %> 56 |
57 | <% end %> 58 | ``` 59 | 60 | --- 61 | 62 | ## Fragment Caching Patterns 63 | 64 | ### Basic Fragment Cache 65 | 66 | ```erb 67 | <% cache card do %> 68 | <%= render "cards/preview", card: card %> 69 | <% end %> 70 | ``` 71 | 72 | ### Composite Cache Keys 73 | 74 | ```erb 75 | <%# Include context that affects output %> 76 | <% cache [card, Current.user, timezone_from_cookie] do %> 77 | <%= render "cards/preview", card: card %> 78 | <% end %> 79 | ``` 80 | 81 | ### Collection Caching 82 | 83 | ```erb 84 | <%= render partial: "cards/preview", 85 | collection: @cards, 86 | cached: true %> 87 | ``` 88 | 89 | ### Cache with Touch Chains 90 | 91 | ```ruby 92 | class Comment < ApplicationRecord 93 | belongs_to :card, touch: true # Invalidates card cache 94 | end 95 | 96 | class Card < ApplicationRecord 97 | belongs_to :board, touch: true # Invalidates board cache 98 | end 99 | ``` 100 | 101 | --- 102 | 103 | ## View Helpers: Stimulus-Integrated Components 104 | 105 | ### Dialog Helper 106 | 107 | ```ruby 108 | # app/helpers/dialog_helper.rb 109 | module DialogHelper 110 | def dialog_tag(id, **options, &block) 111 | options[:data] ||= {} 112 | options[:data][:controller] = "dialog #{options.dig(:data, :controller)}".strip 113 | options[:data][:action] = "click->dialog#closeOnOutsideClick keydown.esc->dialog#close" 114 | 115 | tag.dialog(id: id, **options, &block) 116 | end 117 | 118 | def dialog_close_button(**options) 119 | options[:data] ||= {} 120 | options[:data][:action] = "dialog#close" 121 | 122 | tag.button("Close", **options) 123 | end 124 | end 125 | ``` 126 | 127 | **Usage:** 128 | 129 | ```erb 130 | <%= dialog_tag "settings-dialog", class: "settings" do %> 131 |

Settings

132 | <%= dialog_close_button class: "btn" %> 133 | <% end %> 134 | ``` 135 | 136 | ### Auto-Submit Form Helper 137 | 138 | ```ruby 139 | # app/helpers/form_helper.rb 140 | module FormHelper 141 | def auto_submit_form_with(**options, &block) 142 | options[:data] ||= {} 143 | options[:data][:controller] = "auto-submit #{options.dig(:data, :controller)}".strip 144 | options[:data][:auto_submit_delay_value] = options.delete(:delay) || 300 145 | 146 | form_with(**options, &block) 147 | end 148 | end 149 | ``` 150 | 151 | ### Button Helpers 152 | 153 | ```ruby 154 | # app/helpers/button_helper.rb 155 | module ButtonHelper 156 | def copy_button(content:, **options) 157 | options[:data] ||= {} 158 | options[:data][:controller] = "copy-to-clipboard" 159 | options[:data][:copy_to_clipboard_content_value] = content 160 | options[:data][:copy_to_clipboard_success_class] = "copied" 161 | options[:data][:action] = "click->copy-to-clipboard#copy" 162 | 163 | tag.button("Copy", **options) 164 | end 165 | end 166 | ``` 167 | 168 | --- 169 | 170 | ## HTTP Caching in Views 171 | 172 | ### Fresh When with ETags 173 | 174 | ```ruby 175 | # In controller 176 | def show 177 | @card = Card.find(params[:id]) 178 | fresh_when etag: [@card, Current.user, timezone_from_cookie] 179 | end 180 | ``` 181 | 182 | ### Conditional GET 183 | 184 | ```ruby 185 | def index 186 | @cards = Card.recent 187 | fresh_when etag: @cards 188 | end 189 | ``` 190 | 191 | --- 192 | 193 | ## Turbo Frame Patterns 194 | 195 | ### Lazy Loading Frames 196 | 197 | ```erb 198 | <%= turbo_frame_tag "notifications", 199 | src: notifications_path, 200 | loading: :lazy do %> 201 |

Loading notifications...

202 | <% end %> 203 | ``` 204 | 205 | ### Frame for Inline Editing 206 | 207 | ```erb 208 | <%= turbo_frame_tag dom_id(card, :title) do %> 209 |

<%= card.title %>

210 | <%= link_to "Edit", edit_card_path(card) %> 211 | <% end %> 212 | ``` 213 | 214 | ### Frame-Targeted Forms 215 | 216 | ```erb 217 | <%= turbo_frame_tag dom_id(@card, :edit) do %> 218 | <%= form_with model: @card do |f| %> 219 | <%= f.text_field :title %> 220 | <%= f.submit %> 221 | <% end %> 222 | <% end %> 223 | ``` 224 | 225 | --- 226 | 227 | ## Broadcast Patterns 228 | 229 | ### Model-Level Broadcasting 230 | 231 | ```ruby 232 | class Comment < ApplicationRecord 233 | after_create_commit -> { 234 | broadcast_append_to card, target: "comments" 235 | } 236 | 237 | after_destroy_commit -> { 238 | broadcast_remove_to card 239 | } 240 | end 241 | ``` 242 | 243 | ### Scoped Broadcasting (Multi-Tenant) 244 | 245 | ```ruby 246 | # Always scope broadcasts by account 247 | broadcast_to [Current.account, card], target: "comments" 248 | ``` 249 | 250 | --- 251 | 252 | ## Rendering Conventions 253 | 254 | ### Prefer Locals Over Instance Variables 255 | 256 | ```erb 257 | <%# Good - explicit dependencies %> 258 | <%= render "cards/preview", card: card, draggable: true %> 259 | 260 | <%# Avoid - implicit dependencies %> 261 | <%= render "cards/preview" %> <%# Uses @card implicitly %> 262 | ``` 263 | 264 | ### Partial Naming 265 | 266 | ``` 267 | app/views/ 268 | ├── cards/ 269 | │ ├── _card.html.erb # Single card 270 | │ ├── _preview.html.erb # Card preview/summary 271 | │ ├── _container.html.erb # Card with wrapper 272 | │ ├── _form.html.erb # Card form 273 | │ └── container/ 274 | │ └── _content.html.erb # Nested partial 275 | ``` 276 | 277 | ### DOM ID Conventions 278 | 279 | ```erb 280 | <%# Use Rails dom_id helper %> 281 |
<%# card_123 %> 282 |
<%# preview_card_123 %> 283 |
<%# comments_card_123 %> 284 | ``` 285 | -------------------------------------------------------------------------------- /routing.md: -------------------------------------------------------------------------------- 1 | # Routing Patterns 2 | 3 | > Everything is CRUD - resource-based routing over custom actions. 4 | 5 | --- 6 | 7 | ## The CRUD Principle 8 | 9 | Every action maps to a CRUD verb. When something doesn't fit, **create a new resource**. 10 | 11 | ```ruby 12 | # BAD: Custom actions on existing resource 13 | resources :cards do 14 | post :close 15 | post :reopen 16 | post :archive 17 | post :gild 18 | end 19 | 20 | # GOOD: New resources for each state change 21 | resources :cards do 22 | resource :closure # POST to close, DELETE to reopen 23 | resource :goldness # POST to gild, DELETE to ungild 24 | resource :not_now # POST to postpone 25 | resource :pin # POST to pin, DELETE to unpin 26 | resource :watch # POST to watch, DELETE to unwatch 27 | end 28 | ``` 29 | 30 | **Why**: Standard REST verbs map cleanly to controller actions. No guessing what HTTP method to use. 31 | 32 | ## Real Examples from Fizzy Routes 33 | 34 | ```ruby 35 | # config/routes.rb 36 | 37 | resources :cards do 38 | scope module: :cards do 39 | resource :board # Moving card to different board 40 | resource :closure # Closing/reopening 41 | resource :column # Assigning to workflow column 42 | resource :goldness # Highlighting as important 43 | resource :image # Managing header image 44 | resource :not_now # Postponing 45 | resource :pin # Pinning to sidebar 46 | resource :publish # Publishing draft 47 | resource :reading # Marking as read 48 | resource :triage # Triaging 49 | resource :watch # Subscribing to updates 50 | 51 | resources :assignments # Managing assignees 52 | resources :steps # Checklist items 53 | resources :taggings # Tags 54 | resources :comments do 55 | resources :reactions # Emoji reactions 56 | end 57 | end 58 | end 59 | ``` 60 | 61 | ## Noun-Based Resources 62 | 63 | Turn verbs into nouns: 64 | 65 | | Action | Resource | 66 | |--------|----------| 67 | | Close a card | `card.closure` | 68 | | Watch a board | `board.watching` | 69 | | Pin an item | `item.pin` | 70 | | Publish a board | `board.publication` | 71 | | Assign a user | `card.assignment` | 72 | | Mark as golden | `card.goldness` | 73 | | Postpone | `card.not_now` | 74 | 75 | ## Namespace for Context 76 | 77 | ```ruby 78 | # Board-specific resources 79 | resources :boards do 80 | scope module: :boards do 81 | resource :publication # Publishing publicly 82 | resource :entropy # Auto-postpone settings 83 | resource :involvement # User's involvement level 84 | 85 | namespace :columns do 86 | resource :not_now # "Not Now" pseudo-column 87 | resource :stream # Main stream view 88 | resource :closed # Closed cards view 89 | end 90 | end 91 | end 92 | ``` 93 | 94 | ## Use `resolve` for Custom URL Generation 95 | 96 | Make `polymorphic_url` work correctly for nested resources: 97 | 98 | ```ruby 99 | # config/routes.rb 100 | 101 | resolve "Comment" do |comment, options| 102 | options[:anchor] = ActionView::RecordIdentifier.dom_id(comment) 103 | route_for :card, comment.card, options 104 | end 105 | 106 | resolve "Notification" do |notification, options| 107 | polymorphic_url(notification.notifiable_target, options) 108 | end 109 | ``` 110 | 111 | **Why**: This lets you use `url_for(@comment)` and get the correct card URL with anchor. 112 | 113 | ## Shallow Nesting 114 | 115 | Use `shallow: true` to avoid deep nesting: 116 | 117 | ```ruby 118 | resources :boards, shallow: true do 119 | resources :cards 120 | end 121 | 122 | # Generates: 123 | # /boards/:board_id/cards (index, new, create) 124 | # /cards/:id (show, edit, update, destroy) 125 | ``` 126 | 127 | ## Singular Resources 128 | 129 | Use `resource` (singular) for one-per-parent resources: 130 | 131 | ```ruby 132 | resources :cards do 133 | resource :closure # A card has one closure state 134 | resource :watching # A user's watch status on a card 135 | resource :goldness # A card is either golden or not 136 | end 137 | ``` 138 | 139 | ## Module Scoping 140 | 141 | Group related controllers without changing URLs: 142 | 143 | ```ruby 144 | # Using scope module (no URL prefix) 145 | resources :cards do 146 | scope module: :cards do 147 | resource :closure # Cards::ClosuresController at /cards/:id/closure 148 | end 149 | end 150 | 151 | # Using namespace (adds URL prefix) 152 | namespace :cards do 153 | resources :drops # Cards::DropsController at /cards/drops 154 | end 155 | ``` 156 | 157 | ## Path-Based Multi-Tenancy 158 | 159 | Account ID in URL prefix, handled by middleware: 160 | 161 | ```ruby 162 | # Middleware extracts /:account_id and sets Current.account 163 | # Routes don't need to reference it explicitly 164 | 165 | scope "/:account_id" do 166 | resources :boards 167 | resources :cards 168 | end 169 | ``` 170 | 171 | ## Controller Mapping 172 | 173 | Keep controllers aligned with resources: 174 | 175 | ``` 176 | app/controllers/ 177 | ├── application_controller.rb 178 | ├── cards_controller.rb 179 | ├── cards/ 180 | │ ├── assignments_controller.rb 181 | │ ├── closures_controller.rb 182 | │ ├── columns_controller.rb 183 | │ ├── drops_controller.rb 184 | │ ├── goldnesses_controller.rb 185 | │ ├── not_nows_controller.rb 186 | │ ├── pins_controller.rb 187 | │ ├── watches_controller.rb 188 | │ └── comments/ 189 | │ └── reactions_controller.rb 190 | ├── boards_controller.rb 191 | └── boards/ 192 | ├── columns_controller.rb 193 | ├── entropies_controller.rb 194 | └── publications_controller.rb 195 | ``` 196 | 197 | ## API Design: Same Controllers, Different Format 198 | 199 | No separate API namespace - just `respond_to`: 200 | 201 | ```ruby 202 | class Cards::ClosuresController < ApplicationController 203 | include CardScoped 204 | 205 | def create 206 | @card.close 207 | 208 | respond_to do |format| 209 | format.turbo_stream { render_card_replacement } 210 | format.json { head :no_content } 211 | end 212 | end 213 | 214 | def destroy 215 | @card.reopen 216 | 217 | respond_to do |format| 218 | format.turbo_stream { render_card_replacement } 219 | format.json { head :no_content } 220 | end 221 | end 222 | end 223 | ``` 224 | 225 | ### Consistent Response Codes 226 | 227 | | Action | Success Code | 228 | |--------|--------------| 229 | | Create | `201 Created` + `Location` header | 230 | | Update | `204 No Content` | 231 | | Delete | `204 No Content` | 232 | 233 | ```ruby 234 | def create 235 | @comment = @card.comments.create!(comment_params) 236 | 237 | respond_to do |format| 238 | format.turbo_stream 239 | format.json { head :created, location: card_comment_path(@card, @comment) } 240 | end 241 | end 242 | ``` 243 | 244 | ## Key Principles 245 | 246 | 1. **Every action is CRUD** - Create, read, update, or destroy something 247 | 2. **Verbs become nouns** - "close" becomes "closure" resource 248 | 3. **Shallow nesting** - Avoid URLs like `/a/1/b/2/c/3/d/4` 249 | 4. **Singular when appropriate** - `resource` for one-per-parent 250 | 5. **Namespace for grouping** - Related controllers together 251 | 6. **Use `resolve`** - For polymorphic URL generation 252 | 7. **Same controller, different format** - No separate API controllers 253 | -------------------------------------------------------------------------------- /multi-tenancy.md: -------------------------------------------------------------------------------- 1 | # Multi-Tenancy Patterns 2 | 3 | > URL path-based multi-tenancy patterns from 37signals' Fizzy. 4 | 5 | --- 6 | 7 | ## Path-Based Tenancy with Middleware ([#283](https://github.com/basecamp/fizzy/pull/283)) 8 | 9 | Extract tenant from URL paths and "mount" Rails at that prefix: 10 | 11 | ```ruby 12 | module AccountSlug 13 | PATTERN = /(\d{7,})/ 14 | PATH_INFO_MATCH = /\A(\/#{AccountSlug::PATTERN})/ 15 | 16 | class Extractor 17 | def initialize(app) 18 | @app = app 19 | end 20 | 21 | def call(env) 22 | request = ActionDispatch::Request.new(env) 23 | 24 | if request.path_info =~ PATH_INFO_MATCH 25 | # Move prefix from PATH_INFO to SCRIPT_NAME 26 | request.engine_script_name = request.script_name = $1 27 | request.path_info = $'.empty? ? "/" : $' 28 | env["fizzy.external_account_id"] = AccountSlug.decode($2) 29 | end 30 | 31 | if env["fizzy.external_account_id"] 32 | account = Account.find_by(external_account_id: env["fizzy.external_account_id"]) 33 | Current.with_account(account) { @app.call(env) } 34 | else 35 | Current.without_account { @app.call(env) } 36 | end 37 | end 38 | end 39 | end 40 | 41 | # Insert middleware 42 | Rails.application.config.middleware.insert_after Rack::TempfileReaper, AccountSlug::Extractor 43 | ``` 44 | 45 | **Why path-based**: No wildcard DNS/SSL, simpler local dev, no `/etc/hosts` hacking. 46 | 47 | ## Current Context Pattern ([#168](https://github.com/basecamp/fizzy/pull/168), [#279](https://github.com/basecamp/fizzy/pull/279)) 48 | 49 | ```ruby 50 | class Current < ActiveSupport::CurrentAttributes 51 | attribute :session, :user, :identity, :account 52 | 53 | def with_account(value, &) 54 | with(account: value, &) 55 | end 56 | 57 | def without_account(&) 58 | with(account: nil, &) 59 | end 60 | end 61 | ``` 62 | 63 | ## ActiveJob Tenant Preservation ([#168](https://github.com/basecamp/fizzy/pull/168)) 64 | 65 | Automatically capture/restore tenant in background jobs: 66 | 67 | ```ruby 68 | module FizzyActiveJobExtensions 69 | extend ActiveSupport::Concern 70 | 71 | prepended do 72 | attr_reader :account 73 | self.enqueue_after_transaction_commit = true 74 | end 75 | 76 | def initialize(...) 77 | super 78 | @account = Current.account 79 | end 80 | 81 | def serialize 82 | super.merge({ "account" => @account&.to_gid }) 83 | end 84 | 85 | def deserialize(job_data) 86 | super 87 | if _account = job_data.fetch("account", nil) 88 | @account = GlobalID::Locator.locate(_account) 89 | end 90 | end 91 | 92 | def perform_now 93 | if account.present? 94 | Current.with_account(account) { super } 95 | else 96 | super 97 | end 98 | end 99 | end 100 | 101 | ActiveSupport.on_load(:active_job) do 102 | prepend FizzyActiveJobExtensions 103 | end 104 | ``` 105 | 106 | Uses GlobalID for serialization - works across all job backends. 107 | 108 | ## Recurring Jobs: Iterate All Tenants ([#279](https://github.com/basecamp/fizzy/pull/279)) 109 | 110 | ```ruby 111 | # Recurring jobs run outside request context 112 | class AutoPopAllDueJob < ApplicationJob 113 | def perform 114 | ApplicationRecord.with_each_tenant do |tenant| 115 | Bubble.auto_pop_all_due 116 | end 117 | end 118 | end 119 | ``` 120 | 121 | Easy to forget during multi-tenant migration. 122 | 123 | ## Session Cookie Path Scoping ([#879](https://github.com/basecamp/fizzy/pull/879)) 124 | 125 | For simultaneous login to multiple tenants: 126 | 127 | ```ruby 128 | def set_current_session(session) 129 | cookies.signed.permanent[:session_token] = { 130 | value: session.signed_id, 131 | httponly: true, 132 | same_site: :lax, 133 | path: Account.sole.slug # e.g., "/1234567" 134 | } 135 | end 136 | ``` 137 | 138 | Without path scoping, cookies from one tenant clobber another. 139 | 140 | ## Test Setup for Path-Based Tenancy ([#879](https://github.com/basecamp/fizzy/pull/879)) 141 | 142 | ```ruby 143 | # test_helper.rb 144 | Rails.application.config.active_record_tenanted.default_tenant = 145 | ActiveRecord::FixtureSet.identify(:'37s_fizzy') 146 | 147 | class ActionDispatch::IntegrationTest 148 | setup do 149 | integration_session.default_url_options[:script_name] = 150 | "/#{ApplicationRecord.current_tenant}" 151 | end 152 | end 153 | 154 | class ActionDispatch::SystemTestCase 155 | setup do 156 | self.default_url_options[:script_name] = 157 | "/#{ApplicationRecord.current_tenant}" 158 | end 159 | end 160 | ``` 161 | 162 | ## Always Scope Controller Lookups ([#372](https://github.com/basecamp/fizzy/pull/372)) 163 | 164 | Defense in depth - don't rely solely on middleware: 165 | 166 | ```ruby 167 | # Bad 168 | def set_comment 169 | @comment = Comment.find(params[:comment_id]) 170 | end 171 | 172 | # Good - scope through tenant 173 | def set_comment 174 | @comment = Current.account.comments.find(params[:comment_id]) 175 | end 176 | 177 | # Better - scope through user's accessible records 178 | def set_bubble 179 | @bubble = Current.user.accessible_bubbles.find(params[:bubble_id]) 180 | end 181 | ``` 182 | 183 | ## Default Tenant for Dev Console ([#168](https://github.com/basecamp/fizzy/pull/168), [#879](https://github.com/basecamp/fizzy/pull/879)) 184 | 185 | ```ruby 186 | # config/initializers/tenanting/default_tenant.rb 187 | Rails.application.configure do 188 | if Rails.env.development? 189 | config.active_record_tenanted.default_tenant = "175932900" 190 | end 191 | end 192 | ``` 193 | 194 | Makes console work ergonomic without constant tenant switching. 195 | 196 | ## Solid Cache Multi-Tenant Config ([#168](https://github.com/basecamp/fizzy/pull/168), [#279](https://github.com/basecamp/fizzy/pull/279)) 197 | 198 | Avoid Rails' automatic shard swapping conflicts: 199 | 200 | ```yaml 201 | # config/cache.yml 202 | # DON'T use database: key - causes shard swap issues 203 | 204 | default_connection: &default_connection 205 | connects_to: 206 | database: 207 | writing: :cache 208 | 209 | development: 210 | <<: *default_connection 211 | store_options: 212 | max_size: <%= 256.megabytes %> 213 | namespace: <%= Rails.env %> 214 | ``` 215 | 216 | ## Test Middleware in Isolation 217 | 218 | ```ruby 219 | def call_with_env(path, extra_env = {}) 220 | captured = {} 221 | extra_env = { "action_dispatch.routes" => Rails.application.routes }.merge(extra_env) 222 | 223 | app = ->(env) do 224 | captured[:script_name] = env["SCRIPT_NAME"] 225 | captured[:path_info] = env["PATH_INFO"] 226 | captured[:current_account] = Current.account 227 | [ 200, {}, [ "ok" ] ] 228 | end 229 | 230 | middleware = AccountSlug::Extractor.new(app) 231 | middleware.call Rack::MockRequest.env_for(path, extra_env.merge(method: "GET")) 232 | 233 | captured 234 | end 235 | 236 | test "moves account prefix from PATH_INFO to SCRIPT_NAME" do 237 | account = accounts(:initech) 238 | slug = AccountSlug.encode(account.external_account_id) 239 | 240 | captured = call_with_env "/#{slug}/boards" 241 | 242 | assert_equal "/#{slug}", captured.fetch(:script_name) 243 | assert_equal "/boards", captured.fetch(:path_info) 244 | assert_equal account, captured.fetch(:current_account) 245 | end 246 | ``` 247 | 248 | ## Architecture Decision 249 | 250 | Fizzy settled on **path-based tenancy with shared database** (not database-per-tenant): 251 | - URL paths like `/1234567/boards/123` 252 | - Middleware sets `Current.account` 253 | - Models scoped via `account_id` foreign keys 254 | - Simpler than database-per-tenant while maintaining isolation 255 | -------------------------------------------------------------------------------- /development-philosophy.md: -------------------------------------------------------------------------------- 1 | # 37signals Development Philosophy 2 | 3 | > Core principles observed across 265 PRs in Fizzy. 4 | 5 | --- 6 | 7 | ## Ship, Validate, Refine 8 | 9 | - Merge "prototype quality" code to validate with real usage before cleanup 10 | - Features evolve through iterations (tenanting: 3 attempts before settling) 11 | - Don't polish prematurely - real-world usage reveals what matters 12 | - PR [#335](https://github.com/basecamp/fizzy/pull/335) merged as "prototype quality" to validate design first 13 | 14 | ## Fix Root Causes, Not Symptoms 15 | 16 | **Bad**: Add retry logic for race conditions 17 | **Good**: Use `enqueue_after_transaction_commit` to prevent the race ([#1664](https://github.com/basecamp/fizzy/pull/1664)) 18 | 19 | **Bad**: Work around CSRF issues on cached pages 20 | **Good**: Don't HTTP cache pages with forms ([#1607](https://github.com/basecamp/fizzy/pull/1607)) 21 | 22 | ## Vanilla Rails Over Abstractions 23 | 24 | - Thin controllers calling rich domain models 25 | - No service objects unless truly justified 26 | - Direct ActiveRecord is fine: `@card.comments.create!(params)` 27 | - When services exist, they're just POROs: `Signup.new(email:).create_identity` 28 | 29 | ## DHH's Review Patterns 30 | 31 | See [dhh.md](dhh.md) for comprehensive review patterns extracted from 100+ PR reviews. 32 | 33 | Key themes: 34 | - Questions indirection: "Is this abstraction earning its keep?" 35 | - Pushes for directness - collapsed 6 notifier subclasses into 2 ([#425](https://github.com/basecamp/fizzy/pull/425)) 36 | - Prefers explicit over clever (define methods directly vs introspection) 37 | - Removes "anemic" code that adds layers without value 38 | - Write-time operations over read-time computations 39 | - Database constraints over AR validations 40 | 41 | ## Common Review Themes 42 | 43 | - **Naming**: Use positive names (`active` not `not_deleted`, `unpopped`) 44 | - **DB over AR**: Prefer database constraints over ActiveRecord validations 45 | - **Migrations**: Use SQL, avoid model references that break future runs 46 | - **Simplify**: Links over JavaScript when browser affordances suffice ([#138](https://github.com/basecamp/fizzy/pull/138)) 47 | 48 | ## When to Extract 49 | 50 | - Start in controller, extract when it gets messy 51 | - Filter logic: controller → model concern → dedicated PORO ([#115](https://github.com/basecamp/fizzy/pull/115), [#116](https://github.com/basecamp/fizzy/pull/116)) 52 | - Don't extract prematurely - wait for pain 53 | - Rule of three: duplicate twice before abstracting 54 | 55 | ## Rails 7.1+ `params.expect` ([#120](https://github.com/basecamp/fizzy/pull/120)) 56 | 57 | Replace `params.require(:key).permit(...)` with `params.expect(key: [...])`: 58 | - Returns 400 (Bad Request) instead of 500 for bad params 59 | - Cleaner, more explicit syntax 60 | 61 | ```ruby 62 | # Before 63 | params.require(:user).permit(:name, :email) 64 | 65 | # After 66 | params.expect(user: [:name, :email]) 67 | ``` 68 | 69 | ## StringInquirer for Action Predicates ([#425](https://github.com/basecamp/fizzy/pull/425)) 70 | 71 | Instead of string comparisons, use StringInquirer: 72 | 73 | ```ruby 74 | # Bad 75 | if event.action == "completed" 76 | 77 | # Good 78 | if event.action.completed? 79 | 80 | # Implementation 81 | def action 82 | self[:action].inquiry 83 | end 84 | ``` 85 | 86 | ## Caching Constraints Inform Architecture ([#119](https://github.com/basecamp/fizzy/pull/119)) 87 | 88 | Design caching early - it reveals architectural issues: 89 | - Can't use `Current.user` in cached partials 90 | - Solution: Push user-specific logic to Stimulus controllers reading from meta tags 91 | - Leave FIXME comments when you discover caching conflicts 92 | 93 | ## Write-Time vs Read-Time Operations ([#108](https://github.com/basecamp/fizzy/pull/108)) 94 | 95 | All manipulation should happen when you save, not when you present: 96 | - Use delegated types for heterogeneous collections needing pagination 97 | - Pre-compute roll-ups at write time 98 | - Use `dependent: :delete_all` when no callbacks needed 99 | - Use counter caches instead of manual counting 100 | 101 | See [dhh.md](dhh.md#write-time-vs-read-time-operations) for detailed examples. 102 | 103 | --- 104 | 105 | ## Jason Zimdars: Design & Product Patterns 106 | 107 | See [jason-zimdars.md](jason-zimdars.md) for comprehensive patterns from [@jzimdars](https://github.com/jzimdars) (Lead Designer at 37signals). 108 | 109 | Key themes: 110 | - **Perceived Performance > Technical Performance** - If it *feels* slow, it's slow 111 | - **Prototype Quality Shipping** - "Ship to validate" is a valid standard 112 | - **Production Truth** - Real data reveals what local testing can't 113 | - **Extend Don't Replace** - Branch with parameters, keep old paths working 114 | - **Visual Coherence** - Ship visual redesigns wholesale, not piecemeal 115 | - **Feedback as Vision** - Share UX concerns, let implementers figure out how 116 | 117 | --- 118 | 119 | ## Jorge Manrubia: Architecture & Rails Patterns 120 | 121 | See [jorge-manrubia.md](jorge-manrubia.md) for comprehensive patterns from [@jorgemanrubia](https://github.com/jorgemanrubia) (Programmer at 37signals). 122 | 123 | Key themes: 124 | - **Narrow Public APIs** - Only expose what's actually used 125 | - **Domain Names Over Technical** - `depleted?` not `over_limit?` 126 | - **Objects Emerge from Coupling** - Shared params → extract object 127 | - **Memoize Hot Paths** - Methods called during rendering 128 | - **Layer Caching** - HTTP, templates, queries at different granularities 129 | - **Fixed-Point for Money** - Integers, not floats (microcents) 130 | - **VCR for External APIs** - Fast, deterministic tests 131 | 132 | --- 133 | 134 | ## Rails Patterns 135 | 136 | ### Delegated Types for Polymorphism ([#124](https://github.com/basecamp/fizzy/pull/124)) 137 | 138 | Use `delegated_type` instead of traditional polymorphic associations: 139 | 140 | ```ruby 141 | class Message < ApplicationRecord 142 | belongs_to :bubble, touch: true 143 | delegated_type :messageable, types: %w[Comment EventSummary], 144 | inverse_of: :message, dependent: :destroy 145 | end 146 | 147 | module Messageable 148 | extend ActiveSupport::Concern 149 | included do 150 | has_one :message, as: :messageable, touch: true 151 | end 152 | end 153 | ``` 154 | 155 | **Why**: Automatic convenience methods (`message.comment?`, `message.comment`) without manual type checking. 156 | 157 | ### Store Accessor for JSON Columns ([#113](https://github.com/basecamp/fizzy/pull/113)) 158 | 159 | Use `store_accessor` for structured JSON storage: 160 | 161 | ```ruby 162 | class Bucket::View < ApplicationRecord 163 | store_accessor :filters, :order_by, :status, :assignee_ids, :tag_ids 164 | 165 | validates :order_by, inclusion: { in: ORDERS.keys, allow_nil: true } 166 | end 167 | ``` 168 | 169 | **Why**: Type casting, validation, and cleaner API (`view.order_by` vs `view.filters['order_by']`). 170 | 171 | ### Normalizes for Data Consistency ([#1083](https://github.com/basecamp/fizzy/pull/1083)) 172 | 173 | Use `normalizes` to clean data before validation (Rails 7.1+): 174 | 175 | ```ruby 176 | class Webhook < ApplicationRecord 177 | serialize :subscribed_actions, type: Array, coder: JSON 178 | 179 | normalizes :subscribed_actions, 180 | with: ->(value) { Array.wrap(value).map(&:to_s).uniq & PERMITTED_ACTIONS } 181 | end 182 | ``` 183 | 184 | **Why**: Ensures data consistency before validation, no `before_validation` callbacks needed. 185 | 186 | ### Concern Organization by Responsibility ([#124](https://github.com/basecamp/fizzy/pull/124)) 187 | 188 | Split models into focused concerns: 189 | 190 | ```ruby 191 | class Bubble < ApplicationRecord 192 | include Assignable # Assignment logic 193 | include Boostable # Boost counting 194 | include Eventable # Event tracking 195 | include Poppable # Archive logic 196 | include Searchable # Full-text search 197 | include Staged # Workflow stage logic 198 | include Taggable # Tag associations 199 | end 200 | ``` 201 | 202 | **Guidelines**: 203 | - Each concern should be 50-150 lines 204 | - Must be cohesive (related functionality together) 205 | - Don't create concerns just to reduce file size 206 | 207 | ### Scopes Named for Business Concepts ([#124](https://github.com/basecamp/fizzy/pull/124)) 208 | 209 | ```ruby 210 | # Good - business-focused 211 | scope :active, -> { where.missing(:pop) } 212 | scope :unassigned, -> { where.missing(:assignments) } 213 | 214 | # Not - SQL-ish 215 | scope :without_pop, -> { ... } 216 | scope :no_assignments, -> { ... } 217 | ``` 218 | 219 | ### Transaction Wrapping ([#124](https://github.com/basecamp/fizzy/pull/124)) 220 | 221 | Wrap related updates for consistency: 222 | 223 | ```ruby 224 | def toggle_stage(stage) 225 | transaction do 226 | update! stage: new_stage 227 | track_event event, stage_id: stage.id 228 | end 229 | end 230 | ``` 231 | 232 | **When to use**: Multi-step operations, parent + children records, state transitions. 233 | 234 | ### Touch Chains for Cache Invalidation ([#124](https://github.com/basecamp/fizzy/pull/124)) 235 | 236 | ```ruby 237 | class Comment < ApplicationRecord 238 | has_one :message, as: :messageable, touch: true 239 | end 240 | 241 | class Message < ApplicationRecord 242 | belongs_to :bubble, touch: true 243 | end 244 | ``` 245 | 246 | Changes propagate up: comment → message → bubble, invalidating caches automatically. 247 | -------------------------------------------------------------------------------- /css.md: -------------------------------------------------------------------------------- 1 | # CSS Architecture 2 | 3 | > Native CSS with cascade layers, OKLCH colors, and modern features - no preprocessors. 4 | 5 | --- 6 | 7 | ## Philosophy 8 | 9 | Fizzy uses **native CSS only** - no Sass, PostCSS, or Tailwind. Modern CSS has everything needed: 10 | - Native nesting 11 | - CSS variables 12 | - Cascade layers 13 | - Container queries 14 | - OKLCH color space 15 | 16 | --- 17 | 18 | ## Cascade Layers 19 | 20 | Use `@layer` for explicit specificity management: 21 | 22 | ```css 23 | @layer reset, base, layout, components, utilities; 24 | 25 | @layer reset { 26 | *, *::before, *::after { 27 | box-sizing: border-box; 28 | } 29 | } 30 | 31 | @layer base { 32 | body { 33 | font-family: system-ui, sans-serif; 34 | line-height: 1.5; 35 | } 36 | } 37 | 38 | @layer components { 39 | .card { /* component styles */ } 40 | .btn { /* button styles */ } 41 | } 42 | 43 | @layer utilities { 44 | .hidden { display: none; } 45 | .flex { display: flex; } 46 | } 47 | ``` 48 | 49 | **Why layers**: Explicit control over cascade order without specificity wars. Later layers always win, regardless of selector specificity. 50 | 51 | --- 52 | 53 | ## OKLCH Color Space 54 | 55 | Use OKLCH for perceptually uniform colors: 56 | 57 | ```css 58 | :root { 59 | /* Store LCH values as variables */ 60 | --lch-blue-dark: 57.02% 0.1895 260.46; 61 | --lch-blue-medium: 66% 0.196 257.82; 62 | --lch-blue-light: 84.04% 0.0719 255.29; 63 | 64 | /* Use oklch() to create colors */ 65 | --color-link: oklch(var(--lch-blue-dark)); 66 | --color-selected: oklch(var(--lch-blue-light)); 67 | } 68 | ``` 69 | 70 | **Benefits:** 71 | - **Perceptually uniform** - Equal steps in lightness look equal 72 | - **P3 gamut support** - Wider color range on modern displays 73 | - **Easy theming** - Flip lightness values for dark mode 74 | 75 | --- 76 | 77 | ## Dark Mode via CSS Variables 78 | 79 | Dark mode is achieved by redefining OKLCH values: 80 | 81 | ```css 82 | /* Light mode (default) */ 83 | :root { 84 | --lch-ink-darkest: 26% 0.05 264; /* Dark text */ 85 | --lch-canvas: 100% 0 0; /* White background */ 86 | } 87 | 88 | /* Dark mode */ 89 | html[data-theme="dark"] { 90 | --lch-ink-darkest: 96.02% 0.0034 260; /* Light text */ 91 | --lch-canvas: 20% 0.0195 232.58; /* Dark background */ 92 | } 93 | 94 | /* Also respects system preference */ 95 | @media (prefers-color-scheme: dark) { 96 | html:not([data-theme]) { 97 | --lch-ink-darkest: 96.02% 0.0034 260; 98 | --lch-canvas: 20% 0.0195 232.58; 99 | } 100 | } 101 | ``` 102 | 103 | --- 104 | 105 | ## Native CSS Nesting 106 | 107 | Uses native CSS nesting (no preprocessor): 108 | 109 | ```css 110 | .btn { 111 | background-color: var(--btn-background); 112 | 113 | @media (any-hover: hover) { 114 | &:hover { 115 | filter: brightness(var(--btn-hover-brightness)); 116 | } 117 | } 118 | 119 | html[data-theme="dark"] & { 120 | --btn-hover-brightness: 1.25; 121 | } 122 | 123 | &[disabled] { 124 | cursor: not-allowed; 125 | opacity: 0.3; 126 | } 127 | } 128 | ``` 129 | 130 | --- 131 | 132 | ## Component Naming Convention 133 | 134 | Components use a simple naming convention (BEM-inspired but pragmatic): 135 | 136 | ```css 137 | /* Base component */ 138 | .card { } 139 | 140 | /* Sub-elements with __ */ 141 | .card__header { } 142 | .card__body { } 143 | .card__title { } 144 | 145 | /* Variants with -- */ 146 | .card--notification { } 147 | .card--closed { } 148 | ``` 149 | 150 | But unlike strict BEM: 151 | - **No strict methodology** - pragmatic naming 152 | - **Heavy use of CSS variables** for theming within components 153 | - **:has() selectors** for parent-aware styling 154 | 155 | --- 156 | 157 | ## CSS Variables for Component APIs 158 | 159 | Components expose customization via variables: 160 | 161 | ```css 162 | .btn { 163 | --btn-background: var(--color-canvas); 164 | --btn-border-color: var(--color-ink-light); 165 | --btn-color: var(--color-ink); 166 | --btn-padding: 0.5em 1.1em; 167 | --btn-border-radius: 99rem; 168 | 169 | background-color: var(--btn-background); 170 | border: 1px solid var(--btn-border-color); 171 | color: var(--btn-color); 172 | padding: var(--btn-padding); 173 | border-radius: var(--btn-border-radius); 174 | } 175 | 176 | /* Variants override variables */ 177 | .btn--link { 178 | --btn-background: var(--color-link); 179 | --btn-color: var(--color-ink-inverted); 180 | } 181 | 182 | .btn--negative { 183 | --btn-background: var(--color-negative); 184 | --btn-color: var(--color-ink-inverted); 185 | } 186 | ``` 187 | 188 | --- 189 | 190 | ## Modern CSS Features Used 191 | 192 | ### 1. @starting-style for Entry Animations 193 | 194 | ```css 195 | .dialog { 196 | opacity: 0; 197 | transform: scale(0.2); 198 | transition: 150ms allow-discrete; 199 | transition-property: display, opacity, overlay, transform; 200 | 201 | &[open] { 202 | opacity: 1; 203 | transform: scale(1); 204 | } 205 | 206 | @starting-style { 207 | &[open] { 208 | opacity: 0; 209 | transform: scale(0.2); 210 | } 211 | } 212 | } 213 | ``` 214 | 215 | ### 2. color-mix() for Dynamic Colors 216 | 217 | ```css 218 | .card { 219 | --card-bg-color: color-mix(in srgb, var(--card-color) 4%, var(--color-canvas)); 220 | --card-text-color: color-mix(in srgb, var(--card-color) 75%, var(--color-ink)); 221 | } 222 | ``` 223 | 224 | ### 3. :has() for Parent-Aware Styling 225 | 226 | ```css 227 | .btn:has(input:checked) { 228 | --btn-background: var(--color-ink); 229 | --btn-color: var(--color-ink-inverted); 230 | } 231 | 232 | .card:has(.card__closed) { 233 | --card-color: var(--color-card-complete) !important; 234 | } 235 | ``` 236 | 237 | ### 4. Logical Properties 238 | 239 | ```css 240 | .pad-block { padding-block: var(--block-space); } 241 | .pad-inline { padding-inline: var(--inline-space); } 242 | .margin-inline-start { margin-inline-start: var(--inline-space); } 243 | ``` 244 | 245 | ### 5. Container Queries 246 | 247 | ```css 248 | .card__content { 249 | contain: inline-size; /* Enable container queries */ 250 | } 251 | 252 | @container (width < 300px) { 253 | .card__meta { 254 | flex-direction: column; 255 | } 256 | } 257 | ``` 258 | 259 | ### 6. Field Sizing 260 | 261 | ```css 262 | .input--textarea { 263 | @supports (field-sizing: content) { 264 | field-sizing: content; 265 | max-block-size: calc(3lh + (2 * var(--input-padding))); 266 | } 267 | } 268 | ``` 269 | 270 | --- 271 | 272 | ## Utility Classes (Minimal) 273 | 274 | Unlike Tailwind's hundreds of utilities, Fizzy has ~60 focused utilities: 275 | 276 | ```css 277 | @layer utilities { 278 | /* Text */ 279 | .txt-small { font-size: var(--text-small); } 280 | .txt-subtle { color: var(--color-ink-dark); } 281 | .txt-center { text-align: center; } 282 | 283 | /* Layout */ 284 | .flex { display: flex; } 285 | .gap { column-gap: var(--column-gap, var(--inline-space)); } 286 | .stack { display: flex; flex-direction: column; } 287 | 288 | /* Spacing (using design tokens) */ 289 | .pad { padding: var(--block-space) var(--inline-space); } 290 | .margin-block { margin-block: var(--block-space); } 291 | 292 | /* Visibility */ 293 | .visually-hidden { 294 | clip-path: inset(50%); 295 | position: absolute; 296 | width: 1px; 297 | height: 1px; 298 | overflow: hidden; 299 | } 300 | } 301 | ``` 302 | 303 | --- 304 | 305 | ## Design Tokens 306 | 307 | All values come from CSS custom properties: 308 | 309 | ```css 310 | :root { 311 | /* Spacing */ 312 | --inline-space: 1ch; 313 | --block-space: 1rem; 314 | 315 | /* Typography */ 316 | --text-small: 0.85rem; 317 | --text-normal: 1rem; 318 | --text-large: 1.5rem; 319 | 320 | /* Responsive typography */ 321 | @media (max-width: 639px) { 322 | --text-small: 0.95rem; 323 | --text-normal: 1.1rem; 324 | } 325 | 326 | /* Z-index scale */ 327 | --z-popup: 10; 328 | --z-nav: 30; 329 | --z-tooltip: 50; 330 | 331 | /* Animation */ 332 | --ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1); 333 | --dialog-duration: 150ms; 334 | } 335 | ``` 336 | 337 | --- 338 | 339 | ## Responsive Strategy 340 | 341 | Minimal breakpoints, mostly fluid: 342 | 343 | ```css 344 | /* Fluid main padding */ 345 | --main-padding: clamp(var(--inline-space), 3vw, calc(var(--inline-space) * 3)); 346 | 347 | /* Responsive via container */ 348 | --tray-size: clamp(12rem, 25dvw, 24rem); 349 | 350 | /* Only 2-3 breakpoints used */ 351 | @media (max-width: 639px) { /* Mobile */ } 352 | @media (min-width: 640px) { /* Desktop */ } 353 | @media (max-width: 799px) { /* Tablet and below */ } 354 | ``` 355 | 356 | --- 357 | 358 | ## File Organization 359 | 360 | One file per concern, ~100-300 lines each: 361 | 362 | ``` 363 | app/assets/stylesheets/ 364 | ├── _global.css # CSS variables, layers, dark mode (472 lines) 365 | ├── reset.css # Modern CSS reset (109 lines) 366 | ├── base.css # Element defaults (122 lines) 367 | ├── layout.css # Grid layout (35 lines) 368 | ├── utilities.css # Utility classes (264 lines) 369 | ├── buttons.css # .btn component (273 lines) 370 | ├── cards.css # .card component (519 lines) 371 | ├── inputs.css # Form controls (295 lines) 372 | ├── dialog.css # Dialog animations (38 lines) 373 | ├── popup.css # Dropdown menus (209 lines) 374 | └── application.css # Imports all files 375 | ``` 376 | 377 | --- 378 | 379 | ## What's NOT Here 380 | 381 | 1. **No Sass/SCSS** - Native CSS is powerful enough 382 | 2. **No PostCSS** - Browser support is good 383 | 3. **No Tailwind** - Utilities exist but are minimal 384 | 4. **No CSS-in-JS** - Keep styles in stylesheets 385 | 5. **No CSS Modules** - Global styles with naming conventions 386 | 6. **No !important abuse** - Layers handle specificity 387 | 388 | --- 389 | 390 | ## Key Principles 391 | 392 | 1. **Use the platform** - Native CSS is capable 393 | 2. **Design tokens everywhere** - Variables for consistency 394 | 3. **Layers for specificity** - No specificity wars 395 | 4. **Components own their styles** - Self-contained 396 | 5. **Utilities are escape hatches** - Not the primary approach 397 | 6. **Progressive enhancement** - `@supports` for new features 398 | 7. **Minimal responsive** - Fluid over breakpoint-heavy 399 | -------------------------------------------------------------------------------- /configuration.md: -------------------------------------------------------------------------------- 1 | # Configuration Patterns 2 | 3 | > Rails configuration and environment management lessons from 37signals. 4 | 5 | --- 6 | 7 | ## RAILS_MASTER_KEY Pattern ([#554](https://github.com/basecamp/fizzy/pull/554)) 8 | ```bash 9 | # .kamal/secrets.production 10 | SECRETS=$(kamal secrets fetch --adapter 1password \ 11 | --from Production/RAILS_MASTER_KEY) 12 | RAILS_MASTER_KEY=$(kamal secrets extract RAILS_MASTER_KEY $SECRETS) 13 | ``` 14 | 15 | **Why it matters:** 16 | - Single secret (`RAILS_MASTER_KEY`) unlocks all environment credentials 17 | - Simplifies deployment - only one secret to manage per environment 18 | - Works seamlessly with Kamal and 1Password 19 | 20 | ## YAML Configuration DRYness 21 | 22 | ### Anchor References Over Inheritance ([#584](https://github.com/basecamp/fizzy/pull/584)) 23 | 24 | ```yaml 25 | # Before - verbose repetition 26 | production: &production 27 | <<: *default_connection 28 | <<: *default_options 29 | 30 | beta: 31 | <<: *production 32 | 33 | # After - cleaner anchor reference 34 | production: &production 35 | <<: *default_connection 36 | <<: *default_options 37 | 38 | beta: *production 39 | staging: *production 40 | ``` 41 | 42 | **Why it matters:** 43 | - More concise and readable 44 | - Easier to add new environments 45 | - Standard YAML feature, no magic 46 | 47 | ### Apply to All Config Files ([#584](https://github.com/basecamp/fizzy/pull/584)) 48 | Use this pattern consistently across: 49 | - `config/cable.yml` 50 | - `config/cache.yml` 51 | - `config/queue.yml` 52 | - `config/recurring.yml` 53 | - `config/database.yml` 54 | 55 | ## Environment-Specific Configuration 56 | 57 | ### Explicit RAILS_ENV in Deploy Files ([#554](https://github.com/basecamp/fizzy/pull/554), [#584](https://github.com/basecamp/fizzy/pull/584)) 58 | 59 | ```yaml 60 | # config/deploy.production.yml 61 | env: 62 | clear: 63 | RAILS_ENV: production 64 | 65 | # config/deploy.beta.yml 66 | env: 67 | clear: 68 | RAILS_ENV: beta 69 | ``` 70 | 71 | **Why it matters:** 72 | - Makes environment explicit in deployment config 73 | - Prevents environment confusion 74 | - Clear documentation of which environment you're deploying to 75 | 76 | ### Environment Files Inherit from Production ([#554](https://github.com/basecamp/fizzy/pull/554), [#584](https://github.com/basecamp/fizzy/pull/584)) 77 | 78 | ```ruby 79 | # config/environments/beta.rb 80 | require_relative "production" 81 | 82 | Rails.application.configure do 83 | config.action_mailer.default_url_options = { 84 | host: "%{tenant}.37signals.works" 85 | } 86 | end 87 | ``` 88 | 89 | ```ruby 90 | # config/environments/staging.rb 91 | require_relative "production" 92 | 93 | Rails.application.configure do 94 | config.action_mailer.default_url_options = { 95 | host: "%{tenant}.fizzy.37signals-staging.com" 96 | } 97 | end 98 | ``` 99 | 100 | **Why it matters:** 101 | - Production settings are the default 102 | - Only override what's different 103 | - Reduces configuration drift 104 | - Beta/staging get all production optimizations by default 105 | 106 | ## Test Environment Handling 107 | 108 | ### Avoid Requiring Credentials in Tests ([#647](https://github.com/basecamp/fizzy/pull/647)) 109 | 110 | ```ruby 111 | # Bad - requires encrypted credentials to run tests 112 | http_basic_authenticate_with( 113 | name: Rails.application.credentials.account_signup_http_basic_auth.name, 114 | password: Rails.application.credentials.account_signup_http_basic_auth.password 115 | ) 116 | 117 | # Good - fallback for test environment 118 | http_basic_authenticate_with( 119 | name: Rails.env.test? ? "testname" : 120 | Rails.application.credentials.account_signup_http_basic_auth.name, 121 | password: Rails.env.test? ? "testpassword" : 122 | Rails.application.credentials.account_signup_http_basic_auth.password 123 | ) 124 | ``` 125 | 126 | **Why it matters:** 127 | - Tests run without encrypted credentials 128 | - Faster CI setup - no credential decryption needed 129 | - Developers can run tests immediately after checkout 130 | 131 | ## Environment Variable Precedence 132 | 133 | ### ENV Takes Priority Over Config ([#1976](https://github.com/basecamp/fizzy/pull/1976)) 134 | 135 | ```ruby 136 | # Bad - config.x can't be overridden 137 | config.x.content_security_policy.report_uri ||= ENV["CSP_REPORT_URI"] 138 | 139 | # Good - ENV has precedence 140 | report_uri = ENV.fetch("CSP_REPORT_URI") { 141 | config.x.content_security_policy.report_uri 142 | } 143 | ``` 144 | 145 | **Why it matters:** 146 | - Environment variables win over configuration 147 | - Enables runtime overrides without code changes 148 | - Clearer intent: "presence of ENV → use it" 149 | - Better for containerized deployments 150 | 151 | ### Consistent Pattern for Boolean ENV Vars ([#1976](https://github.com/basecamp/fizzy/pull/1976)) 152 | 153 | ```ruby 154 | # Check for key presence first, then parse value 155 | report_only = if ENV.key?("CSP_REPORT_ONLY") 156 | ENV["CSP_REPORT_ONLY"] == "true" 157 | else 158 | config.x.content_security_policy.report_only 159 | end 160 | ``` 161 | 162 | **Why it matters:** 163 | - Distinguishes between "not set" and "set to false" 164 | - Prevents `ENV["CSP_REPORT_ONLY"]` being nil and defaulting incorrectly 165 | - Explicit about which source is being used 166 | 167 | ## Development Environment Configuration 168 | 169 | ### Feature Flags for Local Development ([#863](https://github.com/basecamp/fizzy/pull/863)) 170 | 171 | ```ruby 172 | # Bad - conditional association breaks test/dev 173 | unless Rails.application.config.x.local_authentication 174 | belongs_to :signal_account, optional: true 175 | end 176 | 177 | # Good - always define, conditionally use 178 | belongs_to :signal_account, optional: true 179 | ``` 180 | 181 | **Why it matters:** 182 | - Avoid conditional model definitions 183 | - Tests and development work the same way 184 | - Feature flags control behavior, not structure 185 | 186 | ### Development Scripts for Flexibility ([#863](https://github.com/basecamp/fizzy/pull/863)) 187 | 188 | Create helper scripts for common dev tasks: 189 | 190 | ```ruby 191 | # script/create-local-user.rb 192 | #!/usr/bin/env ruby 193 | require_relative "../config/environment" 194 | 195 | unless Rails.env.development? 196 | puts "ERROR: This script is intended for development only." 197 | exit 1 198 | end 199 | 200 | # ... script logic 201 | ``` 202 | 203 | **Why it matters:** 204 | - Scriptable development workflows 205 | - Repeatable setup processes 206 | - Self-documenting development tasks 207 | 208 | ### Smart Seed Data ([#863](https://github.com/basecamp/fizzy/pull/863)) 209 | 210 | ```ruby 211 | # db/seeds.rb 212 | def create_tenant(name, bare: false) 213 | if bare 214 | # Simple tenant without external dependencies 215 | queenbee_id = Digest::SHA256.hexdigest(name)[0..8].to_i(16) 216 | Account.create(name: name, queenbee_id: queenbee_id) 217 | else 218 | # Full tenant with external integrations 219 | signal_account = SignalId::Account.find_by_product_and_name!("fizzy", name) 220 | Account.create_with_admin_user(queenbee_id: signal_account.queenbee_id) 221 | end 222 | end 223 | 224 | # Create "cleanslate" minimal tenant 225 | create_tenant "cleanslate", bare: true 226 | # Create full-featured tenants 227 | create_tenant "production-like" 228 | ``` 229 | 230 | **Why it matters:** 231 | - Support both minimal and full development setups 232 | - Work offline with `bare: true` tenants 233 | - Test different data scenarios easily 234 | 235 | ### Dynamic Development Output ([#863](https://github.com/basecamp/fizzy/pull/863)) 236 | 237 | ```bash 238 | # bin/dev - shows available tenants dynamically 239 | bin/rails runner - < Rich domain models with composable concerns and state as records. 4 | 5 | --- 6 | 7 | ## Heavy Use of Concerns for Horizontal Behavior 8 | 9 | Models include many concerns, each handling one aspect: 10 | 11 | ```ruby 12 | # app/models/card.rb 13 | class Card < ApplicationRecord 14 | include Assignable, Attachments, Broadcastable, Closeable, Colored, 15 | Entropic, Eventable, Exportable, Golden, Mentions, Multistep, 16 | Pinnable, Postponable, Promptable, Readable, Searchable, Stallable, 17 | Statuses, Storage::Tracked, Taggable, Triageable, Watchable 18 | 19 | belongs_to :account, default: -> { board.account } 20 | belongs_to :board 21 | belongs_to :creator, class_name: "User", default: -> { Current.user } 22 | 23 | has_many :comments, dependent: :destroy 24 | has_one_attached :image, dependent: :purge_later 25 | has_rich_text :description 26 | 27 | # Minimal model code - behavior is in concerns 28 | end 29 | ``` 30 | 31 | ## Concern Structure: Self-Contained Behavior 32 | 33 | Each concern is self-contained with associations, scopes, and methods: 34 | 35 | ```ruby 36 | # app/models/card/closeable.rb 37 | module Card::Closeable 38 | extend ActiveSupport::Concern 39 | 40 | included do 41 | has_one :closure, dependent: :destroy 42 | 43 | scope :closed, -> { joins(:closure) } 44 | scope :open, -> { where.missing(:closure) } 45 | scope :recently_closed_first, -> { closed.order("closures.created_at": :desc) } 46 | end 47 | 48 | def closed? 49 | closure.present? 50 | end 51 | 52 | def open? 53 | !closed? 54 | end 55 | 56 | def closed_by 57 | closure&.user 58 | end 59 | 60 | def close(user: Current.user) 61 | unless closed? 62 | transaction do 63 | create_closure! user: user 64 | track_event :closed, creator: user 65 | end 66 | end 67 | end 68 | 69 | def reopen(user: Current.user) 70 | if closed? 71 | transaction do 72 | closure&.destroy 73 | track_event :reopened, creator: user 74 | end 75 | end 76 | end 77 | end 78 | ``` 79 | 80 | --- 81 | 82 | ## State as Records, Not Booleans 83 | 84 | Instead of `closed: boolean`, create a separate record. This gives you: 85 | - Timestamp of when it happened 86 | - Who did it 87 | - Easy scoping via `joins` and `where.missing` 88 | 89 | ```ruby 90 | # BAD: Boolean column 91 | class Card < ApplicationRecord 92 | # closed: boolean column in cards table 93 | 94 | scope :closed, -> { where(closed: true) } 95 | scope :open, -> { where(closed: false) } 96 | end 97 | 98 | # GOOD: Separate record 99 | class Closure < ApplicationRecord 100 | belongs_to :card, touch: true 101 | belongs_to :user, optional: true 102 | # created_at gives you when 103 | # user gives you who 104 | end 105 | 106 | class Card < ApplicationRecord 107 | has_one :closure, dependent: :destroy 108 | 109 | scope :closed, -> { joins(:closure) } 110 | scope :open, -> { where.missing(:closure) } 111 | 112 | def closed? 113 | closure.present? 114 | end 115 | end 116 | ``` 117 | 118 | ### Real State Record Examples 119 | 120 | ```ruby 121 | # Closure - tracks when/who closed a card 122 | class Closure < ApplicationRecord 123 | belongs_to :account, default: -> { card.account } 124 | belongs_to :card, touch: true 125 | belongs_to :user, optional: true 126 | end 127 | 128 | # Goldness - marks a card as "golden" (important) 129 | class Card::Goldness < ApplicationRecord 130 | belongs_to :account, default: -> { card.account } 131 | belongs_to :card, touch: true 132 | end 133 | 134 | # NotNow - marks a card as postponed 135 | class Card::NotNow < ApplicationRecord 136 | belongs_to :account, default: -> { card.account } 137 | belongs_to :card, touch: true 138 | belongs_to :user, optional: true 139 | end 140 | 141 | # Publication - marks a board as publicly published 142 | class Board::Publication < ApplicationRecord 143 | belongs_to :account, default: -> { board.account } 144 | belongs_to :board 145 | has_secure_token :key # The public URL key 146 | end 147 | ``` 148 | 149 | ### Query Patterns with State Records 150 | 151 | ```ruby 152 | # Finding open vs closed 153 | Card.open # where.missing(:closure) 154 | Card.closed # joins(:closure) 155 | 156 | # Finding golden cards first 157 | Card.with_golden_first # left_outer_joins(:goldness).order(...) 158 | 159 | # Finding active vs postponed 160 | Card.active # open.published.where.missing(:not_now) 161 | Card.postponed # open.published.joins(:not_now) 162 | ``` 163 | 164 | --- 165 | 166 | ## Default Values via Lambdas 167 | 168 | ```ruby 169 | class Card < ApplicationRecord 170 | belongs_to :account, default: -> { board.account } 171 | belongs_to :creator, class_name: "User", default: -> { Current.user } 172 | end 173 | 174 | class Comment < ApplicationRecord 175 | belongs_to :account, default: -> { card.account } 176 | belongs_to :creator, class_name: "User", default: -> { Current.user } 177 | end 178 | ``` 179 | 180 | --- 181 | 182 | ## Current for Request Context 183 | 184 | ```ruby 185 | # app/models/current.rb 186 | class Current < ActiveSupport::CurrentAttributes 187 | attribute :session, :user, :identity, :account 188 | attribute :http_method, :request_id, :user_agent, :ip_address, :referrer 189 | 190 | def session=(value) 191 | super(value) 192 | self.identity = session.identity if value.present? 193 | end 194 | 195 | def identity=(identity) 196 | super(identity) 197 | self.user = identity.users.find_by(account: account) if identity.present? 198 | end 199 | end 200 | ``` 201 | 202 | --- 203 | 204 | ## Minimal Validations 205 | 206 | ```ruby 207 | class Account < ApplicationRecord 208 | validates :name, presence: true # That's it 209 | end 210 | 211 | class Identity < ApplicationRecord 212 | validates :email_address, format: { with: URI::MailTo::EMAIL_REGEXP } 213 | end 214 | ``` 215 | 216 | ### Contextual Validations 217 | 218 | ```ruby 219 | class Signup 220 | validates :email_address, format: { with: URI::MailTo::EMAIL_REGEXP }, on: :identity_creation 221 | validates :full_name, :identity, presence: true, on: :completion 222 | end 223 | ``` 224 | 225 | --- 226 | 227 | ## Let It Crash (Bang Methods) 228 | 229 | ```ruby 230 | def create 231 | @comment = @card.comments.create!(comment_params) # Raises on failure 232 | end 233 | ``` 234 | 235 | --- 236 | 237 | ## Model Callbacks: Used Sparingly 238 | 239 | Only 38 callback occurrences across 30 files in the entire codebase. When used: 240 | 241 | ```ruby 242 | class MagicLink < ApplicationRecord 243 | before_validation :generate_code, on: :create 244 | before_validation :set_expiration, on: :create 245 | end 246 | 247 | class Card < ApplicationRecord 248 | after_create_commit :send_notifications 249 | end 250 | ``` 251 | 252 | **Pattern:** Callbacks for setup/cleanup, not business logic. 253 | 254 | --- 255 | 256 | ## PORO Patterns (Plain Old Ruby Objects) 257 | 258 | POROs live under model namespaces for related logic that doesn't need persistence: 259 | 260 | ### Presentation Logic 261 | 262 | ```ruby 263 | # app/models/event/description.rb 264 | class Event::Description 265 | include ActionView::Helpers::SanitizeHelper 266 | 267 | attr_reader :event 268 | 269 | def initialize(event) 270 | @event = event 271 | end 272 | 273 | def to_s 274 | case event.action 275 | when "created" then "#{creator_name} created this card" 276 | when "closed" then "#{creator_name} closed this card" 277 | when "reopened" then "#{creator_name} reopened this card" 278 | when "assigned" then assignment_description 279 | when "unassigned" then unassignment_description 280 | else "#{creator_name} updated this card" 281 | end 282 | end 283 | 284 | private 285 | def creator_name 286 | h event.creator.name # Sanitize for safety! 287 | end 288 | 289 | def assignment_description 290 | assignee = User.find_by(id: event.particulars["assignee_id"]) 291 | if assignee == event.creator 292 | "#{creator_name} self-assigned" 293 | else 294 | "#{creator_name} assigned #{h assignee&.name}" 295 | end 296 | end 297 | end 298 | ``` 299 | 300 | ### Complex Operations 301 | 302 | ```ruby 303 | # app/models/system_commenter.rb 304 | class SystemCommenter 305 | attr_reader :card 306 | 307 | def initialize(card) 308 | @card = card 309 | end 310 | 311 | def comment_on(event) 312 | card.comments.create!( 313 | body: Event::Description.new(event).to_s, 314 | system: true, 315 | creator: event.creator 316 | ) 317 | end 318 | end 319 | ``` 320 | 321 | ### View Context Bundling 322 | 323 | ```ruby 324 | # app/models/user/filtering.rb 325 | class User::Filtering 326 | attr_reader :user, :filter, :expanded 327 | 328 | def initialize(user, filter, expanded: false) 329 | @user = user 330 | @filter = filter 331 | @expanded = expanded 332 | end 333 | 334 | def boards 335 | user.boards.accessible 336 | end 337 | 338 | def assignees 339 | user.account.users.active.alphabetically 340 | end 341 | 342 | def tags 343 | user.account.tags.alphabetically 344 | end 345 | 346 | def form_id 347 | "user-filtering" 348 | end 349 | end 350 | ``` 351 | 352 | ### When to Use POROs 353 | 354 | 1. **Presentation logic** - `Event::Description` formats events for display 355 | 2. **Complex operations** - `SystemCommenter` creates comments from events 356 | 3. **View context bundling** - `User::Filtering` collects filter UI state 357 | 4. **NOT service objects** - POROs are model-adjacent, not controller-adjacent 358 | 359 | --- 360 | 361 | ## Scope Naming Conventions 362 | 363 | ### Semantic, Business-Focused Names 364 | 365 | ```ruby 366 | # Good - business-focused 367 | scope :active, -> { where.missing(:pop) } 368 | scope :unassigned, -> { where.missing(:assignments) } 369 | scope :golden, -> { joins(:goldness) } 370 | 371 | # Not - SQL-ish 372 | scope :without_pop, -> { ... } 373 | scope :no_assignments, -> { ... } 374 | ``` 375 | 376 | ### Common Scope Patterns 377 | 378 | ```ruby 379 | class Card < ApplicationRecord 380 | # Status scopes 381 | scope :open, -> { where.missing(:closure) } 382 | scope :closed, -> { joins(:closure) } 383 | scope :published, -> { where(status: :published) } 384 | scope :draft, -> { where(status: :draft) } 385 | 386 | # Ordering scopes 387 | scope :alphabetically, -> { order(title: :asc) } 388 | scope :recently_created, -> { order(created_at: :desc) } 389 | scope :recently_updated, -> { order(updated_at: :desc) } 390 | 391 | # Filtering scopes 392 | scope :created_by, ->(user) { where(creator: user) } 393 | scope :assigned_to, ->(user) { joins(:assignments).where(assignments: { user: user }) } 394 | scope :tagged_with, ->(tag_ids) { joins(:taggings).where(taggings: { tag_id: tag_ids }) } 395 | 396 | # Preloading scopes 397 | scope :preloaded, -> { 398 | includes(:creator, :board, :tags, :assignments, :closure, :goldness) 399 | } 400 | end 401 | ``` 402 | 403 | --- 404 | 405 | ## Concern Organization Guidelines 406 | 407 | 1. **Each concern should be 50-150 lines** 408 | 2. **Must be cohesive** - related functionality together 409 | 3. **Don't create concerns just to reduce file size** 410 | 4. **Name concerns for the capability they provide**: `Closeable`, `Watchable`, `Assignable` 411 | -------------------------------------------------------------------------------- /authentication.md: -------------------------------------------------------------------------------- 1 | # Authentication Patterns 2 | 3 | > Passwordless magic links without Devise - ~150 lines of custom code. 4 | 5 | --- 6 | 7 | ## Why Not Devise? 8 | 9 | Devise is powerful but heavyweight. For passwordless auth, custom code is simpler: 10 | - No password storage complexity 11 | - No password reset flows 12 | - Fewer dependencies 13 | - Full control over the flow 14 | 15 | ## Magic Link Flow 16 | 17 | ``` 18 | 1. User enters email 19 | 2. Server generates 6-digit code, emails it 20 | 3. User enters code on verification page 21 | 4. Server validates code, creates session 22 | ``` 23 | 24 | ## Identity Model 25 | 26 | Separate global identity from per-account users: 27 | 28 | ```ruby 29 | class Identity < ApplicationRecord 30 | has_many :access_tokens, dependent: :destroy 31 | has_many :magic_links, dependent: :destroy 32 | has_many :sessions, dependent: :destroy 33 | has_many :users, dependent: :nullify 34 | has_many :accounts, through: :users 35 | 36 | validates :email_address, format: { with: URI::MailTo::EMAIL_REGEXP } 37 | normalizes :email_address, with: ->(value) { value.strip.downcase.presence } 38 | 39 | def self.find_by_permissable_access_token(token, method:) 40 | if (access_token = AccessToken.find_by(token: token)) && access_token.allows?(method) 41 | access_token.identity 42 | end 43 | end 44 | 45 | def send_magic_link(**attributes) 46 | magic_links.create!(attributes).tap do |magic_link| 47 | MagicLinkMailer.sign_in_instructions(magic_link).deliver_later 48 | end 49 | end 50 | end 51 | ``` 52 | 53 | ## MagicLink Model 54 | 55 | A separate model for magic link codes with automatic expiration and cleanup: 56 | 57 | ```ruby 58 | class MagicLink < ApplicationRecord 59 | CODE_LENGTH = 6 60 | EXPIRATION_TIME = 15.minutes 61 | 62 | belongs_to :identity 63 | 64 | enum :purpose, %w[ sign_in sign_up ], prefix: :for, default: :sign_in 65 | 66 | scope :active, -> { where(expires_at: Time.current...) } 67 | scope :stale, -> { where(expires_at: ..Time.current) } 68 | 69 | before_validation :generate_code, on: :create 70 | before_validation :set_expiration, on: :create 71 | 72 | validates :code, uniqueness: true, presence: true 73 | 74 | class << self 75 | def consume(code) 76 | active.find_by(code: Code.sanitize(code))&.consume 77 | end 78 | 79 | def cleanup 80 | stale.delete_all 81 | end 82 | end 83 | 84 | def consume 85 | destroy 86 | self 87 | end 88 | 89 | private 90 | def generate_code 91 | self.code ||= loop do 92 | candidate = Code.generate(CODE_LENGTH) 93 | break candidate unless self.class.exists?(code: candidate) 94 | end 95 | end 96 | 97 | def set_expiration 98 | self.expires_at ||= EXPIRATION_TIME.from_now 99 | end 100 | end 101 | ``` 102 | 103 | **Why a separate model?** Codes can be cleaned up independently, tracked for abuse, and support different purposes (sign-in vs sign-up). 104 | 105 | ## Session Model 106 | 107 | ```ruby 108 | class Session < ApplicationRecord 109 | belongs_to :identity 110 | end 111 | ``` 112 | 113 | ## Authentication Concern 114 | 115 | The full concern with class-level DSL methods for controller configuration: 116 | 117 | ```ruby 118 | module Authentication 119 | extend ActiveSupport::Concern 120 | 121 | included do 122 | before_action :require_account 123 | before_action :require_authentication 124 | helper_method :authenticated? 125 | end 126 | 127 | class_methods do 128 | # For login/signup pages - redirect if already logged in 129 | def require_unauthenticated_access(**options) 130 | allow_unauthenticated_access **options 131 | before_action :redirect_authenticated_user, **options 132 | end 133 | 134 | # For public pages that optionally show user info 135 | def allow_unauthenticated_access(**options) 136 | skip_before_action :require_authentication, **options 137 | before_action :resume_session, **options 138 | end 139 | 140 | # For non-tenanted pages (login, account selector) 141 | def disallow_account_scope(**options) 142 | skip_before_action :require_account, **options 143 | before_action :redirect_tenanted_request, **options 144 | end 145 | end 146 | 147 | private 148 | def authenticated? 149 | Current.identity.present? 150 | end 151 | 152 | def require_authentication 153 | resume_session || authenticate_by_bearer_token || request_authentication 154 | end 155 | 156 | def resume_session 157 | if session = find_session_by_cookie 158 | set_current_session session 159 | end 160 | end 161 | 162 | def find_session_by_cookie 163 | Session.find_signed(cookies.signed[:session_token]) 164 | end 165 | 166 | def authenticate_by_bearer_token 167 | if request.authorization.to_s.include?("Bearer") 168 | authenticate_or_request_with_http_token do |token| 169 | if identity = Identity.find_by_permissable_access_token(token, method: request.method) 170 | Current.identity = identity 171 | end 172 | end 173 | end 174 | end 175 | 176 | def request_authentication 177 | session[:return_to_after_authenticating] = request.url if Current.account.present? 178 | redirect_to_login_url 179 | end 180 | 181 | def start_new_session_for(identity) 182 | identity.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip).tap do |session| 183 | set_current_session session 184 | end 185 | end 186 | 187 | def set_current_session(session) 188 | Current.session = session 189 | cookies.signed.permanent[:session_token] = { value: session.signed_id, httponly: true, same_site: :lax } 190 | end 191 | 192 | def terminate_session 193 | Current.session.destroy 194 | cookies.delete(:session_token) 195 | end 196 | 197 | def redirect_authenticated_user 198 | redirect_to main_app.root_url if authenticated? 199 | end 200 | end 201 | ``` 202 | 203 | **Key patterns**: 204 | - Class methods create a DSL for controllers: `require_unauthenticated_access`, `allow_unauthenticated_access`, `disallow_account_scope` 205 | - Authentication cascade: cookie session → bearer token → redirect to login 206 | - Multi-tenant aware: stores return URL only when account context exists 207 | 208 | ## Sessions Controller 209 | 210 | ```ruby 211 | class SessionsController < ApplicationController 212 | disallow_account_scope 213 | require_unauthenticated_access except: :destroy 214 | rate_limit to: 10, within: 3.minutes, only: :create, 215 | with: -> { redirect_to new_session_path, alert: "Try again later." } 216 | 217 | def create 218 | if identity = Identity.find_by_email_address(email_address) 219 | redirect_to_session_magic_link identity.send_magic_link 220 | else 221 | # Handle signup flow... 222 | end 223 | end 224 | 225 | def destroy 226 | terminate_session 227 | redirect_to_logout_url 228 | end 229 | 230 | private 231 | def email_address 232 | params.expect(:email_address) 233 | end 234 | end 235 | ``` 236 | 237 | ## Magic Link Controller 238 | 239 | ```ruby 240 | class Sessions::MagicLinksController < ApplicationController 241 | disallow_account_scope 242 | require_unauthenticated_access 243 | rate_limit to: 10, within: 15.minutes, only: :create, 244 | with: -> { redirect_to session_magic_link_path, alert: "Wait 15 minutes, then try again" } 245 | 246 | def show 247 | # Renders code entry form 248 | end 249 | 250 | def create 251 | if magic_link = MagicLink.consume(code) 252 | authenticate_with magic_link 253 | else 254 | redirect_to session_magic_link_path, flash: { shake: true } 255 | end 256 | end 257 | 258 | private 259 | def authenticate_with(magic_link) 260 | if email_address_pending_authentication_matches?(magic_link.identity.email_address) 261 | start_new_session_for magic_link.identity 262 | redirect_to after_sign_in_url(magic_link) 263 | else 264 | redirect_to new_session_path, alert: "Authentication failed. Please try again." 265 | end 266 | end 267 | 268 | def after_sign_in_url(magic_link) 269 | magic_link.for_sign_up? ? new_signup_completion_path : after_authentication_url 270 | end 271 | 272 | def code 273 | params.expect(:code) 274 | end 275 | end 276 | ``` 277 | 278 | **Security detail**: The email entered on the login page is stored in session and must match the magic link's identity email. This prevents code interception attacks. 279 | 280 | ## Magic Link Mailer 281 | 282 | ```ruby 283 | class MagicLinkMailer < ApplicationMailer 284 | def sign_in_instructions(magic_link) 285 | @magic_link = magic_link 286 | @identity = @magic_link.identity 287 | 288 | mail to: @identity.email_address, subject: "Your Fizzy code is #{@magic_link.code}" 289 | end 290 | end 291 | ``` 292 | 293 | **Why code in subject?** Users can authenticate from any device—see code in email notification, type it on the device where they're logging in. 294 | 295 | ## Current Context 296 | 297 | ```ruby 298 | class Current < ActiveSupport::CurrentAttributes 299 | attribute :session, :user, :identity, :account 300 | attribute :user_agent, :ip_address 301 | 302 | def user=(user) 303 | super 304 | self.identity = user&.identity 305 | self.account = user&.account 306 | end 307 | end 308 | ``` 309 | 310 | ## Multi-Account Support 311 | 312 | Users can belong to multiple accounts via the same identity: 313 | 314 | ```ruby 315 | class User < ApplicationRecord 316 | belongs_to :identity 317 | belongs_to :account 318 | 319 | # Same person, different accounts 320 | # identity.users.count > 1 321 | end 322 | ``` 323 | 324 | ## Session Path Scoping 325 | 326 | For multi-tenant apps, scope session cookie to account path: 327 | 328 | ```ruby 329 | cookies.signed.permanent[:session_token] = { 330 | value: session.signed_id, 331 | path: "/#{account.external_id}" # e.g., "/1234567" 332 | } 333 | ``` 334 | 335 | **Why**: Allows simultaneous login to multiple accounts without cookie conflicts. 336 | 337 | ## Development Convenience 338 | 339 | Show magic link code in flash for local development: 340 | 341 | ```ruby 342 | def serve_development_magic_link(magic_link) 343 | if Rails.env.development? 344 | flash[:magic_link_code] = magic_link&.code 345 | end 346 | end 347 | 348 | def ensure_development_magic_link_not_leaked 349 | unless Rails.env.development? 350 | raise "Leaking magic link via flash in #{Rails.env}?" if flash[:magic_link_code].present? 351 | end 352 | end 353 | ``` 354 | 355 | **Safety net**: The `after_action` callback raises in non-development environments if the code accidentally leaks. 356 | 357 | ## Key Principles 358 | 359 | 1. **Passwordless is simpler** - No password storage, reset flows, or breach liability 360 | 2. **Rate limit aggressively** - Prevent email bombing (10 requests per 3-15 minutes) 361 | 3. **Verify email matches** - Store pending email in session, verify against magic link 362 | 4. **Separate model for codes** - Enables cleanup, abuse tracking, multiple purposes 363 | 5. **Separate identity from user** - One person, many accounts 364 | 6. **Class-level DSL** - `require_unauthenticated_access`, `allow_unauthenticated_access`, `disallow_account_scope` make controller setup declarative 365 | -------------------------------------------------------------------------------- /stimulus.md: -------------------------------------------------------------------------------- 1 | # Stimulus Controllers 2 | 3 | > Small, focused, reusable JavaScript controllers. 4 | 5 | --- 6 | 7 | ## Philosophy 8 | 9 | 52 Stimulus controllers split roughly 60/40 between reusable utilities and domain-specific logic. Controllers remain: 10 | - **Single-purpose** - One job per controller 11 | - **Configured via values/classes** - No hardcoded strings 12 | - **Event-based communication** - Controllers dispatch events, don't call each other 13 | 14 | --- 15 | 16 | ## Reusable Controllers Catalog 17 | 18 | These controllers are generic enough to copy into any Rails project. 19 | 20 | ### Copy-to-Clipboard Controller (25 lines) 21 | 22 | Simple async clipboard API wrapper with visual feedback: 23 | 24 | ```javascript 25 | // app/javascript/controllers/copy_to_clipboard_controller.js 26 | import { Controller } from "@hotwired/stimulus" 27 | 28 | export default class extends Controller { 29 | static values = { content: String } 30 | static classes = [ "success" ] 31 | 32 | async copy(event) { 33 | event.preventDefault() 34 | this.reset() 35 | 36 | try { 37 | await navigator.clipboard.writeText(this.contentValue) 38 | this.element.classList.add(this.successClass) 39 | } catch {} 40 | } 41 | 42 | reset() { 43 | this.element.classList.remove(this.successClass) 44 | this.#forceReflow() 45 | } 46 | 47 | #forceReflow() { 48 | this.element.offsetWidth 49 | } 50 | } 51 | ``` 52 | 53 | **Usage:** 54 | ```html 55 | 61 | ``` 62 | 63 | ### Auto-Click Controller (7 lines) 64 | 65 | Clicks an element when it connects. Perfect for auto-submitting forms: 66 | 67 | ```javascript 68 | // app/javascript/controllers/auto_click_controller.js 69 | import { Controller } from "@hotwired/stimulus" 70 | 71 | export default class extends Controller { 72 | connect() { 73 | this.element.click() 74 | } 75 | } 76 | ``` 77 | 78 | **Usage:** ` 99 |
100 | ``` 101 | 102 | ### Toggle Class Controller (31 lines) 103 | 104 | Toggle, add, or remove CSS classes: 105 | 106 | ```javascript 107 | // app/javascript/controllers/toggle_class_controller.js 108 | import { Controller } from "@hotwired/stimulus" 109 | 110 | export default class extends Controller { 111 | static classes = [ "toggle" ] 112 | static targets = [ "checkbox" ] 113 | 114 | toggle() { 115 | this.element.classList.toggle(this.toggleClass) 116 | } 117 | 118 | add() { 119 | this.element.classList.add(this.toggleClass) 120 | } 121 | 122 | remove() { 123 | this.element.classList.remove(this.toggleClass) 124 | } 125 | 126 | checkAll() { 127 | this.checkboxTargets.forEach(checkbox => checkbox.checked = true) 128 | } 129 | 130 | checkNone() { 131 | this.checkboxTargets.forEach(checkbox => checkbox.checked = false) 132 | } 133 | } 134 | ``` 135 | 136 | ### Auto-Resize Controller (32 lines) 137 | 138 | Auto-expands textareas as you type: 139 | 140 | ```javascript 141 | // app/javascript/controllers/autoresize_controller.js 142 | import { Controller } from "@hotwired/stimulus" 143 | 144 | export default class extends Controller { 145 | static values = { minHeight: { type: Number, default: 0 } } 146 | 147 | connect() { 148 | this.resize() 149 | } 150 | 151 | resize() { 152 | this.element.style.height = "auto" 153 | const newHeight = Math.max(this.minHeightValue, this.element.scrollHeight) 154 | this.element.style.height = `${newHeight}px` 155 | } 156 | } 157 | ``` 158 | 159 | **Usage:** 160 | ```html 161 | 164 | ``` 165 | 166 | ### Dialog Controller (45 lines) 167 | 168 | Native `` management: 169 | 170 | ```javascript 171 | // app/javascript/controllers/dialog_controller.js 172 | import { Controller } from "@hotwired/stimulus" 173 | 174 | export default class extends Controller { 175 | connect() { 176 | this.element.addEventListener("close", this.#onClose.bind(this)) 177 | } 178 | 179 | disconnect() { 180 | this.element.removeEventListener("close", this.#onClose.bind(this)) 181 | } 182 | 183 | open() { 184 | this.element.showModal() 185 | } 186 | 187 | close() { 188 | this.element.close() 189 | } 190 | 191 | closeOnOutsideClick(event) { 192 | if (event.target === this.element) { 193 | this.close() 194 | } 195 | } 196 | 197 | #onClose() { 198 | this.dispatch("closed") 199 | } 200 | } 201 | ``` 202 | 203 | **Usage:** 204 | ```html 205 | 207 |

Modal Content

208 | 209 |
210 | 211 | 214 | ``` 215 | 216 | ### Auto-Submit Controller (28 lines) 217 | 218 | Debounced form auto-submission: 219 | 220 | ```javascript 221 | // app/javascript/controllers/auto_submit_controller.js 222 | import { Controller } from "@hotwired/stimulus" 223 | 224 | export default class extends Controller { 225 | static values = { delay: { type: Number, default: 300 } } 226 | 227 | connect() { 228 | this.timeout = null 229 | } 230 | 231 | submit() { 232 | clearTimeout(this.timeout) 233 | this.timeout = setTimeout(() => { 234 | this.element.requestSubmit() 235 | }, this.delayValue) 236 | } 237 | 238 | submitNow() { 239 | clearTimeout(this.timeout) 240 | this.element.requestSubmit() 241 | } 242 | 243 | disconnect() { 244 | clearTimeout(this.timeout) 245 | } 246 | } 247 | ``` 248 | 249 | **Usage:** 250 | ```html 251 |
252 | 253 |