Skip to content
academia.sh

Lesson 01 / 13

Convention over Configuration

The measuring axis of the course is how many requests a value stays for. The lesson runs the core, sorts thirteen observations into four stays, and shows that the template name is not written, is derived from names, and stays for this request.

Published Updated

In this lesson

  • Names how many requests a value stays for in a request loop with one of four classes
  • Measures the distribution of the core's thirteen observations and shows what the stay rule reads
  • Measures that the template name is not written but derived from the controller and action names
  • Shows that a convention removes configuration but does not store the value
Contents

The previous course closed with declarative interfaces: field :price, 0 was a call, and it added methods to a class. A web framework is built on lines like these, but it asks something new. On the web every request starts from zero: the controller is built fresh, the parameters are empty, the request-specific store is empty. Even so, some values do not vanish when the request ends. A notification message (flash) appears on the next page, the id of a signed-in user is present on every page, and a saved product is read by another browser as well.

The course measures every mechanism with a single question: how many requests does this value stay for? The name of the answer’s category is the stay, and it has four values. This request: the value is read only in the request that wrote it. Next request: the value is read only in the same client’s next request. Session: the value is read in all of the same client’s later requests, and not read by another client. Persistent: the value is also read in another client’s request. In the output these four names are written in ASCII: this_request, next, session, persistent.

Model, View, Controller

Model–view–controller (MVC) splits an application into three responsibilities. The model carries the data and the rules about the data. The view turns the data into the output the user will see. The controller receives the request, asks the model, and hands the result to the view. A fourth part stands in front of the three: routing turns the incoming path into the action of a particular controller.

A request’s path follows this order: routing, controller, model, view. In the core of the course each of the four parts exists as a small counterpart: route, ProductsController, Product and compile_template. This core is not a framework, it is a model of a framework. Every measured block is plain Ruby; no ready-made framework is used.

The Core

The core is two files. The first is stay.rb, a small request loop and a helper that classifies the stay. This block is source; it has no output, it is written to disk, and the other files pull it in with require_relative.

# stay.rb — a small request loop and a helper that classifies how many requests a value stays for

# Flash: a value written in this request is carried to the next request only.
class Flash
  def initialize(incoming)
    @incoming = incoming
    @fresh = {}
  end

  def [](key) = @fresh.fetch(key) { @incoming[key] }

  def []=(key, value)
    @fresh[key] = value
  end

  def passed_on = @fresh
end

# Every request gets a new context: params, flash and the request store start empty each time.
class Context
  attr_reader :app, :params, :session, :flash, :request_store

  def initialize(app, session)
    @app = app
    @params = {}
    @session = session
    @flash = Flash.new(session.delete(:flash) || {})
    @request_store = {}
  end
end

# The application store is the same across all requests and all clients;
# the session is chosen per client.
class App
  attr_reader :store

  def initialize
    @sessions = Hash.new { |h, client| h[client] = {} }
    @store = { products: {}, schema: { products: %i[name price] }, templates: {},
               queue: [], cache: {}, counter: 0 }
  end

  def request(client)
    context = Context.new(self, @sessions[client])
    result = yield context
    context.session[:flash] = context.flash.passed_on unless context.flash.passed_on.empty?
    result
  end
end

# Rule: request 1 (client A) writes the value; requests 2 and 3 (A) and request 4 (B) read it.
# If B sees it, persistent; if A's third request sees it, session; if only the second
# request sees it, next; if none sees it, this_request.
def stay(write, read)
  app = App.new
  first = app.request("A") { |b| write.call(b); read.call(b) }
  raise ArgumentError, "value could not be read in the request that wrote it" if first.nil?

  seen = [["A", 2], ["A", 3], ["B", 4]].map do |client, order|
    app.request(client) { |b| read.call(b) }.nil? ? "--" : "#{client}#{order}"
  end
  kind = if seen[2] != "--" then "persistent"
         elsif seen[1] != "--" then "session"
         elsif seen[0] != "--" then "next"
         else "this_request"
         end
  [kind, ["A1", *seen].join(" "), first]
