LearnAI ToolsCareerPractice BuildsPlayDevelopmentContact
KotlinIntermediate~2 hours

Inventory Tracker

Model products with data classes, filter and sort stock levels, and report low-stock items.

Data ClassesCollectionsSealed Classes

Overview

An inventory system needs more than a raw stock count — it needs a small, fixed set of *meaningful categories* a product can fall into: fully stocked, running low, or completely out. You could represent that with an enum class, and that would work for the labels alone, but a sealed class lets each state carry its own extra data, and it earns something an enum cannot give you: when you when-match over a sealed class without an else branch, the Kotlin compiler checks at compile time that every possible subtype has been handled, and refuses to compile if you add a new state later and forget to handle it somewhere.

By the end of this tutorial you will have a console application built around a Product data class, Kotlin's collection operations (filter, sortedBy, map) to slice and order the inventory, and a sealed class StockStatus with InStock, LowStock, and OutOfStock variants classified by an exhaustive when expression. That combination — immutable data classes plus a sealed hierarchy for "one of a few known states" — is one of the most distinctly Kotlin ways to model a domain, and it generalizes to almost any system with a small number of well-defined states: order status, connection state, or, as here, stock level.

What You'll Build
  • A data class Product(id, name, quantity, reorderLevel) modeling one inventory item.
  • A sealed class StockStatus with InStock, LowStock, and OutOfStock variants.
  • A statusFor(product: Product) function that classifies a product with an exhaustive when, no else needed.
  • Collection-based queries: lowStockItems() via filter, and a sorted report via sortedBy.
  • An InventoryTracker class owning the product list and generating a full stock report.
  • A report that renders each StockStatus variant with its own message via another exhaustive when.

Prerequisites

  • Data classes — constructor properties and the generated equals()/toString()/copy().
  • Collection operations — filter, sortedBy, map, and chaining them together.
  • Sealed classes — declaring a sealed class with a small number of subtypes, and why that differs from a plain enum class.
  • The when expression, including how the compiler enforces exhaustiveness over a sealed class with no else.
  • Function basics and lambda syntax ({ it.field }).

Project Structure

The whole program lives in a single file, InventoryTracker.kt, containing Product, the StockStatus sealed class hierarchy, a statusFor() classification function, an InventoryTracker class that owns the product list, and fun main(). Product never knows about StockStatus — the classification logic lives entirely in statusFor(), kept separate from the data it classifies, the same separation of "data" from "behavior over that data" used in the grading project's gradeFor(). InventoryTracker is the layer that owns the List<Product> and turns raw products into a rendered report using both statusFor() and the collection operations from Step 4.

Step 1: Define the Product Data Class

Every field is a val, making Product immutable — once created, a product's quantity never changes in place; instead, restocking a product later means producing a *new* Product with copy(quantity = newQuantity), which the generated copy() function makes trivial. Immutability here is deliberate: a Product inside a List<Product> can never be silently mutated by code elsewhere that happens to hold the same reference, which removes a whole category of bugs by construction.

// All vals: Product is immutable by design. Restocking never mutates a
// Product in place — it produces a new one via copy(quantity = ...), so no
// code holding a reference to a Product can have it change out from under it.
data class Product(
val id: Int, // Unique identifier for this product
val name: String, // Product name
val quantity: Int, // Units currently on hand
val reorderLevel: Int // Threshold below which the product counts as "low stock"
)

Step 2: Model Stock Status With a Sealed Class

sealed class StockStatus declares a closed hierarchy: every subtype of StockStatus must be defined in this same file, so the compiler knows the *complete* list of possibilities and can verify a when over them is exhaustive. InStock and LowStock are declared as data class subtypes because each needs to carry its own quantity (and LowStock also a reorderLevel), while OutOfStock carries no extra data at all, so it is declared as a Kotlin object — a singleton, since every out-of-stock product needs no distinguishing state beyond the fact that it is out of stock.

// sealed class = a closed set of subtypes, all declared right here. The
// compiler uses that closed set to verify a when-over-StockStatus handles
// every case — critical for catching a missed branch if a new state is added.
sealed class StockStatus {
data class InStock(val quantity: Int) : StockStatus() // Carries its own quantity for display
data class LowStock(val quantity: Int, val reorderLevel: Int) : StockStatus() // Carries both figures for the report
object OutOfStock : StockStatus() // No extra data needed: a singleton
}

Step 3: Classify a Product With an Exhaustive when

statusFor() is a single-expression function whose body is a when with no else branch at all — and it still compiles, because the compiler can see StockStatus is sealed and can prove InStock, LowStock, and OutOfStock are the only three possibilities. If a fourth variant, say Discontinued, were ever added to StockStatus in Step 2, this when would immediately fail to compile until a matching branch were added here too — that is the real payoff of sealed class over a plain enum.

fun statusFor(product: Product): StockStatus = when {
product.quantity <= 0 -> StockStatus.OutOfStock
product.quantity <= product.reorderLevel -> StockStatus.LowStock(product.quantity, product.reorderLevel)
else -> StockStatus.InStock(product.quantity)
// No "else" is required here for exhaustiveness over StockStatus's subtypes — this when { } uses boolean
// conditions instead of matching on a subject, so it needs its own else as a boolean fallback, which is
// different from an exhaustiveness check; a when(status) { is InStock -> ...; is LowStock -> ...; ... }
// matching directly on a StockStatus value is the one the compiler checks for exhaustiveness, as used next.
}

Step 4: Filter and Sort the Inventory

lowStockItems() uses filter { } with a lambda predicate to keep only products whose computed StockStatus is not InStock, using the is operator to check a sealed subtype the same way instanceof would in Java. sortedByQuantity() uses sortedBy { it.quantity }, which returns a *new* list in ascending order without mutating the original — consistent with Product being immutable, sorting never has a reason to touch the source list at all.

