DEV Community

João Esperancinha
João Esperancinha

Posted on

Hexagonal Architecture — A Favorite Lyrics Spring Boot — Java Example

Introduction

This architecture principle was created by Alistair Cockburn in 2005. This is one of the many forms of DDD(Domain Driven Design Architecture). The goal was to find a way to solve or otherwise mitigate general caveats introduced by object-oriented programming. This is also known as Ports and Adapters architecture. The hexagon concept isn’t related to a six-side architecture nor does it have anything to do with the geometrical form. A hexagon has six sides indeed, but the idea is to illustrate the concept of many ports. This shape is also easier to split into two and to be used as a representation of the business logic of the application. The idea is to separate the application we want to develop into essentially three separate parts. The left, the core and the right. Going into an even broader concept we want to differentiate the concepts of inside and outside. Inside is the business logic and the application itself and outside is whatever we are using to connect and interact with the application.

Core

The core of an application can be defined as the place where the business logic of the application happens. An application core receives data, performs operations on it and optionally may communicate with other external parties like databases or persistence entities.

Ports

Ports represent the boundaries of the application. Frequently they are implemented as interfaces to be used by outside parties. Their implementation resides outside the application, although they share the same domain.

Primary ports

Primary ports are also known as driving ports. These are the first communication points between the outside and the application core. Therefore, they can still be otherwise known as Inbound Ports. These ports “drive” the application. This means that this is where requests get through to the application. The upstream in this case contains data and the downstream contains the response to that request. Primary ports reside on the left side of the hexagon.

Secondary ports

Secondary ports are in contrast known as driven ports. These also live on the outside and symmetrically to the primary ports they live on the right side of the hexagon. The application core uses secondary ports to upstream data to external entities. For example, an operation that needs data from the database will use a secondary port. The application “drives” the port in order to get data. The downstream contains thus the data coming from external entities on the right. Because the application sends data to the outside, secondary ports also get called Outbound Ports.

Adapters

Adapters are essentially the implementation of ports. They are not supposed to be called directly at any point in the code.

Primary adapters

Primary adapters are implementations of primary ports. These are completely independent of the application core. This presents one of the clear advantages of this architecture. By implementing a port on the outside, we have control over how the implementation is done. This means that we can freely implement different forms of getting the data through to the application, without affecting the application itself. Just as ports primary adapters can also be called driving adapters. Examples of this are REST services and GUIs.

Secondary adapters

Secondary adapters are implementations of secondary ports. Just as primary adapters, these are also independent of the application core with the same clear advantage. More often, we find that it’s in the secondary ports that lie the more difficult questions regarding the choice of technology. Frequently there is always the question of how we actually want to implement a persistence layer. It can be difficult to choose the right database, file system, or anything else. By using adapters, we can easily interchange adapters as we want. This means that regardless of the implementation, our application also does not change. It will only know the operations it needs to call and has no idea of how they are implemented. In the same way as primary adapters, secondary adapters are also referred to as driven adapters.

Implementation

This application manages a lyric’s storage system. It stores the related artist and a lyrics text. We can then access an endpoint that will randomly show a certain lyric and the related artist. We can also perform all other POST, PUT, DELETE, and GET operations to perform CRUD(Create, Read, Update, Delete) operations via JPA(Java Persistence API) repositories. I purposely made this application simple and with common operations. It is important to understand concepts like core, domain, and infrastructure and this is also the reason why I created this application with all of these operations.

blogcenter

Structure

