You already have the three tools: you know how to define and call functions (04-01), you know how to give them parameters and make them return values (04-02), and you know what they should depend on in order to be self-contained pieces (04-03). What is missing is the judgement: faced with a hundred-line program written in one go, where do you cut? How many functions? Of what size? What goes inside each one?

That question has no mechanical answer, but it does have solid, checkable criteria, and this lesson goes through them one by one. At the end we will apply them to the case we have been dragging along since module 3: turning the monolithic wall of easytask.py v0.6 into version 0.7, a program organised into functions with a main() you can read in twenty seconds. What we are about to do has a name of its own: refactoring, that is, reorganising the code without changing what it does. When we finish, EasyTask will behave exactly the same as far as the user is concerned; what changes is everything else — how easy it is to understand, fix and extend.

Contents

  1. One function, one responsibility
  2. Size and level of abstraction
  3. Top-down design: write the main() first
  4. The call tree
  5. Separating input/output from logic
  6. Names that describe the effect
  7. DRY and the opposite excess
  8. The if __name__ == "__main__": block
  9. EasyTask v0.7: the complete refactor
  10. Common mistakes and tips
  11. Exercises
  12. Conclusion

  1. One function, one responsibility

The main criterion is this: a function should do one single thing and do all of it. The practical test is to describe it in a short sentence, with no conjunctions. If you need an "and", you have two functions.

Description of the function Verdict
"Asks for an integer between two limits" One responsibility
"Paints the card of a task" One responsibility
"Asks for the data and saves it and warns on screen" Three responsibilities
"Validates the title and updates the state" Two responsibilities

The advantage of respecting it is not aesthetic, it is economic. A function with a single responsibility has a single reason to change. The day Alba Studio decides to accept days with decimals, you touch ask_integer and nothing else. If that same function also painted the card, changing the card's format would force you to touch validation code, with the risk of breaking it without noticing.

  1. Size and level of abstraction

From the previous rule a typical size follows: between three and twenty lines. It is not a law, it is a consequence: a function that takes up sixty lines is nearly always doing several things. And there is a very reliable sign that it has gone too far: if to explain it to somebody you have to say "first it does this, then this other thing, and at the end that one", those three steps want to be three functions. The second criterion, subtler but very powerful, is a uniform level of abstraction: within one function, every line should tell the story at the same height. Compare these two versions of the same thing:

def process_task():                              # MIXED levels
    title, assignee, priority, days = register_task()   # high level
    print("=" * 46)                              # low level
    print(f"{'Title':<14}{title:>32}")           # low level
    print("-" * 46)                              # low level

def process_task():                              # UNIFORM level
    title, assignee, priority, days = register_task()
    show_card(title, assignee, priority, days, False)
    pause()

The second reads like a summary of what happens; the first forces you to read formatting code to find out the plot. The mnemonic rule: a function should read like a table of contents, not like a novel.

  1. Top-down design: write the main() first

Top-down design consists of writing the main program first as if the functions you need already existed, and only then implementing them. It is counter-intuitive and works surprisingly well, because it forces you to decide what you need before getting lost in the how:

def main():
    """Main loop of the application."""
    while True:
        show_menu()
        option = ask_option("Choose an option (1-5): ", OPTIONS)
        if option == "1":
            register_task()
        elif option == "5":
            break
        pause()

None of those functions exists yet, and yet you have already taken the important decisions: how many pieces there are, what they are called and what each one receives. That sketch is at once a work plan and a to-do list. The practical trick for not getting stuck is to fill the gaps with empty functions containing only a pass or a provisional print. That way the program runs from the very first minute and you can replace those skeletons with real implementations one at a time, checking after each step. It is far better than writing the whole thing and running it for the first time at the end, when any error could be anywhere.

  1. The call tree

Breaking things down produces a layered structure: main() calls action functions and these call helper functions. Drawing it helps to spot imbalances:

