CoursePython · Functions, Composition, and Prompts as Code · part 14 of 79
Part 14 · Functions, Composition, and Prompts as Code

Lesson 1: Function Design

12 min read·9 Sept 2026

The problem this lesson solves

Open the recipe-extractor project from the previous modules and you will find something that happens to every codebase left alone for a few weeks. The cleaning logic exists in four places. One version strips whitespace and lowercases. One strips whitespace, lowercases, and removes HTML tags. One does all of that plus collapses repeated newlines. The fourth was written last Tuesday and nobody remembers why it differs.

When a bug turns up in cleaning, fixing it means finding all four, deciding which behaviour was correct, and hoping nothing depended on the wrong one.

This is not a discipline problem. It is what happens when functions are written as somewhere to put code rather than as designed units. This lesson is about designing them, and the four topics that follow build on the result.

One job, honest name, predictable return

Three properties make a function reusable. Miss any one and the function becomes something people copy rather than call.

One job. A function should do a single thing that can be described without the word "and". Consider this:

python
def process_document(path: Path) -> dict:
    content = path.read_text(encoding="utf-8")
    content = content.strip().lower()
    content = re.sub(r"<[^>]+>", "", content)
    word_count = len(content.split())
    if word_count < 10:
        logger.warning(f"Document {path} is very short")
    save_to_database(path, content, word_count)
    return {"path": str(path), "words": word_count}

Describe it honestly and you get: it reads a file and cleans the text and counts words and logs a warning and writes to a database and returns a summary. Six jobs. You cannot reuse the cleaning without also touching the database. You cannot test the word count without a filesystem. You cannot change the cleaning rules without reading database code.

Split by responsibility:

python
def read_document(path: Path) -> str:
    return path.read_text(encoding="utf-8")


def clean_text(raw: str) -> str:
    without_tags = re.sub(r"<[^>]+>", "", raw)
    return without_tags.strip().lower()


def count_words(text: str) -> int:
    return len(text.split())

Each of these is now reusable, testable in isolation, and changeable without reading anything else.

Honest name. The name should say what the function does, including the parts you would rather not advertise.

python
def get_user(user_id: str) -> User:          # dishonest if it also writes a cache entry
def validate_email(address: str) -> str:      # dishonest if it also normalises the address
def load_config() -> Settings:                # dishonest if it also exits the program on failure

A name starting with getload, or fetch promises a read. If the function also changes something, the name is lying, and the person who calls it in a loop will be surprised. If you cannot name a function honestly in a few words, that is usually a sign it is doing more than one job.

Predictable return. A function should return one kind of thing, always.

python
def parse_servings(raw: str):
    if not raw:
        return None
    if raw.isdigit():
        return int(raw)
    if "-" in raw:
        return raw.split("-")          # returns a list, sometimes
    return raw                          # returns a string, sometimes

Four possible return types. Every caller must handle all four, and the type checker cannot help because the signature promises nothing. Callers will handle the two cases they happened to encounter in testing.

python
def parse_servings(raw: str) -> int | None:
    """Return a serving count, or None if the input does not contain one."""
    stripped = raw.strip()
    if not stripped:
        return None
    if stripped.isdigit():
        return int(stripped)
    match = re.match(r"(\d+)\s*-\s*\d+", stripped)   # "4-6" becomes 4
    if match:
        return int(match.group(1))
    return None

Two possible outcomes, both in the signature. The caller handles absence once and is done. Note that "predictable" does not mean "never returns None". It means the set of possible returns is small, declared, and consistent.

Input, Validate, Transform, Output

Most functions that do real work have the same internal shape, and naming that shape makes functions easier to write and much easier to read.

