LearnAI ToolsCareerPractice BuildsPlayDevelopmentContact
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.