In the previous points, I’ve mentioned a few keywords that are important in order to set up our application. Since this is a demo application, a few considerations are important. I want the application to be simple, but also to represent what most applications do at its core. Most applications have a persistence framework, a business model, and a presentation layer. In this example, I have chosen Spring in order to use the MVC(Model View Controller) pattern. If you want to know more about the MVC pattern please follow the links below in the references section. To run the application we are going to use Spring Boot. To access our database I am using JPA repositories and finally, I have chosen an H2 in-memory database. You will also see that I’m using JUnit Jupiter, Mockito, and AssertJ. These are off the scope of this tutorial, but if you are interested in learning more about these frameworks, then please follow the links below in the references section.

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<packaging>pom</packaging>
<modules>
<module>favourite-lyrics-domain</module>
<module>favourite-lyrics-core</module>
<module>favourite-lyrics-jpa</module>
<module>favourite-lyrics-starter</module>
<module>favourite-lyrics-test</module>
<module>favourite-lyrics-rest</module>
</modules>

    <groupId>org.jesperancinha.lyrics</groupId>
    <artifactId>favourite-lyrics</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>favourite-lyrics</name>
    <description>Favourite Lyrics App</description>

    <properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <java.version>17</java.version>
        <maven.compiler.source>${java.version}</maven.compiler.source>
        <maven.compiler.target>${java.version}</maven.compiler.target>
        <h2.version>1.4.200</h2.version>
        <lombok.version>1.18.20</lombok.version>
        <spring-tx.version>5.2.2.RELEASE</spring-tx.version>
        <assertj-core.version>3.13.2</assertj-core.version>
        <mockito-all.version>1.10.19</mockito-all.version>
        <junit-jupiter.version>5.5.2</junit-jupiter.version>
        <mockito-junit-jupiter.version>3.1.0</mockito-junit-jupiter.version>
        <jacoco-maven-plugin.version>0.8.7</jacoco-maven-plugin.version>
        <coveralls-maven-plugin.version>4.3.0</coveralls-maven-plugin.version>
        <jaxb-api.version>2.3.1</jaxb-api.version>
        <spring-boot-starter-parent.version>2.6.1</spring-boot-starter-parent.version>
        <flyway-core.version>8.1.0</flyway-core.version>
        <postgresql.version>42.2.24</postgresql.version>
    </properties>

    <dependencyManagement>
        <dependencies>
            <!-- Imports -->
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-starter-parent</artifactId>
                <version>${spring-boot-starter-parent.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>

            <!-- Inner dependencies -->
            <dependency>
                <groupId>org.jesperancinha.lyrics</groupId>
                <artifactId>favourite-lyrics-domain</artifactId>
                <version>${project.version}</version>
            </dependency>
            <dependency>
                <groupId>org.jesperancinha.lyrics</groupId>
                <artifactId>favourite-lyrics-core</artifactId>
                <version>${project.version}</version>
            </dependency>
            <dependency>
                <groupId>org.jesperancinha.lyrics</groupId>
                <artifactId>favourite-lyrics-rest</artifactId>
                <version>${project.version}</version>
            </dependency>
            <dependency>
                <groupId>org.jesperancinha.lyrics</groupId>
                <artifactId>favourite-lyrics-jpa</artifactId>
                <version>${project.version}</version>
            </dependency>

            <!-- External dependencies -->
            <dependency>
                <groupId>org.projectlombok</groupId>
                <artifactId>lombok</artifactId>
                <version>${lombok.version}</version>
                <optional>true</optional>
            </dependency>
            <dependency>
                <groupId>org.flywaydb</groupId>
                <artifactId>flyway-core</artifactId>
                <version>${flyway-core.version}</version>
            </dependency>
            <dependency>
                <groupId>org.postgresql</groupId>
                <artifactId>postgresql</artifactId>
                <version>${postgresql.version}</version>
            </dependency>
        </dependencies>
    </dependencyManagement>

    <build>
        <pluginManagement>
            <plugins>
                <plugin>
                    <groupId>org.jacoco</groupId>
                    <artifactId>jacoco-maven-plugin</artifactId>
                    <version>${jacoco-maven-plugin.version}</version>
                    <executions>
                        <execution>
                            <goals>
                                <goal>prepare-agent</goal>
                            </goals>
                        </execution>
                        <execution>
                            <id>report</id>
                            <phase>test</phase>
                            <goals>
                                <goal>report</goal>
                            </goals>
                        </execution>
                    </executions>
                </plugin>
                <plugin>
                    <groupId>org.eluder.coveralls</groupId>
                    <artifactId>coveralls-maven-plugin</artifactId>
                    <version>${coveralls-maven-plugin.version}</version>
                    <dependencies>
                        <dependency>
                            <groupId>javax.xml.bind</groupId>
                            <artifactId>jaxb-api</artifactId>
                            <version>${jaxb-api.version}</version>
                        </dependency>
                    </dependencies>
                </plugin>
            </plugins>
        </pluginManagement>
    </build>
</project>
Enter fullscreen mode Exit fullscreen mode

Domain

Let’s have a look at what we need as a domain. Domain is in this case is anything that we can share between the core of the application and the ports.

The first thing to do is to define how we want data to be transferred around. In our case we do this via a DTO (Data Transfer Object):

public record LyricsDto(
        String lyrics,
        String participatingArtist
) {

    @Builder
    public LyricsDto {
    }
}
Enter fullscreen mode Exit fullscreen mode

Normally you may need an exception that can be propagated throughout your architecture. This is a valid, but also very simplistic approach. Further discussion on this would require a new article and it goes off the scope of this article:

public class LyricsNotFoundException extends RuntimeException {

    public LyricsNotFoundException(UUID id) {
        super("Lyrics with id %s not found!".formatted(id));
    }
}
Enter fullscreen mode Exit fullscreen mode

Furthermore, this is where you create your outbound port. In our case and at this point we know we want persistence, but we are not interested in how it’s implemented. This is why we only create an interface at this point.

public interface LyricsPersistencePort {

    void addLyrics(LyricsDto lyricsDto);

    void removeLyrics(LyricsDto lyricsDto);

    void updateLyrics(LyricsDto lyricsDto);

    List<LyricsDto> getAllLyrics();

    LyricsDto getLyricsById(UUID lyricsId);
}
Enter fullscreen mode Exit fullscreen mode

Notice that our interface declares all necessary CRUD methods.

Core

Core works hand in hand with Domain. Both of them could be incorporated into one single module. However, this separation is very important because it makes core an implementation of only the business logic.
Core is where we find our service interface:

public interface LyricsService {

    void addLyrics(LyricsDto lyricsDto);

    void removeLyrics(LyricsDto lyricsDto);

    void updateLyrics(LyricsDto lyricsDto);

    List<LyricsDto> getAllLyrics();

    LyricsDto getLyricsById(UUID lyricsId);

}
Enter fullscreen mode Exit fullscreen mode

And its implementation:

@Service
public class LyricsServiceImpl implements LyricsService {

    private final LyricsPersistencePort lyricsPersistencePort;

    public LyricsServiceImpl(LyricsPersistencePort lyricsPersistencePort) {
        this.lyricsPersistencePort = lyricsPersistencePort;
    }

    @Override
    public void addLyrics(LyricsDto lyricsDto) {
        lyricsPersistencePort.addLyrics(lyricsDto);
    }

    @Override
    @Transactional
    public void removeLyrics(LyricsDto lyricsDto) {
        lyricsPersistencePort.removeLyrics(lyricsDto);
    }

    @Override
    public void updateLyrics(LyricsDto lyricsDto) {
        lyricsPersistencePort.updateLyrics(lyricsDto);
    }

    @Override
    public List<LyricsDto> getAllLyrics() {
        return lyricsPersistencePort.getAllLyrics();
    }

    @Override
    public LyricsDto getLyricsById(UUID lyricsId) {
        return lyricsPersistencePort.getLyricsById(lyricsId);
    }
}
Enter fullscreen mode Exit fullscreen mode

Changes in core also represent changes in the business logic and this is why, for small applications there isn’t really a reason to further separate the port and the adapter into different modules. However, increased complexity may lead to splitting up the core into other different cores with different responsibilities. Take note that external modules should only use the interface and not the implementation of it.

JPA

Let’s create an entity. First, we should create an entity that reflects the data we want to save. In this case we need to think about the participatingArtist and the lyrics themselves. Because it’s an entity, it also needs its ID. Please note that Artist is a field that could be set into another entity. I’m not doing that in this example because it would add further complexity and another ER(entity relationship) database paradigm, which is off the scope:

@NoArgsConstructor
@AllArgsConstructor
@Builder
@Entity
@Table(name = "lyrics")
@Getter
@Setter
@ToString
public class LyricsEntity {

    @Id
    @GeneratedValue(strategy = IDENTITY)
    @Column
    private UUID id;

    @Column
    private String lyrics;

    @Column
    private String participatingArtist;

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || Hibernate.getClass(this) != Hibernate.getClass(o)) return false;
        LyricsEntity that = (LyricsEntity) o;
        return id != null && Objects.equals(id, that.id);
    }

    @Override
    public int hashCode() {
        return getClass().hashCode();
    }
}
Enter fullscreen mode Exit fullscreen mode

