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
dataat 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.