Spring Boot Project Structure: The Architect's Blueprint
- Spring Boot Project Structure is not a style preference — @SpringBootApplication placement determines which beans exist in the application context. A main class in a sub-package produces a running application with an empty context and 404s for every endpoint, with no error messages to guide diagnosis.
- The one-way dependency rule is the structural foundation of Layered Architecture: Controllers depend on Services, Services depend on Repositories, Repositories depend on nothing above them. Violations create circular dependencies, destroy testability, and make the codebase resistant to change.
- DTOs are the API contract firewall — the ProductResponse DTO defines what clients receive, independent of how the Product @Entity is structured internally. Never return a JPA @Entity from a @RestController method; a security audit will eventually find the fields you forgot to hide.
- @SpringBootApplication placement determines what component scan finds — it must be in the root package, not a sub-package
- Layered Architecture separates Controllers, Services, and Repositories — a change in DB schema does not break the API contract if you use DTOs correctly
- DTOs decouple your API contract from your database entities — never expose @Entity directly in controller responses
- Package-by-Layer works for small projects (fewer than 20 entities, fewer than 5 developers) — switch to Package-by-Feature when adding a single feature requires touching more than 3 packages
- Placing the main class in a sub-package is the most common startup failure — Spring scans downward from the main class location, not sideways
- Business logic in controllers is the fastest path to unmaintainable code — keep controllers thin, push logic to @Service where it is testable without an HTTP context
- Add ArchUnit tests to enforce structural rules — catches package placement violations before they reach production
Production Incident
Production Debug GuideWhen Spring Boot project structure causes unexpected behavior, here is how to go from observable symptom to root cause to resolution. These paths are ordered by how frequently each symptom appears in real teams.
Spring Boot Project Structure is a foundational concept that determines how maintainable, testable, and navigable your codebase remains as it grows from a proof-of-concept to a production system with 50+ classes and multiple developers.
While Spring Boot is deliberately unopinionated about folder layout — it does not force a specific package hierarchy — it provides a 'Convention over Configuration' baseline that the vast majority of professional teams adopt. Understanding why that convention exists, what it buys you, and when to deviate from it is what separates engineers who can maintain large codebases from engineers who build things that nobody else can safely touch.
In this guide we break down exactly what a production-grade Spring Boot project structure looks like, why it was designed this way, and how to scale it correctly as your project grows from a handful of entities to dozens of bounded contexts owned by different teams. We cover the mechanics of component scanning, the DTO firewall pattern, the Package-by-Layer versus Package-by-Feature decision, multi-stage Docker builds, and how to use ArchUnit to enforce structural rules automatically.
By the end you will have both the conceptual understanding and practical code examples to structure a Spring Boot project confidently — one that a new engineer can navigate in an hour rather than a week.
What Is Spring Boot Project Structure and Why Does It Exist?
Spring Boot Project Structure is a set of conventions for organizing code that emerged from years of painful experience with the alternative — Java EE applications where business logic lived in JSPs, database queries were embedded in UI controllers, and the only way to understand what a class did was to read all of it.
The structure exists to enforce a property called 'low coupling, high cohesion': classes that work together are near each other, and classes that work at different levels of abstraction are separated by clearly defined boundaries. The Controller layer handles HTTP translation. The Service layer handles business rules. The Repository layer handles data access. No layer reaches past the one directly below it.
Spring Boot's 'Convention over Configuration' principle means that if you put the @SpringBootApplication class in the root package, the entire scan-and-wire mechanism happens automatically. Spring discovers @RestController, @Service, @Repository, and @Component beans by scanning downward from the main class location. No XML. No explicit bean registration. No manual wiring. The package structure is the configuration.
This is why @SpringBootApplication placement is not a style preference — it is a functional requirement. Move the main class to a sub-package and component scan becomes a partial scan, silently skipping every class that is not beneath it.
The structure below represents the standard layout for a production Spring Boot application. Every directory has a defined purpose and a defined scope of responsibility.
src/ ├── main/ │ ├── java/ │ │ └── io/thecodeforge/myapp/ │ │ ├── Application.java ← @SpringBootApplication: MUST be here, root package │ │ ├── config/ ← @Configuration beans: Security, CORS, OpenAPI, Scheduling │ │ │ ├── SecurityConfig.java │ │ │ ├── CorsConfig.java │ │ │ └── OpenApiConfig.java │ │ ├── controller/ ← @RestController: HTTP in, HTTP out, nothing else │ │ │ └── ProductController.java │ │ ├── service/ ← @Service: business logic, validation, orchestration │ │ │ ├── ProductService.java ← Interface: defines the contract │ │ │ └── impl/ │ │ │ └── ProductServiceImpl.java ← Implementation: the actual behavior │ │ ├── repository/ ← @Repository: JpaRepository extensions, custom queries │ │ │ └── ProductRepository.java │ │ ├── model/ ← @Entity: JPA entities, never returned from controllers │ │ │ └── Product.java │ │ ├── dto/ ← Data Transfer Objects: the API contract, decoupled from DB schema │ │ │ ├── request/ │ │ │ │ └── ProductRequest.java │ │ │ └── response/ │ │ │ └── ProductResponse.java │ │ ├── mapper/ ← Entity ↔ DTO conversion (MapStruct or manual) │ │ │ └── ProductMapper.java │ │ └── exception/ ← Custom exceptions + GlobalExceptionHandler │ │ ├── GlobalExceptionHandler.java │ │ └── ProductNotFoundException.java │ └── resources/ │ ├── application.properties ← Shared defaults across all profiles │ ├── application-dev.properties ← Dev profile: local DB, debug logging, H2 console │ ├── application-prod.properties ← Prod profile: real DB via env vars, no debug logging │ └── db/ │ └── migration/ ← Flyway versioned SQL migrations │ ├── V1__create_products.sql │ └── V2__add_product_index.sql └── test/ └── java/ └── io/thecodeforge/myapp/ ├── controller/ ← MockMvc tests: HTTP layer only, service is mocked ├── service/ ← Plain JUnit tests: no Spring context required ├── repository/ ← @DataJpaTest: JPA layer only, real DB via Testcontainers └── architecture/ └── ArchitectureTest.java ← ArchUnit rules: enforce structural constraints at build time
Component scan root: io.thecodeforge.myapp
All sub-packages discovered automatically — no @ComponentScan configuration required
- @SpringBootApplication in the root package means component scan covers all sub-packages automatically — no @ComponentScan annotation needed unless you span multiple package trees
- Layered Architecture is a one-way dependency rule: Controller → Service → Repository. Services never import from controllers. Repositories never import from services. Violations create circular dependencies and destroy testability.
- DTOs are the contract firewall between your API and your database schema — the ProductResponse DTO defines what clients receive, and changing the Product @Entity does not break that contract as long as the mapper bridges the difference
- The config/ package holds @Configuration classes for cross-cutting infrastructure: security, CORS, OpenAPI documentation, scheduled tasks, async configuration. It does not hold business logic — that belongs in service/
- The exception/ package centralizes error handling — @RestControllerAdvice in GlobalExceptionHandler catches exceptions thrown anywhere in the service layer and translates them to consistent HTTP error responses
- The test/ mirror of the main/ structure enforces that every layer is tested at the right level: controllers with MockMvc, services with plain JUnit, repositories with @DataJpaTest
Common Mistakes and How to Avoid Them
The mistakes teams make with Spring Boot project structure tend to cluster around three root causes: incorrect @SpringBootApplication placement (structural), leaking internals through the API layer (security and coupling), and not adapting the package strategy as the project grows (organizational).
The first mistake is the most immediately damaging because it is invisible. A main class in the wrong package produces a clean startup with a broken application — no errors, no warnings, just 404s for every endpoint. The second mistake is gradual — exposing @Entity objects works fine until a security audit or a schema change proves it does not. The third mistake is a slow tax that compounds over months.
Below is the production-grade Dockerfile that belongs at the root of the project. The multi-stage build pattern is the standard for 2026 Spring Boot deployments — it keeps the final image lean by excluding Maven, source code, and build artifacts, and it uses layer caching to avoid re-downloading dependencies when only application code changes.
# Multi-stage build for io.thecodeforge standard Spring Boot applications # Stage 1: Build — uses full Maven + JDK image, produces the Fat JAR FROM maven:3.9.6-eclipse-temurin-21 AS build WORKDIR /app # Copy pom.xml first and download dependencies separately. # Docker caches this layer — if pom.xml does not change, the dependency # download is skipped on subsequent builds. Only source changes invalidate # the later layers. COPY pom.xml . RUN mvn dependency:go-offline -q # Now copy source and build. This layer is invalidated on every source change. COPY src ./src RUN mvn clean package -DskipTests -q # Stage 2: Runtime — uses lean JRE image, no Maven, no source, no build tools # eclipse-temurin:21-jre-jammy is ~200MB vs ~600MB for the full JDK image FROM eclipse-temurin:21-jre-jammy AS runtime WORKDIR /app # Create a non-root user — running as root inside a container is a security risk # even when the container is isolated. Least privilege applies here. RUN groupadd --system forge && useradd --system --gid forge forge USER forge # Copy only the Fat JAR from the build stage — nothing else COPY --from=build /app/target/*.jar app.jar # JVM flags for containerized environments: # -XX:+UseContainerSupport: respect cgroup memory limits (default in JDK 11+) # -XX:MaxRAMPercentage=75.0: use 75% of the container's memory limit for heap # -Djava.security.egd: faster SecureRandom initialization EXPOSE 8080 ENTRYPOINT ["java", \ "-XX:+UseContainerSupport", \ "-XX:MaxRAMPercentage=75.0", \ "-Djava.security.egd=file:/dev/./urandom", \ "-jar", "app.jar"]
[INFO] BUILD SUCCESS
[INFO] Total time: 45.2 s (cached dependency layer: 0.8 s)
Successfully built runtime image: forge-app:latest
Image size: 203MB (vs 618MB with full JDK stage)
| Aspect | Traditional Java (Manual) | Spring Boot (Structured) |
|---|---|---|
| Component Registration | Manual — every bean defined explicitly in XML configuration files or @Bean methods in @Configuration classes. Forgetting one bean causes a runtime failure. | Automatic — @Component, @Service, @Repository, @RestController annotations trigger registration during component scan. The scan path is determined by @SpringBootApplication placement. |
| Layer Separation | Often inconsistent — business logic drifts into UI controllers and data access logic drifts into service classes. Boundaries erode over time without enforcement. | Convention-enforced — Layered Architecture (Controller → Service → Repository) is the recognized pattern. ArchUnit can enforce it automatically at build time, failing the build on violations. |
| Boilerplate | High — manual connection management, transaction handling, bean wiring, and exception translation for every class. A simple CRUD service is hundreds of lines. | Near zero for standard patterns — @Transactional, JpaRepository, @RestControllerAdvice, and auto-configuration handle the infrastructure plumbing. Focus stays on business logic. |
| Dependency Management | Manual — each library version is specified and conflicts are resolved by hand. Transitive dependency conflicts are common and require significant debugging. | Managed through Spring Boot starters — spring-boot-starter-data-jpa pulls a tested, compatible set of JPA, Hibernate, and HikariCP versions. Version conflicts are rare within the Spring Boot BOM. |
| Testability | Low — tightly coupled classes with implementation dependencies rather than interface dependencies. Testing one class often requires instantiating several others. | High by design — constructor injection with interfaces enables mocking at every layer boundary. Service tests need no Spring context. Controller tests use MockMvc without a real database. |
| Team Scalability | Low — code organization is left to individual developer preference. Every project develops its own conventions, and new engineers must relearn the layout for each project. | High — the standard Spring Boot layout is recognized by every Spring developer immediately. New engineers navigate unfamiliar codebases faster because the structure is predictable. |
| Structural Enforcement | None built-in — violations of intended architecture are caught in code review at best, in production at worst. | Enforceable with ArchUnit — rules like 'controllers must not directly access repositories' or 'the main class must be in the root package' can be expressed as tests that fail the build on violation. |
| Onboarding Time | High — new developers must read documentation, ask teammates, or trace code paths to understand the project's custom organization. No standard exists across projects. | Low — the Controller/Service/Repository/DTO/Exception pattern is industry-standard. A Spring developer from another company can navigate the project within an hour. |
🎯 Key Takeaways
- Spring Boot Project Structure is not a style preference — @SpringBootApplication placement determines which beans exist in the application context. A main class in a sub-package produces a running application with an empty context and 404s for every endpoint, with no error messages to guide diagnosis.
- The one-way dependency rule is the structural foundation of Layered Architecture: Controllers depend on Services, Services depend on Repositories, Repositories depend on nothing above them. Violations create circular dependencies, destroy testability, and make the codebase resistant to change.
- DTOs are the API contract firewall — the ProductResponse DTO defines what clients receive, independent of how the Product @Entity is structured internally. Never return a JPA @Entity from a @RestController method; a security audit will eventually find the fields you forgot to hide.
- Keep controllers thin — a controller's job is to validate the incoming request, delegate to the @Service, and format the response. Every line of business logic in a controller is a line that requires an HTTP context to test and an HTTP context to change.
- Package-by-Layer is the correct starting point for small projects. Package-by-Feature is the correct destination when adding a feature routinely requires touching more than three packages. Migrate incrementally, one feature at a time.
- Use Spring profiles with environment-specific properties files and environment variables for secrets. No credentials, no environment-specific URLs, no feature flags belong in a .properties file committed to version control.
- Add ArchUnit tests that enforce structural rules — main class placement, DTO firewall, controller-to-service dependency direction. Structural violations should fail the build, not fail a production deployment.
- The multi-stage Dockerfile with a non-root user and container-aware JVM flags (-XX:+UseContainerSupport, -XX:MaxRAMPercentage=75.0) is the production standard for 2026 Spring Boot deployments on Kubernetes and cloud container platforms.
⚠ Common Mistakes to Avoid
Interview Questions on This Topic
- QWhy is it recommended to place the main application class in the root package? Explain the mechanics of @ComponentScan in this context.JuniorReveal
- QWhat is the difference between Package-by-Layer and Package-by-Feature? When would you switch from one to the other?Mid-levelReveal
- QExplain the Fat JAR concept in Spring Boot. How does the project structure influence the final executable artifact?Mid-levelReveal
- QHow do you handle cross-cutting concerns like logging and security auditing in a layered structure without polluting the business logic?SeniorReveal
- QIf you must place a @Component outside the base package of your @SpringBootApplication, how do you ensure Spring finds it?SeniorReveal
- QHow would you use ArchUnit to enforce Spring Boot project structure rules as automated tests?SeniorReveal
Frequently Asked Questions
Does package naming really matter for performance?
Package naming has no direct effect on JVM execution performance — bytecode is bytecode regardless of what package it lives in, and component scan adds negligible startup overhead even for projects with hundreds of classes.
The impact is on development velocity and that impact is significant and measurable. A confusing structure slows every engineering activity: adding a feature, debugging a production issue, reviewing a pull request, onboarding a new engineer. At small scale (5 developers, 15 entities) the friction is tolerable. At larger scale (20 developers, 80 entities) it compounds into weeks of lost engineering time per quarter.
The cost of poor structure is not a JVM metric — it is how long it takes your best engineers to answer the question 'where does this code live and why?' If that question takes more than 30 seconds to answer for any class in your codebase, the structure is costing you more than you think.
Can I have multiple @SpringBootApplication classes in one project?
Technically yes — the JVM will compile and run it. In practice, having multiple @SpringBootApplication classes in a single project is a red flag that the project should be split into separate services.
Each @SpringBootApplication creates its own ApplicationContext with its own bean registry. Beans in one context are completely invisible to the other. If you run both main classes in the same JVM they will likely conflict on port 8080 unless you configure different ports. If you run them separately they are effectively separate applications with no shared state — which is exactly what microservices are.
For testing scenarios where you want to start the application with different configurations, use @SpringBootTest with a custom TestApplication class or profile-based configuration rather than multiple main classes. If you genuinely need two independently runnable application contexts in one repository, consider a multi-module Maven/Gradle project where each module has its own main class and its own pom.xml or build.gradle.
Should interfaces and implementations be in the same package?
The pattern we recommend is: the interface (ProductService) lives in the service/ package — it defines the contract that the controller depends on. The implementation (ProductServiceImpl) lives in service/impl/ — it provides the concrete behavior.
This separation has a practical benefit: when a developer opens the service/ package, they see only interfaces — the public API of the service layer. Implementation details are one level deeper in impl/. This makes the service layer's API browsable without navigating through implementation code.
For simple applications where there will never be more than one implementation — which is the majority of projects — using a concrete @Service class directly (no interface) is equally valid. Interfaces add a layer of indirection that provides real value only when you need polymorphism: different implementations for different environments, AOP proxy requirements, or truly interchangeable behaviors. If you are writing a ProductServiceImpl with no ProductService interface today and you may need one later, the refactor from concrete class to interface + impl is straightforward and safe.
When should I switch from Package-by-Layer to Package-by-Feature?
The signal is behavioral, not numerical: switch when adding a new feature consistently requires creating or modifying files in more than three packages. At that point, the scattered nature of the work is creating real risk — files forgotten in review, features that touch shared infrastructure and break other features, and merge conflicts between developers working on different features that happen to share the same package files.
As rough thresholds: with fewer than 5 developers and fewer than 20 entities, Package-by-Layer is almost always the right choice. With 5–10 developers and 20–50 entities, evaluate by tracking the packages touched per feature over 3–4 sprints. With 10+ developers and 50+ entities, Package-by-Feature is almost always worth the migration cost.
Migrate incrementally: pick the most independent business domain (typically something like notifications or search that has few dependencies on other domains), create a new package for it, move all related classes, update imports, run all tests, commit. Then do the next most independent domain. A full migration for a medium-sized project typically takes 2–4 weeks of incremental work without disrupting feature development.
How do I handle shared code between features in a Package-by-Feature structure?
Create a shared/ or common/ package for code that is genuinely used by multiple feature packages. Typical candidates: shared/exception/ for base exception classes (BusinessRuleException, ResourceNotFoundException), shared/dto/ for common response wrappers (PageResponse<T>, ApiResponse<T>), shared/util/ for pure utility functions (DateUtils, StringUtils), and shared/validation/ for custom Bean Validation constraints.
The governance rule for shared/: a class belongs in a feature package if only that feature uses it. A class moves to shared/ only when a second unrelated feature needs to import it. Resist the temptation to put classes in shared/ proactively — that produces a shared/ package with 80 classes that becomes as hard to navigate as the original Package-by-Layer structure.
For infrastructure concerns (security configuration, CORS, OpenAPI, HikariCP properties) that are not feature-specific, keep them in the config/ package at the root level — these are application-wide concerns that do not belong to any single business feature.
Developer and founder of TheCodeForge. I built this site because I was tired of tutorials that explain what to type without explaining why it works. Every article here is written to make concepts actually click.