Now we can implement our JPA repository implementation. It will be our outbound port. This is where our CRUD lives:

public interface LyricsRepository extends JpaRepository<LyricsEntity, UUID> {

    void deleteAllByParticipatingArtist(String name);

    LyricsEntity findByParticipatingArtist(String Name);

    LyricsEntity findByLyrics(String Lyrics);
}
Enter fullscreen mode Exit fullscreen mode

Finally, we can implement our port. This is a step between the core and the JPA repository. This is our adapter and it’s the implementation of how we want to access our JPA repository:

@Service
public class LyricsJpaAdapter implements LyricsPersistencePort {

    private final LyricsRepository lyricsRepository;

    public LyricsJpaAdapter(LyricsRepository lyricsRepository) {
        this.lyricsRepository = lyricsRepository;
    }

    @Override
    public void addLyrics(LyricsDto lyricsDto) {
        final LyricsEntity lyricsEntity = getLyricsEntity(lyricsDto);
        lyricsRepository.save(lyricsEntity);
    }

    @Override
    public void removeLyrics(LyricsDto lyricsDto) {
        lyricsRepository.deleteAllByParticipatingArtist(lyricsDto.getParticipatingArtist());
    }

    @Override
    public void updateLyrics(LyricsDto lyricsDto) {
        final LyricsEntity byParticipatingArtist = lyricsRepository.findByParticipatingArtist(lyricsDto.getParticipatingArtist());
        if (Objects.nonNull(byParticipatingArtist)) {
            byParticipatingArtist.setLyrics(lyricsDto.getLyrics());
            lyricsRepository.save(byParticipatingArtist);
        } else {
            final LyricsEntity byLyrics = lyricsRepository.findByLyrics(lyricsDto.getLyrics());
            if (Objects.nonNull(byLyrics)) {
                byLyrics.setParticipatingArtist(lyricsDto.getParticipatingArtist());
                lyricsRepository.save(byLyrics);
            }
        }
    }