end

The four classes work together. Context is new on every request and receives params, flash and request_store empty. App holds the store shared by all requests and the session chosen by client. Flash leaves the written value in the session at the end of the request; the next request takes it and deletes it. stay runs the rule: in request 1 client A writes the value and reads it, in requests 2 and 3 A only reads, and in request 4 client B only reads. If B sees it, the value is persistent; if A’s third request sees it, it is session; if only the second request sees it, it is next request; if none sees it, it is this request. FS1. The persistent store here is in memory and goes away when the process ends. In a real application this place is a database, a cache or a queue; the rule is meaningful only within a single process.

Thirteen Observations

The second file, values.rb, carries the framework’s small models and the thirteen observations. Each observation consists of a write and a read lambda. At the end of the file it gives all of them to stay and prints the table.

# values.rb — thirteen observations: how many requests does this value stay for
require_relative "stay"

# Routing: a resource-based path pattern is turned into a controller, an action and an id.
ROUTES = { %r{\A/products/(?<id>\d+)\z} => "products#show" }

def route(path)
  ROUTES.each do |pattern, target|
    match = pattern.match(path) or next
    controller, action = target.split("#")
    return { controller:, action:, id: match[:id].to_i }
  end
  nil
end

# Convention: the template name is not written, it is derived from the controller and action names.
def template_name(params) = "#{params[:controller]}/#{params[:action]}"

# Object-relational mapper: a row maps to a record, a table maps to a model.
module Product
  def self.insert(b, attributes)
    errors = []
    errors << "name cannot be blank" if attributes[:name].to_s.empty?
    errors << "price cannot be negative" if attributes[:price].to_i.negative?
    return { errors: } unless errors.empty?

    table = b.app.store[:products]
    id = table.size + 1
    table[id] = attributes.merge(id:)
    { id:, errors: }
  end

  # The same query does not go to the database twice in the same request.
  def self.lookup(b, id)
    queries = (b.request_store[:queries] ||= {})
    queries.fetch([:products, id]) do
      b.request_store[:query_count] = b.request_store.fetch(:query_count, 0) + 1
      queries[[:products, id]] = b.app.store[:products][id]
    end
  end
end

def migrate(b, table, column) = b.app.store[:schema][table] << column

# Controller: a new instance on every request; a filter runs before the action.
class ProductsController
  def self.before(*names) = (@filters ||= []).concat(names)
  def self.filters = @filters || []

  before :load_product

  def initialize(context)
    @context = context
  end

  def run(action)
    self.class.filters.each { |filter| send(filter) }
    public_send(action)
  end

  def show = @product

  private

  def load_product
    @product = Product.lookup(@context, @context.params[:id])
  end
end

def controller(b) = b.request_store[:controller] ||= ProductsController.new(b)

# View: a template is compiled once; local values are escaped on every render.
def escape(text) = text.to_s.gsub("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;").gsub('"', "&quot;")

def compile_template(b, name, source)
  b.app.store[:templates][name] ||= lambda do |locals|
    source.gsub(/\{\{(\w+)\}\}/) { escape(locals.fetch(Regexp.last_match(1).to_sym)) }
  end
end

def enqueue(b, job_name, *args) = b.app.store[:queue] << [job_name, args]

def cached(b, key) = b.app.store[:cache][key] ||= yield

def csrf_token(b) = b.session[:csrf] ||= "token-#{b.app.store[:counter] += 1}"

