Build a Pet Adoption Service

A project-based tutorial that introduces custom types, enums with multiple fields, composite types, imports, and functions by building a pet adoption service.

Prerequisites: Build a Task Tracker, Validation & State Machines. What you'll learn: How to use custom types, enums, composite fields, optional types, functions, and multiple actions in a real-world module.

What We're Building

A pet adoption service where animals are registered, made available for adoption, matched with adopters, and either adopted or returned to available status. The module demonstrates richer data modeling than the task tracker.

Step 1: Define Types and Enums

module pet_adoption

enum Species { dog, cat, rabbit, bird, other }
enum AdoptionStatus { intake, available, pending, adopted, returned }

type Address {
  street: String
  city: String
  state: String
  zip: String
}

type AdopterInfo {
  name: String
  email: String
  phone: String?
  address: Address
}

Key concepts:

Step 2: Define the Record

record {
  kind "pet"
  name: String
  species: Species
  breed: String?
  age_months: Int
  description: String = ""
  status: AdoptionStatus = AdoptionStatus.intake
  adopter: AdopterInfo?
  adoption_date: DateTime?
  notes: List<String>

  invariant name_present {
    record.name != ""
  }

  invariant valid_age {
    record.age_months >= 0
  }

  state status {
    AdoptionStatus.intake -> AdoptionStatus.available on made_available
    AdoptionStatus.available -> AdoptionStatus.pending on adoption_requested
    AdoptionStatus.pending -> AdoptionStatus.adopted on adoption_finalized
    AdoptionStatus.pending -> AdoptionStatus.available on adoption_cancelled
    AdoptionStatus.adopted -> AdoptionStatus.available on pet_returned
  }
}

The state machine enforces a lifecycle: pets go through intake, become available, can be placed in pending status when someone requests adoption, and then either finalized or cancelled.

Step 3: Events

event registered {
  name: String
  species: Species
  breed: String?
  age_months: Int
  description: String
  notes: List<String>
}

event made_available {
  status: AdoptionStatus = AdoptionStatus.available
}

event adoption_requested {
  status: AdoptionStatus = AdoptionStatus.pending
  adopter: AdopterInfo
}

event adoption_finalized {
  status: AdoptionStatus = AdoptionStatus.adopted
  adoption_date: DateTime
}

event adoption_cancelled {
  status: AdoptionStatus = AdoptionStatus.available
  adopter: AdopterInfo? = null
}

event pet_returned {
  status: AdoptionStatus = AdoptionStatus.available
  adopter: AdopterInfo? = null
  adoption_date: DateTime? = null
}

event description_updated {
  description: String
}

event note_added {
  notes: List<String>
}

@delete
event removed {}

Note how adoption_cancelled and pet_returned clear the adopter by setting it to null with a typed default.

Step 4: Validation and Functions

validate name_not_empty {
  params.name != ""
}

validate valid_age {
  params.age_months >= 0
}

function format_pet_summary(name: String, species: Species, breed: String?) -> String {
  let breed_text = breed ?? "Unknown breed"
  return "${name} (${breed_text})"
}

Functions are pure, side-effect-free, and can use let bindings and string interpolation.

Step 5: Actions

@create
action register {
  name: String
  species: Species
  breed: String?
  age_months: Int
  description: String = ""
  notes: List<String> = []
  validate name_not_empty
  validate valid_age
  apply {
    emit registered {
      name: params.name
      species: params.species
      breed: params.breed
      age_months: params.age_months
      description: params.description
      notes: params.notes
    }
  }
}

action make_available {
  apply {
    emit made_available {}
  }
}

action request_adoption {
  adopter: AdopterInfo
  validate adopter_has_email {
    params.adopter.email != ""
  }
  apply {
    emit adoption_requested {
      adopter: params.adopter
    }
  }
}

action finalize_adoption {
  adoption_date: DateTime
  apply {
    emit adoption_finalized {
      adoption_date: params.adoption_date
    }
  }
}

action cancel_adoption {
  apply {
    emit adoption_cancelled {}
  }
}

action return_pet {
  apply {
    emit pet_returned {}
  }
}

action update_description {
  description: String
  apply {
    emit description_updated {
      description: params.description
    }
  }
}

action add_note {
  note: String
  validate note_not_empty {
    params.note != ""
  }
  apply {
    emit note_added {
      notes: record.notes.append(params.note)
    }
  }
}

action remove {
  apply {
    emit removed {}
  }
}

The add_note action demonstrates accessing the current record state (record.notes) and using the .append() method to create the new list value for the event.

Step 6: Tests

module pet_adoption_test

test adoption_lifecycle {
  run register_pet {
    request {
      name: "register",
      params: {
        name: "Luna",
        species: "dog",
        breed: "Golden Retriever",
        age_months: 24,
        description: "Friendly and energetic",
        notes: ["House trained", "Good with kids"]
      },
      meta: {
        id: "pet-1",
        by: "staff-1",
        timestamp: 1700000000000,
        action_id: "cmd-1"
      }
    }
    expect ok
    assert record.name == "Luna"
    assert record.status == AdoptionStatus.intake
    assert record.notes.len == 2
  }

  run make_available {
    request {
      name: "make_available",
      params: {},
      meta: {
        id: "pet-1",
        by: "staff-1",
        timestamp: 1700000001000,
        action_id: "cmd-2"
      }
    }
    expect ok
    assert record.status == AdoptionStatus.available
  }

  run request_adoption {
    request {
      name: "request_adoption",
      params: {
        adopter: {
          name: "Jane Smith",
          email: "jane@example.com",
          phone: "555-0123",
          address: {
            street: "123 Main St",
            city: "Springfield",
            state: "IL",
            zip: "62701"
          }
        }
      },
      meta: {
        id: "pet-1",
        by: "staff-1",
        timestamp: 1700000002000,
        action_id: "cmd-3"
      }
    }
    expect ok
    assert record.status == AdoptionStatus.pending
    assert record.adopter?.name == "Jane Smith"
  }

  run finalize {
    request {
      name: "finalize_adoption",
      params: {
        adoption_date: "2024-01-15T10:00:00Z"
      },
      meta: {
        id: "pet-1",
        by: "staff-1",
        timestamp: 1700000003000,
        action_id: "cmd-4"
      }
    }
    expect ok
    assert record.status == AdoptionStatus.adopted
  }
}

Patterns Demonstrated

PatternExample
Custom types with nestingAdopterInfo contains Address
List fieldsnotes: List<String>
Optional chain in testsrecord.adopter?.name
State machine with multiple pathspending can go to adopted OR back to available
Function with null coalescebreed ?? "Unknown breed"
Record access in actionsrecord.notes.append(params.note)

See Also