flowchart TD
    M["main()"] --> MM["show_menu() and pause()"]
    M --> PO["ask_option()"]
    M --> RT["register_task()"]
    M --> MF["show_card()"]
    M --> CP["change_priority()"]
    M --> MC["mark_completed()"]
    RT --> PT["ask_text()"]
    RT --> PO
    RT --> PE["ask_integer()"]
    CP --> PO
    MF --> CU["classify_urgency()"]
    MC --> CF["confirm()"]

Three things this tree tells you at a glance. First, that there are two clear levels: actions at the top and utilities at the bottom. Second, that ask_option is used by three different functions: it is the most reused piece in the program, and that confirms it was worth extracting. And third, that no branch goes deeper than three levels, a sign that the program is not over-fragmented.

  1. Separating input/output from logic

This is the rule that improves a program most per line of effort: functions that compute do not print and do not ask. The work is shared out among three families:

Family What it does Examples in EasyTask
Input Asks the user and validates ask_text, ask_integer, confirm
Logic Computes and decides, in silence classify_urgency
Output Presents results show_menu, show_card

Why does it matter so much? Because a pure logic function is checkable: you give it some inputs, look at the returned value and know whether it is right, without typing anything or looking at the screen. classify_urgency("high", 2) must return "CRITICAL", and that can be verified a thousand times in a second; if that function printed instead of returning, the only way to check it would be to run the application and read with your eyes. This idea is the basis of automated testing. There is also a more immediate benefit: if tomorrow EasyTask acquires a graphical interface or starts producing a report in a file, all the logic is saved and only the input and output functions change.

  1. Names that describe the effect

A function's name is a contract with whoever reads it, and there are four signals that are easy to apply. The first is prefixes: show_* prints and returns nothing useful, get_* or calculate_* return and do not print, ask_* questions the user; sticking to them means the reader guesses right without opening the function. The second: names with "and" give away two responsibilities, so validate_and_save() is not a long name but a diagnosis — split it into validate() and save(). The third: avoid generic names such as process, manage or data, because if you cannot find a concrete name it is usually because the function has no concrete purpose. And the fourth: the name must warn about the effects; a function called calculate_total that also resets a counter is lying, and whoever uses it will get a nasty surprise.

  1. DRY and the opposite excess

DRYDon't Repeat Yourself — is the principle stating that every piece of knowledge must live in one single place in the program. It is what we have been applying since 04-01. The practical way to apply it is to ask yourself, faced with two similar chunks, what makes them different. If the difference is a value — a message, a range, a catalogue — that value wants to be a parameter and the two chunks want to be one function; if the difference is structural, perhaps they are not the same thing and forcing them together makes both of them worse. Because the opposite excess also exists, over-fragmentation: splitting things into tiny functions that contribute nothing, such as a def add_one(n): return n + 1 or a def is_zero(n): return n == 0. These functions make the program longer and force the reader to jump from place to place only to discover they did the obvious. A function deserves to exist if at least one of these three conditions holds: it is used more than once, its name explains something the code does not say by itself, or it hides a complexity that would clutter the caller. classify_urgency meets the second; ask_integer meets all three; add_one meets none.

  1. The if __name__ == "__main__": block

You will have seen these two lines at the end of almost any Python program, after all the definitions:

if __name__ == "__main__":
    main()

__name__ is a variable Python creates automatically in every file. When the file is run directly (python easytask.py), its __name__ holds "__main__" and the condition is true, so main() starts. When the file is imported from another one to make use of its functions, __name__ holds instead the name of the file ("easytask"), the condition is false and the application does not launch itself. That is what you want: to be able to reuse classify_urgency from another program without the menu popping up on you. The complete theory of importing is Modules, packages and imports; for now adopt the form, which is always the same: define main(), put it at the end alongside the other definitions, and close the file with that two-line block.

  1. EasyTask v0.7: the complete refactor

Let us recall the starting point. v0.6 was a single block of almost a hundred consecutive lines, of this shape:

