Spring Boot has no required project structure. It needs one thing: your @SpringBootApplication class must sit in a package above everything it should scan. Beyond that you choose between package-by-layer (controller/, service/, repository/) and package-by-feature (book/, loan/). For anything beyond a tutorial, start with package-by-feature: it kept 12 of 15 classes package-private in the demo below, and the Java compiler, not code review, stopped a cross-feature shortcut.
Tested on Spring Boot 4.1.1 (Spring Framework 7.0.9) with OpenJDK 21.0.12. One small book-catalogue app is written twice, as ByLayerApp.java and ByFeatureApp.java, in the project-structure module. Every console block on this page is quoted from the transcripts in its output/ folder, which the test suite writes.The two layouts, drawn from the real file trees:
The left side is what most tutorials teach: one folder per technical role. The right side groups by what the code is for. The number to take away is the bottom line of each column: by layer, every class has to be public so that the other layer folders can reach it; by feature, only the classes another feature actually needs are public. The next sections show that with output, not opinion.
The one rule Spring Boot enforces
Component scanning starts at the package of the @SpringBootApplication class and walks down. A class in a sibling or parent package is invisible, and nothing warns you. The test boots two applications. In the first the application class lives in scan.broken.app and the controller in the sibling scan.broken.web; in the second the controller is below the application class (sources: BrokenApp.java, HelloController.java, FixedApp.java, HelloController.java, driven by ScanTrapTests.java).
--- app class in scan.broken.app, controller in scan.broken.web (a sibling) ---
GET /hello -> 404
HelloController registered as a bean: false
--- app class in scan.fixed, controller in scan.fixed.web (below it) ---
GET /hello -> 200
HelloController registered as a bean: trueOutput: 05-scan-starts-at-the-app-class.txt. The broken application starts without an error and answers 404. That silence is why this is the most common structure mistake: the fix is to move the application class up, not to add @ComponentScan everywhere.
The default package. A@SpringBootApplicationclass with nopackagestatement makes scanning cover the whole classpath. Spring Boot says so itself, and in this module, which has two controllers both namedBookController, it then fails on the name clash:Output: 06-default-package.txt. Always put the application class in a real package such as--- what Spring Boot logged about the default package --- WARNING ** : Your ApplicationContext is unlikely to start due to a @ComponentScan of the default package. --- result --- context FAILED to start: BeanDefinitionStoreException / root cause ConflictingBeanDefinitionException: Annotation-specified bean name 'bookController' for bean class [com.ankurm.structure.bylayer.controller.BookController] conflicts with existing, non-compatible bean definition of same name and class [com.ankurm.structure.byfeature.book.BookController]com.company.app.
Package-by-layer: the same app, grouped by role
This is the layout in most tutorials, including the Spring Boot tutorial for beginners. It is easy to find things by role, and it is fine for a small app with one concept. The real tree (from 01-package-trees.txt):
bylayer/
ByLayerApp.java
controller/
BookController.java
dto/
BookRequest.java
BookResponse.java
exception/
ApiExceptionHandler.java
BookNotFoundException.java
model/
Book.java
repository/
BookRepository.java
service/
BookService.java
(9 source files)Because a controller, a service and a repository live in different packages, each one has to be public. The test counted:
public bylayer.ByLayerApp
public bylayer.controller.BookController
public bylayer.dto.BookRequest
public bylayer.dto.BookResponse
public bylayer.exception.ApiExceptionHandler
public bylayer.exception.BookNotFoundException
public bylayer.model.Book
public bylayer.repository.BookRepository
public bylayer.service.BookService
9 of 9 top-level types are publicOutput: 02-public-surface.txt. Nothing stops controller/ reaching straight into repository/, and the compiler agrees:
--- by layer: controller/Shortcut.java reaches into repository/ ---
compiled with 0 errorsOutput: 03-compiler-boundary.txt. With 9 files that is tolerable. With 90, every new feature adds a file to each of the five folders, and a change to loans touches five directories.
Package-by-feature: the same app, grouped by what it does
Now the same API with one package per business capability, plus a small shared package for what is genuinely cross-cutting:
byfeature/
ByFeatureApp.java
book/
Book.java
BookCatalog.java
BookController.java
BookNotFoundException.java
BookRepository.java
BookRequest.java
BookResponse.java
BookService.java
loan/
Loan.java
LoanController.java
LoanRequest.java
LoanService.java
shared/
ApiException.java
ApiExceptionHandler.java
(15 source files)Output: 01-package-trees.txt. Classes inside a feature can be package-private, because their collaborators are in the same package. The public surface collapses:
public byfeature.ByFeatureApp
public byfeature.book.BookCatalog
public byfeature.shared.ApiException
3 of 15 top-level types are publicOutput: 02-public-surface.txt. The three public types are the application class, one interface the loan feature needs, and one shared exception. Everything else (the repository, the service, the controller, the request records) is invisible outside its package. Here is the whole public API of the book feature, BookCatalog.java:
public interface BookCatalog {
Optional<String> titleOf(long id);
}The package-private BookService.java implements it, and LoanService.java depends on the interface and nothing else:
@Service
class LoanService {
private final BookCatalog books; // the only thing loan knows about the book feature
LoanService(BookCatalog books) {
this.books = books;
}
}Now repeat the shortcut from the previous section, this time from loan/ into book/:
--- by feature: loan/Shortcut.java reaches into book/ ---
error: com.ankurm.structure.byfeature.book.BookRepository is not public in com.ankurm.structure.byfeature.book; cannot be accessed from outside packageOutput: 03-compiler-boundary.txt. The build fails. A boundary that the compiler enforces does not erode the way a convention does, and you did not need an extra tool to get it. For rules the compiler cannot express, see ArchUnit for Spring Boot and Spring Modulith module boundaries.
Spring does not mind package-private classes. Package-private controllers, services, repositories and Jackson records all worked in this demo on Boot 4.1: the same HTTP responses came back from both layouts. Package-private is a design choice, not a Boot workaround.
Does the layout change behaviour? No.
Both applications serve the same API. Here are the first five requests against the by-layer app (it has no /loans), and the by-feature app answers them with identical status codes and bodies:
GET /books -> 200
[{"id":1,"title":"Effective Java","author":"Joshua Bloch","pages":416},{"id":2,"title":"Java Concurrency in Practice","author":"Brian Goetz","pages":384}]
GET /books/1 -> 200
{"id":1,"title":"Effective Java","author":"Joshua Bloch","pages":416}
GET /books/99 -> 404
{"detail":"Book 99 not found","instance":"/books/99","status":404,"title":"Not Found"}
POST /books -> 201
{"id":3,"title":"Clean Code","author":"Robert Martin","pages":464}The by-feature app then adds loans, which crosses the feature border through BookCatalog and reports the failure as a ProblemDetail:
POST /loans -> 201
{"id":1,"bookId":2,"bookTitle":"Java Concurrency in Practice","member":"Asha"}
POST /loans -> 404
{"detail":"Book 7 not found","instance":"/loans","status":404,"title":"Not Found"}
GET /loans -> 200
[{"id":1,"bookId":2,"bookTitle":"Java Concurrency in Practice","member":"Asha"}]
status codes and bodies identical for the five shared requests: trueOutput: 04-same-api-both-layouts.txt. (The transcript also holds an invalid POST /books that returns Boot’s default 400 body, whose timestamp changes on every run; it is left out of this excerpt.) Layout is purely about maintainability, and it is cheap to change early and expensive later.
Where config, DTOs, exceptions and tests go
| Thing | Where it goes | Why |
|---|---|---|
@SpringBootApplication class | Root package of the app | Scanning starts here and walks down (section 1). |
| DTOs (request/response records) | In the feature that owns them, package-private | Only that feature’s controller uses them. Put them in a shared dto/ and they become public API. |
| Entities / domain | In the feature | Other features ask through a public interface, as BookCatalog does. |
| Feature-specific exceptions | In the feature, package-private | Thrown and mapped inside the same package. |
Exception handler (@RestControllerAdvice) | shared/ (or web/) | It is cross-cutting by definition. Details: ProblemDetail and global exception handling. |
| Cross-feature exception type | shared/, public | Features throw it; the handler maps it. Here: ApiException.java. |
@Configuration classes and @ConfigurationProperties | Next to the code they configure; truly global ones in config/ | See @ConfigurationProperties vs @Value and profiles and config import. |
| Tests | Same package as the class under test, under src/test/java | Package-private classes are reachable from a test in the same package. |
The package-private DTOs and exceptions in the table are exactly what the demo does: look at BookRequest.java and BookNotFoundException.java.
Recommended layout for Spring Boot 4
The layout is the by-feature tree shown earlier: the application class at the root of your own package (for example com.company.app), one package per feature, and a small shared package for cross-cutting code.
Start with it, keep each feature’s classes package-private, expose one small interface per feature when another feature needs something, and promote a class to public only when a second feature needs it. If you want the boundary to be a first-class concept, Spring Modulith (linked above) verifies it for you; if you want the opposite style, with domain code that knows nothing about Spring, see hexagonal architecture (ports and adapters) on Spring Boot 4.
When package-by-layer is fine. A one-entity service, a spike, a workshop. The cost grows with the number of features, not with the lines of code. Multi-module Maven or Gradle: not run for this post. A module boundary is a build boundary and costs a pom per module, so reserve it for code that is genuinely reused or deployed separately, and let packages do the day-to-day separation.
Common interview questions
Is there a required folder structure for Spring Boot?
No. The only requirement is that the @SpringBootApplication class sits in a package above the classes it should scan, and not in the default package.
Package-by-layer or package-by-feature?
Package-by-feature for anything that will grow: related code changes together and most classes can be package-private. Package-by-layer is acceptable for small apps.
Where should DTOs go?
In the feature that owns them, package-private, unless several features genuinely share one.
Where does the main class go?
In the root package (for example com.company.app) so scanning reaches every feature package below it.
Does package-private work with Spring?
Yes. Package-private controllers, services and repositories were created and served requests in this demo on Spring Boot 4.1.
Common mistakes
- Application class in a sub-package. Controllers elsewhere silently 404 (shown above).
- No package at all. Scanning covers the whole classpath and can fail on duplicate bean names (shown above).
- Everything public. Package-by-layer forces it; the compiler can then no longer protect a boundary.
- One giant
shared/orutil/package. It becomes a second, hidden layer that every feature depends on. - Returning entities from controllers. Use a response record, as the demo does with
BookResponse.
FAQ
What is the best project structure for Spring Boot?
Package-by-feature under a root package that holds the application class, with a small shared package for cross-cutting code.
Why does my controller return 404 in Spring Boot?
One cause is that the controller is outside the packages the application class scans. Move the application class to a parent package.
Can I use package-private classes in Spring Boot?
Yes; component scanning and Jackson handled package-private classes in the demo.
What is the difference between package-by-layer and package-by-feature?
By layer groups classes by technical role; by feature groups them by business capability.
Further reading
- Companion code: project-structure module
- Spring Boot tutorial for beginners
- Spring Boot annotations list
- Spring vs Spring Boot vs Spring MVC
- Hexagonal Architecture (Ports and Adapters) in Spring Boot 4
- Spring Modulith 2.1: Enforcing Module Boundaries Inside a Spring Boot Monolith
- ArchUnit for Spring Boot: Enforcing Layered Architecture as a Unit Test
- Global Exception Handling with ProblemDetail (RFC 9457) in Spring Boot 4
- Top 40 Java Collections interview questions
- Top 40 Java concurrency interview questions
- Top 50 Java Stream API coding interview questions
- Spring Boot reference: structuring your code
No Comments yet!