Skip to content
hekla

Project layout

How a directory becomes a program, which three directories are enforced, and why file order never matters.

A hekla project is a directory. Every .hk file under it is compiled together as one program, so a command can name an event declared three directories away without saying so.

There is no manifest, no import statement, no header item and no build step. A module is a file, not a namespace.

What discovery reads

Everything under the project root ending in .hk, except three things:

  • anything beginning with .
  • a directory named target
  • a directory named data at the project root only, because that is where the runtime keeps its own state

A data/ directory deeper in the tree is read normally, which matters if you happen to have a domain concept called that.

The three enforced directories

Only three directory names change what the runtime does, so only three are enforced.

Directory Holds Enforced
commands/ command declarations yes
commands/internal/ commands not exposed over HTTP a prefix of commands/
projectors/ projector declarations yes
effects/ effect declarations yes
events/ event declarations convention
tests/ test declarations convention
lib/ shared const, fn, refusal, guard, subject convention

The test each one passes is whether putting a declaration somewhere else would change behaviour. A command outside commands/ would have no route, so that is an error naming the declaration. An event in lib/ behaves identically to one in events/, so that is your business.

commands/internal/ is a literal prefix. A command at commands/billing/internal/x.hk stays public: only the path beginning commands/internal/ marks one internal.

A typical project:

orders/
  hekla.toml            optional; defaults apply without it
  events/order.hk
  commands/place-order.hk
  commands/internal/record-confirmation.hk
  projectors/customer-orders.hk
  effects/notify-customer.hk
  lib/config.hk
  tests/place-order.hk
  data/                 created on first run, never yours to edit

Order does not matter

Declarations are read in six passes, so a command may name a guard declared below it, a projector may use an enum from another file, and a const may refer to another const written later. Sorting your files differently changes nothing, and neither does renaming them.

A subject is global rather than module-scoped, which is the point of declaring one: a module-local namespace would put back the two-spellings-one-person problem the declaration removes. lib/ is where the worked example keeps them, and nothing enforces it.

The one thing a file name never does is name anything. A route comes from the declared name:

command PlaceOrder(
  order_id: Uuid,
  customer_id: Customer,
  shop_id: Shop,
  email: String?,
  shipping_address: String?,
  order_total: Money(2),
  notes: String,
) {
  // Narrow: this one order. A caller retrying the same `order_id` is a no-op rather
  // than a second order.
  fold placed: Bool = false
    on @order.placed(order_id) => true

  // Wide on purpose: an allocation is a rule about every order in the shop, so every
  // order in a shop conflicts with every other. That is what a hard cap costs, and the
  // retry loop is what absorbs it.
  fold sold: Int = 0
    on @order.placed(shop_id) => sold + 1

  if placed {
    return
  }
  if sold >= LAUNCH_ALLOCATION {
    reject SoldOut
  }

  emit @order.placed {
    order_id,
    customer_id,
    shop_id,
    email,
    shipping_address,
    order_total,
    notes,
  }
}

That is served at POST /commands/PlaceOrder whatever the file is called. It also shows the passes doing their work: the command folds an event declared under events/, reads a const declared under lib/ and answers a refusal declared beside it, and none of those files says anything about the others.

Six name spaces

These six kinds each get their own name space, so a command and a projector may share a name without colliding:

command, projector, effect, fn, guard, refusal

Three of them are reachable by name from inside a program: invoke Name, guard Name, and reject Name.

Events are not in that list because an event is named by a path (@order.placed) rather than by an identifier, and paths are global.

Type names are one space of their own, shared by enum, record and subject, because a type name resolves to one declaration. A subject may not take a name an enum or a record already has. An entity is not in it, because an entity is named inside its projector rather than at the top level.

Where configuration goes

hekla.toml sits at the project root and is optional. A missing file means every default applies. Every key is validated on load and an unknown one is an error rather than a warning, so a misspelling is caught rather than silently ignored.

Running a project lists every section and default.

Two tools, two rule sets

hek check checks the language. hekla check runs the same compiler and then adds what only the runtime knows: that a declaration sits in the directory its kind requires, and that a read model can be keyed and indexed the way the read API needs.

A program can pass the first and fail the second, so hekla check is the one to gate on.