Is 3-tier Architecture anti-pattern?
π 24 Oct 2023 π 8 min readIn this topic, I would like to discuss about 3-tier architecture, what kind of drawbacks it has and what might be used instead of it.
Three-tier Architecture
Letβs talk about three-tier architecture. Itβs a well-known pattern that defines in which way we should organise modules of our programs. The 3-tier architecture is very popular across web applications, but it might be applied to not only implementing this type of application. Based on the name, you might guess that there are 3 tiers:
- Presentation Layer
- Logic Layer
- Data Layer
Presentation Layer contains logic that is in charge of providing data via REST or other protocols. The code of this layer handles requests, takes needed data,
transforms the data to required formats and sends responses. Usually, a framework is responsible for most of these actions but a programmer still needs to declare
API and to define from which place data will be taken and where data should be passed to.
Logic Layer contains business logic e.g. creating new objects, changing values of existing domain objects and so on.
Itβs the most interesting part of the application.
The last one is the Data Layer. This layer provides access to a database in a convenient way. Due to the Data Layer,
we donβt have to think about how to persist and retrieve objects from a database. Moreover, in theory, this abstraction tier
lets switch database type without changing business logic e.g. from MySQL to PostgreSQL, or even to a NoSQL solution.
As a rule, each tier is represented by a package that contains all classes associated with a corresponding tier. Letβs look at an example.
It is a simple content management system. The system provides a Web Interface and REST API to operate posts, comments, and users.
The picture below depicts the structure of the 3-tier architecture of the application. For convenience, I mark the related classes with the same emojis.
. βββru Β Β βββ izebit Β Β βββ ApplicationLauncher.java Β Β βββ controller Β Β βΒ Β βββ UserController.java π½ Β Β βΒ Β βββ PostController.java ποΈ β βββ CommentController.java βοΈ Β Β βββ repository Β Β βΒ Β βββ UserRepository.java π½ Β Β βΒ Β βββ PostRepository.java ποΈ Β Β βΒ Β βββ CommentRepository.java βοΈ Β Β βΒ Β βββ model Β Β βΒ Β βββ User.java π½ Β Β βΒ Β βββ Post.java ποΈ Β Β βΒ Β βββ Comment.java βοΈ Β Β βββ service Β Β βββ UserService.java π½ Β Β βββ AuthenticationService.java π½ Β Β βββ StopSpamService.java βοΈ Β Β βββ CommentService.java βοΈ Β Β βββ PostService.java ποΈ Β Β βββ SpellCheckerService.java ποΈ Β Β βββ dto Β Β βββ User.java π½ Β Β βββ Post.java ποΈ Β Β βββ Comment.java βοΈ
As we can see, there are classes grouped in packaged by type, not by domain. All classes must be public because they should
be accessible from other packages, otherwise, controllers canβt work with services and services canβt work with repositories.
Moreover, not related classes are also accessible to each other inside a package, for example, AuthenticationService
and SpellCheckerService
.
In this way, based only on the structure, itβs tough to understand the relationship between classes. In addition, the current package structure
contradicts the Encapsulation principle.
A language mechanism for restricting direct access to some of the objectβs components.
- Wikipedia
To restrict access to components from other packages, we might place all related classes into the same βbusiness logicβ package and expose only classes that are used by other components. Other classes might have restricted modifiers e.g. package-private or private modifiers. This approach can be implemented with Hexagonal Architecture.
Hexagonal Architecture
The backbone of Hexagonal Architecture is Ports and Adapters. Business logic has boundaries and is isolated from other components.
There is no direct access to it from other packages.
Ports provide a shareable API that might be used to work with our business logic and vice versa i.e. to define API for interacting
with the external world as well. Thus, it stands for βWHAT our system can do and other systems can do withβ.
Adapters define in which way ports might be invoked and which way our system might work with other components. One port might have many adapters.
In this way, adapter stands for βHOW the system and other systems interact each otherβ.
This approach lets us write maintainable code with isolated domain-specific logic. All dependencies are represented as interfaces and might be mocked
if necessary. It makes writing tests easier. Also, it affects flexibility because we can change adapters without touching core logic.
In other words, this design encourages Orthogonality.
Letβs look at the structure of the app with Hexagonal Architecture:
. βββ ru βββ izebit βββ ApplicationLauncher.java βββ comment βΒ Β βββ CommentServiceImpl.java βΒ Β βββ StopSpamService.java βΒ Β βββ ports βΒ Β βββ in βΒ Β βΒ Β βββ CommentService.java βΒ Β βΒ Β βββ adapters βΒ Β βΒ Β βββ CommentController.java βΒ Β βββ models βΒ Β βΒ Β βββ Comment.java βΒ Β βββ out βΒ Β βββ CommentRepository.java βΒ Β βββ UserService.java βΒ Β βββ adapters βΒ Β βββ CommentRepositoryImpl.java βΒ Β βββ UserServiceImpl.java βββ post βΒ Β βββ PostServiceImpl.java βΒ Β βββ SpellCheckerService.java βΒ Β βββ ports βΒ Β βββ in βΒ Β βΒ Β βββ PostService.java βΒ Β βΒ Β βββ adapters βΒ Β βΒ Β βββ PostController.java βΒ Β βββ models βΒ Β βΒ Β βββ Post.java βΒ Β βββ out βΒ Β βββ PostRepository.java βΒ Β βββ UserService.java βΒ Β βββ adapters βΒ Β βββ PostRepositoryImpl.java βΒ Β βββ UserServiceImpl.java βββ user βββ AuthenticationService.java βββ UserServiceImpl.java βββ ports βββ in βΒ Β βββ UserService.java βΒ Β βββ adapters βΒ Β βββ UserController.java βββ models βΒ Β βββ User.java βββ out βββ UserRepository.java βββ adapters βββ UserRepositoryImpl.java
In comparison to the previous one, this structure brings a few additional interfaces and packages, but at the same time,
it gives more information on the relationships between components.
The protected
package visibility modifier hides all domain-specific classes. Only components of packages */ports/in/
and */ports/model/
must be public
i.e. accessible from other packages. By now, thereβs still no clear relationship between packages.
We need to provide public access to only particular packages.
For instance, during working with comments and posts, we need to check users if they are authenticated, they present in blacklist etc.
It means that classes from the post
and comment
packages should have access to public classes from the user
package, but the reverse is false.
Limiting access might be implemented with Java Modules,
but we discover another approach.
Spring Modulith
This Spring component lets developers implement logical modules in Spring Boot applications. It supports structural validation, generation of Module Component diagrams etc. In this post, we are going to use a few modules of this Spring component.
First of all, we need to mark our public packages with the NamedInterface
annotation.
By default, all top-level packages are considered as modules and there is no need to define them explicitly.
We do it only for sub-packages.
@org.springframework.modulith.NamedInterface("in")
package ru.izebit.user.ports.in;
Then we should define dependencies for each package. For this purpose, we use the following annotation:
@org.springframework.modulith.ApplicationModule(
allowedDependencies = "user::in"
)
package ru.izebit.comment;
This information on packages are stored in package-info.java
files placed in the relevant directories.
After these two steps, we can write a simple test that checks correct packages access.
@Test
void shouldBeCompliant() {
ApplicationModules.of(ApplicationLauncher.class)
.verify();
}
If access is broken, we can see the following output:
org.springframework.modulith.core.Violations: - Module 'comment' depends on module 'user' via ru.izebit.comment.ports.in.adapters.CommentController -> ru.izebit.user.ports.out.UserRepository. Allowed targets: user::in.
The error message is descriptive and gives enough information to find out the root of the issue.
In addition to checking package access violations, one of the features of Spring Modulith is generation of UML diagrams.
If you make the code below a part of build pipeline, you will always have up-date diagrams of your system.
void writeDocumentationSnippets() {
new Documenter(modules)
.writeModuleCanvases()
.writeModulesAsPlantUml()
.writeIndividualModulesAsPlantUml();
}
The picture depicts C4 PlantUML diagram of the application.
You can find the source code of the application here.