python
def summarize_document(doc: Document, max_words: int = 100) -> Summary:
    # Input: everything needed arrives as parameters
    
    # Validate: reject what cannot be handled, early
    if max_words <= 0:
        raise ValueError(f"max_words must be positive, got {max_words}")
    if doc.status != "valid":
        raise ValueError(f"Cannot summarize document with status {doc.status!r}")

    # Transform: the actual work, with no surprises left
    words = doc.content.split()
    truncated = " ".join(words[:max_words])
    
    # Output: one constructed result, returned once
    return Summary(
        document_id=doc.id,
        text=truncated,
        was_truncated=len(words) > max_words,
    )

The validation section is the guard clause pattern from the previous module, applied at the top of every function that takes input it does not control. By the time the transform section starts, every assumption has been checked, so the working code has no defensive branches in it.

Two things worth noticing. The transform section is the only part that varies between functions, which means most of a function is predictable structure. And the output is constructed in one place and returned once, rather than being assembled through mutation across several branches, which is much easier to follow.

[IMAGE PROMPT M3-1
Purpose: Show the four-part internal shape shared by well-designed functions, and where errors exit.
Visual type: Vertical process diagram with an exit branch.
Prompt: A clean educational diagram showing a tall rounded rectangle labelled at the top "one function", divided into four stacked horizontal bands reading top to bottom. Band 1 is labelled "Input" with the sub-label "parameters arrive". Band 2 is labelled "Validate" with the sub-label "guard clauses, reject early". Band 3 is labelled "Transform" with the sub-label "the actual work, no defensive checks". Band 4 is labelled "Output" with the sub-label "construct once, return once". A downward arrow connects each band to the next. From band 2, a separate arrow branches out to the right to a small box labelled "raise", with a caption beneath reading "bad input never reaches Transform". To the right of band 3, a bracket is labelled "the only part that differs between functions".
Required elements: Four labelled bands in order with sub-labels, connecting downward arrows, a branching exit arrow from the Validate band to a raise box, a bracket beside the Transform band.
Style: Clean educational illustration, professional, uncluttered, high contrast, flat vector, evenly sized bands.
Layout: Single vertical column reading top to bottom, with the exit branch and bracket to the right.
Text labels: "one function", "Input", "parameters arrive", "Validate", "guard clauses, reject early", "Transform", "the actual work, no defensive checks", "Output", "construct once, return once", "raise", "bad input never reaches Transform", "the only part that differs between functions".
Aspect ratio: 4:3
Accessibility: Rely on band order, labels, and arrow direction rather than colour to convey sequence.
Avoid: Code screenshots, decorative icons, tiny text, logos, watermarks, clutter.
Alt text: Vertical diagram of a function divided into input, validate, transform, and output bands, with a branch from the validate band to a raise box showing that invalid input exits before reaching the transform stage.
END IMAGE PROMPT]

Parameters, defaults, keyword-only arguments, *args and **kwargs

Positional and keyword arguments. Parameters can be passed by position or by name. Position is concise and keyword is explicit.

python
def chunk_text(text: str, size: int, overlap: int) -> list[str]: ...

chunk_text(content, 500, 50)                        # what are 500 and 50?
chunk_text(content, size=500, overlap=50)           # clear

The first call is ambiguous at the reading site, and swapping the two numbers produces working code with wrong behaviour. Use keyword arguments for anything that is not obvious from the function name, especially numbers and booleans.

Defaults. A default makes a parameter optional and declares the sensible choice.

python
def chunk_text(text: str, size: int = 500, overlap: int = 50) -> list[str]: ...

Two rules. Parameters with defaults must come after those without. And the default must never be a mutable object, for the reason covered in the previous modules.

python
def add_tag(doc: Document, tags: list[str] = []) -> Document:      # shared between calls
def add_tag(doc: Document, tags: list[str] | None = None) -> Document:   # correct

Keyword-only arguments. Placing a bare * in the signature forces everything after it to be passed by name.

python
def chunk_text(text: str, *, size: int = 500, overlap: int = 50) -> list[str]: ...

chunk_text(content, 500, 50)              # TypeError
chunk_text(content, size=500, overlap=50) # required form

