One of the first things a developer does after joining a project with an existing codebase is expand the directory tree to learn what the project does. Often the first thing the developer encounters is a package structure where each package represents a layer — like web, service, repository, model. In this case, the application most likely exposes HTTP endpoints, does some business logic, and persists data to the database. Not very unique, right? But splitting an application by layer comes with serious drawbacks. That structure may work well for small, simple projects. For larger applications, where long-term maintainability is an important aspect of software architecture, there are other options to consider.
Layers don’t tell you anything about the business domain — there is no way to discover what the application does by going through the packages and diving deeper when you need more details. Layers also enforce a particular architecture for each feature: code separation into web, service, model may be a great fit for most use cases, but it’s definitely not the best choice for all.
Each feature is spread across multiple packages, which makes finding related classes hard. It also means that most of the classes have to have a public modifier — and can be used anywhere in the application — which leads to sharing classes between different features. Achieving encapsulation on the feature level becomes close to impossible.
One alternative that has proven to work well in traditional three-tier applications is to slice application into modules. A module can represent a single feature or a bigger chunk. The key is that a module contains classes from all the layers required to build a particular functionality. It also lets you hide most if not all classes from other modules by changing their visibility to package protected. Modules that are meant to be used by other modules are public and become an API of the package.
In complex projects, a single package per module may end up containing too many classes to be easy to comprehend, and you may find that splitting into subpackages would improve overall readability. That’s a perfectly valid option. Just keep in mind that you may need to increase some classes’ visibility to public and loose module encapsulation guaranteed by the compiler. In that case, consider looking into tools such as ArchUnit that make sure module boundaries aren’t violated during the unit test phase.
In addition to improved encapsulation, another advantage is that a modular codebase doesn’t enforce the same structures across different modules. Maybe there is no need for services in some of them, and it’s enough to return data from repositories directly to controllers. Maybe there is no need to have controllers at all, and exposing repositories with Spring Data REST is the way to go. You can organize each module in a way that best serves the particular feature.