Real-world insights for sharper web dev decisions Advertise with Us|Sign Up to the Newsletter @media only screen and (max-width: 100%;} #pad-desktop {display: none !important;} } @media only screen and (max-width: 100%;} #pad-desktop {display: none !important;} } WebDevPro #109: Refactoring Ruby on Rails for Teams at Scale Real-world insights for sharper web dev decisions Hi , Welcome to WebDevPro #109! This issue is a focused feature on Ruby on Rails and what changes as your app grows. Think of this as a field note for future you working on a larger Rails app. We open with the moves we all make early on. Rails gives teams a lot of power with very little ceremony. In the early days of a product, you sprinkle in a before action here, a concern there, maybe lean on Current to keep a request scoped value, and you move fast. Months later, the same choices feel heavier. A callback fires in a place you did not expect. A concern hides three different responsibilities. A bit of global state leaks into a background job and silently turns into nil. Momentum slows, onboarding takes longer, and reviews start to circle around the same questions. This feature is based on the book Layered Design for Ruby on Rails Applicationsby Vladimir Dementyev. The book is a pragmatic field guide for growing Rails apps with clear layers, explicit boundaries, and small, composable patterns. It protects the parts of Rails that make teams productive while offering a sustainable way to separate behavior, route side effects, and handle context without surprises. What follows distills those ideas into an immediately useful checklist for three hot spots that show up in every mature codebase. We will talk about callbacks that do a little too much, concerns that start tidy and end up as grab bags, and global state that feels convenient until work leaves the request. You will see what to keep close to the model, what to extract into events and collaborators, and how to make context explicit so changes stay predictable. The goal is momentum. You get techniques that fit into a normal sprint and refactors that ship in small, confident steps. This feature walks through three places Rails codebases get wobbly as they grow: callbacks, concerns, and global state. You will see what to keep, what to move, and how to do it without pausing your sprint. Everything is hands-on and modern Ruby on Rails. Before diving in, let’s see what news made waves last week: 🐘 PostgreSQL 18 RC1 is out! GA is planned for Sep 25, 2025. Take it for a spin, test pg_upgrade or pg_dump/pg_restore, and check the fixes since Beta 3. 🔐An Illustrated Guide to OAuth, where you get a visual explainer of OAuth’s moving parts, showing how tokens, clients, and authorization servers interact. 📈Big-O Explained Visually bySam Who encompasses a visual, interactive guide to Big-O notation that turns abstract complexity analysis into intuitive graphics. ⏱️Why Browsers Throttle JavaScript Timers and why they cut back the frequency of JavaScript timers in background tabs and idle contexts. Have any ideas you want to see in the next article? Hit Reply! Advertise with us Interested in reaching our audience? Reply to this email or write to kinnaric@packt.com. Learn more about our sponsorship opportunities here. Get a hands-on Ruby guide plus 20+ expert-led books on leading programming languages for $21! 🔁 Why callbacks feel great until they do not Callbacks run with an operation. They hide small chores behind a save or destroy, which keeps controllers quiet and puts checks near the data. Early on, that feels clean and fast. However, as the app grows, behavior spreads across many hooks. Order becomes a hidden dependency. A single save can validate, set defaults, touch other records, enqueue jobs, and send email. Now, one failing hook can roll back the transaction, and the error points nowhere useful. Tests then feel brittle because the setup missed a hook you forgot existed. The result is surprise behavior and time lost to debugging. Around callbacks wrap the model’s lifecycle operation itself. They run code before the record is saved, updated, or destroyed, then yield to that operation, and finally run code after it completes. Used sparingly, they centralize cross-cutting concerns like timing or scoping. Stacked together, they hide control flow and make success depend on the entire chain. Keep them fast and side-effect-free. Avoid business logic, network calls, and anything that should run after commit. One around callback wraps the operation, running before and after via yield. A quick rubric for model callbacks - Score each callback by how close it is to the data the model owns. Strong keepers - Normalize or compute attributes right before validation or save. Keep it local to the record. -Technical utilities near the data such as cache busting and counter caches. - Simple auditing that records facts about this record only. Move elsewhere - Emails, analytics, CRM sync, webhooks, payment calls, and other external services. - Work that reaches into other models or global state. - Multi-step business processes and project bootstrap work. - Anything that should not run inside the transaction or can fail independently. Safer patterns - Use explicit domain methods or service objects to orchestrate processes. Call them from controllers, jobs, or commands. - Trigger side effects after commit so they run outside the transaction. -Keep remaining callbacks small, idempotent, and limited to one per lifecycle event where possible. - Name hooks clearly and avoid complex branching inside them. Keep behavior that belongs to the record’s own data. Move coordination and side effects into events for a calmer, more predictable model. Next, we turn those side effects into events. 🔔 Turn side effects into events When a model change triggers work in another subsystem, publish a domain event and handle it in a subscriber. Using Active Support Notifications Publish on commit and attach a subscriber to a namespace. The <event>.<library> naming makes discovery and subscription easy. # app/models/user.rb class User < ApplicationRecord after_commit on: :update do ActiveSupport::Notifications.instrument("updated.user", user: self) end end # app/subscribers/user_crm_subscriber.rb class UserCRMSubscriber < ActiveSupport::Subscriber def updated(event) user = event.payload[:user] # sync with CRM end end UserCRMSubscriber.attach_to :user Using Downstream Prefer a higher-level API with explicit event classes and optional async delivery. # app/events/user_updated_event.rb class UserUpdatedEvent < Downstream::Event.define(:user) # computed helpers are welcome end # app/models/user.rb after_commit on: :update do Downstream.publish(UserUpdatedEvent.new(user: self)) end # config/initializers/downstream.rb Downstream.subscribe(UserCRMSync, async: true) Not every lifecycle deserves an event on the model. Lifecycle events like UserUpdatedEvent fit well. Whole business processes like registration deserve their own orchestrators instead of a pile of subscribers. ✔️ Performance tip: Hot counter caches can lock up under load. Slotted counters spread writes and keep you productive longer. 🧩 Use concerns with intent A concern is a small behavior that your domain owns. That is different from splitting a class into Associations, Validations, Scopes, and Callbacks, which scatters logic and hurts cohesion. Soft deletion is a perfect example of behavior extraction. The concern reads like a slice of a model and hides the Discard gem behind a clean API. Concerns are still modules. Privacy can leak across included modules, names can collide, and tests get trickier. Keep concerns small, self-contained, and easy to remove. A simple rule helps during review. Remove the concern and watch the tests. Wide failure points to a real behavior. No failure points to busywork extraction. Testing helper with_model spins up one-off Active Record models backed by tables to test a concern in isolation. 🌱 When a concern outgrows itself Two good exits keep code healthy. Promote to a dedicated model A Contactable concern that manages phone numbers, country codes, social accounts, and visibility has many columns and many methods. Extract a ContactInformation model with has_one :contact_information, keep the concern as a small integration layer, and delegate the most common APIs. You get a graceful migration path and a stable public surface. Introduce a value object A WithMedia concern can collapse into a single media_type method that returns a MediaType object. Predicates like svg?, font?, and video? live on the value object. Your models stay quiet and future changes are localized. Looking for a pattern to extract collaborator objects that need more than one attribute from the host model? active_record-associated_object gives you a consistent macro and folder layout. 🌐 Global state without landmines Current is scoped to the execution context and auto resets. That makes it convenient to write and read, yet it also hides dependencies and allows multiple writes within one unit of work. Two common failure modes show up fast. Moving work to a background job drops the context, so attributes like deleted_by silently become nil. Batch operations can overwrite Current.user as the job iterates. Both issues disappear once you pass explicit parameters and keep context local. A concrete pattern looks like this: class Post < ApplicationRecord belongs_to :deleted_by, class_name: "User", optional: true def destroy_by(user:) self.deleted_by = user destroy end end This keeps deletion honest and testable. Set simple team rules for Current: keep the attribute list small, write once per unit of work, restrict writes to inbound layers, and never read from models. For implicit context with guardrails, the dry-effects Reader raises on missing state. 🧭 A migration path you can run this week Measure Add callback_hell, run the report for your busiest models, and share the anti-leaderboard in your PR. Classify Keep normalizations and utilities near the model. Flag side effects and cross boundary work for extraction. Publish events Replace one callback with an event and a subscriber. Start with Notifications. Move to Downstream for explicit events and easy async. Right size concerns Trim one fat concern into a single behavior or promote it to a model with delegation. Make context explicit Replace one Current read in the model layer with a parameter. Write down your rules in CONTRIBUTING.md. 💻 Copy-ready snippets Notifications publisher and subscriber: ActiveSupport::Notifications.instrument("updated.user", user: self) class UserCRMSubscriber < ActiveSupport::Subscriber def updated(event) user = event.payload[:user] end end UserCRMSubscriber.attach_to :user Downstream with async delivery: class UserUpdatedEvent < Downstream::Event.define(:user); end Downstream.publish(UserUpdatedEvent.new(user: self)) Downstream.subscribe(UserCRMSync, async: true) Value object for media types: class MediaType < Data.define(:content_type) include Comparable def <=>(other) = content_type <=> other.content_type def video? = content_type.start_with?("video") def svg? = SVG_TYPES.include?(content_type) def font? = FONT_TYPES.include?(content_type) end Safer destructive action def destroy_by(user:) self.deleted_by = user destroy end ✅ Conclusion Rails gives you speed. The habits discussed in this issue protect that speed as the app grows. Keep data shaping close to your models. Route side effects through events. Treat concerns as focused behaviors. Promote real concepts to real objects. Make context explicit and predictable. You now have a clear path to action. The rubric helps you decide what stays in a model. The event patterns turn hidden work into visible, testable flows. The concern guidance keeps modules small and honest. The Current rules prevent ghost dependencies. Start small this week. Audit one busy model. Publish one event to replace a cross boundary callback. Trim one fat concern or promote it to a model. Swap one Current read for an explicit parameter. Share the anti-leaderboard in your PR so the team can see the before and after. Want the deeper theory and more patterns? Layered Design for Ruby on Rails Applications gives you that foundation while staying practical for real teams. I would love to hear what you ship with these changes. Reply with your wins or blockers, and I will add a focused follow-up. 🎯 Sprint challenge Run a callback audit for three models and post the anti-leaderboard. Replace one integration callback with an event and subscriber. Carve one concern into a single behavior or promote it to a model with delegation. Swap one Current read for an explicit parameter and add rules for Current usage. Got 60 seconds? Tell us what clicked (or didn’t) Cheers! Editor-in-chief, Kinnari Chohan SUBSCRIBE FOR MORE AND SHARE IT WITH A FRIEND! *{box-sizing:border-box}body{margin:0;padding:0}a[x-apple-data-detectors]{color:inherit!important;text-decoration:inherit!important}#MessageViewBody a{color:inherit;text-decoration:none}p{line-height:inherit}.desktop_hide,.desktop_hide table{mso-hide:all;display:none;max-height:0;overflow:hidden}.image_block img+div{display:none}sub,sup{font-size:75%;line-height:0}#converted-body .list_block ol,#converted-body .list_block ul,.body [class~=x_list_block] ol,.body [class~=x_list_block] ul,u+.body .list_block ol,u+.body .list_block ul{padding-left:20px} @media (max-width: 100%;display:block}.mobile_hide{min-height:0;max-height:0;max-width: 100%;overflow:hidden;font-size:0}.desktop_hide,.desktop_hide table{display:table!important;max-height:none!important}} @media only screen and (max-width: 100%;} #pad-desktop {display: none !important;} } @media only screen and (max-width: 100%;} #pad-desktop {display: none !important;} }
Read more