    @Override
    public List<LyricsDto> getAllLyrics() {
        return lyricsRepository.findAll()
                .stream()
                .map(this::getLyrics)
                .collect(Collectors.toList());
    }

    @SneakyThrows
    @Override
    public LyricsDto getLyricsById(UUID lyricsId) {
        return getLyrics(lyricsRepository.findById(lyricsId)
                .orElseThrow((Supplier<Throwable>) () -> new LyricsNotFoundException(lyricsId)));
    }

    private LyricsEntity getLyricsEntity(LyricsDto lyricsDto) {
        return LyricsEntity.builder()
                .participatingArtist(lyricsDto.getParticipatingArtist())
                .lyrics(lyricsDto.getLyrics())
                .build();
    }

    private LyricsDto getLyrics(LyricsEntity lyricsEntity) {
        return LyricsDto.builder()
                .participatingArtist(lyricsEntity.getParticipatingArtist())
                .lyrics(lyricsEntity.getLyrics())
                .build();
    }

}
Enter fullscreen mode Exit fullscreen mode

This completes our application implementation on the right side. Note that I’ve implemented the update operation very simplistically. If the coming DTO already has a parallel via the participatingArtist then update the lyrics. If the coming DTO already has a parallel via the lyrics then update the participatingArtist. Also notice the getLyricsById method. It will throw the domain defined LyricsNotFoundException if the lyrics with the specified ID do not exist. All mechanisms are in place to access the database. Next we are going to see the implementation of a REST service which uses the inbound port to upstream data to the application.
REST
I used the typical way to implement a rest service using the Spring MVC framework. Essentially all we need is firstly an interface to define what we need in our requests. This is, in other words, our inbound port:

