Senior 3 min · March 09, 2026

Spring Boot Project Structure — Wrong Package Causes 404

404 on all endpoints with clean startup? The @SpringBootApplication was in a sibling package.

N
Naren · Founder
Plain-English first. Then code. Then the interview question.
About
 ● Production Incident 🔎 Debug Guide
Quick Answer
  • @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
Plain-English First

Think of Spring Boot Project Structure as the floor plan of a professional kitchen. A well-designed kitchen has a prep station, a cooking station, a plating station, and a cleaning station. Every chef who walks in knows where to find the knives, where to fire the orders, and where the plates go. Nobody puts the trash cans on the prep counter.

A Spring Boot project works the same way. The Controller layer is the front-of-house — it takes requests from the outside world. The Service layer is the kitchen — that is where the actual work happens. The Repository layer is the walk-in cooler — it stores and retrieves ingredients (data). Each station has a defined job, and the rule is that the front-of-house does not go into the cooler directly. Requests flow in one direction: Controller → Service → Repository.

Spring Boot does not force this layout on you at gunpoint, but it provides a 'Convention over Configuration' baseline that every experienced Java team gravitates toward — because the alternative, which is everyone organizing code however they feel that day, produces kitchens where nobody can find anything and nothing ever gets cooked on time.

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.

TheCodeForge_ProjectLayout.txtTEXT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
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/                   ← EntityDTO 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
Output
Standard Layered Package Layout for TheCodeForge Production Projects
Component scan root: io.thecodeforge.myapp
All sub-packages discovered automatically — no @ComponentScan configuration required
Project Structure Is the Map of Your Codebase — @SpringBootApplication Is the Starting Point
  • @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
Production Insight
A team I reviewed was returning JPA @Entity objects directly from their @RestController methods. The User entity had twenty fields — most of them internal: passwordHash, failedLoginAttempts, accountLockUntil, internalAuditFlag. All twenty fields were serialized into every API response because Jackson serializes every non-transient field by default.
A security audit discovered that password hashes had been present in API responses for three months. Every API client that logged responses had captured them. The password hashes were bcrypt, so the immediate risk was limited, but the audit finding was serious and required a full incident response process.
The fix was straightforward: create a UserResponse DTO with the eight fields that clients actually needed, add a UserMapper, update every controller method to return UserResponse instead of User. Two hours of work. The vulnerability had been present since the first commit.
Rule: never return a JPA @Entity from a @RestController method. The DTO exists precisely to make this separation explicit and enforce it structurally. If you treat it as optional, you will eventually expose something you should not have.
Key Takeaway
@SpringBootApplication placement is a functional requirement, not a style preference — component scan starts at the main class package and goes downward into sub-packages only. A main class in a sub-package silently breaks the entire application context.
Layered Architecture enforces a one-way dependency rule: Controllers depend on Services, Services depend on Repositories, Repositories depend on nothing above them. Violations of this rule create circular dependencies, destroy testability, and produce classes that cannot be safely modified.
DTOs are the API contract firewall — the structure of your ProductResponse DTO defines what clients receive, independent of how the Product @Entity is structured internally. This decoupling is what allows database schema evolution without breaking API consumers.
Package-by-Layer vs. Package-by-Feature
IfSmall project: 1–3 developers, fewer than 20 entities, single deployment unit
UseUse Package-by-Layer — controller/, service/, repository/, model/, dto/. Simple, familiar, zero navigation overhead. New developers understand it immediately because the pattern is universal.
IfMedium project: 4–8 developers, 20–50 entities, multiple developers working on different features simultaneously
UseConsider a hybrid approach — Package-by-Feature for the most complex domains (orders/, payments/) and Package-by-Layer for simpler shared infrastructure. Evaluate by measuring how often feature additions require touching more than 3 packages.
IfLarge project: 8+ developers, 50+ entities, multiple teams, or preparing for microservices extraction
UseUse Package-by-Feature with clear bounded context boundaries — orders/, users/, inventory/, payments/ each containing their own controller, service, repository, model, DTO, and exception classes. Cross-team communication happens via shared/ utilities and well-defined API contracts.
IfMigrating an existing project from Package-by-Layer to Package-by-Feature
UseMigrate one feature at a time, starting with the most independent domain. Move all classes for that feature into a single package, update imports, verify all tests pass, commit. Do not attempt a bulk migration — the merge conflicts and test failures will be overwhelming.

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.

