Three-Tier Architecture and Layered Decoupling

📢 This article was translated by gemini-2.5-flash

Case Introduction

Get employee data, return a unified response, and render it on the page.

First, we need to bring in the dom4j dependency to parse XML files.

1
2
3
4
5
<dependency>
    <groupId>org.dom4j</groupId>
    <artifactId>dom4j</artifactId>
    <version>2.1.3</version>
</dependency>

Bring in the XMLParserUtils utility class for XML parsing, the corresponding entity class Emp, and the emp.xml XML file.

Add static page files to resources/static.

For SpringBoot projects, static resources (like HTML, CSS, JS frontend assets) default to classpath:/static, classpath:/public, classpath:/resources.

For Maven, the classpath is src/main/resources.

Write the Controller code to handle requests and respond with data (code omitted here, we’ll use the three-tier architecture below).

Three-Tier Architecture

In the previous emp example, data access, processing logic, and request handling were all in one Controller. This made it hard to reuse and maintain. To fix this and follow the Single Responsibility Principle, we need to separate them. Three-tier architecture makes code more reusable, easier to maintain, and more extensible.

The three layers are Controller, Service, and Dao.

  • Controller: The control layer. It receives requests from the frontend, processes them, and responds with data.
  • Service: The business logic layer. It handles specific business logic.
  • Dao: The Data Access Object (or persistence layer). It handles data access operations like CRUD.

Browser sends request -> Controller receives request, responds with data -> Service processes logic -> Dao accesses data.

The emp example code can be optimized as follows:

  1. Controller
1
2
3
4
5
6
7
8
9
@RestController
public class EmpController {
    @RequestMapping("/listEmp")
    public Result listEmp(){
        EmpService empServiceA = new EmpServiceA();
        List<Emp> empList = empServiceA.listEmp();
        return Result.success(empList);
    }
}
  1. Service
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// interface
public interface EmpService {
    public List<Emp> listEmp();
}

public class EmpServiceA implements EmpService {
    private EmpDao empDao = new EmpDaoA();
    public List<Emp> listEmp(){
        List<Emp> empList = empDao.listEmp();
        empList.forEach(emp -> {
            String gender = emp.getGender();
            if("1".equals(gender)){
                emp.setGender("男");
            } else if ("2".equals(gender)) {
                emp.setGender("女");
            }
            String job = emp.getJob();
            if("1".equals(job)){
                emp.setJob("讲师");
            } else if ("2".equals(job)) {
                emp.setJob("班主任");
            } else if ("3".equals(job)) {
                emp.setJob("就业指导");
            }
        });
        return empList;
    }
}
  1. Dao
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// interface
public interface EmpDao {
    public List<Emp> listEmp();
}

public class EmpDaoA implements EmpDao {
    public List<Emp> listEmp(){
        String file = this.getClass().getClassLoader().getResource("emp.xml").getFile();
        List<Emp> empList = XmlParserUtils.parse(file, Emp.class);
        return empList;
    }
}

Layered Decoupling

Cohesion: The degree of functional relatedness within the elements of a single software module.

Coupling: Measures the degree of dependence or association between different layers/modules in software.

Software design principle: High cohesion, low coupling.

For more details, see https://blog.yexca.net/archives/145

For instance, in the three-tier architecture example above, the Controller is coupled with the Service, and the Service is coupled with the Dao.

In the Controller, directly creating a Service object like EmpService empServiceA = new EmpServiceA(); means using A. If you switch to B, you’d need to change the Controller. To change the Service without modifying the Controller, you can create a container. This allows the Controller to get objects from the container (Dependency Injection), and the Service injects its services into the container (Inversion of Control).

  • Inversion of Control (IoC): The control over object creation shifts from the program itself to an external entity (the container).
  • Dependency Injection (DI): The container provides the application with the resources it needs at runtime.
  • Bean Object: An object created and managed by the IoC container.

IoC

To let the IoC container manage an object, you need to add one of the following annotations to its class:

AnnotationDescriptionLocation
@ComponentBasic annotation to declare a beanUse this when it doesn’t fall into the other three categories (e.g., utility classes)
@ControllerA derived annotation of @ComponentAnnotate on controller classes
@ServiceA derived annotation of @ComponentAnnotate on business classes
@RepositoryA derived annotation of @ComponentAnnotate on data access classes (less common with MyBatis integration)
  • When declaring a bean, you can specify its name using the value attribute. If not specified, the default is the class name with the first letter lowercase.
  • In SpringBoot web development, you can only declare controller beans using @Controller.

Bean Component Scanning

For the four bean declaration annotations above to work, they also need to be scanned by the @ComponentScan annotation.

This annotation is actually included in the @SpringBootApplication annotation used on the startup class. The default scan scope is the package of the startup class and its sub-packages.

You can specify the scan scope via the value or basePackage attribute.

1
@ComponentScan({"dao","net.yexca"})

Example

In the example above:

  1. Controller

Since @RestController already includes @Controller, no changes are needed.

  1. Service
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// interface
public interface EmpService {
    public List<Emp> listEmp();
}

// Add to the implementation class
@Service // Hands this class over to the IoC container for management, making it an IoC container Bean.
public class EmpServiceA implements EmpService {
    private EmpDao empDao = new EmpDaoA();
    public List<Emp> listEmp(){
        List<Emp> empList = empDao.listEmp();
        empList.forEach(emp -> {
            String gender = emp.getGender();
            if("1".equals(gender)){
                emp.setGender("男");
            } else if ("2".equals(gender)) {
                emp.setGender("女");
            }
            String job = emp.getJob();
            if("1".equals(job)){
                emp.setJob("讲师");
            } else if ("2".equals(job)) {
                emp.setJob("班主任");
            } else if ("3".equals(job)) {
                emp.setJob("就业指导");
            }
        });
        return empList;
    }
}
  1. Dao
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// interface
public interface EmpDao {
    public List<Emp> listEmp();
}

