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

Laying Out the Project

taskboard/
  index.zu              entry point
  app.zu                wiring: board + templates + server
  config.zu             every setting, with defaults
  models/
    index.zu
    task.zu             the Task class and its rules
  storage/
    index.zu            the Board
    _json_store.zu      private: reading and writing the file
  routes/
    index.zu
    api.zu              the JSON routes
    pages.zu            the HTML routes
  templates/
    layout.html
    board.html
  static/
    app.css
  tests/
    task.zu             one file per layer
    board.zu
    api.zu

Four decisions are worth explaining, because they are the ones that make the rest of the code short.

Before them, the shape itself. Every arrow in this application points one way:

index.zu  ->  app.zu  ->  routes/  ->  storage/  ->  models/

routes knows about storage; storage knows about models; models knows about nothing but config. Nothing points back the other way, which is what makes each layer readable on its own and testable without the ones above it. Testing the Board is where that second half is cashed in.

config.zu sits outside that chain — everything may read it, and it reads nothing. That is the one module allowed to be depended on from anywhere, and it earns the exemption by containing no behaviour at all.

One Package per Responsibility

models, storage and routes are packages: directories with an index.zu. The index.zu decides what is public:

Filename: models/index.zu

import @.task { * }

That one line re-exports everything task.zu declares, so the rest of the application writes import .models { Task } and never needs to know there is a task.zu at all. Splitting task.zu into two files later changes that one line and nothing else.

The @ is what makes it a re-export rather than a private import. Without it, models/index.zu could use Task itself but nothing outside could reach it through models — the import would be local, and import .models { Task } elsewhere would fail. That distinction is the subject of Exporting, and this is the single most common place to get it wrong.

The index.zu is therefore a deliberate, editable list of what a package offers, rather than an accident of which files happen to exist.

The Underscore Is Load-Bearing

storage/_json_store.zu starts with an underscore, so no module outside storage can import it. The compiler enforces that:

SyntaxError: Cannot import private items from module

The point is not secrecy. It is that swapping the JSON file for a database means rewriting one file, and the compiler guarantees nothing else reached past the Board to touch it. Not “we checked and nothing does” — nothing can, and an attempt does not compile.

That guarantee is what turns a convention into a boundary. A comment saying “internal, do not use” is advice; a leading underscore is enforced.

app.zu and index.zu Are Separate

app.zu builds a fully configured server and returns it. index.zu starts one:

Filename: index.zu

import log
import os

import @.app
import .config

if __root__ == __file__ {
  var server = app.build()

  os.on_signal('INT', @() {
    log.info('shutting down')
    os.exit(0)
  })

  log.info('task board on http://${config.HOST}:${config.PORT}')
  server.listen()
}

Three things are happening in that short file.

if __root__ == __file__ is the whole trick. zuri run taskboard runs the directory, finds index.zu, and the two are equal, so the body runs and the server starts. import .taskboard from another program leaves them different — __root__ is that program’s entry file — so nothing starts, and the importer gets app.build() to use however it likes. One file, both jobs, and Chapter 8 covers the idiom.

The signal handler is installed before serving. listen() does not return, so anything that needs to happen on the way out has to be arranged first. os.on_signal('INT', ...) is what turns Ctrl+C into an orderly exit rather than a killed process.

The log line comes before listen(), so the address is printed the moment the process is ready rather than after it stops. It is one line, and it is the difference between “did it start?” and knowing.

Note what index.zu does not do. It builds nothing, configures nothing and knows nothing about boards, templates or routes. Every one of those decisions is in app.zu, which is why the whole of Middleware, Logging and Errors can walk through one function and cover the entire assembly.

Configuration Has Defaults

Filename: config.zu

import os

var HERE = os.dir_name(__file__)

var PORT = os.get_env('PORT', '8000').to_number()
var HOST = os.get_env('HOST', '127.0.0.1')
var DATA_DIR = os.get_env('DATA_DIR', os.join_paths(HERE, 'data'))

var TEMPLATE_DIR = os.join_paths(HERE, 'templates')
var STATIC_DIR = os.join_paths(HERE, 'static')

var COLUMNS = ['todo', 'doing', 'done']

Four things to notice.

Every environment lookup has a fallback, so the application runs with no configuration at all. git clone, zuri run taskboard, and it works. A program that requires six environment variables before it will start is a program nobody tries.

PORT is converted with to_number(). Environment variables are always strings, and '8000' is not a port a socket will accept. The conversion is here, once, rather than at the place the port is used.

HERE is derived from __file__, not from os.cwd(). The templates and the stylesheet live next to the source, so the person running the program is not required to be standing in the right directory. This is the rule from Chapter 9, and a web application is where ignoring it hurts most: the program starts, serves a page, and fails to find a template only once someone requests it.

COLUMNS is here because it is the one piece of knowledge the model, the storage layer and the templates all share. _clean_column() validates against it, Board.columns() and Board.summary() iterate it, and the template renders one section per entry. Adding a fourth column is a one-line change in this file, and every one of those follows.

That is the test for whether something belongs in config.zu: not “is it a setting”, but “would changing it otherwise mean editing several files consistently?”