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:
- Custom types are nominal --
Addressand another type with the same fields are not interchangeable. - Nested types --
AdopterInfocontains anAddress. - Optional fields --
phone: String?can be null.
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
| Pattern | Example |
|---|---|
| Custom types with nesting | AdopterInfo contains Address |
| List fields | notes: List<String> |
| Optional chain in tests | record.adopter?.name |
| State machine with multiple paths | pending can go to adopted OR back to available |
| Function with null coalesce | breed ?? "Unknown breed" |
| Record access in actions | record.notes.append(params.note) |
See Also
- Versioning & Migration -- Add versioning to existing modules
- Expressions Deep Dive -- Complete expression reference
- Type System -- All type rules