Commands and queries
A method that changes state and also returns data is hard to reason about: calling it just to look is no longer safe. Keep the two apart. A command changes something and returns nothing. A query returns data and changes nothing, so it can run as often as you like.
class Cart:
def __init__(self):
self._items = {}
def add(self, sku, qty): # command
n = self._items.get(sku, 0)
self._items[sku] = n + qty
def count(self): # query
return sum(self._items.values())
cart = Cart()
cart.add("MUG", 2)
cart.add("PEN", 1)
print(cart.count())At the application level the same split separates a write side, which runs commands through the domain, from a read side, which answers queries from data shaped for the screen. Commands become small dataclasses that name an intention, each handled in one place.
Who chooses the id?
If adding returns nothing, how does the caller learn the new id? Let the caller choose it, for example with uuid4(), and put it in the command.
Your task
Write frozen dataclasses AddTask(task_id, title) and CompleteTask(task_id), and TaskList.execute(command) that applies either one and returns None, raising ValueError for an id that already exists, KeyError for completing an unknown id and TypeError for anything else. Write the queries open_titles(), the titles not yet done in the order they were added, and done_count().
The explanation opens here once every check passes.