The Pirates Code of Rails Organization
First, it’s worth stating: anything in here that might look like a “rule” probably is NOT. This is more a “Pirate’s Code” (that’s “guidelines”, for the uninitiated).
These guidelines … nay, opinions? … stem from working on several Rails applications that have had many developers (hundreds) working on them at once. Granted, many applications don’t have that constraint. Arguably, Rails isn’t the framework for that - it’s often touted as the [“one person framework”] - why would you have that many persons working on it? But most applications don’t start with two hundred people working on them. You get there over time, through success. And you want more success, right?
Take this as a quick version of the appcontinuum for a Rails app.
Rails defaults
Rails, by default, comes with a “organize by function” approach. That is to say, you place files and classes in the “app” directory based on the “type” of thing they are - “controllers” go here, “models” go here, etc. This convention makes (almost) all Rails apps look the same, which comes with myriad benefits.
Libraries, even, often take advantage of these conventions to make integration seamless and get you from A->B really fast.
BUT, larger applications (read: more tenured applications with many consumers, developers, and use-cases) often start to show strain with this organization strategy.
That’s largely because there are subcategories of these types that are specific to your application’s domain. If you don’t have a strategy deeper than “keep all the wrenches together”, you can start to get really full “drawers” at the root of the application. In the analogy, you probably wouldn’t want to keep Allen Wrenches in the same drawer as Socket Wrenches … though, as stated before, you might.
Well … then what do I do?
Given the above, I tend to prefer “organization by feature” over “organization by function”. That is, your application’s structure reflects your application’s domain. Ideally, the application’s structure should [tell you about the app]. Let’s try keeping the Allen Wrenches and the Socket Wrenches separate.
One benefit of organizing this way is it enables chunking while reading. We can more readily look at groups of code (chunks) and sniff out if it’s related to what we’re working on.
Another is that it helps developers visualize and maintain boundaries in the code. Packages, folders, and modules can expose clear APIs to other parts of the system. With these in place, divvying up domains of responsibility (for humans and systems) is more straightforward.
It’s worth pausing and acknowledging that if we threw all of Rails’ conventions out the window, we’d lose out on a lot. So … let’s definitely avoid that. We want a way to make our file structure/architecture help us out (for the sake of today’s argument, we’ll say that file structure is roughly equal to architecture). Further, that help should be optimizing for long-term maintainability and reliability, not authoring code. Code is read far more than it’s written.
And what does that look like *in practice in Rails? I generally follow these guidelines below. Much of this will be familiar to you from normal Rails conventions.
The Articles of the Code
- Name files according to Zeitwerks’ (and thus, standard Rails) conventions
- Ruby “namespaces” (e.g. modules that wrap classes/leaf-modules) become directories
- Ruby classes become file names
- File names are always lower_snake_case
- Environment-specific (development, test, staging, prod, etc) setup goes in
config/- Refer to the standard Rails guides
- Database “entities” go in
app/models- Rails convention, don’t break it. Many libraries expect this.
- HTML & Text templates go in
app/views- Rails convention. Requires explicit configuration to override. Avoid changing.
- Anything that derives from `ActionController::Base` (or its siblings) goes in
app/controllers- Controllers are a special beast in Rails. They almost always leverage the entire framework and their tests are costly. They deserve special treatment (in the positive “on a pedestal” and not-so-positive “warrant” senses of the phrase).
- I prefer to think of the
lib/directory as code my application needs, but as code my application should not “own.”- Generally, this means things in the
lib/directory should not “depend on Rails” (or active support, etc). The idea is that you could, eventually, extract them into a separately loadablegem. For example, you might have a CSV ingestion module, or an HTTP API client wrapper you’ve written. These could live inlib/and possibly even be open-sourced at some point!
- Generally, this means things in the
- Have a directory in
app/to find everything else - that’s your “organize by feature” root- In some applications, I just repurpose/reuse
app/services -
Another reasonable name might be
app/domain - If a “feature” module (namespace/directory/what-have-you) has a very-clear/explicit entry-API (in the “interface” sense), create a top-level file (in the domain root) to house that API.
- Imagine a domain like
PhoneNumbers. That probably has a bunch of collaborator objects/classes/data-structures, etc. that all could live inapp/domain/phone_numbers/**/*.rb. But there could be a top-levelPhoneNumbers.parsemethod which lives inapp/domain/phone_numbers.rb - If a module is “small”, it may only get a top-level file in the root
- Imagine a domain like
- Most, if not all, other Rails-framework-y classes can live in this directory.
I don’t think I’ve ever run into another class-type (other than controllers & models) that a library explicitly looks for a top-level directory in
app. That meansActionMailer,Sidekiq::Job/Worker, etc can live here.
- In some applications, I just repurpose/reuse
But, why?
Well, mostly because we expect our application to live a long time and continue to grow. As the application grows, the number of authors working on it probably increases, too. As that happens, specialization of developers and teams will almost certainly occur. With that specialization comes the need for confidence that we’re not unintentionally impacting others. “Organization by feature” helps to promote that certainty. For example, almost every team (author-set) will be expected to touch the database, the HTTP API, mailers, and workers at some point. But it’s unlikely that the Authorization/Trust-And-Safety team will be touching the code used to generate a Widget’s landing page.
Even if our team(s) never reach that level of granularity, splitting the code along those kinds of boundaries can promote mental chunking.
e.g. “I don’t have to worry that my change to how posts get saved could impact people’s ability to login”.
Moreover, if we organize things well, we can draw dependency-relationships between features.
That lets you make statements like “if I change how widgets store their flumwraps, that could impact how they’re rendered in the API.
With this kind of knowledge, you can also start doing things like “selective test execution”.
Enter packwerk. With this tool, you can explicitly draw those relationships in the source. This is something that can be almost trivially done in a statically-typed language, but it’s a bit harder when things are as dynamic as Ruby. But with a tool like this, you can, for example, inspect the changeset in a commit and only run tests for impacted modules in the dependency graph.
Another way to enforce these kinds of boundaries is with inline (e.g. “unbuilt”) gems or Rails engines. These tools can provide much stronger guarantees, but the effort to setup and manage them can be a lot. If you’re just starting a project, this can work, but Packwerk may be easier for incrementalism.
Why wouldn’t you?
If you’re feeling hesitant, it’s understandable. A few reasons come to mind if I try to put myself in your shoes.
-
Over-engineering
Remember, this is a strategy for growth and scale. Not just scale of your application’s user base and technical throughput. This is about scaling your company and codebase to support your team.
-
Cognitive overhead for new teammates
Obviously, this isn’t “the Rails way.” Any standard post, Rails guide, etc, is not going to point you down this path. New teammates, especially those with preconceived notions about the Rails framework, may have a tougher time finding things.
However, while this approach adds a layer of domain-specific structure they have to learn, the long-term benefit is that the structure itself teaches them about the application and domain.
-
How is this different from adding “services”?
Considering the proliferation of the
Servicenaming in the Rails community, this is a totally fair question. That said,Servicemeans so many things - if you ask 10 different developers, you’ll get 15 different responses. Simply adding a “service layer” to your app isn’t going to be enough. These transaction scripts provide a way to organize the imperative logic you have in your codebase. But they don’t provide a place for you to name and abstract domain logic.And, look, if having
servicesis enough for you, great! But when I reflect on my experience with Service classes in Rails, they’re effectively doing a poor emulation of the “Use Case” pattern from the Clean Architecture. If that’s what you’re looking for, your “service” should effectively be a “controller without Rails.” That is, inject utilities and dispatch to the domain, “service classes” should do no more than that.
Conclusion
While every crew’s codebase may differ, having ideals they can trust and lean on provide a heading for navigating the scaling waters. These have worked for me, and if you try them out, I hope they will work for you. Don’t blindly follow them, though. The goal is to put wind in your sails. Remember, these aren’t rules - they’re what you might call guidelines.