Rethinking modularity in Ruby applications - NoteflakesRethinking modularity in Ruby applications<br>18·06·2026<br>I wrote recently about<br>Syntropy, a new Ruby web framework<br>I’m working on (it runs this<br>site). Syntropy’s design is based<br>around the idea of file-based routing, which means that the source files for<br>route handlers (i.e. controllers) that make up the app are organized and named<br>according to the app’s URL namespace. I also discussed the way Syntropy loads<br>the different source files (referred to as modules), and I’d like to discuss<br>this a bit more in detail.
Code Organization in Ruby on Rails
Now, if you’re a Rails developer, you know that Rails’ approach to code<br>organization is based on auto-loading of the different source files that make up<br>the app, performed by the Zeitwerk gem. This<br>approach automates the loading of dependencies, using the directory structure as<br>a representation of the app’s namespace (i.e. classes and modules).
In the Rails approach, all of the app’s classes and modules are global, nested<br>according to the app’s directory structure. There’s no need to explicitly<br>require dependencies, since any constant reference will be automatically<br>loaded, and this means the dependencies between different parts of app are<br>implicit.
This approach works very well, as long as you’re organizing your code in classes<br>that follow certain conventions in terms of naming and file locations. A big<br>advantage is that you don’t need to explicitly require the different source<br>files in your app. Instead, they’re loaded automatically as constants are<br>referenced by running code. Zeitwerk even supports automatic reloading of<br>changed files in development mode.
One important disadvantage of the Rails approach is that you’re bound to the<br>idea of one class per file, and of course the names for the different classes<br>must match the source file’s location. This means that if you move a source file<br>to a different directory, you’ll also need to rename the class to match its new<br>location.
A further disadvantage is that since everything is global, you might risk<br>touching or referencing classes you shouldn’t, and since dependencies are<br>implicit, those "references by error" may go unnoticed and cause some unexpected<br>(read: undefined) behaviour. For example, since any singleton object (and<br>singleton objects are quite handy in web apps) is defined as a global, your code<br>might accidentally access that global object, or even change its state.
In my experience I have found that when an app reaches a certain level of<br>complexity, and its source code is split across a large number of files,<br>explicitly expressing dependencies between different parts of the source code is<br>a tremendous help to understanding the structure of the code and the<br>relationships of the different parts of the app.
I have also come to appreciate the possibility of expressing interfaces not only<br>in terms of classes and modules, but also with procs/lambdas (i.e. closures) and<br>other singleton objects. In many cases you’ll need to access some "global"<br>service or state object, such as a database connection pool, a background job<br>store, a mailer object, or even a configuration hash. Being able to do that with<br>explicit references means that you don’t need to rely on these objects being<br>available as global constants. This in turn makes it easier to implement<br>dependency injection, and it also greatly simplifies testing.
Code Organization in Syntropy
Syntropy is based on the idea that the app’s directory structure reflects its<br>URL namespace. For example, a simple blog app would have a directory structure<br>resembling the following:
files URLs<br>+ app/<br>+ _lib/<br>+ storage.rb<br>+ _layout/<br>+ default.rb<br>+ index.rb /<br>+ about.rb /about<br>+ posts/<br>+ [id]/<br>+ index.rb /posts/[id]<br>+ edit.rb /posts/[id]/edit<br>+ index.rb /posts<br>+ new.rb /posts/new
The example above consists of both controller code (which maps to URLs) and<br>internal dependencies which are typically put in directories such as _lib or<br>_layout (any directory or file whose name starts with an underscore is<br>considered internal and is not exposed by the Syntropy router).
How do those different source files get loaded by the application? In the case<br>of controllers, they’re loaded automatically when the server receives a request<br>with a URL that matches the source file location. For example, a GET /posts<br>request will be routed to app/posts/index.rb.
A second principle in Syntropy is that all dependencies are explicit. In order<br>to load any dependency inside the app, we call import. For example, here’s how<br>the posts index controller may look:
# app/posts/index.rb<br>@storage = import '/_lib/storage'<br>@layout = import '/_layout/default'<br>@template = @layout.apply { |posts:, **|<br>posts.each { |post|<br>article {<br>h1 { a post.title, href: post.url }<br>p post.body
export ->(req) {<br>posts = @storage.get_all_posts<br>req.respond_html(@template.render(posts:))
In the above example, we import two dependencies, a @storage object which<br>serves as the...