The goal of this exercise is to decouple tightly-coupled code by applying the following software design principles and patterns:
For this exercise, we've provided starter code in the exercises/software-design directory. It contains a small program that plays a simulated game between two players rolling a dice.
We won't be changing the functionality of the application at all, but refactoring it to be loosely coupled.
In your terminal, navigate to the software-design directory, then run the following command to execute the application:
./mvnw -q clean compile exec:javaIf you are on Windows, run this command instead:
mvnw -q clean compile exec:javaYou should see output similar to this:
Game started. Target score: 30
Player 1 rolled a 4
Player 2 rolled a 5
Player 1 rolled a 4
Player 2 rolled a 5
Player 1 rolled a 4
Player 2 rolled a 6
Player 1 rolled a 5
Player 2 rolled a 1
Player 1 rolled a 6
Player 2 rolled a 3
Player 1 rolled a 4
Player 2 rolled a 2
Player 1 rolled a 4
Player 2 rolled a 4
Player 1 wins!Open the src/main/java/com/cbfacademy/ directory.
The DiceGame class calls dicePlayer.roll() in order to complete the play() method. DiceGame can't function without a DicePlayer instance, so we say that DiceGame is dependent on DicePlayer or that DicePlayer is a dependency of DiceGame.
The first step towards decoupling our code is to invert the control flow by using the Factory pattern to implement IoC.
- Examine the
PlayerFactoryandGameFactoryclasses. - Replace the
new DicePlayer()statements inDiceGamewithPlayerFactory.create(). - Replace the
new DiceGame()statement inAppwithGameFactory.create(). - Run the application again to confirm you get the same output as before.
- Commit your changes.
This delegated responsibility to the factory allows us to decouple the DiceGame class from the DicePlayer class.
The Dependency Inversion Principle states that:
- High-level modules should not depend on low-level modules. Instead, both should depend on abstractions.
- Abstractions should not depend on details. Details should depend on abstractions.
Currently, our DiceGame class (high-level module) depends on DicePlayer (low-level module). This is a violation of the Dependency Inversion Principle, so we must replace this concrete dependency with an abstraction (interface or abstract class).
- Examine the
GameandPlayerinterfaces. - Modify the
DiceGameclass to implement theGameinterface and theDicePlayerclass to implement thePlayerinterface. - Modify the
GameFactoryandPlayerFactoryclasses to return instances of theGameandPlayerinterfaces rather than the concrete classes. - Modify the
gamemember inAppto be of typeGamerather thanDiceGame. - Modify the
player1andplayer2members inDiceGameto be of typePlayerrather thanDicePlayer. - Run the application again to confirm you get the same output as before.
- Commit your changes.
We have now implemented DIP, where a high-level module (DiceGame) and low-level module (DicePlayer) are both dependent on an abstraction (Player). Also, the abstraction (Player) doesn't depend on details (DicePlayer), but the details depend on an abstraction.
We have now inverted control and introduced abstraction, but our classes are still tightly coupled to the factory classes. Let's resolve this by instead injecting dependencies into the constructor of the DiceGame class.
- Modify the
DiceGameconstructor to accept twoPlayerparameters. - Modify the
GameFactory.create()method to accept twoPlayerparameters and inject them into theDiceGameconstructor. - Modify the
mainmethod inAppto create twoPlayerinstances (usingPlayerFactory) and pass them to theGameFactory.create()method. - Run the application again to confirm you get the same output as before.
- Commit your changes.
By injecting the Player instances into the DiceGame constructor, we have now successfully decoupled DiceGame from DicePlayer.
While we've now decoupled our code, we still have to create instances of our interfaces using multiple factory classes. In a real-world application with numerous interfaces defined, this can quickly become a maintenance nightmare. To address this, we can use a IoC Container to manage our dependencies.
- Examine the
SimpleContainerclass. It may contain code that looks unfamiliar, but focus on the comments describing the behaviour of theregisterandcreatemethods. - Add the following method to the
Appclass:
private static SimpleContainer initialiseContainer() {
SimpleContainer container = new SimpleContainer();
// Register mappings for any required interfaces with their concrete implementations
return container;
}- Modify the
initialiseContainermethod to register mappings for theGameandPlayerinterfaces with their concrete implementations in the container, e.g.container.register(Game.class, DiceGame.class) - Add a call to
initialiseContainerin themainmethod ofApp, before any factory method calls. - Replace the call to
GameFactory.create()withcontainer.get(Game.class) - Remove the calls to
PlayerFactory.create() - Run the application again to confirm you get the same output as before.
- Commit your changes.
By using a container, we're able to simplify our code and eliminate the need for multiple factory classes. This makes our code more modular, maintainable and easier to understand.