LearnAI ToolsCareerPractice BuildsPlayContact
SwiftIntermediate~2 hours

Inventory Tracker

Model products with structs, filter and sort stock levels, and report low-stock items using protocols.

StructsProtocolsCollections

Overview

An inventory tracker is where Swift protocols earn their keep. Every `Product` needs to answer two questions the same way — "am I low on stock?" and "what does my current stock line look like?" — and a `protocol` is how Swift describes that shared contract without forcing every conforming type into a rigid class hierarchy. `Product` conforms to `StockReportable` simply by implementing the two computed properties the protocol requires; any future type that needs the same low-stock behavior could conform to the exact same protocol and slot into the exact same reporting code with zero changes to that code.

By the end of this tutorial you will have a console application built around a `Product` struct and a `StockReportable` protocol it conforms to, plus an `InventoryManager` struct that owns the catalog and exposes restock, sell, and low-stock-report operations. You will also lean on Swift's collection operations — `filter`, `sorted(by:)`, and `first(where:)` — to search and rank products instead of writing manual index-tracking loops.

What You'll Build
  • A `StockReportable` protocol describing `isLowStock` and `stockSummary`.
  • A `Product` struct with `sku`, `name`, `quantity`, and `reorderThreshold` fields that conforms to it.
  • An `InventoryManager` struct owning an `[Product]` catalog.
  • `restock(sku:amount:)` and `sell(sku:amount:)` methods that validate before ever changing quantity.
  • A `lowStockReport()` built with `filter`, typed against the protocol rather than the concrete struct.
  • A `sortedByQuantity()` report using `sorted(by:)` to surface the most urgent items first.

Prerequisites

  • Structs — stored properties, computed properties, and `mutating` methods.
  • Protocols — declaring a `protocol` and having a `struct` conform to it.
  • Collections — `Array`, `filter`, `sorted(by:)`, and `first(where:)`/`firstIndex(where:)`.
  • Optionals — `guard let` for a lookup that might not find a match.
  • Closures — the trailing-closure syntax used by `filter`, `sorted`, and `first(where:)`.

Project Structure

The program lives in a single file, `main.swift`. `StockReportable` is declared before `Product` since `Product` needs the protocol to already exist in order to conform to it. `InventoryManager` owns the actual `[Product]` array and is the only type that ever mutates it — every menu action goes through a method on `InventoryManager`, the same ownership pattern used by `TaskManager` and `StudentManager` in the other Swift projects in this course, just applied here to stock levels instead of tasks or grades.

Step 1: Define the Product Struct and StockReportable Protocol

`StockReportable` describes "anything that can report on its own stock level" as two read-only computed requirements. Declaring it as a separate protocol, rather than just writing these two properties directly on `Product`, means any future type — a bundled multi-pack, a backordered item — could conform to the same protocol and be reported on by the exact same low-stock logic below, without that reporting code needing to know those other types exist.

// A protocol describing "anything that can report on its own stock level."
// Declaring it separately from Product means any future type could conform
// to the same protocol and be reported on with the exact same low-stock
// logic below, without that logic needing to know those types exist.
protocol StockReportable {
var isLowStock: Bool { get } // true once quantity has fallen to or below its reorder threshold
var stockSummary: String { get } // A single human-readable line describing current stock
}
// Product conforms to StockReportable by implementing both computed
// properties the protocol requires — Swift checks at compile time that every
// requirement is actually satisfied, or the struct simply won't compile.
struct Product: StockReportable {
let sku: String // Stock-keeping unit; unique identifier, never changes after creation
let name: String
var quantity: Int // var: quantity changes constantly as stock is sold or restocked
let reorderThreshold: Int // At or below this quantity, the product counts as low stock
// Computed, not stored — isLowStock is always derived from quantity and
// reorderThreshold, so there is no separate Bool that could ever drift
// out of sync with the numbers it's supposed to reflect.
var isLowStock: Bool {
quantity <= reorderThreshold
}
var stockSummary: String {
let status = isLowStock ? " — LOW STOCK" : ""
return "\(name) (\(sku)): \(quantity) in stock\(status)"
}
}

Step 2: Build the InventoryManager and Add Products

`findProduct(sku:)` returns `Product?`, not `Product`, because the sku the caller asks for might not exist in the catalog at all — `first(where:)` already returns an Optional for exactly this reason, so the method just forwards that Optional straight to its own caller instead of forcing an unwrap here.

struct InventoryManager {
private var products: [Product] = [] // Every product currently tracked
mutating func addProduct(_ product: Product) {
products.append(product)
}
// Returns Product?, not Product, because the sku the caller asks for
// might not exist in the catalog at all.
func findProduct(sku: String) -> Product? {
products.first { $0.sku == sku }
}
}