This is worth doing whenever a function takes more than one option, and especially when it takes booleans or several numbers of the same type. It prevents the swapped-argument bug entirely, and it means you can add or reorder options later without breaking existing callers, since nobody is depending on position.

A useful convention: make the data positional and everything else keyword-only. chunk_text(content, size=..., overlap=...) reads well and cannot be called wrongly.

*args and kwargs.** These collect any number of extra positional or keyword arguments.

python
def log_event(name: str, *args: object, **kwargs: object) -> None:
    print(name, args, kwargs)

log_event("parsed", 1, 2, doc_id="abc", duration_ms=12)
# parsed (1, 2) {'doc_id': 'abc', 'duration_ms': 12}

args is a tuple of the extra positional values. kwargs is a dict of the extra keyword values.

Use them for genuine pass-through, where a function must accept arguments it does not itself understand and hand them onward. This is exactly what decorators do, which is why they appear again in Lesson 3.

python
def retry(func, *args, **kwargs):
    """Call func with whatever arguments it was given, retrying on failure."""
    for attempt in range(3):
        try:
            return func(*args, **kwargs)
        except TransientError:
            continue
    raise

Do not use them to avoid deciding on a signature. A function declared as def process(**kwargs) documents nothing, autocompletes nothing, and turns a misspelled parameter name into silent default behaviour rather than an error.

Pure versus impure, and why pure parts are testable

pure function has two properties. Given the same inputs it always returns the same output, and it changes nothing outside itself.

python
# Pure
def clean_text(raw: str) -> str:
    return re.sub(r"<[^>]+>", "", raw).strip().lower()


# Impure: reads the filesystem
def load_document(path: Path) -> str:
    return path.read_text(encoding="utf-8")


# Impure: writes to a database
def save_document(doc: Document) -> None:
    database.insert(doc)


# Impure: output depends on the clock
def make_document_id() -> str:
    return f"doc_{datetime.now().timestamp()}"


# Impure: calls a network service
def summarize_with_model(text: str) -> str:
    return client.generate(prompt=f"Summarize: {text}")

Anything a function does beyond computing its return value is a side effect. Reading a file, writing to a database, sending a request, printing, mutating a global, and appending to a list that was passed in are all side effects.

Why this distinction earns its place. Pure functions are trivially testable, because a test is a call and an assertion.

python
def test_clean_text_removes_tags():
    assert clean_text("<p>Hello</p>") == "hello"

No setup, no database, no mocking, no network, no cleanup, and no flakiness. It runs in microseconds and it will produce the same result on every machine forever.

Testing an impure function means arranging the world first. A temporary directory, a test database, a fake HTTP server, a frozen clock. All of that is possible and a later module covers how, but it is slower to write, slower to run, and more fragile.

The design consequence. Side effects cannot be eliminated, since a program that touches nothing does nothing useful. What you can do is push them to the edges and keep the middle pure.

python
# Impure shell: does the input and output
def process_corpus(folder: Path, output: Path) -> None:
    raw_documents = [p.read_text(encoding="utf-8") for p in folder.iterdir()]

    documents = build_documents(raw_documents)          # pure
    valid, rejected = partition_by_status(documents)    # pure
    report = build_report(valid, rejected)              # pure

    output.write_text(report.to_json())


# Pure core: all the decisions
def build_documents(raw: list[str]) -> list[Document]: ...
def partition_by_status(docs: list[Document]) -> tuple[list[Document], list[Document]]: ...
def build_report(valid: list[Document], rejected: list[Document]) -> Report: ...

Every rule, every edge case, and every judgement lives in the three pure functions, which are tested exhaustively with plain values. The impure function contains no logic worth testing, only the reading and writing, so one integration test covering it is enough.

This shape has a name in wider use, the functional core with an imperative shell, and it is the single most useful structural idea in this lesson.