DockerfileDOCKERFILE
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
# 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"]
Output
# Build output (abbreviated):
[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)
Two Mistakes That Compound Silently Until They Become Expensive
The first is over-engineering too early: creating 12 packages with 2 files each for a project with 8 entities. The cognitive overhead of the structure exceeds the complexity of the actual business logic, and new developers spend more time navigating than coding. Start with Package-by-Layer and migrate to Package-by-Feature only when the friction of Layer is measurable. The second is under-engineering for too long: keeping Package-by-Layer after the project has grown to 40 entities and 8 developers. At that scale, adding a single feature requires creating files in 6 packages, and the risk of forgetting one — leaving a controller without a service or a service without a repository — is real and produces silent runtime failures. The migration to Package-by-Feature is uncomfortable but the compounding cost of avoiding it is worse.
Production Insight
A team running a Package-by-Layer project with 45 entities needed to add a Refund feature. The task required creating: RefundController in controller/, RefundService and RefundServiceImpl in service/ and service/impl/, RefundRepository in repository/, Refund in model/, RefundRequest and RefundResponse in dto/, and RefundNotFoundException in exception/. Eight files across seven locations.
During code review, the reviewer approved the PR. During QA, the Refund endpoint consistently returned 500 errors. The root cause: RefundServiceImpl was created in service/impl/ but was never annotated with @Service. Spring never registered it as a bean. The interface was there, the implementation was there, but the annotation was missing and the package structure provided no structural check to catch it.
In a Package-by-Feature layout, all eight files would have lived in refunds/. A reviewer scanning that single package would have seen immediately that something looked off. The scattered layout made the missing annotation invisible in review.
Rule: when adding a feature routinely requires touching more than three packages, the Package-by-Layer structure is adding risk faster than it is adding clarity. That is the signal to migrate.
Key Takeaway
Placing @SpringBootApplication in a sub-package is the most common structural mistake — it produces a clean startup with an empty application context and 404s for every endpoint. There are no error messages. The only symptom is the absence of 'Mapped' lines in the startup log.
Business logic in controllers makes the codebase unmaintainable and untestable — every line of business logic in a controller requires MockMvc and a Spring context to test. The same logic in a @Service is a plain Java method that a unit test can call directly.
Package strategy is a project lifecycle decision, not a one-time architectural choice — start with Package-by-Layer, measure the friction of adding new features as the project grows, and migrate to Package-by-Feature when that friction becomes a real cost rather than a theoretical concern.
Choosing Between Interface + Impl vs. Concrete Service Class
IfSimple application with one implementation per service and no plans to swap implementations
UseUse a concrete @Service class directly — ProductService.java annotated with @Service, no interface. Interfaces add a layer of indirection that provides no value when there is only one implementation and no polymorphism requirement.
IfNeed different implementations for different environments — in-memory for tests, JPA for production, or external API for integration tests
UseUse an interface (ProductService) in the service/ package and implementations (ProductServiceImpl, InMemoryProductService) in service/impl/. Spring's dependency injection selects the correct implementation based on @Profile or @Primary.
IfMultiple teams may provide different implementations of the same contract — shared module, plugin architecture
UseDefine the interface in a shared module that all teams depend on. Each team provides their own implementation in their own package. The interface is the contract; implementations are interchangeable from the caller's perspective.
IfUsing Spring AOP for cross-cutting concerns — @Transactional, @Cacheable, custom aspects
UsePrefer an interface — Spring creates JDK dynamic proxies for interface-based beans, which are lighter and more reliable than CGLIB class proxies. CGLIB proxies require the class to be non-final and have a no-arg constructor, which adds hidden constraints.
● Production incidentPOST-MORTEMseverity: high

The Bean That Could Not Be Found — @SpringBootApplication in the Wrong Package

