A Year of Lessons: Key Principles and Takeaways for a Rails Apps in 2024

It's been a year since we broke ground on our new Rails application and about 12 years since I started using Rails. I wanted to write down some rules of thumb and concepts that have become more ingrained.

1 Testing: Duh?

Testing is crucial for the longevity and maintainability of your application. Here are some principles from this year I wanted to highlight:

  1. Test Things You Want to Last: If you think a piece of code will be hard to understand when you go back to it later, definitely write tests for it.
  2. Temporary Code Deserves Tests Too: Even if you think you’ll delete some code in the next few months because it’s only temporary, it’s probably worth writing tests for.
  3. Don’t Over-test the Basics: Focus your testing efforts on your application's unique and complex aspects rather than trivial ones. E.g. I don't test that I have a presence validation, but I do test any custom validation for example one that checks that the paid_time_off and unpaid_time_off flags on a Shift are mutually exclusive.
  4. Embrace GitHub Actions: GitHub Actions are relatively cheap, so just use them to automate your testing pipeline.
  5. Test Framework Isn't Important: I usually use RSpec with factories, which I love. This is the first serious project I've used Minitest with fixtures. While I prefer factories over fixtures, the key takeaway is that writing tests allows you to move faster and have fewer defects, and at the end of the day, the test framework doesn't matter as much as writing tests.

2. Enums: Use string values

I went a little overboard with enums, and I now have opinions. Some things to consider with enums: will the raw data in my DB play nicely with other systems? If we only focus on Rails (or if we, really, really need insane performance), then integer values in the db with some strings in the model are fine. As soon as our db was connected to a BI provider and several downstream third parties, I started to regret storing enum values as integers because it made it so that every SQL query had giant, hard-to-maintain CASE statements. E.g.:

SELECT
    CASE
        WHEN ia.room_type = 0 THEN 'Basement'
        WHEN ia.room_type = 1 THEN 'Bathroom'
        WHEN ia.room_type = 2 THEN 'Bedroom'
        WHEN ia.room_type = 3 THEN 'Dining Room'
        WHEN ia.room_type = 4 THEN 'Entryway'
        WHEN ia.room_type = 5 THEN 'Family Room'
        WHEN ia.room_type = 6 THEN 'Garage'
        WHEN ia.room_type = 7 THEN 'Hallway'
        WHEN ia.room_type = 8 THEN 'Kitchen'
        WHEN ia.room_type = 9 THEN 'Laundry Room'
        WHEN ia.room_type = 10 THEN 'Living Room'
        WHEN ia.room_type = 11 THEN 'Loft'
        WHEN ia.room_type = 12 THEN 'Mudroom'
        WHEN ia.room_type = 13 THEN 'Office'
        WHEN ia.room_type = 14 THEN 'Open Area'
        WHEN ia.room_type = 15 THEN 'Other'
        WHEN ia.room_type = 16 THEN 'Powder Room'
        WHEN ia.room_type = 17 THEN 'Sitting Room'
        WHEN ia.room_type = 18 THEN 'Stairwell'
        ELSE 'Unknown'
    END AS room_type,
    AVG((rs.square_footage_max + rs.square_footage_min) / 2.0) AS average_square_footage
FROM
    interior_areas ia
JOIN
    estimate_items ei ON ia.id = ei.paintable_id AND ei.paintable_type = 'InteriorArea'
JOIN
    room_sizes rs ON ia.room_size = rs.id
GROUP BY
    ia.room_type
ORDER BY
    room_type;
  • Don't use DB Enums: You might be considering using database-level enums available through Postgres (or some other DBMS). In addition to other incompatibilities, this approach requires a database migration to update the values, which is annoying, instead of just updating a bit of code or data.
  • Use String Values: This makes your enums more readable and easier to use with other systems and third parties.
  • Separate Model for Large Enums: If you have a hunch that the number will grow over time and exceed 5-7 values, create a separate model. For instance, in the above example we see room_type that's stored as an enum and room_size that is a model. I wish room_type was also a model for several reasons, and we'll likely migrate several of our existing enums to models.

3. Soft Deletes: Exclude discarded objects on associations

Soft deletes have saved me but also created a handful of weird bugs, mostly from excluding discarded models at the wrong place in our code (like views and controllers). Some teams avoid any active record calls in controllers or views, but I think that's likely overkill most of the time and would slow us down unnecessarily to write query objects everywhere.

  • Default to Filtering by Kept: Always filter by kept records when using associations.
  • Use a library: We use the discard gem now, though I’ve used paranoia in the past. Both are fine to in my experience, but @jhawthorn explains here why discard is better.

