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:


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

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