public interface LyricsController {

    @PostMapping("/lyrics")
    ResponseEntity<Void> addLyrics(
            @RequestBody
                    LyricsDto lyricsDto);

    @DeleteMapping("/lyrics")
    ResponseEntity<String> removeLyrics(
            @RequestBody
                    LyricsDto lyricsDto);

    @PutMapping("/lyrics")
    ResponseEntity<String> updateLyrics(
            @RequestBody
                    LyricsDto lyricsDto);

    @GetMapping("/lyrics/{lyricsId}")
    ResponseEntity<LyricsDto> getLyricsById(
            @PathVariable
                    UUID lyricsId);

    @GetMapping("/lyrics")
    ResponseEntity<List<LyricsDto>> getLyrics();

    @GetMapping("/lyrics/random")
    ResponseEntity<LyricsDto> getRandomLyric();

}
Enter fullscreen mode Exit fullscreen mode

And finally its implementation:

@Slf4j
@RestController
public class LyricsControllerImpl implements LyricsController {

    private final LyricsService lyricsService;

    private final Random random = new Random();

    public LyricsControllerImpl(LyricsService lyricsService) {
        this.lyricsService = lyricsService;
    }

    @Override
    public ResponseEntity<Void> addLyrics(LyricsDto lyricsDto) {
        lyricsService.addLyrics(lyricsDto);
        return new ResponseEntity<>(HttpStatus.CREATED);
    }

    @Override
    public ResponseEntity<String> removeLyrics(LyricsDto lyricsDto) {
        lyricsService.removeLyrics(lyricsDto);
        return new ResponseEntity<>(HttpStatus.OK);
    }

    @Override
    public ResponseEntity<String> updateLyrics(LyricsDto lyricsDto) {
        lyricsService.updateLyrics(lyricsDto);
        return new ResponseEntity<>(HttpStatus.OK);
    }

    @Override
    public ResponseEntity<LyricsDto> getLyricsById(UUID lyricsId) {
        try {
            return new ResponseEntity<>(lyricsService.getLyricsById(lyricsId), HttpStatus.OK);
        } catch (LyricsNotFoundException ex) {
            log.error("Error!", ex);
            return new ResponseEntity<>(HttpStatus.NOT_FOUND);
        }
    }

    @Override
    public ResponseEntity<List<LyricsDto>> getLyrics() {
        return new ResponseEntity<>(lyricsService.getAllLyrics(), HttpStatus.OK);
    }

    @Override
    public ResponseEntity<LyricsDto> getRandomLyric() {
        final List<LyricsDto> allLyrics = lyricsService.getAllLyrics();
        final int size = allLyrics.size();
        return new ResponseEntity<>(allLyrics.get(random.nextInt(size)), HttpStatus.OK);
    }
}
Enter fullscreen mode Exit fullscreen mode

Here we have a complete typical rest service implemented where we can create lyrics, update lyrics, delete lyrics and read lyrics. We can do the latter in three different ways. We can read all of them, get one by id or just get one randomly. Application wise we have everything ready. What we still don’t have at this point is the Spring environment and the Spring Boot Launcher. Let’s have a look at this next.
Spring Boot
Our application needs a launcher to get started. This is accomplished with Spring Boot:

@SpringBootApplication
@EnableTransactionManagement
public class LyricsDemoApplicationLauncher {
    public static void main(String[] args) {
        SpringApplication.run(LyricsDemoApplicationLauncher.class);
    }
}
Enter fullscreen mode Exit fullscreen mode

Then we need to configure our environment and make sure that Spring Boot is aware of H2 and the JPA environment. You do this in the application.properties file:

