Objects Working Together: Collaborators and Passing Them In
20 min · Intermediate
Need help? Ask a tutor on WhatsApp (opens in a new tab)Real programs are made of objects that call each other. One object does a small job and asks others for the rest. The design question is who creates each object and who holds it.
A whole with many parts
Collaborator is an object that another object calls on to get part of its work done. An Order and its LineItem objects are collaborators.
An order is a list of lines, and each line knows its own price and quantity. The Order asks each line for its subtotal and adds them up.
class Order: def __init__(self): self.items = []
def add(self, item): self.items.append(item)
def total(self): return sum(item.subtotal() for item in self.items)Example 1 · An order adds up its lines
class LineItem: def __init__(self, name, price, qty): self.name = name self.price = price self.qty = qty
def subtotal(self): return self.price * self.qty
class Order: def __init__(self): self.items = []
def add(self, item): self.items.append(item)
def total(self): return sum(item.subtotal() for item in self.items)
order = Order()order.add(LineItem("Keyboard", 1500, 1))order.add(LineItem("Mouse", 500, 2))print(f"Lines: {len(order.items)}")print(f"Total: {order.total()}")Lines: 2 Total: 2500
What this code does
totalasks every LineItem for its subtotal.- 1500 + 500 * 2 gives 2500.
The Order never reads price or qty itself. It asks each LineItem, so a change in how a line is priced stays inside LineItem.
Pass collaborators in
Dependency injection means giving an object the collaborators it needs from outside, usually through __init__, instead of letting it create them inside.
If OrderService builds its own email sender inside, you cannot change it. Instead the caller creates the sender and hands it to __init__.
class OrderService: def __init__(self, sender): self.sender = sender
def confirm(self, customer): self.sender.send(customer, "Order confirmed")Example 2 · Choose the sender when you create the service
class EmailSender: def send(self, to, text): print(f"email to {to}: {text}")
class SmsSender: def send(self, to, text): print(f"sms to {to}: {text}")
class OrderService: def __init__(self, sender): self.sender = sender
def confirm(self, customer): self.sender.send(customer, "Order confirmed")
OrderService(EmailSender()).confirm("Ravi")OrderService(SmsSender()).confirm("Ravi")email to Ravi: Order confirmed sms to Ravi: Order confirmed
What this code does
- The same OrderService code works with two different senders.
- The caller decides which collaborator to pass in.
OrderService only needs an object with a send method. Email, SMS or anything else will do.
A stand-in for testing
Fake object is a simple stand-in for a real collaborator, used in tests. It has the same methods but records calls instead of doing real work such as sending an email.
A test should not send real emails. Because the sender is passed in, the test hands over a fake one that only remembers what it was asked to send.
class FakeSender: def __init__(self): self.sent = []
def send(self, to, text): self.sent.append((to, text))Example 3 · Test with a fake sender
class FakeSender: def __init__(self): self.sent = []
def send(self, to, text): self.sent.append((to, text))
class OrderService: def __init__(self, sender): self.sender = sender
def confirm(self, customer): self.sender.send(customer, "Order confirmed")
fake = FakeSender()OrderService(fake).confirm("Ravi")print(fake.sent)[('Ravi', 'Order confirmed')]What this code does
- Nothing is sent over a network.
- The test checks the recorded call instead.
The service cannot tell the difference. It calls send, and the fake stores the call so the test can check it.
A bank with accounts
A Bank keeps its accounts in a dictionary keyed by number. A transfer withdraws first. If the withdraw fails, nothing is deposited.
bank = Bank()bank.open("A1", 1000)bank.open("B1", 200)bank.transfer("A1", "B1", 300)Example 4 · Transfer between accounts
class Account: def __init__(self, number, balance=0): self.number = number self.balance = balance
def deposit(self, amount): if amount <= 0: raise ValueError("amount must be positive") self.balance += amount
def withdraw(self, amount): if amount > self.balance: raise ValueError("insufficient funds") self.balance -= amount
class Bank: def __init__(self): self._accounts = {}
def open(self, number, balance=0): account = Account(number, balance) self._accounts[number] = account return account
def get(self, number): return self._accounts[number]
def transfer(self, source, target, amount): self._accounts[source].withdraw(amount) self._accounts[target].deposit(amount)
bank = Bank()bank.open("A1", 1000)bank.open("B1", 200)bank.transfer("A1", "B1", 300)print("A1:", bank.get("A1").balance)print("B1:", bank.get("B1").balance)try: bank.transfer("A1", "B1", 5000)except ValueError as error: print("Failed:", error)print("A1:", bank.get("A1").balance)A1: 700 B1: 500 Failed: insufficient funds A1: 700
What this code does
- The failed transfer stops at withdraw, so B1 is not touched.
- Balances stay at 700 and 500 after the error.
Example 5 · Two holders share one account
class Account: def __init__(self, number, balance=0): self.number = number self.balance = balance
def deposit(self, amount): if amount <= 0: raise ValueError("amount must be positive") self.balance += amount
def withdraw(self, amount): if amount > self.balance: raise ValueError("insufficient funds") self.balance -= amount
class Bank: def __init__(self): self._accounts = {}
def open(self, number, balance=0): account = Account(number, balance) self._accounts[number] = account return account
def get(self, number): return self._accounts[number]
def transfer(self, source, target, amount): self._accounts[source].withdraw(amount) self._accounts[target].deposit(amount)
class Holder: def __init__(self, name, account): self.name = name self.account = account
bank = Bank()joint = bank.open("C1", 100)asha = Holder("Asha", joint)ravi = Holder("Ravi", joint)asha.account.deposit(50)print(ravi.account.balance)print(asha.account is ravi.account)150 True
What this code does
- Both holders point to one Account object, not two copies.
- A deposit through Asha is visible through Ravi.
The Bank coordinates. Each Account guards its own balance and raises ValueError for bad amounts.
Tell, do not dig
Tell, don't ask is a design habit: ask an object to do a job with one method call instead of reaching into its parts and doing the job yourself.
Callers should ask an object to do a job, not reach through it to its parts. order.total() is better than adding order.items[0].price yourself.
order = Order()order.add(LineItem("Keyboard", 1500, 1))
# Digging: the caller knows how Order stores thingsprice = order.items[0].price * order.items[0].qty# Telling: the caller only knows total()price = order.total()If Order later stores lines in a dict, only the telling version keeps working.
Next step
Check your understanding
10 quick questions