Symptom
Every REST endpoint returned 404 Not Found. The application started successfully with no errors, no warnings, and no stack traces in the console. Tomcat was running on port 8080. The Actuator health endpoint returned UP. The startup log showed the embedded server initialized correctly. But every business endpoint was invisible to Spring MVC — as if the controllers had never been written. No 'Mapped' lines appeared in the startup log for any of the team's controller methods.
Assumption
The team assumed a Spring MVC configuration issue and spent the first two hours checking DispatcherServlet configuration, request mapping definitions, and security filter chain ordering. They verified the @RequestMapping annotations were correct. They checked for typos in the path definitions. They added debug logging to the security configuration. None of it helped because none of it was the problem. The issue was not in routing configuration — it was that no controllers existed in the application context at all. The team was debugging the wrong layer entirely.
Root cause
The @SpringBootApplication class was placed in io.thecodeforge.myapp.config — a sub-package intended to hold configuration beans. Spring Boot's component scan, enabled by the @ComponentScan annotation that @SpringBootApplication implicitly includes, scans the package where the annotated class lives and all of its sub-packages. The scan goes downward, not sideways. io.thecodeforge.myapp.config is a sibling of io.thecodeforge.myapp.service and io.thecodeforge.myapp.controller — not their parent. The component scan started at io.thecodeforge.myapp.config and found nothing below it except the main class itself. The controllers, services, and repositories sitting in sibling packages were never discovered. No beans were created. No routes were registered. The application started cleanly because Spring did not know anything was missing — as far as the context was concerned, the application had no components other than the ones Spring Boot auto-configured.
Fix
Moved the @SpringBootApplication class from io.thecodeforge.myapp.config to io.thecodeforge.myapp — the true root package that is the direct parent of all other packages (config, controller, service, repository, model, dto, exception, mapper). The application context immediately populated with all expected beans and all endpoints became reachable. Added two safeguards to prevent recurrence: (1) an ArchUnit test that verifies the class annotated with @SpringBootApplication resides in the root package — the test fails the build if the main class is moved to a sub-package, (2) a startup ApplicationListener that logs the count of registered @RestController beans and fails fast with an explicit error message if the count is zero, distinguishing a structural issue from a genuine 'no controllers defined' situation.
Key lesson
  • @SpringBootApplication must be in the root package that is the direct parent of all other packages — component scan goes downward into sub-packages, not sideways into sibling packages
  • A successful startup with no errors does not mean all beans were created — Spring does not know what you intended to register, only what it found during the scan
  • The absence of 'Mapped' lines in the startup log is a reliable symptom of missing controller registration — check this before debugging routing configuration
  • Add ArchUnit tests that enforce the main class package location — structural violations should fail the build, not fail a production deployment
  • When every endpoint returns 404 with a healthy application, check component scan coverage before checking routing, security, or serialization
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.6 entries
Symptom · 01
Every endpoint returns 404 — application starts cleanly with no errors but no routes are registered
Fix
Check the startup log for 'Mapped' lines — every registered @RequestMapping produces a 'Mapped \"GET /api/products\"' log entry at INFO level. If zero mapping lines appear, zero controllers were registered. Verify the @SpringBootApplication class is in the root package above all controllers — run grep -r '@SpringBootApplication' src/ to find it. Check the ApplicationContext directly: add a temporary ApplicationContextAware bean that logs all @RestController bean names on startup. If the list is empty, the controller package is outside the component scan path.
Symptom · 02
NoSuchBeanDefinitionException at startup — a required @Service or @Repository was not found
Fix
Verify the missing class is in a sub-package of the @SpringBootApplication root package. Verify the class carries the correct stereotype annotation (@Service, @Repository, @Component) — a missing annotation means Spring does not recognize the class as a bean even if it is in the right package. Check for any custom @ComponentScan or @SpringBootApplication(scanBasePackages=...) that might override the default scan path. Check for @Profile annotations on the bean that require a specific active profile.
Symptom · 03
Circular dependency error on startup — BeanCurrentlyInCreationException listing a cycle
Fix
Read the error message carefully — Spring lists the full dependency chain (ServiceA → ServiceB → ServiceA). The cycle tells you which services are inappropriately coupled. Break the cycle by: (1) extracting the shared behavior into a third @Service that both depend on, (2) using @Lazy on one injection point to defer initialization until first use, or (3) redesigning — if ServiceA needs ServiceB and ServiceB needs ServiceA, they likely belong together as one service or the shared behavior belongs in a common utility. Spring Boot 2.6+ disallows circular dependencies by default; add spring.main.allow-circular-references=true only as a short-term diagnostic, not a fix.
Symptom · 04
API responses expose database internal fields — password hashes, internal flags, auto-generated timestamps visible to clients
Fix
Check if the @RestController methods return @Entity objects directly. Search the codebase: grep -r 'ResponseEntity<.*Entity>' src/main/java. Every @Entity returned from a controller is a security and coupling risk. Create dedicated DTO classes for every API response and use a mapper (MapStruct preferred, manual acceptable) to convert entities to DTOs before returning. Add an ArchUnit rule: noClasses().that().areAnnotatedWith(RestController.class).should().dependOnClassesThat().areAnnotatedWith(Entity.class).
Symptom · 05
Adding a new feature requires creating files in 5+ packages — the work feels scattered and the risk of missing a file is high
Fix
The project has outgrown Package-by-Layer. The symptom is that a single feature (e.g., Refunds) requires touching controller/, service/, repository/, model/, dto/, and exception/ simultaneously. Migrate to Package-by-Feature: create a refunds/ package containing RefundController, RefundService, RefundRepository, Refund entity, RefundRequest, RefundResponse, and RefundNotFoundException. Start with the most independent feature to build confidence in the migration pattern, verify all tests pass, then continue.
Symptom · 06
Tests are slow and brittle — every unit test loads the full Spring context and takes 30+ seconds
Fix
Check if service tests use @SpringBootTest instead of plain JUnit instantiation — @SpringBootTest loads the full context and is appropriate for integration tests, not unit tests. Services with constructor-injected dependencies can be instantiated directly in tests: new ProductService(mockRepository). Check if controllers embed business logic — fat controllers require MockMvc and a Spring context to test even simple logic. Check if repositories are tested with the full context instead of @DataJpaTest, which loads only the JPA layer.
Traditional Java (Manual) vs. Spring Boot (Structured)
AspectTraditional Java (Manual)Spring Boot (Structured)
Component RegistrationManual — 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 SeparationOften 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.
BoilerplateHigh — 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 ManagementManual — 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.
TestabilityLow — 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 ScalabilityLow — 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 EnforcementNone 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 TimeHigh — 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

