Onion Architecture With Spring Boot (1/2)

December 31, 2020

The hexagonal-like architectures are helping to increase the longevity of software, effectively eliminating the coupling between the coded business knowledge and the technology. In a couple of posts I will share with you mimacom's way of tackling Onion Architecture with Spring Boot. In this first part I will give you more insights about it through a github-hosted example showing a simple implementation of ShoppingList's API's.

Short Story Of The Hexagonal-like Architectures

πŸ”” All the three architectures are Domain-Driven Design friendly.

How We Are Tackling Onion Architecture With Spring Boot?

Starting from 2015 we have applied Onion Architecture With Spring Boot successfully to so many client's projects in different domains, that I like to see this architectural pattern as something more than a simple vegetable: What about the metaphor of our planet with its layers? πŸ˜„

mimacom-onion-architecture

Layer/s Amount Mapping to Onion Architecture Description POJO? Depends on How to test it?
core 1 object model + object services domain layer with business rules & knowledge: root aggregates, entities, repositories, domain events, domain services etc. x - unit tests
api 1 application services application layer without business rules & knowledge: application services, transactional aspects, security aspects, etc. x core unit tests
infra 0..* infrastructure connects to RDBMS-s, NoSQL DBs, Object Storages, etc.; consumes WebServices; publishes Messages, Event Streams etc. core unit & integration tests
infra-api 0..* infrastructure serves as an entry point to the architecture; provides REST APIs, SOAP APIs, etc.; implements jobs; consumes Messages, Event Streams, etc. api, core unit & integration tests
ui 0..* user interface contains the hosted front-ends by this Spring Boot App (which could be based on a web framework like react, angular, vue etc.) infra-api unit & integration tests
app 1 - the Spring Boot App which envelops all other layers and produces a deployable package (JAR/WAR) ui, infra-api, infra, api, core integration tests
- 3..* tests included in every layer its layer -

πŸ”” As you see, the whole domain and application logic is implemented in the core- and in the api-layer through POJO and tested through simple and fast unit tests (without spring context)! Imagine the advantages of well-isolated pure domain logic! πŸ˜ƒ

πŸ”” Notice the difference between the infra and infra-api layer: the first one consumes REST (or SOAP) services, the second one provides REST (or SOAP) services! The reason to have them in separated layers is the following:

πŸ”” Note that some front-ends, such as native OSX or Android applications, are not part of the ui layer shown, encapsulated in deployable spring boot app. However, they are part of the Onion Architecture, and as such depend on the infra-api layer.

πŸ”” More information about how to test Onion Architecture with Spring Boot is coming soon in the next blog post! Stay tuned!

Dependency Management

There are two alternatives for defining dependency constraints between layers:

Here is an example of different layers in a project which illustrate our naming convention in mimacom:

Layer's name JAR module Package Depends on
core myapp-core myapp.core -
api myapp-api myapp.api core
infra:jpa myapp-infra-jpa myapp.infra.jpa core
infra:kafka myapp-infra-kafka myapp.infra.kafka core
infra:api:rest myapp-infra-api-rest myapp.infra.api.rest api, core
infra:api:job myapp-infra-api-job myapp.infra.api.job api, core
infra:api:kafka myapp-infra-api-kafka myapp.infra.api.kafka api, core
ui N/A N/A infra:api:rest
app myapp myapp ui, all infra layers, api, core

πŸ”” Now imagine how intuitive is to find all back-end's entry points to the architecture of the App: Just open the myapp.infra.api package, and you will get an instant overview of all the REST-APIs, all the jobs and all the Kafka Listeners there!

πŸ”” The version control management in multi module project could seem as an overhead, but if you define the version for every library only one time in parent POM under the dependencyManagement-section, you will solve the problem.

πŸ”” It's important to avoid dependencies between the different infra and infra-api modules: Having every technology in a separate layer gives you the maximum benefits of this architectural pattern (easy maintenance, easy technology upgrade, easy testing) !

Shopping List API's Example

The Backlog

Let's see what user stories I came up with: (Of course I did it in the opposite way: First I coded the example, only after that I defined what I wrote! 😁)

