Capstone: A CLI App
Reviewed & published by Brayan K
Kotlin is a modern, concise language, and in this capstone you'll combine everything you've learned — data classes, enums, objects, collections, and when — to build a complete command-line task manager.
Part of the free Kotlin course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
By the end of this lesson you'll have assembled the data model, a singleton manager, and a command-line interface into one working program.
What You'll Build in This Lesson
- A Task data model with an enum and defaults
- Immutable updates with copy()
- A TaskManager singleton owning the state
- Collection operations to filter and summarize
- A when-based command dispatcher
- A CLI loop tying it all together
1️⃣ The Data Model
Start with the data. A Task is a data class using an enum for priority and default arguments. Because it's immutable, we update it with copy() rather than mutation.
// === The data model ===
// A sealed class makes the task's status a closed set of cases.
enum class Priority { LOW, MEDIUM, HIGH }
// A data class gives us toString, equals, and copy for free.
data class Task(
val id: Int,
val title: String,
val priority: Priority = Priority.MEDIUM,
val done: Boolean = false
)
fun main() {
val task = Task(1, "Write report", Priority.HIGH)
println(task) // -> Task(id=1, title=Write report, priority=HIGH, done=false)
// copy() to mark it done (immutable update):
val finished = task.copy(done = true)
println(finished) // -> ...done=true
}That single data class line gave us a readable toString and a copy for immutable updates — concepts straight from earlier lessons.
2️⃣ The Manager (Singleton + Collections)
Next, an object singleton owns the list of tasks and exposes operations built from collection functions like filter, count, and indexOfFirst.
enum class Priority { LOW, MEDIUM, HIGH }
data class Task(val id: Int, val title: String,
val priority: Priority = Priority.MEDIUM, val done: Boolean = false)
// === The manager: a singleton that owns the task list ===
object TaskManager {
private val tasks = mutableListOf<Task>()
private var nextId = 1
fun add(title: String, priority: Priority = Priority.MEDIUM): Task {
val task = Task(nextId++, title, priority)
tasks.add(task)
return task
}
fun complete(id: Int) {
val index = tasks.indexOfFirst { it.id == id }
if (index >= 0) tasks[index] = tasks[index].copy(done = true)
}
// Collection operations build the summary:
fun pending(): List<Task> = tasks.filter { !it.done }
fun report(): String {
val done = tasks.count { it.done }
return "$done/${tasks.size} done, ${pending().size} pending"
}
}
fun main() {
TaskManager.add("Buy milk", Priority.LOW)
TaskManager.add("Ship release", Priority.HIGH)
TaskManager.add("Email team")
TaskManager.complete(1)
TaskManager.pending().forEach { println("• ${it.title} [${it.priority}]") }
println(TaskManager.report())
}One completed task out of three leaves two pending — and the report is assembled entirely from collection operations. Notice how complete swaps in a copy instead of mutating the task.
3️⃣ The CLI (when Dispatch)
Finally, a command-line layer parses each line and dispatches with a when expression, using safe parsing (toIntOrNull) and string helpers. In a real app you'd read lines with readLine(); here we simulate input.
enum class Priority { LOW, MEDIUM, HIGH }
data class Task(val id: Int, val title: String,
val priority: Priority = Priority.MEDIUM, val done: Boolean = false)
object TaskManager {
private val tasks = mutableListOf<Task>()
private var nextId = 1
fun add(title: String, p: Priority = Priority.MEDIUM) = Task(nextId++, title, p).also { tasks.add(it) }
fun complete(id: Int) {
val i = tasks.indexOfFirst { it.id == id }
if (i >= 0) tasks[i] = tasks[i].copy(done = true)
}
fun all(): List<Task> = tasks
}
// === The CLI loop: parse a command and dispatch with 'when' ===
fun handle(line: String): String = when (val cmd = line.trim().substringBefore(" ")) {
"add" -> {
val title = line.substringAfter(" ", "")
val task = TaskManager.add(title)
"Added #${task.id}: ${task.title}"
}
"done" -> {
val id = line.substringAfter(" ").toIntOrNull()
if (id == null) "Usage: done <id>"
else { TaskManager.complete(id); "Completed #$id" }
}
"list" -> TaskManager.all().joinToString("\n") {
"${if (it.done) "[x]" else "[ ]"} #${it.id} ${it.title}"
}
else -> "Unknown command: $cmd"
}
fun main() {
// Simulate user input (in a real app: read from readLine()):
val script = listOf("add Buy milk", "add Ship release", "done 1", "list")
for (line in script) {
println("> $line")
println(handle(line))
}
}Every command flows through one when, keeping the logic in one readable place — the same pattern you saw in the control-flow lesson, now driving a real app.
Your turn. Replace the TODO, then run and compare.
// 🎯 YOUR TURN — replace each TODO then run main()
data class Note(val id: Int, val text: String, val pinned: Boolean = false)
object NoteBook {
private val notes = mutableListOf<Note>()
private var nextId = 1
fun add(text: String): Note {
val note = Note(nextId++, text)
notes.add(note)
return note
}
// 1) Return only pinned notes using filter.
fun pinned(): List<Note> = notes.TODO { it.pinned } // 👉 replace TODO with 'filter'
fun count(): Int = notes.size
}
fun main() {
NoteBook.add("First")
NoteBook.add("Second")
println("Total: ${NoteBook.count()}") // expect Total: 2
println("Pinned: ${NoteBook.pinned().size}") // expect Pinned: 0
}Mini-Challenge: Extend the App
Add a feature of your own — high-priority filtering, grouping by priority, or a new command — combining the patterns from the whole course.
// 🎯 MINI-CHALLENGE: extend the task app.
// Add a feature of your own to the TaskManager pattern above.
//
// Ideas (pick one and implement it):
// 1. fun highPriority(): List<Task> using filter { it.priority == Priority.HIGH }.
// 2. fun byPriority(): Map<Priority, List<Task>> using groupBy { it.priority }.
// 3. A "stats" command that prints report() in your CLI's when block.
//
// Then write a small main() that exercises your new feature and
// prints a result you can verify by hand.
//
// ✅ Example (for highPriority on two tasks, one HIGH):
// High priority: 1
fun main() {
// your code here — combine data classes, object, collections, and when
}Common Errors (and the fix)
- Mutating a data class field — val properties can't be reassigned. Replace the element with copy(...).
- Crash on bad input — "x".toInt() throws. Use toIntOrNull() and handle null.
- Non-exhaustive when — As an expression it needs an else for unknown commands.
- Forgetting the \n in joinToString — Use joinToString("\n") to put each task on its own line.
Pro Tips
- 💡 Separate concerns: keep the model, the manager, and the CLI in distinct pieces — easy to test and extend.
- 💡 Parse once: isolate command parsing in one function (handle) so the input source can change freely.
- 💡 Keep going: grow this into a Ktor server or a Compose UI — the core logic stays the same.
📋 Quick Reference — Concepts Used
| Concept | Used For |
|---|---|
| data class | The Task model + copy() |
| enum class | Priority values |
| object | The TaskManager singleton |
| filter/count/groupBy | Reports and summaries |
| when | Command dispatch |
| toIntOrNull | Safe input parsing |
🎉 Course Complete!
- ✅ You combined data classes, enums, and defaults into a model
- ✅ You used an object singleton to own state
- ✅ You built reports with collection operations
- ✅ You dispatched commands with a when expression
- ✅ You updated state immutably with copy()
- 🎓 Congratulations — you've finished the Kotlin course! Keep building.
Practice quiz
What does declaring Task as a data class give you for free?
- Thread safety
- A database connection
- toString, equals, hashCode, and copy
- Automatic logging
Answer: toString, equals, hashCode, and copy. data classes auto-generate toString, equals, hashCode, and copy.
Why does the capstone update a task with copy(done = true) instead of mutating it?
- Because Task's val properties make it immutable
- Because copy is faster
- Because data classes forbid methods
- Because the list is read-only
Answer: Because Task's val properties make it immutable. val properties can't be reassigned, so an immutable copy replaces the old task.
What does the object keyword create for TaskManager?
- A new class per call
- An abstract class
- A data class
- A singleton with one shared instance
Answer: A singleton with one shared instance. object declares a singleton that owns the shared task list.
What does tasks.filter { !it.done } return?
- The count of done tasks
- A new list of the not-done tasks
- The original list mutated
- A single task
Answer: A new list of the not-done tasks. filter returns a new list keeping elements where the lambda is true.
Why is toIntOrNull() used to parse a command id instead of toInt()?
- It returns null on bad input instead of throwing
- It is faster
- It rounds the number
- It parses doubles
Answer: It returns null on bad input instead of throwing. toIntOrNull avoids a crash on invalid input by returning null.
What dispatches each command in the CLI's handle function?
- A long if/else only
- A for loop
- A when expression with an else branch
- A try/catch
Answer: A when expression with an else branch. Commands flow through one when expression, with else for unknown commands.
Why must the command-dispatching when have an else branch?
- To improve performance
- As an expression it must be exhaustive and handle unknown commands
- Because when always needs else
- To allow break
Answer: As an expression it must be exhaustive and handle unknown commands. A when used as an expression must cover every case, hence else.
What does the Priority enum represent?
- A mutable list of strings
- A nullable Int
- A companion object
- A closed set of named values: LOW, MEDIUM, HIGH
Answer: A closed set of named values: LOW, MEDIUM, HIGH. An enum class defines a fixed set of named constants.
What does priority: Priority = Priority.MEDIUM in the Task constructor demonstrate?
- A secondary constructor
- A default argument value
- Operator overloading
- A reflection call
Answer: A default argument value. It is a default parameter value used when the caller omits priority.
What does count { it.done } compute on the task list?
- A list of done tasks
- The first done task
- The number of tasks where done is true
- The total task count
Answer: The number of tasks where done is true. count with a predicate returns how many elements satisfy it.
Continue this course
- Previous: Checkpoint: Idiomatic Kotlin
- Next: Jetpack Compose Basics — Declarative UI with @Composable functions, Text, Button, Column and Row
- Quick reference: Kotlin cheat sheet
Frequently asked questions
Which concepts from the course does this capstone combine?
Almost all of them. The Task is a data class (free toString/equals/copy) using an enum (Priority) and default arguments. TaskManager is an object singleton holding mutable state, with functions that use collection operations like filter, count, groupBy, indexOfFirst, and joinToString. The CLI dispatch uses a when expression with smart parsing, and immutable updates are done with copy() rather than mutation. Null handling appears via toIntOrNull. It's a tour of the whole curriculum in one small program.
Why update tasks with copy() instead of changing them in place?
Because Task is built from val properties, it is immutable — you can't reassign its fields. To 'complete' a task you create a new Task that is identical except for done = true, using copy(done = true), and replace the old one in the list. This immutable-update style makes state changes explicit and predictable, avoids accidental shared-mutation bugs, and is the same pattern used in modern UI frameworks and state stores. The list itself is mutable, so swapping one element for its updated copy is cheap.
How would I make this read real user input?
Replace the simulated script list with a loop that calls readLine() to read a line from standard input each iteration, stopping when the user types something like quit or when readLine() returns null (end of input). Pass each line to the same handle function. Because the parsing and dispatch logic is already separated into handle, swapping the input source doesn't change the core logic — a sign of well-structured code.
Where should I go after finishing this course?
Great next steps include: building a slightly larger CLI or a small Ktor web server to apply the same patterns; exploring coroutines more deeply for concurrent I/O; trying Jetpack Compose for Android or Compose Multiplatform for desktop UIs; and reading the official Kotlin documentation and standard-library reference, which are excellent. Most importantly, keep writing programs — pick a small real problem you have and solve it in Kotlin. Fluency comes from building.