# v0.6 (abridged): everything in one single block
has_task = False                       # and five more state variables
while True:
    print("\n" + "=" * WIDTH)          # 8 consecutive lines of menu
    option = input("Choose an option (1-5): ").strip()
    while option not in OPTIONS:       # validation of the option
        option = input("Invalid option. Choose 1-5: ").strip()
    if option == "1":
        ...                            # 15 lines: confirmation + FOUR validations
    elif option == "2":
        if not has_task:               # repeated check (1 of 3)
            ...
    elif option == "3":
        if not has_task:               # repeated (2 of 3) + duplicated validation
            ...
    elif option == "4":
        if not has_task:               # repeated check (3 of 3)
            ...
    input("\nPress Enter to return to the menu...")

And here is the complete v0.7, with the functions grouped by families:

# easytask.py - Alba Studio
# Version 0.7: program broken down into functions

WIDTH = 46
PRIORITIES = ("high", "medium", "low")
TEAM = ("marta", "luis", "nuria")
OPTIONS = ("1", "2", "3", "4", "5")

# --- Input: they ask and validate ---
def ask_text(message, required=True):
    """Ask for a text and return it without leftover spaces."""
    value = input(message).strip()
    while required and value == "":
        value = input("It cannot be empty. " + message).strip()
    return value

def ask_option(message, options):
    """Ask for a value until it is one of the accepted options."""
    value = input(message).strip().lower()
    while value not in options:
        value = input(f"Not valid. Allowed: {', '.join(options)}. ").strip().lower()
    return value

def ask_integer(message, minimum, maximum):
    """Ask for an integer in the given range and return it converted."""
    text = input(message).strip()
    while not text.isdigit() or not minimum <= int(text) <= maximum:
        text = input(f"Integer between {minimum} and {maximum}. ").strip()
    return int(text)

def confirm(message):
    """Return True only if the user answers 'y'."""
    return input(f"{message} (y/n): ").strip().lower() == "y"

# --- Logic: it computes, it does not print ---
def classify_urgency(priority, days):
    """Return the urgency label that corresponds to the task."""
    if priority == "high" and days <= 2:
        return "CRITICAL"
    if priority == "high":
        return "Urgent"
    if priority == "medium" and days <= 3:
        return "Attention"
    return "Normal"

# --- Output: they print, they do not compute ---
def show_menu():
    """Paint the header and the five available options."""
    print("\n" + "=" * WIDTH)
    print(f"{'EasyTask v0.7 - Alba Studio':^{WIDTH}}")
    print("=" * WIDTH)
    print(" 1. Register / replace the task")
    print(" 2. View the task card")
    print(" 3. Change the priority")
    print(" 4. Mark as completed")
    print(" 5. Quit")
    print("-" * WIDTH)

def show_card(title, assignee, priority, days, completed):
    """Paint the full card of the given task."""
    status = "Completed" if completed else "Pending"
    print("-" * WIDTH)
    print(f"{'Title':<14}{title:>{WIDTH - 14}}")
    print(f"{'Assignee':<14}{f'{assignee} ({priority})':>{WIDTH - 14}}")
    print(f"{'Days / status':<14}{f'{days} / {status}':>{WIDTH - 14}}")
    print(f"{'Urgency':<14}{classify_urgency(priority, days):>{WIDTH - 14}}")

def pause():
    """Halt execution until the user presses Enter."""
    input("\nPress Enter to return to the menu...")

# --- Menu actions ---
def register_task():
    """Ask for the data of a task and return the four fields."""
    title = ask_text("Title         : ")
    assignee = ask_option("Assignee      : ", TEAM).capitalize()
    priority = ask_option("Priority      : ", PRIORITIES)
    days = ask_integer("Days (1-365)  : ", 1, 365)
    return title, assignee, priority, days

def change_priority(title, priority):
    """Ask for a new priority and return the one now in force."""
    new_priority = ask_option("New priority  : ", PRIORITIES)
    print(f"Priority of '{title}': {priority} -> {new_priority}")
    return new_priority

def mark_completed(title, completed):
    """Return the completed status after asking the user."""
    if completed:
        print("The task was already completed.")
        return True
    if confirm(f"Mark '{title}' as completed?"):
        print("Task completed. Good work.")
        return True
    print("Operation cancelled.")
    return False