πŸ”” Bellow follows a very simplified solution with a lot of shortcuts just for the sake of illustration how Onion Architecture can be applied:

The Core Layer

ShoppingItem

@Data
@Entity
@AllArgsConstructor(staticName = "of")
@NoArgsConstructor
public class ShoppingItem {

    @Id
    private final UUID id = UUID.randomUUID();

    @Column(nullable = false)
    @Setter(AccessLevel.PRIVATE)
    private String productName;

    @Column(nullable = false)
    @Setter(AccessLevel.PRIVATE)
    private Double price;

    @Column(nullable = false)
    @Size(min = 1, max = 1000)
    private int quantity;

    public Double getSubtotalPrice() {
        return price * quantity;
    }
}

πŸ”” Did you notice the javax.persistence and .validation annotations? You should consider them as "deactivated" aspects: They are completely ignored by this layer, and they do not influence the business logic in it. They will be activated only later in the infra-jpa and above!

πŸ”” You are free to add here more javax.annotations if you need them for your implementations, like @EntityListeners, @Version, @CreatedDate, @LastModifiedDate, @JoinColumn, @OneToMany and so on.

πŸ”” A small drawback would be if you decide to migrate one day to other technology, for example spring-data-jdbc. In this case you have not only to change the jpa infra layer, but to replace the javax-annotations from here as well. Nevertheless, your domain logic will remain intact because all these aspects should be treated almost as comments at this layer!

πŸ”” Somebody could object that these annotations don't belong to the domain (aka core) layer. Yes, we have tried the "pure" implementations in the past, but we have found out that it's not worth it. Without adding this extra overhead of double model, we have all the benefits: no coupling with the domain logic, unit tests only (without spring context), and the core layer does not propagate the javax dependencies further, thanks to the maven dependency's scope "provided"!

ShoppingList

@Data
@NoArgsConstructor
@Entity
public class ShoppingList {

    private static final double SUBTOTAL_PRICE_BOUNDARY_FOR_FREE_SHIPPING = 100;
    protected static final double STANDARD_SHIPPING_COSTS = 10;

    @Id
    private final UUID id = UUID.randomUUID();

    @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
    @Column(nullable = false)
    @Getter(AccessLevel.PRIVATE)
    private final Set<ShoppingItem> shoppingItems = new LinkedHashSet<>();

    public static ShoppingList of(){
        return new ShoppingList();
    }

    public void addItem(ShoppingItem shoppingItem) {
        shoppingItems.add(shoppingItem);
    }

    public int getSize() {
        return shoppingItems.size();
    }

    public void removeItem(ShoppingItem shoppingItem) {
        shoppingItems.remove(shoppingItem);
    }

    public ShoppingItem getItem(UUID shoppingItemId) {
        return shoppingItems.stream()
                .filter(shoppingItem -> shoppingItem.getId().equals(shoppingItemId))
                .findFirst()
                .orElseThrow(() -> new IllegalArgumentException("Item with id: <" + id + "> not found!"));
    }

    public Double getTotalItems() {
        return shoppingItems.stream()
                .mapToDouble(ShoppingItem::getQuantity)
                .sum();
    }

    public Double getSubtotalPrice() {
        return shoppingItems.stream().mapToDouble(ShoppingItem::getSubtotalPrice).sum();
    }

    public Double getShippingCosts() {
        if (getTotalItems() == 0) {
            return 0d;
        }

        if (getSubtotalPrice() >= SUBTOTAL_PRICE_BOUNDARY_FOR_FREE_SHIPPING) {
            return 0d;
        }

        return STANDARD_SHIPPING_COSTS;
    }

    public Double getTotalPrice() {
        return getSubtotalPrice() + getShippingCosts();
    }
}

πŸ”” Did you see the amount of business logic and rules in this class? As Eric Evans underlined in his famous book Domain-Driven Design: Tackling Complexity in the Heart of Software, you should avoid implementing your business logic in the Application Services (aka api), but instead express it in the classes and in its relations in the domain layer (aka core). Martin Fowler identifies the very same anti-pattern as anemic model. As usual, we all agree when we read about it, but it often requires to go the extra mile to achieve it for every concrete case! 🀯

ShoppingListRepository

