Python, OOP, Unit Tests and Design Patterns

OOP
Design Patterns
Refactoring
Lab 2 - Refactoring a Monolithic Service
Author

Ludovic Deneuville

Before you start

This tutorial combines explanations and code phases. The explanations should not take precedence over those of your teacher.

NoteConcepts covered
  • Code Refactoring: Transforming a Monolith into a Modular System
  • Design Patterns: Factory Pattern & Strategy Pattern
  • Software Principles: Open/Closed Principle & Separation of Concerns
  • Decoupling: Separating game rules, scoring, and coordination

Create your branch

For each practical session, the teacher provides a starting branch: tp<N>-start.

You will have to create a personal branch: <username>-tp<N> (e.g. camille-tp2) and work on your own branch.

To start with a clean and up-to-date codebase:

Then use the standard Git cycle: add, commit, push after each part.

1 The Mission 🎯

You might be thinking: “All this architecture just for a simple coin flip?” … You’re right. And don’t get too comfortable, because we’re about to add a dozen more classes just for fun!” 🙃

Currently, our gaming engine only supports a single game mode: Coin Flip.

Our goal is to refactor the system to easily integrate new games, such as Dice 🎲, and more complex betting mechanics.

1.1 Current State: A “God Service”

Currently, the GameService class is a Monolith.

It is too heavy because it handles too many responsibilities:

  • 🎲 Game Logic: It decides the outcome (e.g., random coin flip)
  • 📈 Scoring Logic: It manages the mathematical Elo calculation
  • 💾 Data Management: It directly interacts with PlayerDao to update scores
  • 🪦 No History: Once a game is played, the result is lost. It lacks any way to track match history

This design violates the Open/Closed Principle: adding a new game (like “Dice”) or a new scoring rule (like “Flat points”) requires modifying the core GameService code, making it fragile and hard to test.

1.2 Target Architecture: A Modular Engine

Our goal is to transform this monolith into a decoupled system where:

  • The Service is an Orchestrator
    • it tells others what to do, but doesn’t know how they do it
  • The Rules are interchangeable (Strategy Pattern)
  • The Creation is automated (Factory Pattern)
  • The History is permanent (Persistence)

classDiagram
  class GameService {
    - scoring_strategy : ScoringStrategy
    + play(player_id, opponent_id, game_mode) Game
  }

  class GameModeFactory {
    + get_mode(game_mode) GameMode
  }

  class GameMode {
    <<interface>>
    + play(p1, p2) Game
  }

  class ScoringStrategy {
    + compute(p1, p2, winner) tuple
  }

  class CoinFlipMode {
    + play(p1, p2) Game
  }

  class DiceMode {
    + play(p1, p2) Game
  }

  class Game {
    <<Business Object>>
    + id_game : int
    + player1 : Player
    + player2 : Player
    + game_mode : str
    + winner : Player
    + description: str
    + timestamp : datetime
  }

  GameService o-- ScoringStrategy : uses
  GameService --> GameModeFactory : asks for
  GameModeFactory ..> GameMode : creates
  GameMode <|-- CoinFlipMode
  GameMode <|-- DiceMode
  GameService ..> Game : creates

Roles and Responsibilities:

  • GameService: Orchestrates the entire game lifecycle
    • Fetch Players
    • Uses GameModeFactory to select the correct GameMode
    • Delegates gameplay to the selected GameMode
    • Uses ScoringStrategy to calculate ELO updates
    • Saves the Game record via the DAO
  • GameMode: Game rules contract
    • Forces subclasses to implement the play() method
    • CoinFlipMode: Concrete implementation of a coin-toss game
    • DiceMode: Concrete implementation of a dice-roll game
  • GameModeFactory: Creates the appropriate GameMode object
    • if game_mode = "coinflip" ➡️ CoinFlipMode
    • if game_mode = "dice" ➡️ DiceMode
  • ScoringStrategy: Logic for calculating Elo ratings updates
  • Game: Data carrier representing a game

2 Modelling and Implementation

Every journey begins with a single step.

2.1 Game business object

We want to keep a record of each game. Let’s start by creating a Game business object to store the necessary information after each game is played.

classDiagram
  class Game {
    + id_game : int
    + player1 : Player
    + player2 : Player
    + game_mode : str
    + winner : Player
    + description: str
    + timestamp : datetime
  }

  class Player {
    + id : int
    + username : str
    + elo : int
  }

  Game o-- Player : involves

Important

Whenever you are asked to create a new class, this implicitly means you must also create a new file.

    • Set id = None it will be filled later when the object is persisted in the database
    • winner: Player or None if it is a draw
    • game_mode: The type of game played (“coinflip” or “dice”)
    • description: To store more details about the game

coinflip between Jacky and Jackie. Winner: Jackie

We now have an object to store the results. We’ll see later how to insert them into the database.

2.2 Polymorphic Game Modes

Let’s focus on the different game modes and their rules.

The Problem: Currently, we have this code below in the play method.

game_service.py
class GameService:
    def play(self, player_id, opponent_id, choice="heads"):
        ...
        result = secrets.choice(["heads", "tails"])
        winner = p1 if result == choice else p2
        ...