Consider a simplified model that looks like this where both Invoice and EstimateItem are discardable.

class Estimate < ApplicationRecord
  include Discard::Model

  has_many :estimate_items, -> { kept }, dependent: :destroy
  has_many :invoices, dependent: :destroy
  # ...

When we use the association elsewhere we need to know that we have to chain .kept on the invoices list:

estimate = Estimate.first
p estimate.estimate_items # only includes estimate items that are not soft deleted
p estimate.invoices # includes all invoices including those that have been deleted
p estimate.invoices.kept # only the invoices that have been deleted

This crops up as users thinking that delete buttons are broken.

4. Inbound Webhooks: Hoard em

When I worked at MyVR (2015-2019), I iterated on our inbound webhook system with our Django application a ton, and I formed some strong opinions about the features of a great webhook system. Here are some of those takeaways, but expect a longer article covering the topic soon.

  • Store Webhook Payloads Indefinitely: Keep all inbound payloads as long as you can afford the storage. They can be invaluable for backfilling data.
  • Async || Sync: Try to handle all work async in jobs and fall back to handling synchronously if the integrations (like Twilio calling) require a relevant response.
  • Retry: Make it straightforward to retry webhooks.
  • Debug Easily: Track the state of the inbound webhook, report failures to your error monitoring service, and build an admin dashboard to quickly debug. If all third parties had a developer dashboard like Stripe's where we could see the webhook traffic, this would be less important, the reality is that very few third parties show any logs at all.

5. Observability: Know What’s Happening

Monitoring your application is essential for maintaining performance and reliability:

  • Logging: We use Papertrail for logging.
  • Performance Monitoring: Scout APM to track performance.
  • Error Alerting: Sentry helps catch and address errors quickly.
  • Session Replays: PostHog can be used to replay sessions to understand user interactions.

6. Two-Way Messaging: Use conversation and message

If you’re implementing two-way messaging, bite the bullet and support Conversations with Conversation Participants:

  • Conversation Model: Use a conversation model instead of just messages.
  • Proper Message Model: Ensure your message model is well-defined.
  • Support Scheduling: Start off by sending all messages in a background job so that you can easily support scheduling the message to send later.
  • Table Stakes: All the stuff we've grown to love from Slack is now just expected. You'll need MessageRead (is it read or unread by a given user), MessageEvent (sent, delivered, clicked, opened, etc.).

7. Jobs: Beyond Request-Response

Not everything happens within a request-response cycle anymore. Here’s how to handle background jobs:

  • Scheduling and Monitoring: Have a robust system for scheduling and monitoring jobs.
  • Triggering Jobs from Controllers: Ensure you can trigger jobs based on controller actions.

8. Audit Trails: Track Changes

Keeping track of changes is crucial for debugging and audits:

  • Use Audited Gem: I like the audited gem for maintaining a history of changes to objects.

9. Separate Controllers for API and Web

Using a single controller for both HTML and JSON rendering can get messy. Here’s a better approach:

  • Separate Controllers: Use distinct controllers for web (rendering HTML and Turbo Streams) and API (rendering JSON).
  • Interactive Bits: Use API controllers for interactive web UI components, marketing sites (Next.js), and mobile apps (ReactNative).

10. Generators: Customize for Consistency

Customizing generators can help maintain consistency across your codebase:

  • API Client Generator: The API Client generator from Jumpstart Pro has been an awesome addition, helping us maintain 19 different API clients that follow the same patterns.

11. Collection Patterns: Standardize Features

Nail down the features you need for collections to ensure consistency:

  • Filtering, Sorting, Searching: Use common patterns for filtering, sorting, searching, grouping, displaying properties, and pagination.
  • Different Layouts: Offer different layouts, like tables and kanban views, to meet diverse stakeholder needs.

12. Media Management: Control and Enrich

Think carefully about media management:

  • Custom Models for Attachments: Instead of using has_many_attached :attachments directly, control your own model that has an attachment and is decorated with additional attributes.
  • AI Enrichment: Use vision models to enrich media with descriptions to assist with systems like an AI comms co-pilot.

13. Hot Swappable AI Backends

AI is evolving rapidly, so being able to switch between models quickly is beneficial. I'll expand on this and detail our structure in a future edition:

  • Flexible AI Services: We built a custom AI Service that allows us to inject providers (e.g., OpenAI, Claude) and models, enabling quick switching and granular control.
  • Consistent Interfaces: Wrap all providers in the same interface for easy hot-swapping.

These principles and practices have guided our development over the past year and significantly contributed to the robustness and maintainability of our application. By following these guidelines, you can build a Rails application that stands the test of time and adapts to evolving needs.