def main():
    """Run the main loop of the application."""
    has_task = False
    title = ""
    assignee = ""
    priority = ""
    days = 0
    completed = False

    while True:
        show_menu()
        option = ask_option("Choose an option (1-5): ", OPTIONS)

        if option == "5":
            if confirm("Are you sure you want to quit?"):
                print("See you later. EasyTask is closing.")
                break
        elif option == "1":
            if has_task and not confirm(f"'{title}' already exists. Replace it?"):
                print("Operation cancelled.")
            else:
                title, assignee, priority, days = register_task()
                completed = False
                has_task = True
                print("Task registered successfully.")
        elif not has_task:
            print("There is no task registered yet.")
        elif option == "2":
            show_card(title, assignee, priority, days, completed)
        elif option == "3":
            priority = change_priority(title, priority)
        elif option == "4":
            completed = mark_completed(title, completed)

        pause()

if __name__ == "__main__":
    main()

What has changed, point by point:

  • The main loop fits in twenty lines and reads like a decision table. There is no longer a single input or a formatting print inside it: only calls to named functions.
  • if not has_task: was written three times; now it is written once. The trick is the elif not has_task: placed after options 5 and 1 — the only two that work without a registered task — and before options 2, 3 and 4. If there is no task, the elif chain stops there and none of the three runs.
  • The four duplicated validations are now three functions, and ask_option serves for the menu, for the assignee and for the priority. confirm(message) also unifies the three confirmations and locks into a single place the decision that only "y" confirms.
  • register_task() returns four values that are unpacked in one go (04-02), and change_priority and mark_completed return the new value instead of modifying it on their own account: no function touches state that does not belong to it, as 04-03 demands.
v0.6 v0.7
Total lines ~95 ~150
Lines in the main loop ~75 22
Longest function 75 (the whole loop) 10 (show_menu)
Validation loops written 5 3 (in functions)
not has_task checks 3 1
Functions reusable outside the program 0 8

Yes: the file has more lines than before. That is normal in a refactor and it is not a bad bargain, because what is measured is not how much space the program takes up but how much you have to read to understand one part of it: before, to know what option 3 did, you had to read the seventy-five lines of the loop; now you read four. There is, mind you, one loose end left, and it is a big one. Look at the header of show_card: five parameters that are always the same five, travelling together from function to function. And look at the beginning of main(): six loose variables declared one after another. Those six variables are one single thing — a task — that still has no way of being one single thing in our code. When you meet tuples and nested structures you will be able to group them, and when you reach classes you will be able to give them a name of their own and behaviour. Until then, they travel hand in hand.

Common Mistakes and Tips

Extracting functions that still depend on globals. Moving ten lines into a def is not breaking down if those lines carry on reading and writing variables of the main program. Check every function with the cut-out test from 04-03. And functions that do two things usually give themselves away in the name (validate_and_save) or when you describe them: split them, even if each half ends up very short.

Mixing levels of abstraction. A main() with a print("=" * 46) in the middle of high-level calls: that print belongs in show_menu. The opposite defect is over-fragmenting: twenty two-line functions used only once are as hard to read as a block of two hundred. Refactoring and adding functionality at the same time. It is the recipe for not knowing what broke what. First reorganise leaving the behaviour identical, check that everything still works the same, and only then add the new thing. And do not forget the if __name__ == "__main__":, nor leave loose code at the left margin between the definitions: it will run the moment somebody imports the file.

Tip: refactor in small steps and run after each one. Extract a function, run, check; if something fails, you know exactly which step caused it. And write the main() the way you would like it to be: if reading it lets you understand the program without opening any other function, the breakdown is a good one, and into the bargain you have the best documentation your program will ever have.

Exercises

Exercise 1: Break down a script

This program works but is written in one go. Identify its responsibilities, propose the names of the functions you would split it into — stating the parameters and what each one returns — and write the resulting main().

name = input("Client: ").strip()
while name == "":
    name = input("It cannot be empty. Client: ").strip()
hours = input("Hours: ").strip()
while not hours.isdigit() or int(hours) < 1:
    hours = input("Integer greater than 0. Hours: ").strip()
hours = int(hours)
amount = hours * 55.0
if hours > 40:
    amount = amount * 0.9          # 10% volume discount