1
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.
2
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.
3
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.
4
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.
5
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.
6
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.
7
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.
8
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

6 patterns
×

Placing the @SpringBootApplication class in a sub-package instead of the root package

Symptom
Every endpoint returns 404 Not Found. The application starts with no errors. No stack traces appear in the log. The Actuator health endpoint returns UP. The startup log shows no 'Mapped' lines for any controllers. The application context contains no @Service, @Repository, or @RestController beans registered by the team — only Spring Boot auto-configured infrastructure beans.
Fix
Place the @SpringBootApplication class in the root package — the package that is the direct parent of all other packages (io.thecodeforge.myapp, not io.thecodeforge.myapp.config). Add an ArchUnit test to enforce this: classes().that().areAnnotatedWith(SpringBootApplication.class).should().resideInAPackage("io.thecodeforge.myapp"). If code legitimately spans multiple package trees, add @ComponentScan(basePackages = {"io.thecodeforge", "com.other"}) explicitly rather than relying on the default scan.
×

Returning @Entity objects directly from @RestController methods — leaking database structure as the API contract

Symptom
API responses contain database-internal fields: password hashes, soft-delete flags, internal audit columns, Hibernate-managed version counters, and raw foreign key IDs that have no meaning to API consumers. When a database column is added or renamed, the API contract changes automatically and breaks frontend clients. Security audits flag sensitive internal fields in API responses.
Fix
Create dedicated DTO classes for every API response (ProductResponse, UserSummaryResponse) and request (ProductRequest). Use MapStruct for the mapping — it generates type-safe, compile-time-verified mapping code with zero reflection overhead: @Mapper(componentModel = "spring") public interface ProductMapper { ProductResponse toResponse(Product product); }. Add an ArchUnit rule that prevents @RestController methods from returning @Entity types directly.
×

Embedding business logic in @RestController methods — fat controllers

Symptom
Controllers are 300–500 lines with complex conditional logic, direct repository calls, data transformation, and business rule validation. Unit testing requires starting a full Spring context with MockMvc because the business logic cannot be separated from the HTTP layer. Changing a business rule requires understanding and modifying HTTP request handling code.
Fix
Controllers have exactly three responsibilities: validate the incoming request (using @Valid and Bean Validation), delegate to the @Service with clean parameters, and format the response into HTTP. All business logic, calculations, state transitions, and data transformations belong in the @Service layer. A service method is a plain Java method that a unit test can call directly with no HTTP infrastructure. The test for a controller should be 5 lines of MockMvc assertions. The test for a service should be 20 lines of business logic verification.
×

Circular dependencies between @Service classes — ServiceA depends on ServiceB, ServiceB depends on ServiceA

