Skip to main content

Spring Boot Project Structure: Package-by-Layer vs Package-by-Feature (Best Practices)

The same Spring Boot 4.1 app written package-by-layer and package-by-feature, with the public surface counted, the compiler boundary captured and the component-scanning trap shown.

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:

Package-by-layerPackage-by-featurecontrollerBookControllerserviceBookServicerepositoryBookRepositorymodelBookdtoBookRequest, BookResponseexceptionhandler + not-foundbook8 types, 1 publicloan4 types, 0 publicsharedApiException (public) + handlerloan reaches book only through BookCatalog9 files, 9 of 9 types public: every package can reach every other.15 files, 3 of 15 types public: the compiler enforces the borders.

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: true

Output: 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 @SpringBootApplication class with no package statement makes scanning cover the whole classpath. Spring Boot says so itself, and in this module, which has two controllers both named BookController, it then fails on the name clash:

--- 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]
Output: 06-default-package.txt. Always put the application class in a real package such as 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 public

Output: 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 errors

Output: 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 public

Output: 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 package

Output: 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: true

Output: 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

ThingWhere it goesWhy
@SpringBootApplication classRoot package of the appScanning starts here and walks down (section 1).
DTOs (request/response records)In the feature that owns them, package-privateOnly that feature’s controller uses them. Put them in a shared dto/ and they become public API.
Entities / domainIn the featureOther features ask through a public interface, as BookCatalog does.
Feature-specific exceptionsIn the feature, package-privateThrown 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 typeshared/, publicFeatures throw it; the handler maps it. Here: ApiException.java.
@Configuration classes and @ConfigurationPropertiesNext to the code they configure; truly global ones in config/See @ConfigurationProperties vs @Value and profiles and config import.
TestsSame package as the class under test, under src/test/javaPackage-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/ or util/ 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

No Comments yet!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.