print("=" * 40)
print(f"Client: {name}")
print(f"Amount: {amount:.2f} EUR")

Exercise 2: Diagnose a breakdown

A colleague has split their program into these three functions. Point out four design defects, each with the criterion from the lesson that it breaks, and propose an alternative.

def process():                           # declares 'global total'
    data = input("Value: ")              # asks the user for a value
    total = total + int(data)            # updates the global
    print(f"Total: {total}")             # prints it
    return total                         # and returns it as well

def add_two(a, b):
    return a + b

def validate_and_show_client(name):      # if empty it warns; otherwise it prints it
    ...

Solutions

Solution 1. There are four responsibilities: ask for the validated name, ask for the validated hours, compute the amount with its discount and present the receipt. The discount logic must be kept apart from the input and the output.

RATE = 55.0
WIDTH = 40

def ask_text(message):
    """Ask for a compulsory text and return it cleaned up."""
    value = input(message).strip()
    while value == "":
        value = input("It cannot be empty. " + message).strip()
    return value

def ask_positive_integer(message):
    """Ask for an integer greater than zero and return it converted."""
    text = input(message).strip()
    while not text.isdigit() or int(text) < 1:
        text = input("Integer greater than 0. " + message).strip()
    return int(text)

def calculate_amount(hours, rate=RATE):
    """Return the amount, with a 10% discount above 40 hours."""
    amount = hours * rate
    if hours > 40:
        return amount * 0.9
    return amount

def show_receipt(name, amount):
    """Paint the framed receipt for the client."""
    print("=" * WIDTH)
    print(f"Client: {name}")
    print(f"Amount: {amount:.2f} EUR")

def main():
    """Ask for the job details and show the receipt."""
    name = ask_text("Client: ")
    hours = ask_positive_integer("Hours: ")
    show_receipt(name, calculate_amount(hours))

if __name__ == "__main__":
    main()

Notice that calculate_amount neither prints nor asks: you can check by hand that calculate_amount(40) gives 2200.0 and calculate_amount(41) gives 2029.5, without running the program. And that the main() has three lines that read like the statement of the problem.

Solution 2.

Defect Criterion broken Alternative
process uses global total A function must not depend on changing global state (04-03) def accumulate(total, value): return total + value
process asks, computes, prints and returns One function, one responsibility Split into ask_integer, accumulate and show_total
add_two is over-fragmentation Not reused, clarifies nothing, hides no complexity Write a + b wherever it is needed
validate_and_show_client has "and" in the name Two responsibilities: validating and presenting def is_valid_name(name): return name != "" plus show_client(name)

Besides, the name process is generic and gives no warning that it modifies the global state. An honest name for what it does would be ask_value_and_accumulate_it_into_total: its very ugliness proves the function is badly split.

Conclusion

Breaking a program down is not chopping it into pieces: it is finding its parts. The criteria are few and checkable: one function, one responsibility, verifiable with the test of describing it without using "and"; a size that rarely goes beyond twenty lines; a uniform level of abstraction within each function; input, logic and output in separate families, because silent logic is the only kind that can really be checked; names that announce the effect; DRY so as not to repeat yourself, and common sense so as not to over-fragment. Top-down design — writing the main() as if the functions already existed — is the most comfortable way to apply all of that, and the if __name__ == "__main__": block closes the file so it can be either run or imported.

EasyTask has reached version 0.7. It does exactly the same as v0.6 — not one new feature as far as Marta is concerned — but its main loop has gone from seventy-five lines to twenty-two, the duplication has disappeared and eight of its functions can be reused as they are in another program. The task's state, mind you, still lives in six loose variables that have to be passed from function to function: that is the debt modules 5 and 7 will come to collect. One last surprise remains before we close the module. So far functions have been things you call; but in 04-01 you saw in passing that pause without parentheses is a value with its own type(). If a function is a value, it can be stored in a variable, passed as an argument to another function and returned from another. That opens up a different way of programming, and it is the module's closing piece: Functions as values: lambda and higher order.

© Copyright 2026. All rights reserved