Step 3: Restock Products

`restock(sku:amount:)` chains two conditions inside one `guard`: the sku must actually exist in the catalog, found through `firstIndex(where:)`, and `amount` must be positive — a restock of zero or a negative amount is rejected before `products[index].quantity` is ever touched.

// `mutating` because this writes to one Product's quantity inside `products`.
mutating func restock(sku: String, amount: Int) -> Bool {
guard let index = products.firstIndex(where: { $0.sku == sku }), amount > 0 else {
return false // Either the sku doesn't exist, or the caller passed a non-positive amount
}
products[index].quantity += amount
return true
}

Step 4: Sell Products

`sell(sku:amount:)` adds a third condition to the same `guard` pattern from Step 3: `products[index].quantity >= amount`, which rejects selling more units than are actually in stock. All three checks live in one `guard`, so a failure for any reason — unknown sku, non-positive amount, or insufficient stock — takes the exact same early-exit path, keeping the "happy path" below it free of nested `if` statements.

mutating func sell(sku: String, amount: Int) -> Bool {
guard let index = products.firstIndex(where: { $0.sku == sku }),
amount > 0,
products[index].quantity >= amount else {
return false // Rejects an unknown sku, a non-positive amount, or selling more than is in stock
}
products[index].quantity -= amount
return true
}

Step 5: Filter and Sort for a Low-Stock Report

`lowStockReport()` is typed to return `[StockReportable]`, not `[Product]`, on purpose — this function only actually needs the two properties the protocol guarantees, so typing its result against the protocol rather than the concrete struct means it would keep working unchanged for any future type that also conforms to `StockReportable`. `sortedByQuantity()` uses `sorted(by:)`, which returns a brand-new array in ascending order without mutating `products` itself, putting the most urgent (lowest-quantity) products first.

// Returns [StockReportable], not [Product] — this function only needs the
// two properties the protocol guarantees, so typing its result against the
// protocol keeps it working unchanged for any future StockReportable type.
func lowStockReport() -> [StockReportable] {
products.filter { $0.isLowStock }
}
// sorted(by:) returns a new array; ascending order puts the most urgent
// (lowest-quantity) products first without mutating `products` itself.
func sortedByQuantity() -> [Product] {
products.sorted { $0.quantity < $1.quantity }
}
func listAll() {
for product in sortedByQuantity() {
print(product.stockSummary) // Reuses the same summary the low-stock report uses
}
}
func printLowStockReport() {
let lowStock = lowStockReport()
if lowStock.isEmpty {
print("No products are currently low on stock.")
return
}
print("\n--- Low Stock Report ---")
for item in lowStock {
print(item.stockSummary) // Calling through the protocol, not the concrete Product type
}
}

Step 6: Build the Menu Loop

When adding a product, `Int(readLine() ?? "") ?? 0` chains two nil-coalescing operators: the first supplies an empty string if the line can't be read at all, and the second supplies `0` if that string can't be parsed as a number — either way, the menu loop never crashes on bad input, it just falls back to a sensible default.

var manager = InventoryManager()
var running = true
while running {
print("""
===== INVENTORY TRACKER =====
1. Add Product
2. List All Products
3. Restock
4. Sell
5. Low Stock Report
6. Exit
""")
print("Enter your choice: ", terminator: "")
guard let choiceLine = readLine(), let choice = Int(choiceLine) else {
print("Invalid input, try again.")
continue
}
switch choice {
case 1:
print("Enter SKU: ", terminator: "")
let sku = readLine() ?? ""
print("Enter product name: ", terminator: "")
let name = readLine() ?? ""
print("Enter starting quantity: ", terminator: "")
let quantity = Int(readLine() ?? "") ?? 0 // Non-numeric input safely becomes a starting quantity of 0
print("Enter reorder threshold: ", terminator: "")
let threshold = Int(readLine() ?? "") ?? 0
manager.addProduct(Product(sku: sku, name: name, quantity: quantity, reorderThreshold: threshold))
print("Product added.")
case 2:
manager.listAll()
case 3:
print("Enter SKU to restock: ", terminator: "")
let sku = readLine() ?? ""
print("Enter amount: ", terminator: "")
if let amount = Int(readLine() ?? "") {
print(manager.restock(sku: sku, amount: amount) ? "Restocked." : "Restock failed.")
}
case 4:
print("Enter SKU to sell: ", terminator: "")
let sku = readLine() ?? ""
print("Enter amount: ", terminator: "")
if let amount = Int(readLine() ?? "") {
print(manager.sell(sku: sku, amount: amount) ? "Sale recorded." : "Sale failed — check stock level.")
}
case 5:
manager.printLowStockReport()
case 6:
print("Goodbye!")
running = false
default:
print("Invalid choice, try again.")
}
}