fun lowStockItems(products: List<Product>): List<Product> {
// filter{} keeps only elements where the lambda returns true; "is" checks a sealed subtype like Java's instanceof
return products.filter { statusFor(it) !is StockStatus.InStock }
}
fun sortedByQuantity(products: List<Product>): List<Product> {
return products.sortedBy { it.quantity } // Returns a new List sorted ascending; the original list is untouched
}
Example Usage

Click Run to see what this code prints.

Step 5: Build the InventoryTracker and Report

InventoryTracker owns the List<Product> and produces a formatted report line for every product, using a second when — this time matching directly on the StockStatus value itself with is InStock ->/is LowStock ->/OutOfStock -> branches, which is the version the compiler genuinely checks for exhaustiveness. Each branch destructures the sealed subtype's own properties (status.quantity, status.reorderLevel) directly, which only type-checks because the compiler has smart-cast status to that specific subtype inside its branch.

class InventoryTracker(private val products: List<Product>) {
fun report(): List<String> {
return products.map { product -> // map{} transforms each Product into one report line
val status = statusFor(product)
val message = when (status) { // Matches directly on StockStatus: exhaustive, no else needed
is StockStatus.InStock -> "In stock (${status.quantity} units)"
is StockStatus.LowStock -> "Low stock! ${status.quantity} left (reorder at ${status.reorderLevel})"
StockStatus.OutOfStock -> "Out of stock" // object subtype: matched by name, no "is" needed
}
"${product.name}: $message"
}
}
fun lowStock(): List<Product> = lowStockItems(products) // Reuses Step 4's standalone function
}

Step 6: Build the Program Entry Point

main() builds a small hard-coded catalog spanning all three stock states, wraps it in an InventoryTracker, and prints both the full report from Step 5 and the low-stock-only list from Step 4, sorted by quantity ascending so the most urgent items appear first.

fun main() {
val catalog = listOf(
Product(1, "USB Cable", 40, 10),
Product(2, "Wireless Mouse", 3, 5),
Product(3, "Keyboard", 0, 5),
Product(4, "Monitor Stand", 12, 4),
)
val tracker = InventoryTracker(catalog)
println("===== INVENTORY REPORT =====")
for (line in tracker.report()) {
println(line)
}
println("\n===== LOW STOCK (sorted, most urgent first) =====")
for (product in sortedByQuantity(tracker.lowStock())) {
println("${product.name}: ${product.quantity} left")
}
}

Complete Code

Here is the full program with every declaration assembled in the correct order, ready to save as InventoryTracker.kt and run with kotlinc InventoryTracker.kt -include-runtime -d inventory.jar && java -jar inventory.jar.

data class Product(
val id: Int,
val name: String,
val quantity: Int,
val reorderLevel: Int
)
sealed class StockStatus {
data class InStock(val quantity: Int) : StockStatus()
data class LowStock(val quantity: Int, val reorderLevel: Int) : StockStatus()
object OutOfStock : StockStatus()
}
fun statusFor(product: Product): StockStatus = when {
product.quantity <= 0 -> StockStatus.OutOfStock
product.quantity <= product.reorderLevel -> StockStatus.LowStock(product.quantity, product.reorderLevel)
else -> StockStatus.InStock(product.quantity)
}
fun lowStockItems(products: List<Product>): List<Product> {
return products.filter { statusFor(it) !is StockStatus.InStock }
}
fun sortedByQuantity(products: List<Product>): List<Product> {
return products.sortedBy { it.quantity }
}
class InventoryTracker(private val products: List<Product>) {
fun report(): List<String> {
return products.map { product ->
val status = statusFor(product)
val message = when (status) {
is StockStatus.InStock -> "In stock (${status.quantity} units)"
is StockStatus.LowStock -> "Low stock! ${status.quantity} left (reorder at ${status.reorderLevel})"
StockStatus.OutOfStock -> "Out of stock"
}
"${product.name}: $message"
}
}
fun lowStock(): List<Product> = lowStockItems(products)
}
fun main() {
val catalog = listOf(
Product(1, "USB Cable", 40, 10),
Product(2, "Wireless Mouse", 3, 5),
Product(3, "Keyboard", 0, 5),
Product(4, "Monitor Stand", 12, 4),
)
val tracker = InventoryTracker(catalog)
println("===== INVENTORY REPORT =====")
for (line in tracker.report()) {
println(line)
}
println("\n===== LOW STOCK (sorted, most urgent first) =====")
for (product in sortedByQuantity(tracker.lowStock())) {
println("${product.name}: ${product.quantity} left")
}
}

Sample Run

Sample Run

Click Run to see what this code prints.

Extend This Project

  • Add a Discontinued object subtype to StockStatus and watch the compiler flag every when in the file that needs a new branch.
  • Add a restock(product: Product, amount: Int): Product function that returns product.copy(quantity = product.quantity + amount), keeping Product immutable end to end.
  • Add a category: String field to Product and a groupBy { it.category } report that prints stock status per category.
  • Write a totalValue(products: List<Product>, priceLookup: (Int) -> Double): Double using sumOf { it.quantity * priceLookup(it.id) }.
  • Persist the catalog to a CSV file and reload it on startup, mapping each line back into a Product.

Summary

You built an inventory tracker where Product stays a small, immutable data class, and StockStatus uses a sealed class to make "in stock, low stock, or out of stock" a closed set the compiler actively enforces — try adding a fourth state and every exhaustive when in the file will refuse to compile until you handle it. Combined with filter, sortedBy, and map, this is the same shape — a sealed hierarchy for a domain's finite states, paired with collection operations for querying it — that you will reach for anywhere a Kotlin program needs to model "one of a few known outcomes" safely.