Once you’ve followed a tutorial, you can write Controllers, Services, and Repositories well enough—but as features pile up, you suddenly freeze: “Where should this class go?” You know that feeling.
There’s no single correct answer for package structure. But if you know how to choose an approach and how to keep it from breaking down, you’ll have fewer headaches later. In this article, we’ll compare the two major approaches—layered and feature-based—and look at how to pick the one that fits your project, plus how to build mechanisms that keep your structure from eroding after you’ve chosen it.
Why Package Structure Is So Tricky
At the tutorial stage, you only have a handful of classes, so it doesn’t matter where you put them. But once you have 10 or 20 features, your packages keep swelling without any consistent placement criteria.
The tricky part is that uncertainty about where to put things spills directly over into inconsistent naming, tangled dependencies, and tests that are hard to write. If you decide up front on an axis for “how to divide things,” you can prevent a lot of this decay.
This article isn’t about implementation steps themselves; it focuses purely on “how to decide where things go.” For how to actually write CRUD, see Implementing CRUD for a REST API with Spring Boot.
What Is a Layered Package Structure?
The layered approach is the one most people pass through first. You slice packages horizontally by technical role, like controller, service, repository, and domain.
com.example.app
├── controller
│ ├── UserController.java
│ └── OrderController.java
├── service
│ ├── UserService.java
│ └── OrderService.java
├── repository
│ ├── UserRepository.java
│ └── OrderRepository.java
└── domain
├── User.java
└── Order.java
Each layer has a clear responsibility, and the structure is intuitive to grasp even while you’re still learning. Another advantage is that it’s easy to apply conventions on a per-layer basis—“Controllers go here,” “Repositories go here.” For a small app with one or two features, this works just fine.
The Bloat Problem of the Layered Structure
Problems start to surface as features grow. You open the service package to find 20 or 30 Services lined up, and you have to scan the names by eye just to figure out where the User-related ones end and the Order-related ones begin.
Another subtly painful issue is cross-cutting changes. Even a change as simple as “add one field to the user feature” forces you to bounce back and forth between controller, service, repository, and domain.
And dependencies between features aren’t visible from the package structure. Even if OrderService is quietly calling UserService, a flat structure makes it hard to notice that coupling is creeping in.
What Is a Feature-Based Package Structure?
This is where the feature-based approach comes in. You divide packages by business feature—user, order, payment—and place each layer inside. Slicing vertically, you might say, makes it easier to picture.
com.example.app
├── user
│ ├── UserController.java
│ ├── UserService.java
│ ├── UserRepository.java
│ └── User.java
├── order
│ ├── OrderController.java
│ ├── OrderService.java
│ ├── OrderRepository.java
│ └── Order.java
└── config
└── WebConfig.java
With this shape, changes to the user feature are basically self-contained within the user package. Changes become localized and cohesion rises, so adding or removing features—and splitting work across a team—becomes easier to do on a per-package basis.
On the other hand, while a feature still has few classes, a single package may contain only a few files, so there’s an up-front cost of it feeling a bit redundant.
How to Express Layers Inside a Feature-Based Structure
Once the contents of a feature grow further, you carve sub-packages inside the feature package to express the layers.
com.example.app
├── user
│ ├── web
│ ├── service
│ └── repository
├── order
│ ├── web
│ ├── service
│ └── repository
├── common
└── config
This is a compromise that slices vertically by feature while preserving layers within. Cross-cutting elements like config, common, and shared don’t belong to any particular feature, so you group them into independent packages. That said, common tends to become a junk drawer where anything gets tossed, so the trick is to limit it to things that are genuinely shared across multiple features.
Which to Choose: Decide by Scale and Growth Outlook
The choice is simple: decide based on scale and growth outlook.
For a small tool with few features and a short lifespan, the layered approach is often enough to start. Forcing it into a feature-based layout would just leave packages nearly empty.
Conversely, if features are likely to keep growing, or if you’re moving from solo development toward a mid-sized team, the feature-based approach is less prone to breaking down. Since each person’s area of work is clearly separated, you’ll also have fewer conflicts.
When in doubt, the recommendation is to weight “future growth outlook” and lean toward the feature-based approach. When you later migrate from layered to feature-based, don’t try to do it all at once—first carve out just one feature into a user package, get a feel for it, and then expand. That’s the safe way.
The Principle of Preserving Dependency Direction
Once you’ve chosen a structure, the next thing that matters is dependency direction. If this breaks down, your structure ends up a mess no matter which one you picked.
The basic rule is a one-way flow from higher to lower layers. Allow only the direction web → service → repository → domain, and prevent lower layers from referencing higher ones. In particular, keeping the domain (entities and business rules) as free of framework dependencies as possible helps it last.
In a feature-based structure, it’s additionally important to avoid direct dependencies between features. Rather than order touching the internal classes of user directly, call through a public entry-point class or interface. Making the coupling points explicit this way makes it easier to split off a feature later.
The way you write your DI also changes how visible your dependencies are. For why constructor injection should be the default, see Spring Boot DI: Comparing Field, Setter, and Constructor Injection.
Automatically Testing Dependency Rules with ArchUnit
If dependency direction is just a matter of “let’s be careful,” it will slip past reviews and erode bit by bit. So let’s use ArchUnit to lock down dependency rules as tests.
First, the layer dependency rule. This can detect violations such as a Controller touching a Repository directly.
import com.tngtech.archunit.junit.AnalyzeClasses;
import com.tngtech.archunit.junit.ArchTest;
import com.tngtech.archunit.lang.ArchRule;
import static com.tngtech.archunit.library.Architectures.layeredArchitecture;
@AnalyzeClasses(packages = "com.example.app")
class LayerDependencyTest {
@ArchTest
static final ArchRule layerRule = layeredArchitecture().consideringOnlyDependenciesInLayers()
.layer("Web").definedBy("..web..")
.layer("Service").definedBy("..service..")
.layer("Repository").definedBy("..repository..")
.whereLayer("Web").mayNotBeAccessedByAnyLayer()
.whereLayer("Service").mayOnlyBeAccessedByLayers("Web")
.whereLayer("Repository").mayOnlyBeAccessedByLayers("Service");
}
Another one worth adding is cycle detection. When a cycle forms between packages, the dependency direction is effectively broken, so we forbid it.
import com.tngtech.archunit.junit.AnalyzeClasses;
import com.tngtech.archunit.junit.ArchTest;
import com.tngtech.archunit.lang.ArchRule;
import static com.tngtech.archunit.library.dependencies.SlicesRuleDefinition.slices;
@AnalyzeClasses(packages = "com.example.app")
class CycleTest {
@ArchTest
static final ArchRule noCycles = slices()
.matching("com.example.app.(*)..")
.should().beFreeOfCycles();
}
If you build these tests into CI, changes that break the structure get stopped before review. It’s more reliable than having a human check every time—and it never gets tired.
Evolving from Feature-Based to Spring Modulith
If you’ve organized your packages by feature with the feature-based approach, those boundaries become natural candidates for “module boundaries.” This is exactly where the natural entry point to Spring Modulith lies.
Spring Modulith treats feature packages as modules and helps you verify dependencies between features and keep modules loosely coupled with one another via events. In other words, think of it as an evolutionary path that takes the structure you’ve organized with the feature-based approach up a level.
I’ve covered the concrete steps—like declaring module boundaries and persisting events—in How to Build a Modular Monolith with Spring Modulith, so I’ll leave the details to that article. Just keep in mind this one point: the more cleanly you separate features with the feature-based approach now, the lower the cost of migrating to Modulith in the future.
Summary: A Guide for When You’re Unsure
Package structure isn’t something to perfect from the start—it’s something you grow to fit your scale. Here’s the guidance, kept simple.
- Use the layered approach for small, short-lived projects; default to the feature-based approach when there’s a growth outlook
- Whichever you choose, the principle of dependency direction and automated verification with ArchUnit are the long-term keys
- Don’t treat the structure as fixed—it’s fine to migrate gradually as your scale changes
If you’re unsure where to put cross-cutting concerns like exception handling, Exception Handling for REST APIs in Spring Boot is also a good reference. Start by looking at your project’s “current scale” and “future growth,” and lean toward one side from there.