Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Structuring a larger app

app.rb for routes, lib/ for everything else, plain top-level methods for helpers, and case wherever 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, never rescue HTTP::Error — a rescue through a constant alias silently fails to match in a compiled app, while working perfectly under melee 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 { }, not DB.transaction { }).

The Ruby that compiles is the full list.

Where state lives

LifetimeWhere
One requestlocal variables, instance variables — everything is thrown away at the end
Between requests, small and settable by the appsetting("key") / setting("key", value)
Between requests, structuredthe app database, db
Between requests, owned by one long-lived thing, plus a timera durable object’s storage
Set from outside, secretENV["..."], 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.

See also