This guide tells every coding agent exactly how to build and extend a multi-tenant Rails SaaS. Follow it before touching code, and keep it updated whenever conventions change.
- Monolithic Rails: One app, one database (per environment). Prefer server-rendered HTML with Hotwire (Turbo + Stimulus) for interactivity.
- Opinionated defaults: Use importmap, Propshaft, Tailwind (or utility CSS), Solid Queue, Active Storage, Active Record encryption, and Rails’ CurrentAttributes.
- Multi-tenancy first: Every feature assumes a tenant-aware request cycle. Cross-tenant leaks are treated as security bugs.
- Small, reversible changes: Ship tiny commits with behavioral tests and plain-English intent. Remove dead code on sight.
- Security and performance baked in: Run Brakeman/Bundler-Audit, encrypt sensitive columns, cache aggressively, and minimize transactions.
- Tenant identification: Use a URL path prefix (
/{account_slug}/...) or subdomain. Parse it in Rack middleware and setCurrent.account. Never infer account from arbitrary params later in the request. - Current attributes:
class Current < ActiveSupport::CurrentAttributes attribute :account, :user, :session, :request_id, :user_agent def with_account(value, &block) set(account: value, &block) end end
- Routing: Mount the app under the slug by rewriting
SCRIPT_NAME/PATH_INFOin middleware so controllers can use normalresources. - Background jobs: Prepend an Active Job extension that serializes
Current.account(via Global IDs) and restores it inperform. Reject jobs that run without a tenant. - ActionCable/Turbo: Pass the slug in stream/channel identifiers. Turbo Streams rendered in jobs must use a renderer initialized with
script_name: Current.account.slug.
- Foundational models:
Account: represents the tenant. Include settings JSON, branding, quotas, join rules.Identity: global user identity (email); may belong to multiple accounts.User: account membership. Stores role (owner,admin,member,system), status, preferences.- Domain objects (boards/cards, rooms/messages, etc.) always include
account_idas a foreign key and default scope/filter by account.
- Primary keys: Use UUIDv7 everywhere (
primary_key: :uuid), including join tables. Configure adapters to treatuuidcolumns properly for MySQL/SQLite/Postgres and set migration defaults to-> { "uuid_to_bin(uuid_generate_v7())" }(or the adapter equivalent). Provide deterministic fixtures. - Settings & flags: Use
has_json :settings-style helpers orstore_accessorto keep tenant/user settings flexible. Always validate JSON schema defaults. - Associations: Keep
dependent:rules explicit (e.g.,destroyvsdelete_all). Ensure cascading deletes respect tenant isolation. - Component library:
- Adopt Shadcn Rails components (Tailwind + Radix) as the default UI kit. Before creating new UI, inventory existing Shadcn components (buttons, dialogs, dropdowns, forms, cards) and reuse them.
- If a needed component doesn’t exist, extend Shadcn’s design language: create the component, document it, and upstream it to the shared library.
- Keep custom utility classes minimal; rely on Shadcn tokens/variants for consistent styling.
- Concerns & services:
- Create a concern only if two or more models/controllers share non-trivial behavior (tagging, workflowing, search indexing, notification dispatch).
- Name concerns after the domain capability (
Taggable,Workflowing,AccountScoped) and place them underapp/models/concernsorapp/controllers/concerns. - Concerns must encapsulate real logic (callbacks, scopes, helper methods). If a concern just delegates or aliases a single method, inline it.
- Document each concern’s expectations (required associations, callbacks) in code comments and tests to prevent misuse.
- Sessions: passwordless magic links or short-lived codes per identity. Sessions table stores token, user agent, IP,
account_id. - Join flow: invite codes scoped to account, with rate limits and usage counters. Join endpoints must verify the code before creating a membership.
- Roles:
enum role: %i[owner admin member system]. Gate controller actions with dedicated concerns (AccountScoped,BoardScoped, etc.) and policy helpers. - RBAC pattern:
- Store role on the account membership (
Usermodel) rather than the global identity so permissions remain tenant-specific. - Use enums for coarse roles, and feature flags/settings for finer permissions (e.g.,
account.settings.restrict_room_creation_to_admins?). - Centralize permission checks in policy helpers or services (
Permission.for(user).can_manage?(resource)), but keep controllers/models readable:authorize! :manage, @card. - Include integration tests for each protected action: owner/admin/member/system, plus banned/deactivated states.
- When introducing a new capability, add both a policy method and a feature toggle in account settings so tenants can adjust without code changes.
- Store role on the account membership (
- Bots & API access: If bots exist, use explicit tokens scoped to account and restrict endpoints via before_actions (
allow_bot_access). - Verified communication: Track
verified_aton users/identities. Only send email or push notifications to verified addresses/devices. Surface verification status in UI.
- Resources: Keep nested routes for domain hierarchies (e.g.,
resources :boards do ... resources :cards). Use namespaced controllers for sub-resources. - Activity streams: Store immutable
Eventrecords describing who did what. Use JSON particulars for payloads to power audit logs, notifications, and webhooks. - Watchers/notifications: Model
AccessorSubscriptionrecords that tie users to resources (boards, rooms). Provide UI toggles for involvement levels (“watching”, “mentions only”). - Entropic cleanups: Periodically archive stale objects (cards/rooms) via background jobs to keep interfaces tidy.
- Subscription management:
- Track
Plan,Subscription, andInvoicemodels per account. Store status (trialing,active,past_due,canceled), billing cycle dates, and payment provider identifiers. - Sync billing events via webhooks from the payment provider (e.g., Stripe). Validate payload signatures, enqueue jobs to update subscriptions, and log events for auditing.
- Surface subscription state in the admin area: plan name, seats used vs. limit, next renewal date, payment method status.
- Enforce limits at the application layer (e.g.,
Account::SeatLimitExceeded), blocking creation of users/resources when a subscription is inactive or over quota. - Provide lifecycle emails/notifications: trial ending, payment failed, subscription canceled. Gate them behind verified contacts.
- Allow owners to upgrade/downgrade plans in-app. Use background jobs to apply plan changes and update seat counts atomically.
- Implement grace periods for failed payments; restrict access incrementally (e.g., read-only after X days, lock after Y days).
- Document pricing models (flat, per-seat, usage-based). When introducing a feature, specify which plan tiers include it and how usage is measured. Ask product owners to clarify plan eligibility before implementing premium-only functionality.
- Track
- Render-first Hotwire flow:
- Return full HTML responses for simple interactions.
- Wrap sections in Turbo Frames when only part of the page should refresh.
- Use Turbo Stream templates for server-side broadcasts that update multiple clients.
- Introduce Stimulus controllers only when client-side state or complex gestures are unavoidable (drag/drop, keyboard navigation, timers). Document which approach you picked and why in code comments or review notes when it isn’t obvious.
- Stimulus conventions:
import { Controller } from "@hotwired/stimulus" export default class extends Controller { static targets = ["panel"] resize() { this.panelTarget.style.height = `${window.innerHeight - 120}px` } }
- Option evaluation: Document whether an interaction uses plain requests, Turbo Frames, or Streams. Pick the simplest variant that satisfies latency and collaboration needs.
- Accessibility & UX guardrails:
- Provide ARIA roles, labels, and keyboard shortcuts for interactive elements (dialogs, menus, hotkeys).
- Manage focus explicitly when dialogs/menus open/close; return focus to the invoking element.
- Ensure color choices meet WCAG contrast requirements; document tokens (primary, secondary, accent) in a CSS variables file.
- Verify forms and critical flows with system tests that tab through controls and assert focus states.
- Responsive layout:
- Use mobile-first CSS (Tailwind utilities or equivalent). Verify breakpoints at narrow (<480px), tablet (~768px), and desktop (>1024px) widths.
- Avoid hiding critical actions on mobile; collapse into menus only when documented.
- Copy & guidance:
- Use plain, action-oriented copy (“Watch this board”, “Invite member”). Note any dynamic text in translation files if i18n is enabled.
- Surface destructive actions with confirm dialogs and clear labels (“Delete board permanently”).
- View composition:
- Keep partials at consistent abstraction levels. Prefer helpers when markup is simple and repeated.
- Use
dom_idhelpers for stable targets, and always set explicitidattributes on Turbo Frames. - Favor Shadcn-supplied components in ERB/partials; when custom markup is necessary, style it with Shadcn’s CSS tokens (e.g.,
btn,badge,card). - Document reusable components (buttons, menus, cards) in a UI README or Storybook-like doc so agents know when to reuse vs. create new.
- Turbo Stream templates: Store under
app/views/.../*.turbo_stream.erb. Even simple appends/removals should use templates to keep behavior consistent and testable.
- Queue: Use Solid Queue (Rails 8 default) with dedicated workers or Puma plugin. Mark jobs
self.queue_adapter = :solid_queue. - Tenancy handoff: Jobs must serialize
Current.accountand raise if missing. Background code should never query without scoping. - Recurring tasks: Define in
config/recurring.yml(or similar) for notification bundling, entropy cleanup, cache purges, etc. Keep schedules documented and idempotent. - Job testing:
test "auto archive respects tenant" do Current.with_account(accounts(:alpha)) do assert_enqueued_with(job: AutoArchiveJob) { cards(:alpha_task).auto_archive_later } end perform_enqueued_jobs assert cards(:alpha_task).reload.archived? end
- Turbo Streams: Broadcast model changes to tenant-specific streams (
broadcast_replace_to [Current.account, resource]). Use templates for anything non-trivial. - ActionCable: Identify connections by tenant + user. Provide
remote_connections.where(current_user: user).disconnecthelpers for session revocation. - Web Push & Email: Store push subscriptions with
account_id,user_id, and user agent string. Enforce VAPID keys via env/credentials. Queue outbound mail/push through jobs that respect involvement settings. - Bundling: Batch notifications (e.g., digest emails) via recurring jobs to avoid spam. Only include verified and opted-in users.
- Search: Choose backend (SQLite FTS, Postgres tsvector, MySQL fulltext). Maintain shadow tables or triggers for denormalized search data. Provide sanitized query builders (
search_queries.sanitize_terms). - Attachments: Active Storage with tenant scoping (store
account_idon blobs/attachments). Process thumbnails/previews asynchronously. Use S3-compatible storage in production; local disk in dev/test. - Metadata: Keep message/card
plain_text_bodyhelpers for indexing, mention detection, and previews.
- Always generate migrations with Rails generators. Default new tables to UUIDv7 primary keys via
create_table :foo, id: :uuid, default: -> { "uuid_to_bin(uuid_generate_v7())" }(adjust per adapter). Use reversible migrations and two-phase deployments for dangerous changes. - Include
account_idand composite indexes (index [:account_id, :created_at]) on all tenant data. - Use database constraints (
null: false,foreign_key: true, unique indexes) plus model validations. - Run
bin/rails db:migrate,db:test:prepare, and update fixtures/factories after schema changes. - Encrypt sensitive columns with
encryptsand manage keys viabin/rails db:encryption:init.
- SSRF: Funnel any outbound HTTP through a guard that validates IP ranges and hostnames.
- CSP/Permissions Policy: Maintain strict CSP per environment; document any relaxations. Deny by default.
- Credentials: Use
bin/rails credentials:editor.envtemplates checked into repo (without values). Never commit secrets. - Rate limiting: Implement throttles for login/magic links/join codes/webhooks.
- Auditing: Log key actions with request IDs, user IDs, and account IDs for traceability.
- Unit/model tests: Cover scopes, callbacks, validations, encryption, tenant scoping.
- Controller/request tests: Assert auth, tenancy enforcement, Turbo Stream responses, JSON payloads.
- System tests: Use Capybara + Selenium (or Cuprite) for primary flows (sign-in, board creation, messaging).
- Job tests: Use
ActiveJob::TestHelperwith tenant context. - Security tests: Reproduce prior CVEs (SSRF, unverified emails) to prevent regressions.
- Performance tests: Where needed, use integration tests to ensure caching/etag behavior.
Example controller test:
test "non-admin cannot create room when restricted" do
accounts(:alpha).settings.restrict_room_creation_to_admins = true
accounts(:alpha).save!
sign_in users(:member)
assert_no_difference -> { Room.count } do
post account_rooms_path(account_slug: accounts(:alpha).slug), params: { room: { name: "Ops" } }
end
assert_response :forbidden
end- Run
bin/setup→bin/dev(orbin/rails server) before coding. - Use generators for everything (
bin/rails g model,controller,stimulus controller, etc.). - Prototype data flows in
bin/rails console. - Tail logs with
tail -f log/development.log. - Run
bin/rails test(orbin/ci) + Brakeman + Bundler-Audit before committing. - Document new env vars or setup steps in README/AGENTS immediately.
- Re-read this guide; summarize affected sections in your worklog.
- Create/modify migrations for new persistence.
- Write/adjust models + concerns with tenant scopes.
- Build controllers/views with Turbo/Stimulus hooks and accessibility.
- Add tests (model, controller, job, system).
- Run linters/tests/scans.
- Update docs/config (README, architecture, AGENTS) if conventions changed.
rails new app_name --css=tailwind --javascript=importmap --database=sqlite3cd app_name && bin/setupbundle add foreman→ createProcfile.dev(web, js, css, queue)bin/rails turbo:install stimulus:install importmap:install- Create
Currentclass, tenancy middleware, ActiveJob extensions, ActionCable config. - Add baseline models (Account, Identity, User, Session) with UUID PKs.
- Setup Solid Queue, Solid Cache, Action Mailer previews, Hotwire scaffolding.
- Document dev URL, credentials, seed accounts, and testing commands.
- Prefer Kamal or similar containerized deployment. Provide
config/deploy.ymlwith host, env vars, secrets references, and proxy SSL setup. - Persistence: application DB + Redis (if needed) + Active Storage bucket.
- Background processing: either Solid Queue workers or Puma plugin with
SOLID_QUEUE_IN_PUMA. - Observability: expose
/up, Prometheus metrics (if required), structured logs with tenant/user IDs. - Recurring tasks: ensure cron/que scheduler runs
config/recurring.yml. - Disaster recovery: back up DB and storage regularly; document restore procedures.
- Scope & context: Identify the tenant-aware domain area you are touching. Note relevant sections of this guide and cite them in your status updates.
- Reuse building blocks: Controllers must include the right concerns; models must include existing modules. Don’t reinvent policies, scopes, or Turbo helpers.
- When adding behavior shared by multiple controllers/models, extend the existing concern or create a new one that houses the logic and tests.
- Document any new concern (required associations, callbacks, helper methods) and ensure callers include it explicitly.
- Thread tenancy everywhere: Queries must scope by
Current.account, background jobs must serialize the account, and ActionCable/Turbo must namespace streams by tenant. - Security-first: Reuse SSRF guards, encryption helpers, CSP settings, verified-email enforcement, and ensure secrets stay out of code.
- Test before shipping: Unit, controller, job, and system tests are mandatory for behavior changes. Run
bin/ci(or equivalent) plus security scanners. - Communicate via Turbo/Stimulus: Build UI state transitions using Turbo Streams/Frames. Keep Stimulus controllers tiny and accessible.
- Document changes: Update README/architecture/AGENTS when conventions or env requirements shift. Note manual verification steps in PRs.
- Generator-first workflow: Create new models/controllers/jobs via generators to get routes/tests/fixtures scaffolded. Remove boilerplate you don’t need afterward.
- Cleanup bias: Remove dead code/warts you touch (“boy scout rule”). Mention cleanups in commit notes.
- Peer review: Every change must be reviewed by a DHH-voice agent who enforces this guide.
Follow this playbook and every multi-tenant Rails app you build will be intentional, secure, and easy to maintain.