Complete Code

Here is the full program assembled in the correct order, ready to save as `main.swift` and run with `swift main.swift`, or paste into a Swift Playground.

protocol StockReportable {
var isLowStock: Bool { get }
var stockSummary: String { get }
}
struct Product: StockReportable {
let sku: String
let name: String
var quantity: Int
let reorderThreshold: Int
var isLowStock: Bool {
quantity <= reorderThreshold
}
var stockSummary: String {
let status = isLowStock ? " — LOW STOCK" : ""
return "\(name) (\(sku)): \(quantity) in stock\(status)"
}
}
struct InventoryManager {
private var products: [Product] = []
mutating func addProduct(_ product: Product) {
products.append(product)
}
func findProduct(sku: String) -> Product? {
products.first { $0.sku == sku }
}
mutating func restock(sku: String, amount: Int) -> Bool {
guard let index = products.firstIndex(where: { $0.sku == sku }), amount > 0 else {
return false
}
products[index].quantity += amount
return true
}
mutating func sell(sku: String, amount: Int) -> Bool {
guard let index = products.firstIndex(where: { $0.sku == sku }),
amount > 0,
products[index].quantity >= amount else {
return false
}
products[index].quantity -= amount
return true
}
func lowStockReport() -> [StockReportable] {
products.filter { $0.isLowStock }
}
func sortedByQuantity() -> [Product] {
products.sorted { $0.quantity < $1.quantity }
}
func listAll() {
for product in sortedByQuantity() {
print(product.stockSummary)
}
}
func printLowStockReport() {
let lowStock = lowStockReport()
if lowStock.isEmpty {
print("No products are currently low on stock.")
return
}
print("\n--- Low Stock Report ---")
for item in lowStock {
print(item.stockSummary)
}
}
}
var manager = InventoryManager()
var running = true
while running {
print("""
===== INVENTORY TRACKER =====
1. Add Product
2. List All Products
3. Restock
4. Sell
5. Low Stock Report
6. Exit
""")
print("Enter your choice: ", terminator: "")
guard let choiceLine = readLine(), let choice = Int(choiceLine) else {
print("Invalid input, try again.")
continue
}
switch choice {
case 1:
print("Enter SKU: ", terminator: "")
let sku = readLine() ?? ""
print("Enter product name: ", terminator: "")
let name = readLine() ?? ""
print("Enter starting quantity: ", terminator: "")
let quantity = Int(readLine() ?? "") ?? 0
print("Enter reorder threshold: ", terminator: "")
let threshold = Int(readLine() ?? "") ?? 0
manager.addProduct(Product(sku: sku, name: name, quantity: quantity, reorderThreshold: threshold))
print("Product added.")
case 2:
manager.listAll()
case 3:
print("Enter SKU to restock: ", terminator: "")
let sku = readLine() ?? ""
print("Enter amount: ", terminator: "")
if let amount = Int(readLine() ?? "") {
print(manager.restock(sku: sku, amount: amount) ? "Restocked." : "Restock failed.")
}
case 4:
print("Enter SKU to sell: ", terminator: "")
let sku = readLine() ?? ""
print("Enter amount: ", terminator: "")
if let amount = Int(readLine() ?? "") {
print(manager.sell(sku: sku, amount: amount) ? "Sale recorded." : "Sale failed — check stock level.")
}
case 5:
manager.printLowStockReport()
case 6:
print("Goodbye!")
running = false
default:
print("Invalid choice, try again.")
}
}

Sample Run

Sample Run

Click Run to see what this code prints.

Extend This Project

  • Add a `Supplier` type and a `reorder(product:)` function that "places an order" automatically once `isLowStock` becomes true.
  • Track a `priceHistory: [Double]` per product and add an `averagePrice` computed property.
  • Persist the catalog to a JSON file using `Codable` with `JSONEncoder`/`JSONDecoder`.
  • Add a `category: String` field and a `report(for category:)` function built with `filter`.
  • Make `Product` conform to `Comparable` so `products.sorted()` works directly without a custom closure.

Summary

You built an inventory tracker where a `protocol` defined a shared contract — "can report on your own stock level" — that `Product` conforms to, and the low-stock report was written against that protocol instead of the concrete struct. That is the core payoff of protocol-oriented Swift: `lowStockReport()` genuinely does not care whether it is iterating `Product` values or some other `StockReportable` type introduced later, because it only ever calls the two properties the protocol promises. Combined with `filter` and `sorted(by:)` for querying the catalog, that is the same pattern you will reach for anywhere a Swift program needs to report on a collection of things that can each answer the same small set of questions about themselves.