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 {
+ p1_choice : str
+ 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
+ str: detail
+ timestamp : datetime
}
GameService o-- ScoringStrategy : uses
GameService --> GameModeFactory : asks for
GameModeFactory ..> GameMode : creates
GameMode <|-- CoinFlipMode
GameMode <|-- DiceMode
GameService ..> Game : creates
Python, OOP, Unit Tests and Design Patterns
🚧
-
- for GameService.play()
- or maybe on next lab mocking dao call
Before you start
This tutorial combines explanations and code phases. The explanations should not take precedence over those of your teacher.
- Refactoring existing code
- Design Patterns: Factory Pattern & Strategy Pattern
- Dependency Injection
- Data Persistence (History tracking)
- Advanced Unit Testing
Work on a branch
🚧
Each member of the team works on its own branch
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
PlayerDaoto 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)
2 Modelling and Implementation
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
+ str: detail
+ timestamp : datetime
}
class Player {
+ id : int
+ username : str
+ elo : int
}
Game o-- Player : involves
Whenever you are asked to create a new class, this implicitly means you must also create a new file.
-
- Set
id = Noneit 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”)
- detail: To store more details about the game
- Set
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:
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.
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.
-
- in a folder called game_mode in business_object
-
- including method play(p1, p2)
- both players roll a die, and the one with the highest roll wins
-
- The choice (heads/tails) must be passed to the constructor when the mode is created.
- then use it in method play()
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:
-
- that takes the game_mode (str) as parameter
- and returns the corresponding
GameModeFactoryobject
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.
stateDiagram
direction LR
[*] --> GameModeFactory : game_mode (str)
GameModeFactory --> CoinFlipMode : if "game_mode=coinflip"
GameModeFactory --> DiceMode : if "game_mode=dice"
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.3 Decoupling Scoring
We want to extract the methods used to calculate the Elo rating in GameService.
-
- it updates elo of both players
2.4 GameService refactor
Let’s finally put the pieces of the puzzle together in the GameService class
We should return a Game object, but for now, let’s leave it as is.
3 Unit tests
🚧
- Explain mocking
- Few easy tests
4 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
When you have finished coding, don’t forget to:
-
- If your service is terminated, all unpushed code is lost…
-
- to free up reserved resources