If we want to add the ability to roll dice, we’ll end up with something like:

class GameService:
    def play(self, player_id, opponent_id, game_mode, choice="heads"):
        # new paramter game_mode -------------↑ 
        ...
        if game_mode == "coinflip":
            result = secrets.choice(["heads", "tails"])
            winner = p1 if result == choice else p2
        elif game_mode == "dice":
            d1 = secrets.choice(range(1, 7))
            d2 = secrets.choice(range(1, 7))
            if d1 > d2:
              winner = p1
            elif d1 < d2:
              winner = p2
            else:
              winner = None              

This is a violation of the Open/Closed Principle. Every time we want to add a new game (e.g., Blackjack, Poker heads up, Pokemon battle, etc.), we have to modify the core GameService.

NoteGoal

Refactor the system so that the GameService doesn’t care which game is being played.

It should simply ask a “provider” to give it the correct rules.

The aim is this sort of code:

game_service.py
class GameService:
    def play(self, player_id, opponent_id, game_mode, **kwargs):

        # Find players by id -> the code does not change
        p1 = PlayerDao().find_by_id(id_player)
        p2 = PlayerDao().find_by_id(id_opponent)

        # Get rules
        mode = get_mode(game_mode)

        # Play the game following rules
        # Eventualy add extra parameters like choice (included in kwargs)
        game = mode.play(p1, p2, **kwargs)

        # Calculate Elo ratings based on the game result 
        compute(game)

        # Update Players object in the database (not this week)
        PlayerDao().update(player1)
        PlayerDao().update(player2)

        return game
Note**kwargs
  • Initially, the service only handled coin-flip games, which required a choice parameter (heads or tails).
  • Now, the service is generic and can handle various game modes. The **kwargs parameter is used to pass any additional arguments required by different game modes.

In Python, **kwargs (short for keyword arguments) allows a function to accept an arbitrary number of keyword arguments (arguments passed with a name, like name=“Alice”).

When you use **kwargs in a function definition, Python collects all the extra named arguments and stores them in a dictionary, where the key is the argument name and the value is the argument’s value.

**kwargs will allow passing specific arguments (like choice) to the modes.

    • in a folder called game_mode in business_object
    • including method play(p1, p2) -> Game
    • both players roll a die, and the one with the highest roll wins (Draw if equals)
    • including method play(p1, p2, choice) -> Game
    • cut and paste the code that was previously in GameService

2.3 A factory for producing GameMode

💬 I have a “coinflip” string and I want a CoinFlipMode object.

Fine, we’ll also need a way to generate either a CoinFlipMode object or a DiceMode object based on a game_mode (str) parameter. To do this, let’s create a factory class.

stateDiagram
    [*] --> GameModeFactory : game_mode (str)
    GameModeFactory --> CoinFlipMode : if "game_mode=coinflip"
    GameModeFactory --> DiceMode : if "game_mode=dice"

    • the documentation for which is given below
    @classmethod
    def get_mode(cls, game_mode: str) -> GameMode:
        """
        Returns the corresponding GameMode object.
        Args:
            game_mode (str): The identifier of the game mode (e.g., 'coinflip', 'dice').
        Returns:
            GameMode: An instance of a class implementing GameMode.
        Raises:
            ValueError: If the requested game_mode is not supported.
        """

        # Code here

When you pass a string to the factory, it returns an object of type DiceMode or CoinFlipMode (or throws an exception).

Important

With this new architecture, you can add a new game mode with very few changes to the existing code (almost exclusively additions):

  • by creating this mode (inheriting from GameMode)
  • and simply modifying the factory class

2.4 Decoupling Scoring

Note

The methods calculate_expected_score(), calculate_new_ratings() and update_player_ratings() do not belong in the GameService class.

They are used to update players’ Elo ratings, so let’s move them to a separate class.

By moving the calculation logic from GameService to ScoringStrategy, you have implemented the Strategy Pattern and applied the principle of Separation of Concerns.

The Strategy Pattern decouples the game orchestration from the scoring mathematics. This makes your system highly extensible: you can swap the Elo algorithm for another without touching the core game logic, adhering to the Open/Closed Principle.

2.5 GameService refactor

Let’s finally put the pieces of the puzzle together in the GameService class, method play():

    • remove choice parameter
    • add game_mode: str
    • add at the end **kwargs to carry any additional parameters

2.6 GameController refactor

In the controller, you call the play() method of the service that has been updated.

You receive a Game object; use it.

    return GameResponse(
        username1=game.player1.username,
        username2=game.player2.username,
        description=game.description,
        winner=game.winner.username,
        new_elo1=game.player1.elo,
        new_elo2=game.player2.elo,
    )

3 Conclusion

In this lab, we formalized the business logic and created an object to store the games.

However, without database storage, all the games created will be lost when the application is closed.

Next time, we’ll focus on storing these games in a database.

End of the Lab

Important

When you have finished coding, don’t forget to:

    • If your service is terminated, all unpushed code is lost…
    • to free up reserved resources