public interface ShoppingListRepository {
    ShoppingList save(ShoppingList shoppingList);

    Optional<ShoppingList> findById(UUID id);

    default ShoppingList findByIdOrFail(UUID id) {
        return findById(id)
                .orElseThrow(() -> new IllegalArgumentException("Item with id: <" + id + "> not found!"));
    }

    void deleteAll();
}

OK, at least everybody will agree now that our Repositories are pure POJOs! 🀣

The Api Layer

ShoppingListService

@Slf4j
@Service
@RequiredArgsConstructor
public class ShoppingListService {

    private final ShoppingListRepository shoppingListRepository;

    @Transactional
    public UUID createShoppingList(){
        ShoppingList shoppingList = ShoppingList.of();
        shoppingListRepository.save(shoppingList);
        log.info("Created a new ShoppingList <id: {}>", shoppingList.getId());
        return shoppingList.getId();
    }

    @Transactional
    public UUID addItemToTheShoppingList(UUID shoppingListId, String productName, double price, int quantity){
        ShoppingList shoppingList = shoppingListRepository.findByIdOrFail(shoppingListId);
        ShoppingItem shoppingItem = ShoppingItem.of(productName, price, quantity);
        shoppingList.addItem(shoppingItem);
        shoppingListRepository.save(shoppingList);
        log.info("Added a new item <{}> to the ShoppingList <id: {}>",
                shoppingItem, shoppingList.getId());
        return shoppingItem.getId();
    }

    @Transactional(readOnly = true)
    public double getTotalPrice(UUID shoppingListId){
        ShoppingList shoppingList = shoppingListRepository.findByIdOrFail(shoppingListId);
        log.info("Retrieved the totalPrice={} for ShoppingList <id: {}>",
                shoppingList.getTotalPrice(), shoppingListId);
        return shoppingList.getTotalPrice();
    }
}

πŸ”” As shown in the core-layer, the @Transactional and the @PreAuthorize annotations have to be treated here again as "deactivated" aspects. They will become "active" only in the infra-api layers and above, where the Spring IoC Application Context Container will load and interpret them. In such a way the coupling with the spring framework in this layer is reduced to the minimum. Moreover, you could test all your use case scenarios in this layer through unit tests (without spring context).

πŸ”” Actually, the presence of @Transactional, @Service and eventually @PreAuthorize annotations in Application layer (aka api) are perfectly fine according to the Domain-Driven Design and to the Onion Architecture. Yet, this is the layer which intermediates between the Domain Layer (aka core) and all other layers, and this is the place where you should handle the transactions and the security!

πŸ”” Should you use the general @Component annotation or other annotations based on it, different from @Service here? In our experience, you should have only application services in this layer, marked with @Service annotation for better code readability. If you think about some other spring beans, which you may want to activate, maybe they do belong to the infra or to the infra-api layers. Check out later in the explanations where I put the SwaggerConfig for example (hint: infra-api-rest layer)!

The Infra-JPA Layer

JpaShoppingListRepository

public interface JpaShoppingListRepository extends CrudRepository<ShoppingList, UUID>, ShoppingListRepository {
}

Now... that was short! Oh, these lovely Spring Data repos! πŸ€—

The Infra-Api-Rest Layer

ShoppingListController

@RestController
@RequestMapping(BASE_URI)
@Api(value = "API to shopping list", produces = "application/json")
@RequiredArgsConstructor
public class ShoppingListController {

    protected static final String BASE_URI = "/api/v1/shoppinglists";

    private final ShoppingListService shoppingListService;

    @ApiOperation(value = "Create new shopping list", produces = "application/json")
    @PostMapping("/")
    ResponseEntity<?> createShoppingList(UriComponentsBuilder uriComponentsBuilder) {
        UriComponents uriComponents = uriComponentsBuilder
                .path(BASE_URI + "/{shoppingListId}")
                .buildAndExpand(
                        shoppingListService.createShoppingList()
                );

        return created(uriComponents.toUri()).build();
    }

