When we design an application, one of the first decisions we have to make is what software architecture to implement. Software architecture describes the major components of an application and how they are expected to interact with each other. In this topic, we will look at a typical software architecture for web apps: the layered architecture.
What is a layered architecture?
The goal of a layered architecture is to divide our application into several layers, each playing a specific role in the application. We organize our layers into a vertical structure resembling a stack of items. Each layer will interact only with the layers directly above or below it. In a standard layered architecture, there are four core layers:
The presentation layer handles the interface that is displayed to the user.
The business layer implements rules for handling the problem your application was designed to solve.
The persistence layer contains database storage logic and handles the translation of objects into database formats.
The database layer contains the actual database storage system and handles tables, indices, and any database-related operations.
To understand how a layered architecture works, let's see what happens when a request is sent and a response is returned. When a request is sent, it starts at the top layer (the presentation layer) and moves down layer by layer until it reaches the bottom layer (the database layer). When a response is returned, it starts at the bottom layer and moves up layer by layer until the top layer is reached.
To see why this architecture is valuable, imagine an application with an international user base. Since the users may be based in different countries, we would ideally display the user interface in multiple languages, depending on the user's preference. In a layered architecture, we could implement multiple user interaction layers, each in a different language. Since the layers are autonomous, we can vary the user interaction layer without impacting the rest of the application. As such, app maintenance is easier, as the changes required are minimized for each layer.
Although a layered architecture can make development easier, some disadvantages should be considered. Having multiple layers can also make your application more difficult to maintain. For example, each change to a layer requires analysis of how it impacts the application. In addition, a layered approach can affect performance since a request may have to travel through many layers. In other words, it is important to consider if the benefits of a layered approach outweigh the potential maintenance and performance impacts.
Now that we understand what a layered architecture is let's see how to implement it in Spring.
Implementing layered architectures
It is possible to create a Spring application without any of the layers. However, this is not recommended for real applications.
Suppose we wanted to create an application that stores user details in a database. We want users to be able to access the application through REST endpoints, and we will store the data inside an H2 database. The H2 database represents the database layer of our application. The configuration below shows how we can set up our database.
# Setup for the H2 console for viewing data in the database
spring.h2.console.enabled=true
spring.h2.console.path=/h2
# H2 data source setup
spring.datasource.url=jdbc:h2:file:~/test
spring.datasource.username=sa
spring.datasource.password=
# Automatically update tables when persistence objects have changed
spring.jpa.hibernate.ddl-auto=updateTo store data in our database, we must implement an object representing the stored data. Therefore, we will create a User class. Our User objects will include the @Table and @Column annotations to be stored in the database. We will give each User a unique ID and store the username and first and last name in our database.
We will create a new package for the code of each application layer. This setup will help us isolate the components. We will store the User component in the com.example.demo2.businesslayer package, corresponding to the business layer of our application.
Java
package com.example.demo2.businesslayer;
import jakarta.persistence.*;
@Entity
@Table(name = "users")
public class User {
@Id
private long id;
@Column(name = "username")
private String username;
@Column(name = "firstName")
private String firstName;
@Column(name = "lastName")
private String lastName;
public User() {
}
public User(long id, String username, String firstName, String lastName) {
this.id = id;
this.username = username;
this.firstName = firstName;
this.lastName = lastName;
}
// getters and setters
}Kotlin
package com.example.demo2.businesslayer
import jakarta.persistence.*
@Entity
@Table(name = "users")
class User(
@Id
var id: Long = 0,
@Column(name = "username")
var username: String? = null,
@Column(name = "firstName")
var firstName: String? = null,
@Column(name = "lastName")
var lastName: String? = null
)Next, we can build a persistence layer, which will interact with our database layer. We will implement our persistence layer using a @Repository, specifically, a CrudRepository. This will allow us to create basic CRUD queries for our database. The CrudRepository for this example will be stored in the com.example.demo2.persistence package. The code below shows how you can implement a CrudRepository.
Java
package com.example.demo2.persistence;
import org.springframework.data.repository.CrudRepository;
import org.springframework.stereotype.Repository;
import com.example.demo2.businesslayer.User;
@Repository
public interface UserRepository extends CrudRepository<User, Long> {
User findUserById(Long id);
}Kotlin
package com.example.demo2.persistence;
import org.springframework.data.repository.CrudRepository;
import org.springframework.stereotype.Repository;
import com.example.demo2.businesslayer.User;
@Repository
interface UserRepository : CrudRepository<User, Long> {
fun findUserById(id: Long): User
}The CrudRepository interface comes with several useful built-in methods that we will utilize to interact with the database. These methods allow us to perform basic tasks like adding data to our database. If we want other methods, we can add them or implement them in the UserRepository declaration. In this example, we have added one custom method called findUserById. This method will allow us to query our H2 database, searching for a user based on their ID.
Any query to the persistence layer should come through the business layer. This is typically implemented through a service. This service acts as an intermediary between the business and persistence layers. The service will apply business rules and forward the request to the persistence layer to manipulate the database as required.
In our example, we have two methods that would be helpful in our service. The first is the findUserById method, which can be used to find a User object using an ID. The second method is called save, a built-in method provided by CrudRepository for saving the provided object to the database. Here is a basic example of a service for our repository:
Java
package com.example.demo2.businesslayer;
import org.springframework.stereotype.Service;
import org.springframework.beans.factory.annotation.Autowired;
import com.example.demo2.persistence.UserRepository;
@Service
public class UserService {
private final UserRepository userRepository;
@Autowired
public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
public User findUserById(Long id) {
return userRepository.findUserById(id);
}
public User save(User toSave) {
return userRepository.save(toSave);
}
}Kotlin
package com.example.demo2.businesslayer;
import org.springframework.stereotype.Service;
import org.springframework.beans.factory.annotation.Autowired;
import com.example.demo2.persistence.UserRepository;
@Service
class UserService(private val userRepository: UserRepository) {
fun findUserById(id: Long): User {
return userRepository.findUserById(id)
}
fun save(toSave: User): User {
return userRepository.save(toSave)
}
}Currently, the service calls the userRepository object with the required parameters. We could extend this service with business logic for verifying requests before they are sent. Such business logic could define which operations are valid for the repository. As currently implemented, the service only has the save and findUserById methods. As such, only queries that add new User objects or query existing ones by ID are valid.
Our final layer is the presentation layer, which allows end users to interact with the application. In this example, we use a RESTful API for user interaction. We will utilize the UserService to send requests to the database to retrieve and modify data. Below, you can see how we can implement simple GET and POST requests to retrieve and add User objects to our API.
Java
package com.example.demo2.presentation;
import org.springframework.web.bind.annotation.*;
import org.springframework.beans.factory.annotation.Autowired;
import com.example.demo2.businesslayer.UserService;
import com.example.demo2.businesslayer.User;
@RestController
public class UserController {
@Autowired
UserService userService;
@PostMapping("/user")
public User saveUser(@RequestBody User user) {
User createdUser = userService.save(new User(
user.getId(), user.getUsername(),
user.getFirstName(), user.getLastName()));
return createdUser;
}
@GetMapping("/user/{id}")
public User getUser(@PathVariable long id) {
return userService.findUserById(id);
}
}Kotlin
package com.example.demo2.presentation
import org.springframework.web.bind.annotation.*
import org.springframework.beans.factory.annotation.Autowired
import com.example.demo2.businesslayer.UserService
import com.example.demo2.businesslayer.User
@RestController
class UserController(private val userService: UserService) {
@PostMapping("/user")
fun saveUser(@RequestBody user: User): User {
return userService.save(
User(
user.id, user.username,
user.firstName, user.lastName
)
)
}
@GetMapping("/user/{id}")
fun getUser(@PathVariable id: Long): User {
return userService.findUserById(id)
}
}We now have a fully working layered application. The diagram below summarizes how each component fits into the layered architecture.
In a large application, a controller can have references to multiple services, and a service can have links to several repositories.
Conclusion
A layered architecture provides a way to design applications with separated components. It also provides a way to encapsulate software and functionality, allowing developers to easily change single layers without affecting the others. The encapsulation of components also makes testing individual components easier. With these benefits, it is important to note that layered architectures are not suitable for all applications. Smaller applications with many layers will perform worse and require more complex maintenance with a layered architecture. Thus, it is best to use layered architectures with larger applications to get their full benefits.