# Postgres
spring.datasource.url=jdbc:postgresql://192.168.0.11:5432/fla?currentSchema=hexadecimal
spring.datasource.username=fla
spring.datasource.password=admin
spring.sql.init.mode=never
# Hibernate
spring.jpa.hibernate.ddl-auto=none
spring.jpa.show-sql=true
# Flyway
spring.flyway.enabled=true
spring.flyway.baseline-on-migrate=true
spring.flyway.url=${spring.datasource.url}
spring.flyway.user=${spring.datasource.username}
spring.flyway.password=${spring.datasource.password}
spring.flyway.locations=classpath:/db
Enter fullscreen mode Exit fullscreen mode

Luckily for us spring will look for the schema file with the name schema.sql. So let’s create our very basic schema:

create schema if not exists hexadecimal;
drop table if exists hexadecimal.LYRICS;

create table hexadecimal.LYRICS
(
ID                   UUID DEFAULT gen_random_uuid(),
PARTICIPATING_ARTIST VARCHAR(100) NOT NULL,
LYRICS               VARCHAR(100) NOT NULL
);
Enter fullscreen mode Exit fullscreen mode

Spring, also looks for data.sql. So let’s put in some data:

insert into LYRICS (PARTICIPATING_ARTIST, LYRICS) values ('William Orbit', 'Sky fits heaven so fly it');
insert into LYRICS (PARTICIPATING_ARTIST, LYRICS) values ('Ava Max', 'Baby I''m torn');
insert into LYRICS (PARTICIPATING_ARTIST, LYRICS) values ('Faun', 'Wenn wir uns wiedersehen');
insert into LYRICS (PARTICIPATING_ARTIST, LYRICS) values ('Abel', 'Het is al lang verleden tijd');
insert into LYRICS (PARTICIPATING_ARTIST, LYRICS) values ('Billie Eilish', 'Chest always so puffed guy');
Enter fullscreen mode Exit fullscreen mode

We are now ready. Now it’s all about starting the application and making tests. To do this, we have to make sure that we have Java SDK 17 installed and Node JS 16 or any version above these. They just have to be LTS versions. I have made scripts and created instructions about this in the Readme.md file located at the root of the project. Once you have this you need to make sure you have docker. Then please have a look at the Makefile at the root where I have created handy scripts for the project. Once all of this is checked, you can just run:

make docker-clean-build-start
Enter fullscreen mode Exit fullscreen mode

Alternatively, you can run:

mvn clean install
cd favourite-lyrics-gui
yarn build
cd ..
docker-compose up -d
Enter fullscreen mode Exit fullscreen mode

This should start the application in a docker-compose environment.
All methods should be easily testable via curl or postman. As an example you can use curl to get a random lyric:

curl localhost:8080/lyrics/random
Enter fullscreen mode Exit fullscreen mode

And the result could be:

{“lyrics”:”Chest always so puffed guy”,”participatingArtist”:”Billie Eilish”}
Enter fullscreen mode Exit fullscreen mode

On the GUI you should see something like this:

blogcenter

Conclusion

This has been an exercise to mostly understand the hexagonal architecture principles. Using Spring is just one possibility. You can implement this architecture with other languages. You can find original examples in C#, Python, Ruby, and many other languages. In the Java landscape, you can do this also with any EE framework like JavaEE, JakartaEE, or any other enterprise framework. The point is to always remember to isolate the inside from the outside and make sure that the communication is clear via ports and that the implementation via adapters remains independent of the application core.
I have implemented this application with different modules which represented different responsibilities. However, you may also implement this in a single module. You can try that and you will see that the principles don’t really change. The only difference is that separate modules allow you to make changes independently and allow you to create different versions of your modules. In one single module, you will have to make releases of everything at the same time as a follow-up to code changes. However, both ways respect and follow this architecture, because, in the end, the point is to make interfaces and to use them in the data streaming instead of their implementations. Their implementations will be used underwater, and they are interchangeable without affecting the inside also known as the application core.
I have placed all the source code of this application in GitHub.
I hope that you have enjoyed this article as much as I enjoyed writing it.

Thanks in advance for your help, and thank you for reading!

References

Top comments (0)