    @ApiOperation(value = "Add new item to a shopping list", produces = "application/json")
    @PostMapping("/{shoppingListId}/items")
    ResponseEntity<?> addItemToTheShoppingList(
            UriComponentsBuilder uriComponentsBuilder,
            @PathVariable UUID shoppingListId,
            @RequestParam String productName,
            @RequestParam Double price,
            @RequestParam Integer quantity) {

        Validate.noNullElements(Arrays.asList(shoppingListId, productName, price, quantity),
                "Invalid input parameter/-s: shoppingListId=%s, productName=%s, price=%f, quantity=%d",
                shoppingListId, productName, price, quantity);

        Validate.isTrue(quantity >= 1, "The quantity has to be greater or equal to 1");

        UriComponents uriComponents = uriComponentsBuilder
                .path(BASE_URI + "/" + shoppingListId + "/items/{itemId}")
                .buildAndExpand(
                        shoppingListService.addItemToTheShoppingList(shoppingListId, productName, price, quantity)
                );

        return created(uriComponents.toUri()).build();
    }

    @ApiOperation(value = "Get shopping list's total price, with the shipping costs of 10 credits included"
            +" (if the price is >=100 credits, then the shipping is free of charge)!",
            produces = "application/json")
    @GetMapping("/{shoppingListId}/totalprice")
    ResponseEntity<?> getTotalPrice(@PathVariable UUID shoppingListId) {
        Validate.notNull(shoppingListId, "Missing mandatory input parameter: shoppingListId");
        return ok().body(shoppingListService.getTotalPrice(shoppingListId));
    }
}

πŸ”” Please do not code application or business logic here! Just do the bare minimum like parameter validation, authorisation checks etc. before passing the ball further to the application layer (aka api)!

SwaggerConfig

@Configuration
public class SwaggerConfig {
    
    @Bean
    public Docket api() {
        return new Docket(DocumentationType.SWAGGER_2)
                .apiInfo(DEFAULT_API_INFO)
                .produces(DEFAULT_PRODUCES_AND_CONSUMES)
                .consumes(DEFAULT_PRODUCES_AND_CONSUMES);
    }

    public static final Contact DEFAULT_CONTACT = new Contact(
            "Andrey Zahariev Stoev", "https://www.linkedin.com/in/andistoev", "");

    public static final ApiInfo DEFAULT_API_INFO = new ApiInfo(
            "Onion Architecture Example - Shopping List API's",
            "Try the following Shopping List's APIs to test how the onion architecture works!",
            "1.0",
            null, DEFAULT_CONTACT,
            "GNU General Public License v3.0",
            "https://www.gnu.org/licenses/gpl-3.0.en.html",
            Collections.emptyList());

    private static final Set<String> DEFAULT_PRODUCES_AND_CONSUMES = new HashSet<>(
            Arrays.asList("application/json", "application/xml")
    );
}

πŸ”” Yes, the swagger config belongs to the infra-api-rest layer! In the same way, most of your spring config files should be distributed between the different infra or infra-api layers!

The App Layer

OnionArchServiceApplication

@Slf4j
@SpringBootApplication
public class OnionArchServiceApplication {

    public static void main(String[] args) {
        Environment env = SpringApplication.run(OnionArchServiceApplication.class, args).getEnvironment();
        log.info("Start SwaggerUI to use the Shopping List's APIs: "
                + "http://localhost:{}/swagger-ui/index.html#/shopping-list-controller", 
                env.getProperty("server.port"));
    }
}

πŸ”” The easiest code comes at the end: Just import all layers and package the JAR/WAR, so that they are ready for deployment to your favorite K8S cluster! πŸ€—

Test It With Swagger

onion-architecture-api-swagger

After you have started the project locally, navigate to: http://localhost:8081/swagger-ui/index.html#/shopping-list-controller and then use the following end points:

πŸ”” Did you notice that the final price is increased with 10 more credits than you may have expected? Yes, this is the shiny business rule to have shipping costs of 10 credits (it will become free of charge if the shopping list has items for more than 100 credits). You may have already forgotten about it, but the software implemented with the onion architecture will never forget! πŸ˜‰

Summary

πŸ”” This example is available at GitHub

About the author: Andrey Zahariev Stoev

Loves software craftsmanship and systems thinking. Passionate about travel, languages and cultural diversity exploration.

Comments
Join us