5-Minute Tutorial
Build a complete todo application with records, events, actions, validation, state machines, and tests -- all in one module.
Prerequisites: Installation, Hello World. What you'll learn: How to model a real entity lifecycle with Grove's core features.
What We're Building
A todo application where items can be created, renamed, started, completed, and deleted. Items follow a state machine: open -> in_progress -> done.
Step 1: Define the Record
Create the project structure:
mkdir -p my-todo/todo
Create my-todo/grove.toml:
project_id = "my-todo"
Create my-todo/todo/module.grove and start with the record:
module todo
enum TodoStatus { open, in_progress, done }
record {
kind "todo"
title: String
notes: String?
status: TodoStatus = TodoStatus.open
priority: Int = 1
assignee_id: Id? @lookup(user)
}
Key concepts:
enum-- Defines a set of named values. Used for the state machine.String?-- The?makes a field optional (nullable).= TodoStatus.open-- Default value. New records start withstatus = open.@lookup(user)-- Annotation that marks this field as a foreign key reference.
Step 2: Add Validation and Invariants
Add constraints to the record and a reusable validator:
module todo
enum TodoStatus { open, in_progress, done }
record {
kind "todo"
title: String
notes: String?
status: TodoStatus = TodoStatus.open
priority: Int = 1
assignee_id: Id? @lookup(user)
invariant title_present {
record.title != ""
}
state status {
TodoStatus.open -> TodoStatus.in_progress on started
TodoStatus.in_progress -> TodoStatus.done on completed
}
}
validate title_not_empty {
params.title != ""
}
invariant-- A condition that must always be true. Checked after every event application.state-- Declares valid transitions. Thestartedevent can only move status fromopentoin_progress. Any other transition is rejected.validate-- A reusable validation rule. Actions reference it by name.
Step 3: Define Events
Events are immutable facts. Each one carries only the data that changed:
event created {
title: String
notes: String?
status: TodoStatus = TodoStatus.open
priority: Int
assignee_id: Id?
}
event renamed {
title: String
}
event started {
status: TodoStatus = TodoStatus.in_progress
}
event completed {
status: TodoStatus = TodoStatus.done
}
@delete
event deleted {}
- Events with default values (like
status: TodoStatus = TodoStatus.in_progress) always set that value when applied. @deleteon an event marks the record as deleted when this event is applied.
Step 4: Define Actions
Actions are the entry points for state changes. They validate input and emit events:
@create
action create {
title: String
notes: String?
priority: Int = 1
assignee_id: Id?
validate title_not_empty
validate priority_positive {
params.priority > 0
}
apply {
emit created {
title: params.title
notes: params.notes
priority: params.priority
assignee_id: params.assignee_id
}
}
}
action rename {
title: String
validate title_not_empty
apply {
emit renamed {
title: params.title
}
}
}
action start_todo {
apply {
emit started {}
}
}
action complete_todo {
apply {
emit completed {}
}
}
action delete {
apply {
emit deleted {}
}
}
@create-- This action creates new records. Without it, the runtime expects an existing record.validate title_not_empty-- References the reusable validator defined earlier.- Inline validation --
validate priority_positive { params.priority > 0 }defines validation inline. params-- The root object for accessing action input fields.
Step 5: Check It
manzano check my-todo/todo
OK
The checker validates everything: types, state machine transitions, event-action relationships, validation references, and invariant expressions.
Step 6: Write Tests
Create my-todo/todo/todo_test.grove:
module todo_test
test todo_lifecycle {
run create {
request {
name: "create",
params: {
title: "buy milk",
notes: "2% organic",
priority: 2,
assignee_id: "user-1"
},
meta: {
id: "todo-test-1",
by: "tester",
timestamp: 1700000000000,
action_id: "cmd-test-create"
}
}
expect ok
assert record.status == TodoStatus.open
assert events.len == 1
assert events[0].name == "created"
}
run start {
request {
name: "start_todo",
params: {},
meta: {
id: "todo-test-1",
by: "tester",
timestamp: 1700000000100,
action_id: "cmd-test-start"
}
}
expect ok
assert record.status == TodoStatus.in_progress
assert events[0].name == "started"
}
run complete {
request {
name: "complete_todo",
params: {},
meta: {
id: "todo-test-1",
by: "tester",
timestamp: 1700000000200,
action_id: "cmd-test-complete"
}
}
expect ok
assert record.status == TodoStatus.done
assert events[0].name == "completed"
}
}
Run the tests:
manzano test my-todo/todo
Each run block sends a request, checks the outcome (expect ok or expect error), and asserts on the resulting record state and events.
Step 7: Run with a Database
For persistent storage, use run-db with a SQLite backend:
manzano run-db my-todo/todo \
--request my-todo/todo/request_create.json \
--backend sqlite \
--db /tmp/my-todo.sqlite
The runtime creates the necessary tables, stores events, and maintains the current record state.
The Complete Module
Here's the full module.grove -- 96 lines that define a complete entity lifecycle:
module todo
enum TodoStatus { open, in_progress, done }
record {
kind "todo"
title: String
notes: String?
status: TodoStatus = TodoStatus.open
priority: Int = 1
assignee_id: Id? @lookup(user)
invariant title_present {
record.title != ""
}
state status {
TodoStatus.open -> TodoStatus.in_progress on started
TodoStatus.in_progress -> TodoStatus.done on completed
}
}
validate title_not_empty {
params.title != ""
}
event created {
title: String
notes: String?
status: TodoStatus = TodoStatus.open
priority: Int
assignee_id: Id?
}
event renamed {
title: String
}
event started {
status: TodoStatus = TodoStatus.in_progress
}
event completed {
status: TodoStatus = TodoStatus.done
}
@delete
event deleted {}
@create
action create {
title: String
notes: String?
priority: Int = 1
assignee_id: Id?
validate title_not_empty
validate priority_positive {
params.priority > 0
}
apply {
emit created {
title: params.title
notes: params.notes
priority: params.priority
assignee_id: params.assignee_id
}
}
}
action rename {
title: String
validate title_not_empty
apply {
emit renamed {
title: params.title
}
}
}
action start_todo {
apply {
emit started {}
}
}
action complete_todo {
apply {
emit completed {}
}
}
action delete {
apply {
emit deleted {}
}
}
See Also
- Concepts Overview -- Mental model and terminology
- Build a Task Tracker -- Extended tutorial with more features
- Validation & State Machines -- Deep dive into constraints