// Add to the implementation class
@Repository // Hands this class over to the IoC container for management, making it an IoC container Bean.
public class EmpDaoA implements EmpDao {
    public List<Emp> listEmp(){
        String file = this.getClass().getClassLoader().getResource("emp.xml").getFile();
        List<Emp> empList = XmlParserUtils.parse(file, Emp.class);
        return empList;
    }
}

DI

You can inject dependencies using the @Autowired annotation. However, by default, it injects by type. If multiple beans of the same type exist, it will throw an error.

Let’s add DI to the previous example (modified code will be commented out).

  1. Controller
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
@RestController
public class EmpController {
    @Autowired // At runtime, the IoC container will provide a Bean object of this type and assign it to this variable -- Dependency Injection.
    private EmpService empService;
    
    @RequestMapping("/listEmp")
    public Result listEmp(){
        
        //EmpService empServiceA = new EmpServiceA();
        //List<Emp> empList = empServiceA.listEmp();
        List<Emp> empList = empService.listEmp();
            
        return Result.success(empList);
    }
}
  1. Service
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// interface
public interface EmpService {
    public List<Emp> listEmp();
}

// Add to the implementation class
@Service // Hands this class over to the IoC container for management, making it an IoC container Bean.
public class EmpServiceA implements EmpService {
    
    @Autowired // At runtime, the IoC container will provide a Bean object of this type and assign it to this variable -- Dependency Injection.
    private EmpDao empDao;
    // private EmpDao empDao = new EmpDaoA();
    
    public List<Emp> listEmp(){
        List<Emp> empList = empDao.listEmp();
        empList.forEach(emp -> {
            String gender = emp.getGender();
            if("1".equals(gender)){
                emp.setGender("男");
            } else if ("2".equals(gender)) {
                emp.setGender("女");
            }
            String job = emp.getJob();
            if("1".equals(job)){
                emp.setJob("讲师");
            } else if ("2".equals(job)) {
                emp.setJob("班主任");
            } else if ("3".equals(job)) {
                emp.setJob("就业指导");
            }
        });
        return empList;
    }
}
  1. Dao

No data injection needed, so no changes.

@Primary

If you add another EmpServiceB implementation for Service and also use @Autowired, the program will throw an error. You can use the @Primary annotation to specify which one to use.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
// interface
public interface EmpService {
    public List<Emp> listEmp();
}

// Add to the implementation class
@Service // Hands this class over to the IoC container for management, making it an IoC container Bean.
public class EmpServiceA implements EmpService {
    
    @Autowired // At runtime, the IoC container will provide a Bean object of this type and assign it to this variable -- Dependency Injection.
    private EmpDao empDao;
    // private EmpDao empDao = new EmpDaoA();
    
    public List<Emp> listEmp(){
        List<Emp> empList = empDao.listEmp();
        empList.forEach(emp -> {
            String gender = emp.getGender();
            if("1".equals(gender)){
                emp.setGender("男");
            } else if ("2".equals(gender)) {
                emp.setGender("女");
            }
            String job = emp.getJob();
            if("1".equals(job)){
                emp.setJob("讲师");
            } else if ("2".equals(job)) {
                emp.setJob("班主任");
            } else if ("3".equals(job)) {
                emp.setJob("就业指导");
            }
        });
        return empList;
    }
}

@Primary // Use this bean.
@Service // Hands this class over to the IoC container for management, making it an IoC container Bean.
public class EmpServiceB implements EmpService {
    
    @Autowired // At runtime, the IoC container will provide a Bean object of this type and assign it to this variable -- Dependency Injection.
    private EmpDao empDao;
    // private EmpDao empDao = new EmpDaoA();
    
    public List<Emp> listEmp(){
        List<Emp> empList = empDao.listEmp();
        empList.forEach(emp -> {
            String gender = emp.getGender();
            if("1".equals(gender)){
                emp.setGender("男");
            } else if ("2".equals(gender)) {
                emp.setGender("女");
            }
            String job = emp.getJob();
            if("1".equals(job)){
                emp.setJob("讲师");
            } else if ("2".equals(job)) {
                emp.setJob("班主任");
            } else if ("3".equals(job)) {
                emp.setJob("就业指导");
            }
        });
        return empList;
    }
}

@Qualifier

Specify which bean to use. Use it in the Controller and remove the @Primary annotation from above.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
@RestController
public class EmpController {
    
    @Qualifier("empServiceA") // Use bean empServiceA.
    @Autowired // At runtime, the IoC container will provide a Bean object of this type and assign it to this variable -- Dependency Injection.
    private EmpService empService;
    
    @RequestMapping("/listEmp")
    public Result listEmp(){
        
        //EmpService empServiceA = new EmpServiceA();
        //List<Emp> empList = empServiceA.listEmp();
        List<Emp> empList = empService.listEmp();
            
        return Result.success(empList);
    }
}

@Resource

Similar to @Qualifier, but without using @Autowired.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
@RestController
public class EmpController {
    
    // @Qualifier("empServiceA") // Use bean empServiceA.
    // @Autowired 
    @Resource(name = "empServiceB") // Use bean empServiceB.
    private EmpService empService;
    
    @RequestMapping("/listEmp")
    public Result listEmp(){
        
        //EmpService empServiceA = new EmpServiceA();
        //List<Emp> empList = empServiceA.listEmp();
        List<Emp> empList = empService.listEmp();
            
        return Result.success(empList);
    }
}

@Autowired vs. @Resource Differences

@Autowired is a Spring Framework annotation, while @Resource is a JDK annotation.

@Autowired injects by type by default, whereas @Resource injects by name by default.