Skip to content
Home Java Spring Boot Project Structure: The Architect's Blueprint

Spring Boot Project Structure: The Architect's Blueprint

Where developers are forged. · Structured learning · Free forever.
📍 Part of: Spring Boot → Topic 2 of 15
Master Spring Boot Project Structure and package organization.
🧑‍💻 Beginner-friendly — no prior Java experience needed
In this tutorial, you'll learn
Master Spring Boot Project Structure and package organization.
  • 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.
✦ Plain-English analogy ✦ Real code with output ✦ Interview questions
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
Production IncidentThe Bean That Could Not Be Found — @SpringBootApplication in the Wrong PackageA team placed their main application class in io.thecodeforge.myapp.config but their services and repositories were in io.thecodeforge.myapp.service and io.thecodeforge.myapp.repository. Spring Boot's component scan never found them — every endpoint returned 404 with no errors in the logs and no indication anything was wrong.
SymptomEvery 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.
AssumptionThe 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 causeThe @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.
FixMoved 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 packagesA 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 scanThe absence of 'Mapped' lines in the startup log is a reliable symptom of missing controller registration — check this before debugging routing configurationAdd ArchUnit tests that enforce the main class package location — structural violations should fail the build, not fail a production deploymentWhen 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.
Every endpoint returns 404 — application starts cleanly with no errors but no routes are registeredCheck 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.
NoSuchBeanDefinitionException at startup — a required @Service or @Repository was not foundVerify 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.
Circular dependency error on startup — BeanCurrentlyInCreationException listing a cycleRead 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.
API responses expose database internal fields — password hashes, internal flags, auto-generated timestamps visible to clientsCheck 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).
Adding a new feature requires creating files in 5+ packages — the work feels scattered and the risk of missing a file is highThe 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.
Tests are slow and brittle — every unit test loads the full Spring context and takes 30+ secondsCheck 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.

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.txt · TEXT
123456789101112131415161718192021222324252627282930313233343536373839404142434445
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
Mental Model
Project Structure Is the Map of Your Codebase — @SpringBootApplication Is the Starting Point
The @SpringBootApplication class is the anchor point for everything Spring discovers. Component scan starts there and goes downward. Every bean in your application exists because it was below that anchor in the package hierarchy. Move the anchor to the wrong place and large parts of your application silently disappear from the Spring context.
  • @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.

