Skip to main content
Learning Center
Python Programming

Module 11: Object-Oriented Python and Maintainability

Two Designs for One Feature, and How to Choose

Writing the same matching rule as a function and as a class, comparing them on evidence rather than taste, and recognising the code smells that decide it either way.

Lesson 36 of 46 in the recommended order · About 30 min (estimate)

On this page

Outcome

By the end of this lesson you can write the same feature in both a functional and an object-oriented shape, demonstrate that they behave identically, and defend a choice between them with reasons someone else can dispute.

Why it matters

"Should this be a class?" gets answered by habit far more often than by argument, and the habit is usually inherited from whichever language someone learned first.

It is worth answering properly, because the cost lands on whoever reads the code next. A class that holds no state adds a level of indirection for nothing. A function threaded with the same six configuration arguments at every call site adds noise and invites one of them to be wrong.

Being able to write both, show they agree, and then say why you kept one is also exactly what a code review asks of you, and what you should ask of an assistant's suggestion.

Concept

The functional shape: data in, result out, configuration passed as arguments. Nothing is stored between calls.

The object-oriented shape: configuration supplied once at construction, held on the instance, reused by every call.

They are the same computation with the configuration in a different place. That framing is more useful than the labels, because it points straight at the criterion: how often does the configuration change relative to the data?

Six criteria, in rough order of how often they decide it:

  1. Repeated configuration. If the same settings apply to a thousand records, an object holding them removes a thousand repetitions and one whole class of mistake.
  2. Number of parameters. Once a function needs five or six arguments that always travel together, they are a thing, and that thing usually wants a name.
  3. Substitutability. Several interchangeable variants, as in the previous lesson, favour objects, because a variant becomes an argument.
  4. Testability. A pure function is the easiest thing to test. A class with no mutable state is nearly as easy. A class that accumulates state across calls is meaningfully harder, and that is a real cost.
  5. Lifetime. Something that must be opened and closed, a connection, a file, a session, is naturally an object, because that lifetime has to live somewhere.
  6. Reader effort. How many files must someone open to answer "what does this do"? Fewer is better, and this criterion overrules the others more often than people expect.

Smells pointing away from a class: no state at all; exactly one method besides __init__; only ever one instance; a name ending in "Manager", "Handler", or "Helper" that does not say what it manages.

Smells pointing away from plain functions: the same three arguments threaded through five call sites; a module-level variable that functions mutate; a growing family of near-identical functions differing only in a constant.

And one rule that applies whichever you pick: the two versions must produce identical results. Prove it before choosing, so the discussion stays about design instead of quietly becoming a behaviour change.

Read the code

RECORDS = [
    {"notice_id": "A-1", "amount": 310000, "set_aside": "Total Small Business"},
    {"notice_id": "A-2", "amount": 42000, "set_aside": "Total Small Business"},
]


def qualifies(record, minimum, required_set_aside):
    """Functional: configuration arrives with every call."""
    return record["amount"] >= minimum and record.get("set_aside") == required_set_aside


class MatchRule:
    """Object-oriented: configuration arrives once."""

    def __init__(self, minimum, required_set_aside):
        self.minimum = minimum
        self.required_set_aside = required_set_aside

    def qualifies(self, record):
        return (
            record["amount"] >= self.minimum
            and record.get("set_aside") == self.required_set_aside
        )

    def shortlist(self, records):
        return [r for r in records if self.qualifies(r)]


rule = MatchRule(100000, "Total Small Business")

functional = [qualifies(r, 100000, "Total Small Business") for r in RECORDS]
object_oriented = [rule.qualifies(r) for r in RECORDS]

print(functional)
print(object_oriented)
print(functional == object_oriented)
print([r["notice_id"] for r in rule.shortlist(RECORDS)])

The condition is character-for-character the same in both, apart from where the two configuration values come from. That is the point: the difference between these designs is not the logic.

The functional call site repeats 100000, "Total Small Business" every time. Over one call that is noise; over five call sites it is five chances for one of them to drift.

shortlist is where the class starts paying for itself. It exists because the rule holds its own configuration, so it can apply itself to a collection without being handed the settings again. The functional equivalent needs the settings threaded through, or a second function that closes over them.

Predict the output

Predict all four printed lines.

Check your prediction
[True, False]
[True, False]
True
['A-1']

The third line is the one that matters. It is the assertion a refactor between these two shapes should be built around, and it is what turns "I rewrote it as a class" into a claim with evidence attached.

