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:
- 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.
- 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.
- 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
andunpaid_time_off
flags on aShift
are mutually exclusive. - Embrace GitHub Actions: GitHub Actions are relatively cheap, so just use them to automate your testing pipeline.
- 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 androom_size
that is a model. I wishroom_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 usedparanoia
in the past. Both are fine to in my experience, but @jhawthorn explains here whydiscard
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.