The Data Access Layer
Learning Objectives:
- Understand why data storage is handled separately to logic
- Understand when and how to use the
@Repository
annotation - Be able to configure Spring to use a PostgreSQL database
- Be able to annotate a model to store records in a database
As our app's complexity increases, so does the need for further separation of concerns. In the next section we will introduce you to a way to separate out the logic of persisting data in our apps using a data access layer.
The need for separating concerns will only increase, as this allows us to evolve different parts of our app in different directions, while strictly adhering to SOLID principles.
Just like how we showed you how to use a DTO to communicate between components, our new data access layer will handle communication when access to data is needed within and between business logic and the controllers. We will use the repository pattern in our app.
Separating Storage from Logic
A major advantage of our new separation of concerns is that it removes some of the brittleness from our application: if we modify one of the methods in the service and break something it won't affect anything else. With that in mind we can think about improving the game play.
An obvious flaw is that we have the same mystery word every time we play. We will add the ability to pick a random word from a list but we need to think carefully about how we approach this. Remember that single responsibility hasn't been satisfied just by moving our business logic out of the controller, we also need to consider the division of labour within each layer. Instead of having GameService
handle the logic for choosing a word we will create a separate WordService
to take care of it.
// services/WordServicce.java
@Service
public class WordService {
public String getRandomWord(){
}
}
It's tempting to add a list of words to choose from and have our method return one of them but that would immediately violate the Single Responsibility Principle again. The service layer is meant to handle the logic underpinning our app and storing data doesn't fall under that remit. Instead we want that to be handled by the data access layer. Exactly how the data is stored isn't the service's problem, all it cares about is having somewhere to get new words from.
The classes we will use to handle data storage will be called repositories. We will create a repositories
package in our project and inside that we will create a WordList
class.
// repositories/WordList.java
@Repository
public class WordList {
}
Note that we have annotated this class with the @repository
annotation. This extends @Bean
and, in the same way as @Service
, enables us to autowire the bean into other classes.
WordList
will need two things: some way of storing the data and methods to facilitate access to it. We will handle the first by initialising a list of strings in the constructor.
// repositories/WordList.java
@Repository
public class WordList {
private List<String> words;
public WordList(){
this.words = Arrays.asList(
"hello",
"goodbye",
"testing",
"mystery",
"games",
"spring",
"controller",
"repository"
);
}
}
When it comes to the methods we can be quite selective in what we permit developers to do with the data. If we stored the list in the service we can do anything we like to it, but with it in a separate class the service can only interact with it through the methods we make available. We could add functionality to add or remove words should we want to do so, but for now all we need is a method to pick a random word from the list.
// repositories/WordList.java
@Repository
public class WordList {
// ...
public String getRandomWord(){
Random random = new Random();
int randomIndex = random.nextInt(this.words.size());
return this.words.get(randomIndex);
}
}
With our data being stored elsewhere we need to update WordService
to make use of our new class.
// services/Wordservice.java
@Service
public class WordService {
@Autowired
WordList wordList;
public String getRandomWord(){
return wordList.getRandomWord();
}
}
Extending the Game Logic
Our WordService
bean exposes a method to pick a word at random from a list. Since it is a @Service
we can inject it into another class, including another @Service
. As we have already seen with GameController
and GameService
it is quite common to inject one bean into another like this. Our secret word is being set by GameService
so we will inject WordService
there.
// services/GameService.java
@Service
public class GameService {
@Autowired
WordService wordService
// ...
}
The process of starting a new game can be refactored to use the getRandomWord()
method. We also need to dynamically build the hidden currentWord
property.
// services/GameService.java
@Service
public class GameService {
// ...
public Reply startNewGame(){
String targetWord = wordService.getRandomWord(); // ADDED
Game game = new Game(targetWord); // MODIFIED
this.currentWord = Strings.repeat("*", targetWord.length()); // MODIFIED
this.guessedLetters = new ArrayList<>();
return new Reply(
this.currentWord,
"Started new game",
false
);
}
}
Use Postman to check the API is still working. Don't forget to re-run your server and to first create a new game, before submitting a guess.
Persisting Data With Postgres
Although the architecture is looking much better now, we still have a significant problem with our application - when we shut it down our data disappears! With WordList
this isn't so much of an issue as we recreate the whole list as soon as the application starts but any game data will be gone for good. We haven't refactored GameService
yet but just moving things to a repository won't be enough. We need to bring in a database to store all our data!
Spring gives us a plethora of different databases to use with it. We can easily set up Spring with database engines like MongoDB, H2 or different flavours of SQL - including Postgres! In order for us to set up data persistence for our Spring app, we need to make sure the appropriate dependencies are added, the database is created, and the application.properties
file is set up correctly.
Configuring the Dependencies
We need to add two extra dependencies to our pom.xml
file:
- The Jakarta Persistence API, or JPA
- A driver for the database we are using, in this case PostgreSQL.
The dependencies can be added from the Spring Boot Initialiser when creating a project, or added to pom.xml
later.
<!--pom.xml-->
<dependencies>
<!--...-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
</dependencies>
Simply adding a database driver isn't quite enough, we also need to connect our application to a specific database. The application.properties
file will store the necessary details, plus any other configuration we mat want to add.
spring.datasource.url=jdbc:postgresql://localhost:5432/word_guesser
spring.datasource.username=
spring.datasource.password=
spring.datasource.driver-class-name=org.postgresql.Driver
spring.jpa.hibernate.ddl-auto=create-drop
spring.datasource.url
is the path towards the database. PSQL runs on localhost:5432, and under it, we can find our freshly created dbspring.datasource.username=
andspring.datasource.password=
are optional, although in real life we absolutely must have both of these set up - for security purposes!spring.datasource.driver-class-name=org.postgresql.Driver
tells Spring what should it use to integrate with our db. Since it's PSQL, we will ask it to use the postgresql driverspring.jpa.hibernate.ddl-auto=create-drop
is telling Spring what to do when we start our app.create-drop
does what it says on the tin: Creates our DB schema, then it will drop it on closing the app, and in case we have something set up for it, seeds it with seed data. This is commonly used in development for testing purposes, in production we either leave it out or you set it tonone
- otherwise for each new deployment, valuable production data will be erased!
The final step is to create the database we will be working with.
createdb word_guesser
We now have the pieces in place to start setting up our models for persistence.
Persisting Data with the JPA
JPA stands for Jakarta Persistence API
(formerly known as Java Persistence API). It is an ORM (another acronym...), so first let's clarify what's an ORM!
ORM stands for Object Relational Mapper, and if dissected properly, it is a good description of what it does - ORMs help us map objects into databases. An ORM library is a completely ordinary library written in your language of choice that encapsulates the code needed to manipulate the data, so you don't use SQL anymore; you interact directly with an object in the same language you're using.
Since Java does not speak SQL natively, we need some help communicating our intentions with SQL if we were to use Java. JPA enables us to create tables, set up columns with datatypes, and execute SQL queries using Java - all the while simplifying our job immensely!
In order for us to start using JPA, we need 2 things: Our classes must be POJO classes, annotated with JPA annotations, and they also need to use JpaRepositories to give us SQL functionality.
Let's start by updating our POJO Game
class. Every class that is to use JPA functionality must get the @Entity
annotation. Ours will represent the structure of a database table so we will also give it the @Table
annotation.
// models/Game.java
@Entity // UPDATED
@Table(name = "games"). // UPDATED
public class Game {
//...
}
The name
argument provided will be given as the table name for out entity once Postgres is set up. If nothing is given, it will use the class' name as a table name.
Next an id
attribute must be added - we want JPA to handle the setting and tracking of unique id
s. We will annotate it with @Id
, and with @GeneratedValue(strategy = GenerationType.IDENTITY)
- this is what makes it a PRIMARY KEY
with type SERIAL
.
// models/Game.java
@Id // ADDED
@GeneratedValue(strategy = GenerationType.IDENTITY) // ADDED
private long id; // ADDED
Next, for every attribute we want to store, we will annotate it with @Column
, with the appropriate column names. If we don't add anything for its name
property, the variable name will be used. Don't worry about datatypes - JPA will auto-detect it through the Java datatypes, and set them up accordingly - INT
for int
, TEXT
for String
, etc. We will also add a new column to represent the current state of the word, which will enable us to pause a game then pick up where we left off later.
// models/Game.java
@Column(name = "word") // UPDATED
private String word;
@Column(name = "guesses") // UPDATED
private int guesses;
@Column(name = "complete") // UPDATED
private boolean complete;
@Column(name = "current_state")
private String currentState;
currentString
but since the JPA will handle values for the id
property we don't need to include that. We do, however, need to provide getters and setters for both of our new property.
// models/Game.java
public Game(String word, String currentState){ // MODIFIED
this.word = word;
this.currentState = currentState; // ADDED
this.guesses = 0;
this.completed = false;
}
public long getId(){
return this.id;
}
public void setId(long id){
this.id = id;
}
public String getCurrentState(){
return this.currentState;
}
public void setCurrentState(String currentState){
this.currentState = currentState;
}
Setting up the Repository
Our games will follow the same rules as our words and we won't be storing the details in the service any more, but nor will we simply have a list of Game
object being held in a list. Now we have integrated the JPA and annotated our model we can store the details in a databse, but we need to take some extra steps setting up our repository.
First we create a GameRepository
interface inside our repositories
package.
// repositories/GameRepository.java
public interface GameRepository{
}
In order to connect it to our database we need to extend the JpaRepository
interface. It needs to be parameterized with two bits of information: the model where we specify the table's structure and the data type of its id
property.
// repositories/GameRepository.java
public interface GameRepository extends JpaRepository<Game, Long>{
}
From here Hibernate takes over and does the rest, including defining methods which will give us full CRUD functionality for this table. There are other methods which give us some limited options to get specific entries (SELECT * WHERE id = ??;
) but we can extend this functionality by writing our own methods in a specific format. We will investigate this further in a future lesson.
Storing Game Details
With our new ability to persist the details of our games it's time to wire everything together! Our first step will be to autowire GameRepository
into GameService
.
// services/GameService.java
@Service
public class GameService {
@Autowired
WordService wordService;
@Autowired
GameRepository gameRepository; // ADDED
// ...
}
The first significant change will come when we start a new game. Instead of storing our game as a property of the service we can use one of our new repository's methods to save it into the database.
// services/GameService.java
public Reply startNewGame(){
String targetWord = wordService.getRandomWord();
String currentWordStatus = Strings.repeat("*", targetWord.length()); // MODIFIED
Game game = new Game(targetWord, currentWordStatus); // MODIFIED
gameRepository.save(game); // ADDED
return new Reply(
game.getCurrentState(), // MODIFIED
"Started new game",
false
);
}
When we send a request from Postman there's no difference in the reply we receive, but there are big changes on the back-end. If we open Postico and check our database we'll see a games
table has been created for us with columns matching those specified in the Game
model. That's not all though - there's also a row in the database representing the game we just started! If we start a couple more games and refresh Postico we'll see more games being created.
Importantly each of these games has an id
associated with it, which means we can target our requests towards a specific game. Our getGameStatus
method in GameController
now needs a bit more information to help it identify which game we want details of which we can provide using a path variable. A path variable forms part of the route which a request is sent to and unlike a query string it must be included. Similar to a query string though, the value sent can be accessed from within the method.
// controllers/GameController.java
@RestController
@RequestMapping(value = "/games")
public class GameController {
// ...
@GetMapping(value = "/{id}") // MODIFIED
public ResponseEntity<Reply> getGameStatus(@PathVariable long id){ // MODIFIED
// ...
}
//...
}
Our controller will need some help from our service, which will in turn need to call upon our new repository.
// services/GameService.java
// ...
public Optional<Game> getGameById(long id){
return gameRepository.findById(id);
}
Note the return type - Optional
indicates that there's a possibility that the id we send to the repository will turn up no results though. It isn't the service's responsibility to check though, that's the repository's job. It also isn't the service's responsibility to determine what to do with the information it receives so it sends it straight on to the controller.
When it gets to the controller there's a decision to be made. The logic is similar to what we have in place already, but instead of looking for a property which is part of the game service we'll use the results of our getGameById
method.
// controllers/GameController.java
// ...
@GetMapping(value = "/{id}")
public ResponseEntity<Reply> getGameStatus(@PathVariable long id){
Optional<Game> game = gameService.getGameById(id); // Use method from service to find information
if (game.isEmpty()){ // Check if we found a game. If not...
Reply reply = new Reply(
null, // no current word, so return null
"Game not found",
false
);
return new ResponseEntity<>(reply, HttpStatus.NOT_FOUND); // Return a 404 status code to indicate no game found with this id
} else {
Reply reply = new Reply(
game.get().getCurrentState(), // Use .get() to extract game details from the Optional
"Game in progress",
false
);
return new ResponseEntity<>(reply, HttpStatus.OK);
}
}
Playing a Specific Game
The last thing we need to update is the logic for processing a guess. In order to do so we will add some helper methods which will be useful when updating or checking the status of a game. These are methods which are only going to be used within the service so we can make them private
.
// services/GameService.java
private boolean checkWinCondition(Game game){
String gameWord = game.getWord();
String gameState = game.getCurrentState();
return gameWord.equals(gameState);
}
private void incrementGuesses(Game game){
game.setGuesses(game.getGuesses() + 1);
}
We will also need to update our controller to enable a game's id
to be sent in the request. We will use a path variable in the same way as when retrieving a game's details.
// controllers/GameController.java
@RestController
@RequestMapping(value = "/games")
public class GameController {
// ...
@PatchMapping(value = "/{id}") // MODIFIED
public ResponseEntity<Reply> handleGuess(@RequestBody Guess guess, @PathVariable long id){ // MODIFIED
Reply reply = gameService.processGuess(guess, id); // MODIFIED
return new ResponseEntity<>(reply, HttpStatus.OK);
}
//...
}
Now we can tell our service which game it should be checking a guess against. Although our game logic is fundamentally the same, there are some significant changes to how it accesses information.
Strongly recommend copy/paste to replace the method here instead of editing it in place
// services/GameService.java
// ...
public Reply processGuess(Guess guess, long id){
// find the game we want to play
Optional<Game> gameOptional = getGameById(id);
// Check if game exists
if (gameOptional.isEmpty()) {
return new Reply(
null,
"Game not found",
false
);
}
// extract game details from Optional
Game game = gameOptional.get();
// check if game already complete
if(game.isComplete()){
return new Reply(
game.getCurrentState(),
"Game already complete",
false
);
}
// record guess
incrementGuesses(game);
// check if letter has already been guessed
if (game.getCurrentState().contains(guess.getLetter())) {
gameRepository.save(game);
return new Reply(
game.getCurrentState(),
String.format("Already found %s", guess.getLetter()),
false
);
}
// check for incorrect guess
if (!game.getWord().contains(guess.getLetter())) {
gameRepository.save(game);
return new Reply(
game.getCurrentState(),
String.format("%s is not in the word", guess.getLetter()),
false
);
}
// process correct guess
String runningResult = game.getWord();
// build list of previously guessed letters
List<String> guessedLetters = new ArrayList<>(Arrays.asList(
game.getCurrentState().split(""))
);
// remove * characters
guessedLetters.removeAll(Collections.singleton("\\*"));
// add current guess
guessedLetters.add(guess.getLetter());
// update current state of game
for (Character letter : game.getWord().toCharArray()) {
if (!guessedLetters.contains(letter.toString())) {
runningResult = runningResult.replace(letter, '*');
}
}
game.setCurrentState(runningResult);
// check if game won
if (checkWinCondition(game)){
game.setComplete(true);
gameRepository.save(game);
return new Reply(
game.getCurrentState(),
"Congratulations, you win!",
true
);
}
gameRepository.save(game);
return new Reply(
game.getCurrentState(),
String.format("%s is in the word", guess.getLetter()),
true
);
}
Note that there are now no methods in GameService
which require the game
, currentWord
or guessedLetters
properties. This is by design - if a service needs information like that it should either be provided by the controller or retrieved from a repository. We can delete those properties and the associated getters and setters. We can also remove the default GameService
constructor as we will never instantiate it ourselves.
Next Steps
We have made huge progress with our games. We can have multiple games running at once and switch between them at will, and thanks to the functionality provided by Hibernate and the JPA we already have methods available to us to even more with them. We're only capturing a small part of what we can do with with an SQL database though, and indeed what the JPA can help us out with. In the next lesson we will start investigating relationships and how they can be implemented in Spring by adding players to our game.