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("&", "&").gsub("<", "<").gsub(">", ">").gsub('"', """)
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><b>pen</b></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.