Dockerfile · DOCKERFILE
123456789101112131415161718192021222324252627282930313233343536373839
# 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.
🗂 Traditional Java (Manual) vs. Spring Boot (Structured)
Spring Boot's component scanning and layered architecture eliminate manual bean wiring and enforce separation of concerns. The trade-off is that less visible infrastructure means structural mistakes are also less visible — which is why ArchUnit tests exist.
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

  • 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

    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 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
    @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.
  • QWhat is the difference between Package-by-Layer and Package-by-Feature? When would you switch from one to the other?Mid-levelReveal
    Package-by-Layer groups classes by their technical role: all controllers together, all services together, all repositories together. The package names reflect technical function: controller/, service/, repository/, model/. This is simple, universally familiar, and has zero navigation overhead for small projects. The weakness: adding a single business feature requires touching multiple packages — create the controller in controller/, the service in service/ and service/impl/, the repository in repository/, the entity in model/, the DTOs in dto/. For large projects this produces high cross-package coupling and makes features hard to isolate, test independently, or extract into separate services. Package-by-Feature groups all code for a business capability in one package: orders/, users/, payments/. Each package contains its own controller, service, repository, entity, DTOs, and exceptions. The package name reflects business function, not technical role. Adding a new feature means creating one new package — all related files live together, the feature is cohesive, and it can be extracted into a microservice by moving the package. The weakness: shared infrastructure (common utilities, base exception classes, OpenAPI configuration) needs a shared/ or common/ package, and discipline is required to prevent shared/ from becoming a dumping ground. The signal to switch: when adding a feature consistently requires creating files in more than three packages, Package-by-Layer is adding organizational friction faster than it is adding clarity. Migrate incrementally — one feature at a time, starting with the most independent business domain.
  • QExplain the Fat JAR concept in Spring Boot. How does the project structure influence the final executable artifact?Mid-levelReveal
    A Fat JAR (also called an executable JAR or uber-JAR) is a single JAR file that contains your compiled application code plus every dependency JAR nested inside it. Spring Boot's Maven plugin (spring-boot-maven-plugin) or Gradle plugin repackages the standard JAR into a Fat JAR with a specific internal layout: BOOT-INF/classes/ contains your compiled .class files, BOOT-INF/lib/ contains all dependency JARs, and org/springframework/boot/loader/ contains Spring Boot's custom class loader that knows how to load classes from nested JARs. Project structure influences the artifact in two ways: (1) the main class location determines the entry point — MANIFEST.MF specifies Start-Class as the @SpringBootApplication class, and Spring Boot's JarLauncher uses this to bootstrap the application context, (2) the component scan path determines which classes are active at runtime — classes can be compiled into BOOT-INF/classes/ but never registered as beans if they are outside the scan path. Spring Boot also supports Layered JARs (spring-boot-maven-plugin layertools) for Docker optimization. The Fat JAR is split into layers: dependencies (changes rarely), Spring Boot loader (changes per Spring Boot version), application code (changes with every commit). Each layer becomes a separate Docker layer. On redeployment, only the changed layers are rebuilt and pushed — for most commits, only the application code layer (a few KB) changes instead of the entire 50MB dependency layer.
  • QHow do you handle cross-cutting concerns like logging and security auditing in a layered structure without polluting the business logic?SeniorReveal
    Spring AOP (Aspect-Oriented Programming) is the mechanism for applying cross-cutting behavior declaratively rather than embedding it in business code. Aspects are defined in the config/ or aspect/ package and applied via pointcut expressions — they intercept method calls at defined join points without the business code knowing the aspect exists. Common patterns: (1) Logging aspect — @Around advice on all @Service methods that logs method entry, parameters (with PII masking), exit, and execution time in milliseconds. The service implementation has zero logging code. (2) Security audit aspect — @Before advice on @RestController methods that logs the authenticated user, the action, and the resource being accessed for compliance audit trails. (3) Performance monitoring aspect — @Around advice that publishes Micrometer metrics for every service method, feeding Prometheus and Grafana dashboards without any metrics code in business classes. The business logic in @Service classes remains clean and readable — no logging statements, no security checks, no metrics recording. The aspects are configured once in the config/aspect/ package and applied to all matching methods via pointcut expressions like execution( io.thecodeforge.myapp.service..(..)). This is how @Transactional itself works — it is an AOP aspect applied declaratively that wraps the annotated method in a database transaction without the method containing any transaction management code.
  • QIf you must place a @Component outside the base package of your @SpringBootApplication, how do you ensure Spring finds it?SeniorReveal
    Three mechanisms, in order of preference: (1) @ComponentScan with explicit basePackages — add it directly to @SpringBootApplication: @SpringBootApplication @ComponentScan(basePackages = {"io.thecodeforge.myapp", "com.other.shared"}). This extends the scan to cover both package trees. The drawback: you lose the default scan of the main class's own package and must list it explicitly. (2) @Import for specific classes — @Import(SomeExternalConfig.class) registers exactly one class without extending the component scan path. Use this for individual @Configuration or @Component classes from external libraries where a full package scan would be excessive. (3) Separate annotation-specific configuration for JPA — component scan, entity scan, and JPA repository scan are three independent mechanisms. A bean in the wrong package may not be found by component scan but could be found by: @EntityScan(basePackages = {"io.thecodeforge", "com.other"}) for @Entity classes, @EnableJpaRepositories(basePackages = {"io.thecodeforge", "com.other"}) for JpaRepository extensions. Each must be configured independently if your code spans multiple package trees. The root cause of needing these configurations is usually that third-party libraries or shared modules place classes in their own package namespace. Prefer keeping all application code in a single root package tree — the need for explicit scan configuration is a signal that package organization may have drifted.
  • QHow would you use ArchUnit to enforce Spring Boot project structure rules as automated tests?SeniorReveal
    ArchUnit is a Java testing library that analyzes compiled bytecode to enforce architectural rules. Rules are expressed as JUnit tests and fail the build if the codebase violates them — catching structural problems at build time rather than in code review or production. Practical rules for Spring Boot project structure: (1) Main class placement: classes().that().areAnnotatedWith(SpringBootApplication.class).should().resideInAPackage("io.thecodeforge.myapp") — fails if @SpringBootApplication moves to a sub-package. (2) No @Entity in controller responses: noClasses().that().areAnnotatedWith(RestController.class).should().dependOnClassesThat().areAnnotatedWith(Entity.class) — fails if a controller imports a JPA entity directly. (3) Controllers only depend on services: classes().that().resideInAPackage("..controller..").should().onlyDependOnClassesThat().resideInAnyPackage("..controller..", "..service..", "..dto..", "java..", "org.springframework..") — fails if a controller directly calls a repository. (4) Services do not depend on controllers: noClasses().that().resideInAPackage("..service..").should().dependOnClassesThat().resideInAPackage("..controller..") — enforces the one-way dependency rule. These tests run in milliseconds because they analyze compiled bytecode, not running code. Add them to the architecture/ test package and run them as part of the standard test suite — they document the intended structure and fail immediately when someone violates it.

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.

🔥
Naren Founder & Author

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.

← PreviousSpring Boot Application Properties ExplainedNext →Spring Boot Auto-Configuration Explained
Forged with 🔥 at TheCodeForge.io — Where Developers Are Forged