pen = ->(b) { Product.insert(b, name: "pen", price: 12) }
observations = {
  "template name"  => [->(b) { b.params.merge!(route("/products/7")) },
                       ->(b) { b.params[:action] && template_name(b.params) }],
  "path parameter" => [->(b) { b.params.merge!(route("/products/7")) },
                       ->(b) { b.params[:id] }],
  "flash"          => [->(b) { b.flash[:notice] = "product saved" },
                       ->(b) { b.flash[:notice] }],
  "compiled template" => [->(b) { compile_template(b, "products/show", "<h1>{{name}}</h1>") },
                          ->(b) { b.app.store[:templates]["products/show"]&.call(name: "<b>pen</b>") }],
  "record"         => [pen, ->(b) { b.app.store[:products][1]&.fetch(:name) }],
  "schema column"  => [->(b) { migrate(b, :products, :stock) },
                       ->(b) { b.app.store[:schema][:products].find { |c| c == :stock } }],
  "validation error" => [->(b) { b.request_store[:record] = Product.insert(b, name: "", price: 5) },
                         ->(b) { b.request_store.dig(:record, :errors, 0) }],
  "query cache"    => [->(b) { pen.call(b); Product.lookup(b, 1) },
                       ->(b) { b.request_store.dig(:queries, [:products, 1], :name) }],
  "user id"        => [->(b) { b.session[:user_id] = 42 },
                       ->(b) { b.session[:user_id] }],
  "queued job"     => [->(b) { enqueue(b, "send_invoice", 1) },
                       ->(b) { b.app.store[:queue].first }],
  "fragment cache" => [->(b) { cached(b, "product/1") { "<li>pen</li>" } },
                       ->(b) { b.app.store[:cache]["product/1"] }],
  "controller variable" => [->(b) { pen.call(b); b.params[:id] = 1; controller(b).run(:show) },
                            ->(b) { controller(b).instance_variable_get(:@product)&.fetch(:name) }],
  "csrf token"     => [->(b) { csrf_token(b) }, ->(b) { b.session[:csrf] }],
}

tally = { "this_request" => 0, "next" => 0, "session" => 0, "persistent" => 0 }

printf("%-22s%-14s%-13s%s\n", "observation", "stay", "evidence", "value")
observations.each do |name, (write, read)|
  kind, evidence, value = stay(write, read)
  tally[kind] += 1
  printf("%-22s%-14s%-13s%s\n", name, kind, evidence, value.inspect)
end

puts
tally.each { |kind, n| printf("%-14s%d\n", kind, n) }
puts "total observations: #{tally.values.sum}"
observation           stay          evidence     value
template name         this_request  A1 -- -- --  "products/show"
path parameter        this_request  A1 -- -- --  7
flash                 next          A1 A2 -- --  "product saved"
compiled template     persistent    A1 A2 A3 B4  "<h1>&lt;b&gt;pen&lt;/b&gt;</h1>"
record                persistent    A1 A2 A3 B4  "pen"
schema column         persistent    A1 A2 A3 B4  :stock
validation error      this_request  A1 -- -- --  "name cannot be blank"
query cache           this_request  A1 -- -- --  "pen"
user id               session       A1 A2 A3 --  42
queued job            persistent    A1 A2 A3 B4  ["send_invoice", [1]]
fragment cache        persistent    A1 A2 A3 B4  "<li>pen</li>"
controller variable   this_request  A1 -- -- --  "pen"
csrf token            session       A1 A2 A3 --  "token-1"

this_request  5
next          1
session       2
persistent    5
total observations: 13

Of the thirteen observations, 5 stayed for this request, 1 for the next request and 2 for the session; 5 are persistent. The evidence column is a record of four requests: the request that sees the value is written by name, and one that does not is written --. Everything that stays for this request is born in the request’s context: the template name, the path parameter, the validation error, the query cache and the controller’s instance variable. Because the context is renewed on every request, none of them reaches A2. The persistent ones are in App#store: the record, the schema column, the compiled template, the queued job and the fragment cache. In reality these are five separate places, but the rule does not separate them, because their behavior is the same: another client reads them too. FS2. The rule reads how the value behaves, not where it is stored. Landing in the same class does not say they are the same thing; in the coming lessons both faces of this sentence are measured.

This lesson examines the first row of the table.

The Template Name Is Not Written

The template name row fell into the this-request class and its value was "products/show". This name was written nowhere. route("/products/7") gave the controller and action names, and template_name turned the two into a path. This is a convention: the template is expected to be the combination of the controller name and the action name, and there is no configuration line that says so. This is the principle of convention over configuration: the answer to the common case is the default, and only the deviation is written.

