An order knows which SKUs were requested and in what quantities. A catalog knows the current unit price of a SKU. Those responsibilities cooperate, but an order is not a kind of catalog. Composition expresses the relationship directly: an Order has a Catalog collaborator and asks it for prices.
This separation lets several orders use the same catalog while maintaining
independent quantities. It also makes the price policy visible. In this lab,
total() is a live quote: changing a catalog price changes existing orders’ next
totals. A historical invoice would need captured prices and a different contract.
Never let a convenient implementation accidentally decide that business rule.
Pass a collaborator into the constructor
Dependency injection can be this small: accept the object needed to do the job as a constructor parameter. There is no framework involved. Order retains that object and calls its public interface; it does not reach into its internal price dictionary or copy the collaborator behind the caller’s back.
class Catalog:
def __init__(self, prices):
self._prices = dict(prices)
def price(self, sku):
return self._prices[sku]
def set_price(self, sku, cents):
self._prices[sku] = cents
class Quote:
def __init__(self, catalog):
self._catalog = catalog
self._units = {}
def add(self, sku, units):
self._catalog.price(sku)
self._units[sku] = self._units.get(sku, 0) + units
def total(self):
return sum(self._catalog.price(sku) * units
for sku, units in self._units.items())
catalog = Catalog({"tea": 100})
quote = Quote(catalog)
quote.add("tea", 2)
print(quote.total())
catalog.set_price("tea", 125)
print(quote.total())This runnable example assumes valid counts and prices. The graded classes add strict type/range checks, deterministic line snapshots and failure guarantees. Unknown SKU lookup happens before the order stores a new line, so a failed addition cannot leave a ghost entry behind.
Ownership is a separate choice for each edge
The catalog copies the caller’s initial mapping. That blocks later edits through an alias from bypassing catalog validation. The order instead retains the Catalog object itself: this sharing is the declared live-pricing behavior. Each order also creates its own quantities dictionary. “Always copy everything” and “always retain references” would both violate some part of this contract.
The lines() query returns sorted immutable pairs. A caller can retain a tuple
without gaining write access to current quantities. Sorting makes the result
independent of insertion order and easier to compare in tests. The total uses
integer cents, avoiding floating rounding questions in this introductory model.
A zero price and an empty order are valid and both can produce total zero.
Do not cache total unless you also design an invalidation rule. Catalog prices can change without any call to the order, so a cached value could silently become stale. Looking up each price when queried is the simpler correct implementation.
Before coding, sketch two orders pointing to one catalog. Change the original input mapping, then change the catalog, then add a second line to one order. Predict each total and snapshot. The bug hunts target lost quantities and unit prices counted only once; the fill practices delegation. The complete build must make these ownership and failure decisions observable through public methods.