Skip to content
HN On Hacker News ↗

Rethinking modularity in Ruby applications

▲ 19 points 4 comments by ciconia 3w ago HN discussion ↗

Pangram verdict · v3.3

We believe that this document is fully human-written

0 %

AI likelihood · overall

Human
100% human-written 0% AI-generated
SEGMENTS · HUMAN 5 of 5
SEGMENTS · AI 0 of 5
WORD COUNT 1,571
PEAK AI % 0% · §2
Analyzed
Jun 23
backend: pangram/v3.3
Segments scanned
5 windows
avg 314 words each
Distribution
100 / 0%
human / AI fraction
Verdict
Human
Pangram v3.3

Article text · 1,571 words · 5 segments analyzed

Human AI-generated
§1 Human · 0%

18·06·2026I wrote recently about Syntropy, a new Ruby web framework I’m working on (it runs this site). Syntropy’s design is based around the idea of file-based routing, which means that the source files for route handlers (i.e. controllers) that make up the app are organized and named according to the app’s URL namespace. I also discussed the way Syntropy loads the different source files (referred to as modules), and I’d like to discuss 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 organization is based on auto-loading of the different source files that make up the app, performed by the Zeitwerk gem. This approach automates the loading of dependencies, using the directory structure as 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 according to the app’s directory structure. There’s no need to explicitly require dependencies, since any constant reference will be automatically loaded, and this means the dependencies between different parts of app are implicit.

This approach works very well, as long as you’re organizing your code in classes that follow certain conventions in terms of naming and file locations. A big advantage is that you don’t need to explicitly require the different source files in your app. Instead, they’re loaded automatically as constants are referenced by running code. Zeitwerk even supports automatic reloading of changed files in development mode.

One important disadvantage of the Rails approach is that you’re bound to the idea of one class per file, and of course the names for the different classes must match the source file’s location. This means that if you move a source file to a different directory, you’ll also need to rename the class to match its new location.

A further disadvantage is that since everything is global, you might risk touching or referencing classes you shouldn’t, and since dependencies are implicit, those “references by error” may go unnoticed and cause some unexpected (read: undefined) behaviour.

§2 Human · 0%

For example, since any singleton object (and singleton objects are quite handy in web apps) is defined as a global, your code 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 complexity, and its source code is split across a large number of files, explicitly expressing dependencies between different parts of the source code is a tremendous help to understanding the structure of the code and the relationships of the different parts of the app.

I have also come to appreciate the possibility of expressing interfaces not only in terms of classes and modules, but also with procs/lambdas (i.e. closures) and other singleton objects. In many cases you’ll need to access some “global” service or state object, such as a database connection pool, a background job store, a mailer object, or even a configuration hash. Being able to do that with explicit references means that you don’t need to rely on these objects being available as global constants. This in turn makes it easier to implement 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 URL namespace. For example, a simple blog app would have a directory structure resembling the following:

files URLs ===== ==== + app/ + _lib/ + storage.rb + _layout/ + default.rb + index.rb / + about.rb /about + posts/ + [id]/ + index.rb /posts/[id] + edit.rb /posts/[id]/edit + index.rb /posts + new.rb /posts/new

The example above consists of both controller code (which maps to URLs) and internal dependencies which are typically put in directories such as _lib or _layout (any directory or file whose name starts with an underscore is considered internal and is not exposed by the Syntropy router).

How do those different source files get loaded by the application? In the case of controllers, they’re loaded automatically when the server receives a request with a URL that matches the source file location. For example, a GET /posts request will be routed to app/posts/index.rb.

§3 Human · 0%

A second principle in Syntropy is that all dependencies are explicit. In order to load any dependency inside the app, we call import. For example, here’s how the posts index controller may look:

# app/posts/index.rb @storage = import '/_lib/storage' @layout = import '/_layout/default' @template = @layout.apply { |posts:, **| posts.each { |post| article { h1 { a post.title, href: post.url } p post.body } } }

export ->(req) { posts = @storage.get_all_posts req.respond_html(@template.render(posts:)) }

