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.
- A
StockReportableprotocol describingisLowStockandstockSummary. - A
Productstruct withsku,name,quantity, andreorderThresholdfields that conforms to it. - An
InventoryManagerstruct owning an[Product]catalog. restock(sku:amount:)andsell(sku:amount:)methods that validate before ever changing quantity.- A
lowStockReport()built withfilter, typed against the protocol rather than the concrete struct. - A
sortedByQuantity()report usingsorted(by:)to surface the most urgent items first.
Prerequisites
- Structs — stored properties, computed properties, and
mutatingmethods. - Protocols — declaring a
protocoland having astructconform to it. - Collections —
Array,filter,sorted(by:), andfirst(where:)/firstIndex(where:). - Optionals —
guard letfor a lookup that might not find a match. - Closures — the trailing-closure syntax used by
filter,sorted, andfirst(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
Click Run to see what this code prints.
Extend This Project
- Add a
Suppliertype and areorder(product:)function that "places an order" automatically onceisLowStockbecomes true. - Track a
priceHistory: [Double]per product and add anaveragePricecomputed property. - Persist the catalog to a JSON file using
CodablewithJSONEncoder/JSONDecoder. - Add a
category: Stringfield and areport(for category:)function built withfilter. - Make
Productconform toComparablesoproducts.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.