Symptom
Spring fails to start with BeanCurrentlyInCreationException listing the full cycle: 'The dependencies of some of the beans in the application context form a cycle: orderService → paymentService → orderService'. The error appears at startup, not at runtime. Spring Boot 2.6+ fails hard on circular dependencies by default — the spring.main.allow-circular-references=true property exists only as a diagnostic escape hatch, not a fix.
Fix
Extract the shared behavior that creates the cycle into a third @Service class that both original services depend on. If OrderService needs to trigger payment processing and PaymentService needs to check order status, extract the shared state query into an OrderQueryService that neither writes to. Redesign the dependency graph so services have a clear ownership hierarchy — services should depend downward toward repositories, not sideways or upward toward other services at the same level. Use @Lazy on one injection point as a last resort when refactoring is not immediately feasible — it defers initialization but does not eliminate the architectural problem.
×

Not separating environment-specific configuration — hardcoded database URLs, API keys, and feature flags in application.properties

Symptom
Developers manually edit application.properties before deployment to change database URLs. Different environments use different versions of the same file. Secrets appear in the Git repository. A developer deploys to production with the development database URL still in the properties file because the manual edit was forgotten.
Fix
Use Spring profiles: application.properties for shared defaults that apply everywhere, application-dev.properties for local development (H2 in-memory, debug logging, Swagger enabled), application-prod.properties for production (real DB via environment variables, no debug logging). Reference environment variables for all secrets: spring.datasource.url=${DB_URL} — the actual URL is injected at runtime by the deployment environment (Kubernetes Secrets, AWS Parameter Store, environment variable). Activate profiles at runtime: java -jar app.jar --spring.profiles.active=prod. Never store credentials in any .properties file that is committed to version control.
×

Over-engineering with Package-by-Feature too early — 15 packages for a 5-entity application

Symptom
New developers spend more time understanding the package structure than writing business logic. Each feature package contains 2–3 files. Import statements span the entire package tree. The cognitive overhead of navigating the structure exceeds the cognitive overhead of the actual business logic. The structure was designed for a scale that the project may never reach.
Fix
Start with Package-by-Layer: controller/, service/, repository/, model/, dto/, exception/. This structure is immediately familiar to every Spring developer and has zero navigation overhead for small projects. Migrate to Package-by-Feature when a specific, measurable signal appears: adding a new feature consistently requires creating or modifying files in more than three packages. At that point, the migration pays for itself within the first feature built under the new structure.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01JUNIOR
Why is it recommended to place the main application class in the root pa...
Q02SENIOR
What is the difference between Package-by-Layer and Package-by-Feature? ...
Q03SENIOR
Explain the Fat JAR concept in Spring Boot. How does the project structu...
Q04SENIOR
How do you handle cross-cutting concerns like logging and security audit...
Q05SENIOR
If you must place a @Component outside the base package of your @SpringB...
Q06SENIOR
How would you use ArchUnit to enforce Spring Boot project structure rule...
Q01 of 06JUNIOR

Why is it recommended to place the main application class in the root package? Explain the mechanics of @ComponentScan in this context.

ANSWER
@SpringBootApplication is a composed annotation that includes @ComponentScan. By default, @ComponentScan scans the package where the annotated class resides and all packages nested below it in the hierarchy — it does not scan sibling packages or parent packages. If the main class is in io.thecodeforge.myapp, Spring scans io.thecodeforge.myapp, io.thecodeforge.myapp.controller, io.thecodeforge.myapp.service, io.thecodeforge.myapp.repository, and all other sub-packages. Every @Component, @Service, @Repository, and @RestController in those packages is discovered and registered as a Spring bean. If the main class is in io.thecodeforge.myapp.config, Spring scans only io.thecodeforge.myapp.config and its sub-packages. io.thecodeforge.myapp.service and io.thecodeforge.myapp.controller are sibling packages — at the same level, not below. They are never scanned. No service beans or controller beans are created. Every endpoint returns 404 with no error in the log because Spring MVC has no controllers to register. The root package placement is the mechanism that makes zero-configuration component discovery work. To verify: look for 'Mapped' lines in the startup log — if zero mapping lines appear, controllers were not registered and @SpringBootApplication is likely in the wrong location.
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
Does package naming really matter for performance?
02
Can I have multiple @SpringBootApplication classes in one project?
03
Should interfaces and implementations be in the same package?
04
When should I switch from Package-by-Layer to Package-by-Feature?
05
How do I handle shared code between features in a Package-by-Feature structure?
🔥

That's Spring Boot. Mark it forged?

3 min read · try the examples if you haven't

Previous
Spring Boot Application Properties Explained
2 / 15 · Spring Boot
Next
Spring Boot Auto-Configuration Explained