Structuring a larger app
app.rbfor routes,lib/for everything else, plain top-level methods for helpers, andcasewherever you would have reached for metaprogramming.
A melee app has no autoloader and no app/models. It has require_relative, ordinary Ruby files, and a
compiler that reads the whole program at once. That is less than Rails gives you and more than it sounds.
The layout that works
app.rb routes and filters only
lib/
household.rb a durable object
ical.rb a module of pure functions
formatting.rb helpers used by templates
views/
layout.erb
admin.erb
_events.erb
db/
migrations/001_schema.sql
objects/household/001_feeds.sql
public/
test/
ical_test.rb
app.rb requires what it needs, at the top, with require_relative:
# frozen_string_literal: true
require_relative "lib/household"
require_relative "lib/formatting"
title "Kitchen"
get "/" do
render :display, events: Household.get("home").day_events(Time.now.to_i)
end
There is no autoloading. Every file requires what it uses, including the standard-library modules —
require "json" in the file that calls JSON.parse, not just in app.rb. A missing require is not a
NameError you can read; it turns into an opaque “unsupported call” from the compiler.
Helpers are top-level methods
# app.rb, below the routes
def current_user = session[:user_id] && db.first("SELECT * FROM users WHERE id = ?", session[:user_id].to_i)
def admin? = session[:admin] == "1"
They work because a request is a process of its own, so session and db mean one unambiguous thing. There
is no controller instance to hang them on and nothing to pass around.
Helpers a template needs must also be top-level — a template can see its declared locals and the top-level
surface, nothing else. Putting them in lib/formatting.rb as top-level defs and requiring the file is
fine:
# lib/formatting.rb
# frozen_string_literal: true
def money(pennies) = format("£%.2f", pennies.to_i / 100.0)
def short_date(unix) = Time.at(unix.to_i).strftime("%-d %b")
Modules of pure functions
For anything with real logic, a module with module functions keeps it testable and keeps the compiler happy:
# lib/ical.rb
# frozen_string_literal: true
module ICal
DAY = 86_400
class Error < StandardError; end
def self.parse(text)
# ... returns a calendar
end
def self.expand(calendar, from, to)
# ... returns the occurrences between two Times
end
end
Call it as ICal.expand(...). See Testing an app for why this shape is the one that is easy to
test.
Classes, with the metaprogramming taken out
Plain classes are fine. What is not available is anything that decides what to call at run time.
# no
def handle(kind) = send("handle_#{kind}")
# yes
def handle(kind)
case kind
when "note" then handle_note
when "event" then handle_event
else raise ArgumentError, "unknown kind #{kind}"
end
end
The same goes for a Hash or Array of Procs — a Proc reached out of a container cannot be called in a
compiled app. A case is the dispatch mechanism. It is more typing and it is readable by both the compiler
and the next person.
Three more habits that keep large files compiling:
- Spell a rescued class in full.
rescue Melee::HTTP::Error, neverrescue HTTP::Error— a rescue through a constant alias silently fails to match in a compiled app, while working perfectly undermelee dev. - Frozen string literals at the top of every file; build strings with
+""and<<. - Do not nest a yielding call inside another block, and call yielding methods on a method rather than a
constant (
db.transaction { }, notDB.transaction { }).
The Ruby that compiles is the full list.
Where state lives
| Lifetime | Where |
|---|---|
| One request | local variables, instance variables — everything is thrown away at the end |
| Between requests, small and settable by the app | setting("key") / setting("key", value) |
| Between requests, structured | the app database, db |
| Between requests, owned by one long-lived thing, plus a timer | a durable object’s storage |
| Set from outside, secret | ENV["..."], written with melee env |
There is no process-lifetime cache. A constant computed at the top level of app.rb is computed once, in
the warm process, and inherited by every request child through fork — so a frozen lookup table as a
constant is genuinely free. Anything computed during a request is not.
COUNTRIES = { "gb" => "United Kingdom", "fr" => "France" }.freeze # built once, inherited by every request
When the app gets big
A melee app is meant to be small; if it is growing past a few thousand lines, the constraints start to bite (single worker, one SQLite writer, no gems). That is the point where the honest answer may be that this app belongs on a normal server — see What melee cannot do.