Calling the same helper with different names shows that the derivation works. values.rb prints the table as it runs, so this measurement runs in a separate file with its own definition; the template_name line is an exact copy of the one in values.rb.

# convention.rb — deriving the template name
def template_name(params) = "#{params[:controller]}/#{params[:action]}"

p template_name(controller: "products", action: "show")
p template_name(controller: "products", action: "edit")
p template_name(controller: "orders", action: "show")
"products/show"
"products/edit"
"orders/show"

When the action name changed, the template name changed with it. The third line shows the limit of the convention: there is no controller named orders in the core, but template_name produced a name. A convention produces a name; it does not check whether such a template exists. FS3. This was measured only for the core’s one-line template_name. How a real framework’s file lookup behaves, and what it does when a template is missing, was not tested in this lesson.

This question is separate from the question of M08/K06. There the cause of a behavior in the source was measured: a call, an annotation, or a default. In that language the template name is a default. Here the cause is not asked, the stay is asked: even if the name derives from a default, its value is read only in this request.

What the Convention Produces from Missing Input

A convention is a rule, and a rule does not validate input. Looking at what the single line that produces the name gives for a missing parameter shows where the convention ends.

# missing.rb — the name produced from a missing parameter
require_relative "stay"

# Convention: the template name is not written, it is derived from the controller and action names.
def template_name(params) = "#{params[:controller]}/#{params[:action]}"

p template_name(controller: "products", action: "show")
p template_name(controller: "products")
p template_name({})
p template_name(controller: "products", action: "")
"products/show"
"products/"
"/"
"products/"

When the action was missing no error was raised; the name became products/. When no parameter was given at all, the name became only /. An empty string and a missing key gave the same result, because nil also enters a string as an empty string. The convention builds the name; it does not check whether the name is meaningful. For this reason a missing name does not surface as an error but as a key that cannot be found in the compiled template store: the lookup silently returns nil. Code that wants to catch the error must itself check that the controller and action are filled in before producing the name; the convention does not take on this check.

The Bounding Measurement

A convention removes configuration; it does not store the value. There are two ways to measure this: whether the same name is absent in a next request, and what happens in a request where routing never ran.

# limit.rb — the stay of the derived name
require_relative "stay"

def template_name(params) = "#{params[:controller]}/#{params[:action]}"

write = ->(b) { b.params.merge!(controller: "products", action: "show") }
read = ->(b) { b.params[:action] && template_name(b.params) }
p stay(write, read)

app = App.new
p app.request("A") { |b| read.call(b) }
p app.request("A") { |b| b.params.merge!(controller: "products", action: "show"); read.call(b) }
p app.store[:templates]
["this_request", "A1 -- -- --", "products/show"]
nil
"products/show"
{}

The first line is the same as the result in the table: the writing request sees the name, and the next three requests do not. The second line shows a request where routing did not run. Because params[:action] was empty, read gave nil; that is, the name was not computed, and there was no source from which it could have been computed. In the third line routing was written again into the same request and the name came out the same. The last line says the name was left nowhere: the application’s template store is still empty. The name is recomputed from the parameters on every request. FS4. This measurement was made with a single pair of parameters; the stay of producing a name must be measured separately for a different convention.

Summary

  • The stay of a value is how many requests it can be read for: this request, next request, session, or persistent.
  • In the core, of the thirteen observations 5 stayed for this request, 1 for the next request and 2 for the session; 5 are persistent.
  • The stay rule reads how the value behaves, not where it is stored.
  • The template name is not written, it derives from the controller and action names; a convention produces a name but does not check that the template exists.
  • The stay of this lesson is this request: the derived name is recomputed on every request and is not left in the store.

Next Step

The template name derived from the controller and action names that routing gave. So where did routing get those names? The next lesson measures the pattern that splits a path into its parts, and looks at whether the number 7 in the path passes to a next request.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close