Feature-based modular code organization in Java

Stefan Heinzer
ELCA IT
Published in
13 min readMay 2, 2023

--

When starting a new code project, we have to come up with a suitable structure of our code base. While many approaches are possible, some are better suited with respect to guide readers through the code and to enhance developer productivity.

Figure 1: Strong boundaries help keeping us organized, and that even long before the invention of computers. Photo by Olivier Giboulot on Unsplash

A typical project comes with a core that implements a business logic, plus a handful of interfaces to the outside world. The classical approach is to prepare a package structure by technical layer, as shown in Listing 1.

Listing 1: Structure by technical layers

/src/main/java/
com/
example/
app/
model/ --> containing the persistent entities
repository/ --> containing the data access implementation
service/ --> containing business services
web/ --> containing the REST API

This approach comes with several potential problems. First of all, it imposes a technical view on the code, which was implemented to solve a business problem. So far, it does not communicate anything about the business it is all about.

Second, it encourages implementation of a what Martin Fowler calls an anemic domain model, where the effective domain model lives in a relational database, and the Java objects in the domain package are rather a logic-less representation of that database schema, full of getters and setters that don’t know about any business logic. This logic is then scattered throughout various business services, making it hard to reason about the code and to enforce atomic business rules.

This article explores how to establish a package structure that subdivides the code in modules along the features of the business functionality. Native Java language constructs are promoted to express which parts of a module represent its public API and which parts are protected implementation details. The article concludes with the introduction of libraries which help protecting the module architecture of the code base.

You may be surprised to read arguments that favor a monolith over microservices, and the resulting code structure may look unorthodox to you. So be prepared to learn something new and don’t hesitate to comment where you disagree!

TL;DR

For readers in a hurry, who don’t want to follow the thinking behind the proposed code structure, here a glimpse of what we will end up with towards the end of the article:

Listing 2: Final package structure

/src/main/java/
com/
example/
app/
api/ <-- infrastructure ring of the onion architecture
remote-api-1/
remote-api-2/
common/ <-- common types and functionality available for all modules
feature1/ <-- feature module 1
calculator/ <-- sub package encapsulating helper logic if the base package grows too large
web/ <-- REST API of feature module 1
feature2/
web/

You can find a working application which implements the proposed design here https://github.com/ELCAIT/modular-monoliths. It comes with copies of the original code base that use the different libraries (ArchUnit, Spring Modulith, jMolecules) introduced below.

Decomposition of a complex system

When implementing a non-trivial software system, we try to group similar functionality into modules. They allow us to decompose a complex system in a set of non-complex, well-understood units, thus reducing brain overload and guiding developers when implementing or maintaining a software project. An approach to identify suitable modules comes from the domain driven design community and is called “bounded context”. Such a context is oriented along the ‘natural’ boundary of business functionality. In a system for selling goods, for example, we could have bounded contexts “customer master data”, “order management”, or “invoicing”.

With the hype of microservices, each bounded context is often implemented in its own service. While well suited for modularity and introducing hard boundaries between each context, it comes at the cost of distributed computing, i.e. inherent asynchrony, limited bandwidth, unavailability of neighboring services etc. (see also Fallacies of distributed computing).

Still another approach is to implement each context in its own Maven module, including all modules in a main application module. When working with many fine-grained modules, this approach may also become cumbersome, as it makes navigation in the project tree difficult and produces additional overhead on the build for managing related Maven artifacts.

Modular monoliths to the rescue!

An alternative solution to this problem is to start from a mono repository that hosts all the code, producing only one executable which can easily be started both locally and in a cloud environment. Given that at the start of a project typically only a handful of developers work with the code base, there is not much danger of complexity and misunderstandings, as communication paths are short and visibility of changes is high. Without further measures, the threats of the old monoliths — the swamp of intertangled, poorly structured source code — are however just behind the corner. That is exactly the point where modularity comes to the rescue.

