Build a Task Tracker
In this tutorial you will build a complete task tracker module from scratch. Along the way you will use records, events, actions, types, enums, validation, state machines, and tests -- the core building blocks of every Grove application. By the end you will have a working module that creates, assigns, comments on, and completes tasks with full lifecycle enforcement.
Prerequisites: You should be comfortable reading basic Grove syntax and have completed the Getting Started guide.
What you'll learn:
- Defining custom types and enums
- Declaring records with fields, defaults, invariants, and state machines
- Writing events with field defaults
- Building actions with
@create, validation, andapplyblocks - Writing tests that exercise the full lifecycle
Step 1: Set Up the Module
Every Grove file begins with a module declaration. This names the bounded
context that contains all of the definitions that follow.
module task_tracker
All types, records, events, actions, and tests in this file belong to
task_tracker.
Step 2: Define Enums
Enums let you express a closed set of choices. A task tracker needs a priority level and a status that will later drive a state machine.
enum Priority {
low,
medium,
high,
critical
}
enum Status {
open,
in_progress,
blocked,
done,
cancelled
}
Enum variants are referenced with dot syntax: Priority.high, Status.open.
Step 3: Define Custom Types
Types give structure to data that appears in multiple places. Here we define a
Comment type that will be stored inside a list on each task.
type Comment {
author: String
body: String
posted_at: DateTime
}
Types are value objects -- they have no identity of their own and are always embedded inside a record.
Step 4: Declare the Record
The record block is the heart of the module. It defines the persistent entity,
its fields, defaults, invariants, and state machine transitions.
record {
kind "task"
title: String
description: String = ""
status: Status = Status.open
priority: Priority = Priority.medium
assignee: String?
due_date: Date?
tags: List<String> = []
comments: List<Comment> = []
completed_at: DateTime?
invariant title_not_blank {
record.title.trim().len() > 0
}
invariant due_date_in_future_on_create {
record.due_date == null || record.due_date >= meta.today()
}
state status {
Status.open -> Status.in_progress on task_started
Status.open -> Status.cancelled on task_cancelled
Status.in_progress -> Status.blocked on task_blocked
Status.in_progress -> Status.done on task_completed
Status.blocked -> Status.in_progress on task_unblocked
Status.done -> Status.open on task_reopened
}
}
Key points
- Defaults are specified with
=. When an event does not supply a value the default is used. - Optional fields use
T?. Theassigneeanddue_datefields may be null. - Invariants are named boolean expressions evaluated against the
recordroot object. If any invariant returnsfalsethe write is rejected. - State machines declare legal transitions. Grove enforces them
automatically -- emitting
task_completedwhen the status isStatus.openwill fail.
Reserved fields
Grove automatically manages several fields on every record. You do not declare them, but they are always available:
pk, id, version, created_at, updated_at, created_by, updated_by
Step 5: Define Events
Events describe things that have happened. Each event lists the fields it carries. Fields may have defaults.
event task_created {
title: String
description: String = ""
priority: Priority = Priority.medium
assignee: String?
due_date: Date?
tags: List<String> = []
}
event task_started {
assignee: String
}
event task_completed {
completed_at: DateTime
}
event task_blocked {
reason: String
}
event task_unblocked {}
event task_cancelled {
reason: String = ""
}
event task_reopened {
reason: String
}
event comment_added {
author: String
body: String
posted_at: DateTime
}
event task_priority_changed {
priority: Priority
}
@delete
event task_deleted {}
The @delete annotation on task_deleted tells Grove to remove the record when
this event is applied.
Step 6: Write Reusable Validators
Validators are named boolean expressions that can be referenced from any action.
They receive the record root object for the current state of the entity.
validate assignee_present {
record.assignee != null
}
validate not_already_done {
record.status != Status.done
}
You can combine validators with inline validation inside actions (shown next).
Step 7: Build Actions
Actions are the public API of your module. They accept parameters, validate
input, and emit events inside an apply block.
Creating a task
The @create annotation marks an action that produces a new record.
@create
action create_task {
title: String
description: String = ""
priority: Priority = Priority.medium
assignee: String?
due_date: Date?
tags: List<String> = []
validate {
params.title.trim().len() > 0
}
apply {
emit task_created {
title: params.title
description: params.description
priority: params.priority
assignee: params.assignee
due_date: params.due_date
tags: params.tags
}
}
}
Inside an action body the params root object holds the incoming request
fields. The validate block here is inline -- it is an anonymous boolean
expression evaluated before apply runs. If it returns false the request is
rejected.
Starting a task
action start_task {
assignee: String
validate assignee_present
validate not_already_done
apply {
emit task_started {
assignee: params.assignee
}
}
}
This action references two reusable validators by name. Both must pass
before the apply block runs.
Completing a task
action complete_task {
validate assignee_present
validate {
record.status == Status.in_progress
}
apply {
emit task_completed {
completed_at: meta.now()
}
}
}
Here we mix a reusable validator (assignee_present) with an inline validator
that checks the current status. The meta.now() call provides the current
timestamp.
Adding a comment
action add_comment {
body: String
validate {
params.body.trim().len() > 0
}
apply {
emit comment_added {
author: meta.user()
body: params.body
posted_at: meta.now()
}
}
}
Changing priority
action change_priority {
priority: Priority
validate not_already_done
apply {
emit task_priority_changed {
priority: params.priority
}
}
}
Blocking, unblocking, cancelling, and reopening
action block_task {
reason: String
apply {
emit task_blocked {
reason: params.reason
}
}
}
action unblock_task {
apply {
emit task_unblocked {}
}
}
action cancel_task {
reason: String = ""
apply {
emit task_cancelled {
reason: params.reason
}
}
}
action reopen_task {
reason: String
apply {
emit task_reopened {
reason: params.reason
}
}
}
Deleting a task
action delete_task {
apply {
emit task_deleted {}
}
}
Step 8: Write Tests
Tests describe a sequence of steps that exercise the module. Each step sends a request, asserts the result, and optionally inspects the record state afterward.
test full_task_lifecycle {
run create {
request create_task {
title: "Write Grove docs"
priority: Priority.high
tags: ["docs", "grove"]
}
expect ok
assert record.title == "Write Grove docs"
assert record.status == Status.open
assert record.priority == Priority.high
assert record.tags.len() == 2
}
run assign_and_start {
request start_task {
assignee: "alice"
}
expect ok
assert record.status == Status.in_progress
assert record.assignee == "alice"
}
run add_a_comment {
request add_comment {
body: "Looking good so far."
}
expect ok
assert record.comments.len() == 1
}
run complete {
request complete_task {}
expect ok
assert record.status == Status.done
assert record.completed_at != null
}
run reopen {
request reopen_task {
reason: "Needs another section"
}
expect ok
assert record.status == Status.open
}
}
Testing validation failures
test blank_title_rejected {
run create_blank {
request create_task {
title: " "
}
expect error
}
}
test cannot_complete_from_open {
run create {
request create_task {
title: "Some task"
assignee: "bob"
}
expect ok
}
run try_complete {
request complete_task {}
expect error
}
}
Testing state machine enforcement
test invalid_transition_rejected {
run create {
request create_task {
title: "Blocked test"
}
expect ok
}
run try_block_from_open {
request block_task {
reason: "dependency missing"
}
expect error
}
}
The state machine declared in the record does not allow a transition from
Status.open directly to Status.blocked -- only from Status.in_progress.
Grove rejects the request automatically.
Putting It All Together
Below is the complete module in a single listing for reference.
module task_tracker
enum Priority {
low,
medium,
high,
critical
}
enum Status {
open,
in_progress,
blocked,
done,
cancelled
}
type Comment {
author: String
body: String
posted_at: DateTime
}
record {
kind "task"
title: String
description: String = ""
status: Status = Status.open
priority: Priority = Priority.medium
assignee: String?
due_date: Date?
tags: List<String> = []
comments: List<Comment> = []
completed_at: DateTime?
invariant title_not_blank {
record.title.trim().len() > 0
}
invariant due_date_in_future_on_create {
record.due_date == null || record.due_date >= meta.today()
}
state status {
Status.open -> Status.in_progress on task_started
Status.open -> Status.cancelled on task_cancelled
Status.in_progress -> Status.blocked on task_blocked
Status.in_progress -> Status.done on task_completed
Status.blocked -> Status.in_progress on task_unblocked
Status.done -> Status.open on task_reopened
}
}
event task_created {
title: String
description: String = ""
priority: Priority = Priority.medium
assignee: String?
due_date: Date?
tags: List<String> = []
}
event task_started {
assignee: String
}
event task_completed {
completed_at: DateTime
}
event task_blocked {
reason: String
}
event task_unblocked {}
event task_cancelled {
reason: String = ""
}
event task_reopened {
reason: String
}
event comment_added {
author: String
body: String
posted_at: DateTime
}
event task_priority_changed {
priority: Priority
}
@delete
event task_deleted {}
validate assignee_present {
record.assignee != null
}
validate not_already_done {
record.status != Status.done
}
@create
action create_task {
title: String
description: String = ""
priority: Priority = Priority.medium
assignee: String?
due_date: Date?
tags: List<String> = []
validate {
params.title.trim().len() > 0
}
apply {
emit task_created {
title: params.title
description: params.description
priority: params.priority
assignee: params.assignee
due_date: params.due_date
tags: params.tags
}
}
}
action start_task {
assignee: String
validate assignee_present
validate not_already_done
apply {
emit task_started {
assignee: params.assignee
}
}
}
action complete_task {
validate assignee_present
validate {
record.status == Status.in_progress
}
apply {
emit task_completed {
completed_at: meta.now()
}
}
}
action add_comment {
body: String
validate {
params.body.trim().len() > 0
}
apply {
emit comment_added {
author: meta.user()
body: params.body
posted_at: meta.now()
}
}
}
action change_priority {
priority: Priority
validate not_already_done
apply {
emit task_priority_changed {
priority: params.priority
}
}
}
action block_task {
reason: String
apply {
emit task_blocked {
reason: params.reason
}
}
}
action unblock_task {
apply {
emit task_unblocked {}
}
}
action cancel_task {
reason: String = ""
apply {
emit task_cancelled {
reason: params.reason
}
}
}
action reopen_task {
reason: String
apply {
emit task_reopened {
reason: params.reason
}
}
}
action delete_task {
apply {
emit task_deleted {}
}
}
test full_task_lifecycle {
run create {
request create_task {
title: "Write Grove docs"
priority: Priority.high
tags: ["docs", "grove"]
}
expect ok
assert record.title == "Write Grove docs"
assert record.status == Status.open
assert record.priority == Priority.high
assert record.tags.len() == 2
}
run assign_and_start {
request start_task {
assignee: "alice"
}
expect ok
assert record.status == Status.in_progress
assert record.assignee == "alice"
}
run add_a_comment {
request add_comment {
body: "Looking good so far."
}
expect ok
assert record.comments.len() == 1
}
run complete {
request complete_task {}
expect ok
assert record.status == Status.done
assert record.completed_at != null
}
run reopen {
request reopen_task {
reason: "Needs another section"
}
expect ok
assert record.status == Status.open
}
}
test blank_title_rejected {
run create_blank {
request create_task {
title: " "
}
expect error
}
}
test cannot_complete_from_open {
run create {
request create_task {
title: "Some task"
assignee: "bob"
}
expect ok
}
run try_complete {
request complete_task {}
expect error
}
}
test invalid_transition_rejected {
run create {
request create_task {
title: "Blocked test"
}
expect ok
}
run try_block_from_open {
request block_task {
reason: "dependency missing"
}
expect error
}
}
See Also
- Validation & State Machines -- deep dive into validators, invariants, and state transitions
- Expressions Deep Dive -- full reference for the expression language used in validators, apply blocks, and tests
- Events & Actions -- comprehensive guide to event and action declarations
- Testing -- advanced testing patterns and strategies
- Types & Enums -- complete type system reference