A popular approach is packaging by technical concerns. But this approach has some drawbacks. Instead, we can package by feature and create self-contained and independent packages. The result is a codebase that is easier to understand and less error-prone.
- Drawbacks of packaging classes by technical concerns:
- Poor overview of all classes that belong to a feature.
- Tendency to generic, reused and complex code, which is hard to understand and changes can easily break other use cases as the impact of a change is hard to grasp.
- Instead, package by feature and create packages that contain all classes that are required for a feature. The benefits are:
- Better discoverability and overview
- Self-contained and independent
- Simpler code
Package by Layer
A very popular approach for a project structure is to package by layer. This leads to a package for each technical group of classes.
Let’s add the call hierarchy to the picture to “clearly” see which class depends on which class.
So, what are the drawbacks of packaging by layer?
- Poor feature overview. Usually, when we approach the code in a project, we have a certain domain or feature in mind that we want to change. So we are coming from a domain perspective. Unfortunately, technical packaging forces us to jump around from one package to another to grasp the big picture of a feature.
- Tendency to generic, reused, and complex code. Often, this approach leads to central classes containing all methods for every use case. Over time, those methods get more and more abstracted (with extra parameters and generics) to fulfill more use cases. Only one example in the above picture is the
ProductDAOwhere the methods for the
ExportControllerare located. The consequences are:
- A class gets bigger when more methods are added. So understanding it becomes harder just because of the amount of code.
- Changing the generic reused code is dangerous. You can easily break all use cases although you only want to work on one use case.
- Abstracted and generic methods are harder to understand for the following two reasons: First, to be generic, you usually need additional technical constructs (if, else, switch, parameter, generics) which makes it harder to see the business logic that the relevant for the current use case (signal-noise-ratio). Second, the cognitive demand is higher because you have to know all the other use cases to be sure you don’t break them. Sandi Metz nailed this:
“I felt like I had to understand everything in order to help with anything.” Sandi Metz. See my post about our Wall of Coding Wisdom.
We achieve DRY but violate KISS.
Package by Feature
Let’s rearrange the classes into self-contained feature packages.
The new package
userManagement contains all classes that belong to this feature: the controller, the DAO, the DTOs and the entities.
The new package
productManagement contains the same class types plus the
StockServiceClient and the corresponding
StockDTO. This fact states clearly: The stock service is only used by product management.
productManagement are using different domain entities and tables. Separating them into different packages is simple. But what happens when a feature needs similar or the even same domain entities than another feature?
Now, it’s getting interesting. The package
exportProduct also deals with the product entity but has a different use case.
Our goal is to have self-contained independent feature packages. Consequently, the
exportProduct should have it’s own DAO, DTO classes, and entities classes even if they may look similar to the classes in the
productManagement. Resist the urge to reuse the classes from
- We can use structs (DTO, entites) that are tailored for the export use case. They only contain the relevant fields and the entities can be created based on a query with a nice projection of the relevant columns - and nothing else.
- The dedicated
ExportProductDAOcontains export-specific queries and projections.
We may have to write more code (again) but end up with a very beneficial situation:
- Changes in the
productManagementwill never break the
exportProductcode and vice versa. They can evolve independently.
- When changing code, we only have to keep the current feature in mind.
- The code itself will become much simpler and easier to understand because it’s not generic and doesn’t have to work for both use-cases.
The above feature packages are great but in reality, we will always need a
- It contains technical configuration classes (e.g. for DI, Spring, object mapping, http clients, database connection, connection pooling, logging, thread pools)
- It contains small useful code snippets that can be reused. But be very careful with the premature abstraction of your code. I always start by putting util code as close as possible to its usage, which is the feature package or even the using class. Only if I really have more usages for a snippet (not: I think I might have in the future), I move it to the
commonpackage. The Rule Of Three gives good guidance.
- It might make sense to locate all entities in the
commonpackage. We also did this for some projects, where many feature packages are using the same entities again and again. Some devs also prefer to have all entities in a central place to be able to see the mapping of the database schema as a whole. I’m not dogmatic at this point because both locations for entities can be reasonable. Still, I always start to move as much code to the feature package as possible and rely on tailored use-case-specific entities and projections.
Finally, our big picture looks like this:
Let’s briefly wrap up the benefits:
- Better discoverability and overview from the domain point of view. Most of the code that belongs to a business feature is located together. This is crucial because we are approaching a codebase usually with a certain business requirement in mind.
- Self-contained and independent. Most of the code that a feature needs, is located in the package. So we are avoiding dependencies to other feature packages. The consequences are:
- It’s less likely that we break other features while evolving a feature.
- Less cognitive capacity is required to estimate the impact of changes. Often, we only have to keep the current package in mind.
- Simpler code. As we are avoiding generic and abstracted code, the code becomes simpler because it only has to handle a single use-case. Hence, it’s easier to understand and to evolve the code.
- Testability. Usually, a class in a feature package has fewer dependencies compared to a “god-class” in a technical package that tries to fulfill all use-cases. So testing becomes easier as we have to create less test fixture.
- We have to write more code.
- We might write similar code multiple times.
- It’s tricky to decide when we are better off moving code to the
commonpackage and to reuse it. The Rule of Three is useful when in doubt. I like to highlight that reuse is still allowed and useful.
- It’s also tricky to find out the adequate scope and size of a feature package. See the questions sections for details about this.
However, I believe that the advantages outweigh the drawbacks.
The Principles Behind
The proposed package-by-feature approach follows a principle that’s very close to my heart:
KISS > DRY
Again, I like to quote Sandi Metz
“Prefer duplication over the wrong abstraction.” Sandi Metz. See The Wall Of Coding Wisdom.
A Recipe to Package by Feature
Our team documents its coding guidelines and principles that it commits on. The section about packaging by feature looks like this:
We package our code based on features. Each feature package contains most of the code that is required to serve the feature. Each feature package should be self-contained and independent.
├── feature1 │ ├── Feature1Controller │ ├── Feature1DAO │ ├── Feature1Client │ ├── Feature1DTOs.kt │ ├── Feature1Entities.kt │ └── Feature1Configuration ├── feature2 ├── feature3 └── common
- This approach affects all layers. For instance, each package has its own DAO and client. There should be no huge god DAO class.
- A package should have only a few relationships with other packages. Everything that is required for the feature should be placed inside the package.
- Rule of thumb: If you want to delete a feature, you should only have to delete the corresponding package.
- Still, it’s okay to reuse stuff in a
commonpackage but it should only contain the code that is used multiple times (see rule of three). It doesn’t contain business logic. Technical utils are okay.
- If there are feature-specific Spring beans, we place their configuration in the feature package.
What About the Structure Within a Feature Package?
This depends on the size of your project and feature packages.
For small and mid-size projects, I like to avoid defining rules that may add more ceremony than value (e.g. by requiring certain interfaces and subpackages). As long as you build independent and self-contained packages derived from your domain you are on the right track.
If you are dealing with a bigger codebase you may want to define more rules about the subpackage structure and the way, one feature package is allowed to access another one. The notion of “modules” or “components” instead of “feature packages” may be more helpful. For example, Tom Hombergs suggests adding
internal packages in each component package that defines which parts of the component are allowed to be used by other components. See his post “Clean Architecture Boundaries with Spring Boot and ArchUnit” for details.
Will I End Up Writing the Same Code Again and Again?
Yes, there will be some duplication but after my experience, there is not so much 100% identical code as you may believe. As the similar code covers different use-cases it is often different. For instance, two methods may query for products by the product name but they differ in the projected fields, sorting and additional criteria. So it’s totally fine to keep the methods separated in different packages.
Moreover, duplication is not evil per se. I like to apply the rule of three before I start to extract code to a generic reused methods.
Finally, I like to highlight that centralizing reusable code is still allowed and sometimes reasonable, BUT those cases are not so frequent anymore.
Can Kotlin Support This Approach?
The packaging approach is language-independent. But Kotlin makes it easier to follow it:
- With data classes, writing tailored feature-specific structs (like DTOs or entities) takes only a few lines and no boilerplate.
- Kotlin allows putting multiple classes in one file. So instead of having a subpackage
entitiescontaining many Java files for each POJO class, we can have a single
Entities.ktfile containing all data class definitions.