The approach is simple: Each part of the application that qualifies for implementation as a microservice or Maven module as described above, is designed as a module with well protected boundaries. Still being a monolith, the application is at the same time modular, coming with all the benefits of a subdivision into smaller units.

Implementation strategies

Package hierarchy

When implementing a modular monolith, the modules obviously play a central role. In fact, they should appear on the top level structure of the application. If using Java, a natural element for grouping related code is the package. So our initial application layout could look like this:

Listing 3: Top-level packages by feature

src/main/java/
com/
example/
app/
feature1/
feature2/
feature3/

Considering that every module is like an own microservice, we could be tempted to replicate the technical layers into each feature package. There are however good reasons not to do so as we will see shortly.

Modelling the domain

When following a domain-driven approach, we try to keep the technical implementation in line with the business model as much as possible, as it aligns the thinking of developers with that of the business analysts, leading to more coherent solutions and less misunderstandings. Technical layers are not part of that business model. To achieve a technical model which is congruent with the business model, we would like to keep our entities and domain services very close to each other, since they jointly implement the core of the application. If we do it well, the technical model and the business model are no longer two, but one!

Aspects like persistence can nowadays easily be abstracted away, for example by placing a repository interface into the same package along with the entity and the domain service, where the implementation is provided by the framework (e.g. Spring Data). We may also strip the technical notion of “Repository” from the name and simply use the plural form of the entity being persisted (here: “ProductionOrders”), thus seeking again a match with the language of the domain expert, also referred to as the ubiquitous language.

In sum, this results in a layout where entity, repository interface and domain service end up in the same package:

Listing 4: Implementing the domain

/src/main/java/
com/
example/
app/
productionorder/ <-- module
ProductionOrder.java <-- entity
ProductionOrders.java <-- repository interface
ProductionOrderService.java <-- domain service

Avoiding the ‘public’ keyword

One big advantage of this flat structure inside the module package is the fact that we can easily steer the visibility of the classes using standard Java keywords such as ‘private’, ‘protected’, ‘public’ or the default, which is package private. Yes, it allows us to define the public API of our module!

We may for example define the repository interface as package private, so that other modules don’t see it, and the domain service as public, serving well defined functionality to other modules. Whether we make the entities (or aggregates thereof) public or not is our design choice. We could for example introduce a set of command objects through which the entities are updated, and return immutable data transfer objects (DTOs) to query the current state of an entity, keeping the entities private to the module. Fact is that we very easily control the visibility of all these elements.

If, on the contrary, we would put some of the domain classes in sub-packages of the module, we have to make them public to be accessible from the classes in the module’s top-level package, making them at the same time visible to all other modules, which is usually not what we want, with exception of API models such as commands / events and DTOs.

Adding a REST API

When we think about it, the REST API is not part of the domain model, but only a technical aspect making the business functionality accessible from the outside world. An elegant approach is to keep it close to the business code, but putting it in its own package “web”:

Listing 5: Adding the web layer

/src/main/java/
com/
example/
app/
productionorder/
ProductionOrder.java
ProductionOrders.java
ProductionOrderService.java
web/
ProductionOrderController.java <-- REST controller

Since we don’t want the business code to access the web API, we can declare the controller class as package private. There is then also no danger that it is accessed by other modules directly via Java method calls.

Where to implement the infrastructure layer?

So far we have elaborated on an approach how to subdivide the domain logic into bounded contexts and implementing each context in a well-defined module. If the application in addition to the REST-API also integrates with other technologies such as a message bus, where would we place that code?

When building software with a focus on the domain, onion or hexagonal architecture can be a good choices. In its simplest form, the onion architecture consists of the domain layer at its core, surrounded by the application services, and the infrastructure layer at the outside. The architecture says that dependencies of classes and modules must only go from outside to inside, but never the other way around. If for example a domain service must send messages to a remote service, it could declare an interface with a send-method, which is then implemented in the infrastructure layer using a specific messaging technology such as http, JMS, Kafka etc.

