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:java
If you are on Windows, run this command instead:
mvnw -q clean compile exec:java
You 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
PlayerFactory
andGameFactory
classes. - Replace the
new DicePlayer()
statements inDiceGame
withPlayerFactory.create()
. - Replace the
new DiceGame()
statement inApp
withGameFactory.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
Game
andPlayer
interfaces. - Modify the
DiceGame
class to implement theGame
interface and theDicePlayer
class to implement thePlayer
interface. - Modify the
GameFactory
andPlayerFactory
classes to return instances of theGame
andPlayer
interfaces rather than the concrete classes. - Modify the
game
member inApp
to be of typeGame
rather thanDiceGame
. - Modify the
player1
andplayer2
members inDiceGame
to be of typePlayer
rather 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
DiceGame
constructor to accept twoPlayer
parameters. - Modify the
GameFactory.create()
method to accept twoPlayer
parameters and inject them into theDiceGame
constructor. - Modify the
main
method inApp
to create twoPlayer
instances (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
SimpleContainer
class. It may contain code that looks unfamiliar, but focus on the comments describing the behaviour of theregister
andcreate
methods. - Add the following method to the
App
class:
private static SimpleContainer initialiseContainer() {
SimpleContainer container = new SimpleContainer();
// Register mappings for any required interfaces with their concrete implementations
return container;
}
- Modify the
initialiseContainer
method to register mappings for theGame
andPlayer
interfaces with their concrete implementations in the container, e.g.container.register(Game.class, DiceGame.class)
- Add a call to
initialiseContainer
in themain
method 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.