Mixing Paradigms in Real Code
A working kitchen has a head chef and a stack of recipe cards. The head chef owns the station: she knows what is on order, who is sitting at table 4, and which burner is free. The recipe cards do not own anything. Each card takes raw ingredients and returns a finished plate, the same way every time. A real Python program is built with the same split. Classes live at the boundary, where state has to live somewhere, and pure functions live at the leaves, where the actual work happens. Neither paradigm wins on its own. The blend is the answer.
The split was not obvious for a long time. In the late 1990s and early 2000s the Java world pushed object-oriented programming to a place where every single thing was a class — classes that had only one method, classes named RequestProcessorFactoryFactory. The reaction in the Python community came in waves. PEP 484 in 2015, written by Guido and Mypy's creator Jukka Lehtosalo, added type hints so a function signature could carry as much contract as a class definition. PEP 557 in 2018 added dataclasses, a way to write a class that holds data without writing the boring __init__ and __repr__ by hand. By 2020 most working Python codebases had stopped arguing about OOP versus FP and settled into the same pattern: dataclasses for data shapes, classes for things that own state and behavior together, pure functions for transformations between data shapes. Hynek Schlawack's attrs library had been doing the same thing in user space since 2015 and pulled the standard library along behind it.

A dataclass is the cleanest place to start. Open the cards.py file from the OOP lesson. The Card class you wrote had four pieces of boilerplate: an __init__ that assigned two attributes, a __repr__ that printed them, an __eq__ that compared them. Every line was mechanical. The @dataclass decorator writes those methods for you from the type-annotated attributes. Replace the Card class with this version:
from dataclasses import dataclass
@dataclass(frozen=True)
class Card:
rank: str
suit: str
def __repr__(self) -> str:
return f"{self.rank}{self.suit}"
king = Card("K", "h")
print(king)
print(king == Card("K", "h"))Output:
Kh
TrueSame Card. Same behavior. Half the lines. The frozen=True argument tells the decorator to also make the dataclass immutable — once you build a Card, its rank and suit cannot be reassigned. That matters when you start passing Cards around to pure functions, because nobody can secretly mutate a card the scoring function is looking at. We kept the custom __repr__ because the auto-generated one would print Card(rank='K', suit='♥'), which is fine for debugging but ugly for a hand of 5 cards on screen.
Now look at the score function from the last lesson. It was already pure: a list of Cards in, a number out. That stays exactly as it was. Drop it into a file called score.py next to cards.py:
from cards import Card
def rank_counts(cards: list[Card]) -> dict[str, int]:
counts: dict[str, int] = {}
for c in cards:
counts[c.rank] = counts.get(c.rank, 0) + 1
return counts
def score_hand(cards: list[Card]) -> int:
counts = rank_counts(cards)
multiples = sorted(counts.values(), reverse=True)
if multiples[0] == 4:
return 700
if multiples[0] == 3 and multiples[1] == 2:
return 600
if multiples[0] == 3:
return 300
if multiples[0] == 2 and multiples[1] == 2:
return 200
if multiples[0] == 2:
return 100
return 0The type hints (list[Card], dict[str, int], -> int) are documentation the editor reads. We will go deep on them in the next lesson. For now they are labels saying what the recipe card takes and what it returns.
The Deck class still owns state — the list of cards left to deal. Keep it as a class in cards.py, alongside the dataclass Card. Slim the body down to what only a Deck can answer:
import random
class Deck:
def __init__(self) -> None:
self.cards: list[Card] = [Card(r, s) for s in SUITS for r in RANKS]
def __len__(self) -> int:
return len(self.cards)
def shuffle(self) -> None:
random.shuffle(self.cards)
def deal(self, n: int) -> list[Card]:
dealt = self.cards[:n]
self.cards = self.cards[n:]
return dealtNow the boundary class — the one piece that makes this a real game. A PokerGame is a thing that has state (a deck, two players, a pot, a list of hands played) and behavior (deal a round, score the round, pay the winner). The class sits on top. When it needs to score a hand, it does not implement scoring. It calls the pure score_hand function. The class owns the state. The function does the work. Save this as game.py:
from dataclasses import dataclass, field
from cards import Card, Deck
from score import score_hand
@dataclass
class PokerGame:
deck: Deck
players: list[str]
hands_played: list[tuple[str, list[Card], int]] = field(default_factory=list)
def play_round(self) -> str:
self.deck.shuffle()
results = []
for player in self.players:
hand = self.deck.deal(5)
score = score_hand(hand)
self.hands_played.append((player, hand, score))
results.append((player, hand, score))
print(f"{player} drew {hand} for score {score}")
winner = max(results, key=lambda r: r[2])
print(f"winner: {winner[0]} with score {winner[2]}")
return winner[0]
game = PokerGame(deck=Deck(), players=["Aarit", "Aditya"])
game.play_round()Run the file:
Aarit drew [10s, 10c, 7h, 4d, Ks] for score 100
Aditya drew [Jh, Jc, 9s, 9d, 2c] for score 200
winner: Aditya with score 200The PokerGame instance owns the deck, the players, and the history of hands. The scoring is delegated, in one line, to the pure function. If you want to test the scoring, you import score_hand and call it with hand-built lists of Cards — no game, no deck, no players. If you want to test the game, you mock the deck or seed the random module, and you assert that play_round returned the right winner. The two responsibilities are separate, which means each one is small enough to test on its own.
The before-and-after of the same idea is the cleanest way to feel why the split helps. Here is what play_round would look like if you wrote everything inside the class, in the all-OOP style the early Java world pushed. Both versions produce the same output. The second one is the one you actually want to read 6 months from now.
class PokerGameOOPHeavy:
def __init__(self, deck, players):
self.deck = deck
self.players = players
self.hands_played = []
def play_round(self):
self.deck.shuffle()
results = []
for player in self.players:
hand = self.deck.deal(5)
counts = {}
for c in hand:
counts[c.rank] = counts.get(c.rank, 0) + 1
multiples = sorted(counts.values(), reverse=True)
if multiples[0] == 4:
score = 700
elif multiples[0] == 3 and multiples[1] == 2:
score = 600
elif multiples[0] == 3:
score = 300
elif multiples[0] == 2 and multiples[1] == 2:
score = 200
elif multiples[0] == 2:
score = 100
else:
score = 0
self.hands_played.append((player, hand, score))
results.append((player, hand, score))
print(f"{player} drew {hand} for score {score}")
winner = max(results, key=lambda r: r[2])
print(f"winner: {winner[0]} with score {winner[2]}")
return winner[0]The OOP-heavy version buries the scoring logic inside a method that is also responsible for shuffling, dealing, recording history, and announcing the winner. To test the scoring you would have to construct a whole game, give it a player, and call play_round. To reuse the scoring in another program you would have to copy 15 lines of inner logic. The mixed version pulled the scoring out into a leaf function, kept the class for what only the class can answer, and shrank play_round to 10 lines that read top to bottom in English.
A small question for the reader: in the mixed PokerGame above, where does the actual ranking logic for "two pair beats one pair" live?
Inside the pure function score_hand in score.py. The class never asks. The class only knows that scoring returns a number and that bigger is better. Tomorrow you can replace score_hand with a real Texas Hold'em evaluator without touching PokerGame, because the boundary between them is one function call.

Where do dataclasses win and where do classes win? A dataclass is the right tool when the object is mostly data and the few methods it has are descriptive: a Card, an order line, a row from a CSV, a config bundle. A regular class is the right tool when the object owns mutable state and has real behavior over time: a Game, a Connection, a Player whose chip stack rises and falls. The rough rule, after a few years of writing Python, is that you reach for @dataclass first and only switch to a plain class when the dataclass starts feeling like a bag with a method bolted on.
Your code now mixes paradigms the way real codebases do. Some functions take Cards. Some take strings. The annotations on those functions are working as labels, but Python does not actually check that you only pass a Card where a Card is expected. The next lesson turns those labels into a contract the language can enforce.