Simplified onion architecture showing a feature slice spanning domain, application, and part of the infrastructure ring.
Figure 2: The simplified onion architecture, where one feature slice covers domain, application, and a part of the infrastructure layer.

If such an external API is specific to one bounded context, it could be placed in an additional package next to the web package. Integration modules may however serve more than one business module, in which case it is better to place them in their own top-level module “api” or “infrastructure”. A proven approach is to create a sub-package for each integrated external API, which contains the external data model (possibly generated from an XSD, or an own implementation in the sense of a consumer driven contract).

The classes implementing the connection to the outside world can again be declared package private so that the IDE does not propose them for usage in places outside of the package where they are implemented.

Where to place common functionality?

When building a modular monolith, we often run into the situation that we have some code to be shared between modules. For example, we have custom annotations that can be used in any module. Where do we put them? A natural place is a dedicated module called “common”, which does not have dependencies to any other module, but which can be accessed from any other module.

The project structure may then look like shown in Listing 6:

Listing 6: Complete package structure

/src/main/java/
com/
example/
app/
api/
remote-api-1
remote-api-2
common <-- the common package
feature1
web
feature2
web

When to introduce sub-packages?

We see that the code base so far has only few packages. This at first may feel strange, but can make code navigation easier, because there are less packages to open in the navigation tree. As we have seen, an important reason to avoid unnecessary packages is that they may force us to declare classes public that are not part of the public API of the module.

However, in certain cases it can make sense to introduce sub-packages, e.g. for declaring domain events that can be consumed by dependent business modules and thus are part of the public API of the module.

Listing 7: Sub-packages for commands and events

/src/main/java/
com/
example/
app/
feature/
commands/ <-- package for (public) commands
events/ <-- package for (public) domain events

If the classes in the feature module grow larger than 10–15 classes, we should ask ourselves whether the bounded context has become too large and should be split into smaller ones. Or we have indeed business related logic that is best expressed in a set of related classes, which we want to extract to their own package. To avoid the need to make these classes public, a good approach can be to declare an interface in the package of the feature module, which is then implemented in the sub-package in package private classes. We can then use dependency injection based on the interface to have the implementation provided by the framework where required.

Listing 8: Sub-package for a business helper

/src/main/java/
com/
example/
app/
feature/
calculator/ <-- sub-package implementing business related helper functionality
CalculatorImpl.java <-- implementation kept package private
Formula.java
Operation.java
...
Calculator.java <-- interface

Setting up and enforcing module dependencies

The approach described so far lets us modularize our code, declaring the public API of each module and hiding the implementation details. If in addition we want to impose further constraints how the modules can access each other, we need additional tooling to do so.

Let’s assume that our application involves a set of masterdata which is used in other modules. We could implement that in a masterdata module, accessible by all the business modules. However we want to avoid that dependencies in the other direction, say from masterdata to business modules, creep into our codebase. How can we enforce this?

Controlling module dependencies with ArchUnit