In the above example, we import two dependencies, a @storage object which serves as the model interface, and a @layout object which serves as a Papercraft layout template. We then create a template that renders a list of posts, and finally we export the controller lambda, which takes an incoming HTTP request, retrieves the list of posts from @storage renders an HTML response by rendering @template.

Since we can export any object from a module, and that will serve as its value, we might express the layout module as follows:

# app/_layout/default.rb export template { html { head { ... } body { render_children } } }

In this module the layout template (a Papercraft template) is the actual interface of the module, such that each time you import it, you get the same object that was exported. Here’s how the storage module might look:

# app/_lib/storage.rb export self

def get_posts ... end

...

In the case of the storage module, we export the module itself as a singleton, which lets us call any of its methods:

storage = import '/_lib/storage' storage.get_posts

Loading code in this way has some important advantages:

Loading the module code has zero side effects on global state: all constants, and instance variables are completely local to the module’s context, and do not leak to the global context. A module’s interface can be any object, not just a class, which makes it so much easier to use singletons. Dependencies are explicit, which makes it easier to understand how the different pieces of code fit together and where everything is located.

§4 Human · 0%

The fact that each module is completely self-contained and doesn’t “leak” into the global namespace makes it much easier to test it in separation. It also makes it much easier to implement code reloading. Since all dependencies are known at runtime, we can track calls to import, and if any module’s underlying file has changed, we can simply discard the old module, reload the new code, then recursively reload all of its reverse-dependencies.

Injecting State into Modules

Since each module is evald in a separate, anonymous context, this means we can also easily inject state into the module when loading it. This is especially useful for injecting an environment hash (holding the app’s configuration), or other app concerns that may be used by the module, such as the app object itself, the underlying UringMachine instance, or any other global service. This too may simplify the task of unit testing those modules. This is done by setting instance variables that are available to the module. Here’s how a Syntropy module may access the app’s configuration:

# app/_lib/mailer.rb require 'my_mailer'

export MyMailer.new(@env[:config][:smtp_server])

Here, we access @env, which is injected into the module when it’s loaded. And of course, we could easily inject a custom @env object if we wanted to, for testing purposes.

How Module Loading Works in Syntropy

In the previous article presenting Syntropy, I discussed the basic mechanics of how Syntropy loads modules. Let’s look in more detail at how Syntropy achieves this. The entire Syntropy module loader code is completely self-contained and does not depend on any other part of Syntropy, it just needs to be provided with a few dependencies that are passed to the modules being loaded.

# Greatly simplified version: class ModuleLoader def initialize(env) @env = env @app_root = env[:app_root] @modules = {} # maps ref to module entry end

def load(ref) ref = "/#{ref}" if ref !

§5 Human · 0%

~ /^\// if !(entry = @modules[ref]) entry = load_module(ref) @modules[ref] = entry end entry[:export_value] end

private

def load_module(ref) fn = File.expand_path(File.join(@app_root, "#{ref}.rb")) code = read_file(fn) env = @env.merge(module_loader: self, ref: clean_ref(ref)) mod = ModuleContext.new(env, code, fn)

{ fn: fn, module: mod, export_value: mod.__export_value__, } end end

The module loader is initialized with an env object representing the app’s environment (including its configuration). The #load method reads the source code for the module, then instantiates a ModuleContext object that’s going to serve as the context in which the module’s source code is going to run:

# Greatly simplified version: class ModuleContext def initialize(env, code, fn) @env = env @module_loader = env[:module_loader] @ref = env[:ref] instance_eval(code, fn) end

private

def export(v) @__export_value__ = v end def import(ref) @module_loader.load(ref) end end

The entire mechanism of module loading is represented in these few lines: we create a module context instance, evaluate the source code using #instance_eval, and the import/export methods are defined as private methods and allow explicit dependencies between the different modules, and the usage of any object as a module interface.

Here’s how we may use this in a testing situation:

class FooTest < Minitest::Test def setup @module_loader = ModuleLoader.new( # injected environment hash { app_root: File.join(__dir__, 'fixtures') foo: 'foo' } ) end

def test_foo_module foo = @module_loader.load('foo') refute_nil foo

# suppose the