Modify the code

Add a third record, {"notice_id": "A-3", "amount": 505000}, with no set_aside key at all. Predict all four lines.

What changes, and why
[True, False, False]
[True, False, False]
True
['A-1']

Both versions handle the missing key identically, because both use record.get("set_aside"), which returns None rather than raising.

They agree because they share the defect. Neither distinguishes "does not match the required category" from "publishes no category", which Module 2 established as a distinction worth keeping. A record that cannot be judged is reported as a plain False.

That is the useful lesson about comparing two designs: agreement proves the refactor preserved behaviour, and says nothing about whether the behaviour was right. Both need the same fix, and it is easier to make once, before choosing which version to keep.

Debug the bug

An assistant was asked to make the matching rule reusable. It produced this.

class MatchRuleManager:
    def __init__(self):
        self.results = []

    def set_config(self, minimum, required_set_aside):
        self.minimum = minimum
        self.required_set_aside = required_set_aside

    def qualifies(self, record):
        outcome = (
            record["amount"] >= self.minimum
            and record.get("set_aside") == self.required_set_aside
        )
        self.results.append(outcome)
        return outcome

    def pass_rate(self):
        return sum(self.results) / len(self.results)


manager = MatchRuleManager()
manager.set_config(100000, "Total Small Business")
print(manager.qualifies({"amount": 310000, "set_aside": "Total Small Business"}))
print(manager.pass_rate())
What's actually wrong

It prints True and 1.0, and there are four problems.

Configuration can be forgotten. qualifies called before set_config raises AttributeError: 'MatchRuleManager' object has no attribute 'minimum'. Anything required to use an object belongs in __init__, where it cannot be skipped.

Configuration can change mid-run. Calling set_config again halfway through a batch means half the results were computed under different rules, and results mixes them with no record of which is which.

A question mutates state. qualifies appends to results, so asking the same question twice changes the answer to pass_rate. This is Module 4's "a function named as a question should not change anything", now stored on an instance where it is harder to see.

pass_rate raises on a fresh instance, because len(self.results) is zero, which is Module 8's missing-denominator problem given a new place to hide.

The name is the tell. "Manager" describes no responsibility, and a class that cannot be named more specifically is usually two things:

class MatchRule:
    def __init__(self, minimum, required_set_aside):
        self.minimum = minimum
        self.required_set_aside = required_set_aside

    def qualifies(self, record):
        return (
            record["amount"] >= self.minimum
            and record.get("set_aside") == self.required_set_aside
        )


def pass_rate(rule, records):
    if not records:
        return None
    return sum(rule.qualifies(r) for r in records) / len(records)

An immutable rule that answers questions, and a separate function that measures a batch and returns None rather than raising on an empty one. Neither hides state from the other.

Try it yourself

Write both versions of the same rule and confirm they agree on every record.

Loading this exercise…

Practical challenge (optional)

Optional: write a short design note for the capstone, no more than ten lines, choosing one shape for the matching rule and giving three reasons drawn from the six criteria. Include one reason against your choice, and say why it does not outweigh the others. A recommendation that acknowledges its own cost is far more persuasive than one that does not, and the capstone's retrospective asks for exactly this kind of note.

Sign in to track your progress on this exercise.

AI collaboration

Checkpoint

  1. What is the single question that most often decides between the two shapes?
  2. Name two smells suggesting a class is not earning its place.
  3. What must be true before a choice between two designs is a design decision at all?
  4. Why is a name ending in "Manager" worth questioning?
Answers
  1. How often the configuration changes relative to the data. Stable settings across many calls favour an object; settings that change every call favour arguments.
  2. It holds no state; it has one method besides __init__; only one instance is ever created; its name does not say what it is responsible for.
  3. Both versions must produce identical results on the same inputs. Otherwise the comparison is between two behaviours, and the design question has not been asked yet.
  4. It describes no specific responsibility, which usually means the class has more than one. Naming what it actually manages either produces a better name or reveals that it should be two things.

Sign in to track your progress on this exercise.

Summary and next step

Both shapes are the same computation with the configuration in a different place; decide on stated criteria, prove the two versions agree before choosing, and treat a class with no state or an unnameable responsibility as a signal. Module 12 gives the assistant a boundary other software can talk to: routes, validation, and a read-only local interface.

learning.goultergroup.com

The interactive parts of this page have not loaded. Reading and links still work; reload the page to try again.