A proven tool for controlling dependencies is [ArchUnit|https://www.archunit.org/]. It allows us to implement a set of rules which are checked at build time in form of a unit test. If a rule breaks, the build fails.

The following listing shows an example of an architecture rule which says that classes in the common package must not have dependencies to other modules of the application. As the tests are executed at build time, there is no impact whatsoever at runtime.

Listing 9: Sample of a architecture rules using ArchUnit.

@AnalyzeClasses(packagesOf = ArchitectureTests.class, importOptions = ImportOption.DoNotIncludeTests.class)
public class ArchitectureTests {

static final JavaClasses classes = new ClassFileImporter().importPackages("com.example.modularmonoliths");

@ArchTest
public static final ArchRule commonRule = noClasses()
.that().resideInAPackage("..common..")
.should().dependOnClassesThat().resideInAnyPackage(
"..masterdata..", "..productinventory..", "..productionorder..", "..api..");

@ArchTest
public static final ArchRule masterdataRule = noClasses()
.that().resideInAPackage("..masterdata..")
.should().dependOnClassesThat().resideInAnyPackage(
"..productinventory..", "..productionorder..", "..api..");@

}

The Spring Modulith library

Spring Modulith is an extension to the Spring framework which allows to modularize a Spring application. Similar to Spring Boots @DataJpaTest or @WebMvcTest, which only load beans of the data or of the web layer, Spring Modulith allows loading beans of an individual module only when running integration tests, which can drastically reduce the test execution time when the modulith grows larger.

To switch from a Spring Boot application to Spring Modulith, we just add the Maven dependency and change the @SpringBootApplication annotation to @Modulith. By convention, the library treats every top-level package as module and offers out-of-the-box functionality to verify and enforce the module structure:

ApplicationModules.of(Application.class).verify();

The library can also generate PlantUML diagrams documenting the module architecture of the application (see Figure 3). For details about all the features, see the documentation.

Figure 3: PlantUML diagram generated by Spring Modulith.

jMolecules: Expressing and enforcing architectural concepts

jMolecules is a thin set of annotations and interfaces which can be used to express architectural concepts and elements of domain driven design in the code. This not only helps developers communicate their design, it also allows adding tooling to the development environment that makes use of that additional information. For example, we can design our application along the simplified onion architecture, annotating the web package with @InfrastructureRing, to protect it from access from classes of the domain ring.

Listing 10: Making architecture evident in code (in package-info.java).

@org.jmolecules.architecture.onion.simplified.InfrastructureRing
package com.example.modularmonoliths.productinventory.web;

Assuming that the domain model uses jMolecules abstractions for aggregates, domain services, domain events etc., both the consistency of the onion architecture and the DDD code can be verified using the ArchUnit integration of jMolecules as shown in the following listing.

Listing 11: Using ArchUnit rules provided by jMolecules

@AnalyzeClasses(packages = "com.example.modularmonoliths")
class ArchitectureTests {

@ArchTest
static ArchRule dddRules = JMoleculesDddRules.all();

@ArchTest
static ArchRule onion = JMoleculesArchitectureRules.ensureOnionSimple();

}

To also protect module boundaries, jMolecules can be combined with the Spring Modulith library and / or jQAssistant, ensuring architectural consistency both on the horizontal application layers and on the vertical business modules.

Pitfalls

Sizing of modules

At times it can be difficult to identify a suitable size of an application module. As mentioned above, event storming can be of great help identifying suitable boundaries. Another helpful tool is domain story telling. Both approaches lead to a picture where the entities — and with them the aggregate roots — of the application become visible. As a rule of thumb it usually makes sense to allocate a module for each aggregate root.

Developer experience

Many developers are used to the traditional packaging of an application by architectural layer. Using modules for packages at top-level, the remaining classes for a module usually don’t rectify to employ a full-blown technical package structure within a module, as for example the persistence layer would only contain a single repository interface. Keeping aggregates, repository interface and service in a single package comes at the benefit that the visibility can be reduced to package private.

Both the different package structure and the consequent use of package private classes can be a challenge for developers that are used to the classical approach. It is therefore recommended to have somebody in the project who can teach the team or to ask for a project external coach to adopt this code structure.

Conclusion

In a world which is more and more depending on software systems, we as software engineers or software craftsmen must make sure that these systems work as intended. Domain driven design is a well proven approach to design software which is in line with the business it implements. This article shows an approach how to organize code in such a setting, which hopefully can inspire your own work building trustworthy software systems.

Credits

This work is heavily inspired by the talk of Oliver Drotbohm Refactoring to a System of Systems, his Spring Modulit repository and the jMolecules project.

A big thank you also goes to my colleagues who reviewed this article and suggested important improvements and corrections.

--

--

Stefan Heinzer
ELCA IT
Writer for

Senior Architect at ELCA, with passion for software craftsmanship, architecture, DDD, and UX. https://www.linkedin.com/in/stefan-heinzer-05852820/