[IMAGE PROMPT M3-2
Purpose: Show the functional core and imperative shell pattern, and how testing effort differs between the two regions.
Visual type: Concentric architecture diagram with an annotated comparison.
Prompt: A clean educational diagram showing two concentric rounded rectangles. The outer ring is labelled "impure shell" with the sub-label "files, database, network, clock" and contains small icons or boxes on its left edge labelled "read" and on its right edge labelled "write". The inner rectangle is labelled "pure core" and contains three small connected boxes labelled "build_documents", "partition_by_status", and "build_report", linked left to right by arrows. An arrow enters from the left edge of the outer ring, passes into the core, and exits through the right edge. To the right of the whole figure sit two stacked annotation boxes: the upper reads "pure core: fast tests, plain values, no setup" and the lower reads "impure shell: few tests, real resources, slow".
Required elements: Two concentric regions correctly labelled, three named functions inside the core, read and write points on the outer ring, a single flow arrow passing through, two annotation boxes on the right.
Style: Clean educational illustration, professional, uncluttered, high contrast, flat vector, generous whitespace.
Layout: Concentric figure occupying the left two thirds, annotation boxes stacked on the right.
Text labels: "impure shell", "files, database, network, clock", "pure core", "build_documents", "partition_by_status", "build_report", "read", "write", "pure core: fast tests, plain values, no setup", "impure shell: few tests, real resources, slow".
Aspect ratio: 16:9
Accessibility: Distinguish the two regions using containment and explicit text labels rather than colour alone.
Avoid: Vendor logos, decorative icons, screenshots, tiny text, watermarks, clutter.
Alt text: Concentric diagram showing an impure outer shell handling files, databases, network, and clock, surrounding a pure core of three named transformation functions, with annotations noting that the core is fast to test while the shell needs few and slower tests.
END IMAGE PROMPT]

Composition and pipelines

When functions each do one job and return a predictable type, they connect.

python
def prepare(raw: str) -> Document:
    cleaned = clean_text(raw)
    normalized = normalize_whitespace(cleaned)
    return build_document(normalized)

That is composition, meaning the output of one function becoming the input of the next. It works only because the types line up: clean_text returns a string, normalize_whitespace takes a string, and so on. Types are what make composition possible, which is why the previous module put so much weight on them.

For a repeated sequence, express the pipeline as data rather than as a chain of calls:

python
from collections.abc import Callable

TextTransform = Callable[[str], str]

CLEANING_PIPELINE: list[TextTransform] = [
    strip_html,
    normalize_whitespace,
    normalize_unicode,
    lowercase,
]


def apply_pipeline(text: str, transforms: list[TextTransform]) -> str:
    for transform in transforms:
        text = transform(text)
    return text


cleaned = apply_pipeline(raw, CLEANING_PIPELINE)

Callable[[str], str] is a type hint meaning "a function taking one string and returning a string". Naming it TextTransform makes the list's contents self-explanatory.

Three things this buys you. The pipeline is now visible in one place, so a reader can see the whole cleaning process without following four function calls. It is configurable, since a different corpus can use a different list. And it is testable at two levels, meaning each transform on its own and the composed pipeline as a whole.

This also solves the problem the lesson opened with. There are no longer four cleaning implementations. There is one list, and any variation is a different list built from the same tested pieces.

Concept check. You have a function def enrich(doc: Document) -> Document that fetches metadata from an API, adds it to the document, writes an audit log line, and returns the result. Is it pure? What would you change and why?

Answer

It is impure twice over. It calls a network service, so the same input can produce different outputs, and it writes a log line, which is a side effect.

The useful change is to split the fetch from the merge. fetch_metadata(doc_id) -> Metadata is impure and small, containing no logic. merge_metadata(doc, metadata) -> Document is pure and contains all the decisions about how fields combine, what wins on conflict, and what happens when metadata is missing. Those decisions are the part with bugs in it, and they can now be tested with plain values and no network at all.

The audit log is a side effect belonging to the caller rather than to a data transformation. Move it out to the impure shell.