Coding demo project and implementation of the API adaptor (Hexagonal Architecture with Spring Boot — Part 2)
This is the second article of a 4 5 part series explaining Hexagonal Architecture and how to implement such an architecture using Spring Boot and TDD.
Part 1 - Introduction to Hexagonal Architecture and Key Concepts
Part 2 - Coding demo project and implementation of the API adaptor
Part 3 - Implementation of Domain Services
Part 4 - Implementation of MongoDB repository adaptor
Part 5 - Implementation of REST adaptor to an external data source
Update: The corresponding video is live on YouTube now
Spring Framework
Using the Spring Framework and its projects does not guarantee the hexagonal architecture or any good architecture. However, it is designed with the modularisation and dependency support consistent with hexagonal architecture.
Among other factors, its Dependency Injection and Inversion of Control patterns directly support the ports and adaptor architecture, and its integration capabilities accelerate adaptor implementation.
Demo Application: Stock Position and Market Value Service
I’ll demonstrate the development os an application how to implement a hexagonal architecture compliant application using Spring Boot. We want to implement a service that returns how many shares a user has (stock position) and the current market value. This requires integration to both a data repository and a online source of stock prices.
The code can be found in this Github repository. First let’s look at the pom.xml:
<?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> | |
<parent> | |
<groupId>org.springframework.boot</groupId> | |
<artifactId>spring-boot-starter-parent</artifactId> | |
<version>2.2.6.RELEASE</version> | |
<relativePath/> <!-- lookup parent from repository --> | |
</parent> | |
<groupId>com.example</groupId> | |
<artifactId>hexa-demo</artifactId> | |
<version>0.0.1-SNAPSHOT</version> | |
<name>hexa-demo</name> | |
<description>Hexagonal Architecture Demo project for Spring Boot</description> | |
<properties> | |
<java.version>1.8</java.version> | |
</properties> | |
<dependencies> | |
<dependency> | |
<groupId>org.springframework.boot</groupId> | |
<artifactId>spring-boot-starter-webflux</artifactId> | |
</dependency> | |
<dependency> | |
<groupId>org.springframework.boot</groupId> | |
<artifactId>spring-boot-starter-security</artifactId> | |
</dependency> | |
<dependency> | |
<groupId>org.springframework.boot</groupId> | |
<artifactId>spring-boot-starter-data-mongodb-reactive</artifactId> | |
</dependency> | |
<dependency> | |
<groupId>org.projectlombok</groupId> | |
<artifactId>lombok</artifactId> | |
<optional>true</optional> | |
</dependency> | |
<dependency> | |
<groupId>org.springframework.boot</groupId> | |
<artifactId>spring-boot-starter-test</artifactId> | |
<scope>test</scope> | |
<exclusions> | |
<exclusion> | |
<groupId>org.junit.vintage</groupId> | |
<artifactId>junit-vintage-engine</artifactId> | |
</exclusion> | |
</exclusions> | |
</dependency> | |
<dependency> | |
<groupId>io.projectreactor</groupId> | |
<artifactId>reactor-test</artifactId> | |
<scope>test</scope> | |
</dependency> | |
<dependency> | |
<groupId>org.springframework.security</groupId> | |
<artifactId>spring-security-test</artifactId> | |
<scope>test</scope> | |
</dependency> | |
<dependency> | |
<groupId>com.github.javafaker</groupId> | |
<artifactId>javafaker</artifactId> | |
<version>1.0.2</version> | |
<scope>test</scope> | |
</dependency> | |
<dependency> | |
<groupId>de.flapdoodle.embed</groupId> | |
<artifactId>de.flapdoodle.embed.mongo</artifactId> | |
</dependency> | |
</dependencies> | |
<build> | |
<plugins> | |
<plugin> | |
<groupId>org.springframework.boot</groupId> | |
<artifactId>spring-boot-maven-plugin</artifactId> | |
</plugin> | |
</plugins> | |
</build> | |
</project> |
In this article, I’ll describe the stages of coding and show the final code. There will be a screencast video to go through the step-by-step description of TDD and Hexagonal Architecture considerations in the process.
REST API Adapter
We’ll use TDD (Test Driven Development) for this project, so let’s start with a test for the REST API.
What should the API adapter test cover? Let’s list out the concerns of a API adapter.
- It should present an endpoint (e.g. “GET /stock-position”) with optional selection predicates (e.g. “accept: application/json”)
- It should check for authorisation when required
- It should validate and convert the parameters in the request (which can come in path variables, query params, request body, header values, cookies, authentication, etc) into domain models and types where necessary
- It should call one or more domain service with the converted parameters
- It should convert the returned results from domain models into API DTOs (Data Transfer Object)
- It should return an appropriate HTTP status
In other words, the concerns of the API adapter includes everything between a request and the domain service for that request, but does not include any domain logic at all.
The fully fleshed out API test is:
package com.example.hexademo.api; | |
// imports not shown | |
@WebFluxTest | |
public class GetStockPositionAndMarketValueApiTest { | |
@Autowired | |
private WebTestClient client; | |
// Domain Service | |
@MockBean | |
private GetStockPositionService getStockPositionService; | |
@MockBean | |
private GetStockMarketValueService getStockMarketValueService; | |
@Test | |
@WithMockUser("peterpan") | |
void get() { | |
// arrange | |
String symbol = "aapl"; | |
String user = "peterpan"; | |
StockPosition fakeStockPosition = fakeStockPosition(user, symbol); | |
when(getStockPositionService.get(user, symbol)).thenReturn(Mono.just(fakeStockPosition)); | |
BigDecimal fakeMarketPrice = fakeAmount(); | |
when(getStockMarketValueService.get(symbol, fakeStockPosition.getQuantity())).thenReturn(Mono.just(fakeMarketPrice)); | |
// act | |
makeGetRequest(symbol) | |
// assert | |
.expectStatus().isOk() | |
.expectBody(GetStockPositionAndMarketValueApiResponseDto.class) | |
.value(dto -> assertAll( | |
() -> assertThat(dto.getSymbol()).isEqualTo(symbol), | |
() -> assertThat(dto.getQuantity().doubleValue()).isCloseTo(fakeStockPosition.getQuantity() | |
.doubleValue(), Offset.offset(0.01)), | |
() -> assertThat(dto.getCurrencyCode()).isEqualTo(fakeStockPosition.getCurrencyCode()), | |
() -> assertThat(dto.getCost().doubleValue()).isCloseTo(fakeStockPosition.getCost() | |
.doubleValue(), Offset.offset(0.0001)), | |
() -> assertThat(dto.getMarketValue() | |
.doubleValue()).isCloseTo(fakeMarketPrice.doubleValue(), Offset.offset(0.0001)) | |
)); | |
} | |
@Test | |
@WithMockUser("peterpan") | |
void emptyPosition() { | |
String symbol = "appl"; | |
when(getStockPositionService.get("peterpan", symbol)).thenReturn(Mono.empty()); | |
when(getStockMarketValueService.get(eq(symbol), any())) | |
.thenReturn(Mono.just(fakeAmount())); | |
makeGetRequest(symbol) | |
.expectStatus().isOk() | |
.expectBody(Void.class); | |
} | |
@Test | |
@WithAnonymousUser | |
void anonymousGet() { | |
makeGetRequest("aapl") | |
.expectStatus().isForbidden(); | |
} | |
@Test | |
void unauthenticatedGet() { | |
makeGetRequest("aapl") | |
.expectStatus().isUnauthorized(); | |
} | |
private WebTestClient.ResponseSpec makeGetRequest(String symbol) { | |
return client.get().uri("/stock-position-market-value/" + symbol) | |
.accept(MediaType.APPLICATION_JSON) | |
.exchange(); | |
} | |
} |
And the corresponding REST Controller
package com.example.hexademo.api; | |
// imports not shown | |
@RestController | |
public class StockPositionsController { | |
private final GetStockPositionService getStockPositionService; | |
private final GetStockMarketValueService getStockMarketValueService; | |
public StockPositionsController( | |
GetStockPositionService getStockPositionService, | |
GetStockMarketValueService getStockMarketValueService) { | |
this.getStockPositionService = getStockPositionService; | |
this.getStockMarketValueService = getStockMarketValueService; | |
} | |
@GetMapping("/stock-position-market-value/{symbol}") | |
Mono<GetStockPositionAndMarketValueApiResponseDto> getPositionAndMarketValue( | |
@AuthenticationPrincipal Mono<Principal> principalMono, | |
@PathVariable String symbol | |
) { | |
return principalMono.flatMap(principal -> getStockPositionService.get(principal.getName(), symbol)) | |
.zipWhen(stockPosition -> getStockMarketValueService.get(symbol, stockPosition.getQuantity()), | |
(stockPosition, marketValue) -> new GetStockPositionAndMarketValueApiResponseDto(symbol, | |
stockPosition.getQuantity(), stockPosition.getCurrencyCode(), stockPosition.getCost(), marketValue)); | |
} | |
} |
Noted that the DTO for the response body, GetStockPositionAndMarketValueApiResponseDto
has a long specific name. This is intentional as this DTO is specifically for this API endpoint. Unless clearly intended and specified in the API specs, using the same DTO for different responses is unnecessary coupling.