← Back to Articles
6/6/2026Admin Post

springboot jpa part1 project structure

Part 1: Spring Boot Project Structure

Navigation: Index | Part 1 | Part 2 | Part 3 | Part 4 | Part 5 | Part 6


Table of Contents

  1. Why Project Structure Matters
  2. Maven Directory Layout
  3. The pom.xml - Your Project Blueprint
  4. Package Organization Strategies
  5. The Main Application Class
  6. Configuration Management with application.yml
  7. Spring Profiles - Environment Management
  8. Type-Safe Configuration with @ConfigurationProperties
  9. Multi-Module Maven Projects
  10. Spring Boot Auto-Configuration Demystified
  11. Bean Lifecycle and Scopes
  12. Dependency Injection Best Practices
  13. Tips and Best Practices
  14. Anti-Patterns to Avoid
  15. Real-World Challenges and Solutions

1. Why Project Structure Matters

Project structure is not just a cosmetic concern. It is the foundation that determines:

  • Maintainability: Can a developer who has never seen this codebase find what they need in 30 seconds?
  • Testability: Is each layer isolated enough to be tested independently?
  • Scalability: Can the codebase grow from 5 to 50 developers without becoming a mess?
  • Extractability: If you need to extract a feature into a microservice, is it self-contained?
  • Onboarding speed: How long does it take a new hire to become productive?

Think of a large hospital. There are separate departments: emergency, cardiology, radiology, pharmacy. Each has a clear purpose. Patients, information, and staff flow in defined patterns. Nobody wonders where to find the X-ray machine. Your codebase should work the same way.


2. Maven Directory Layout

Spring Boot follows the standard Maven project layout. Every Spring Initializr project starts with this:

ecommerce-service/
|
|-- src/
|   |
|   |-- main/
|   |   |-- java/
|   |   |   `-- com/
|   |   |       `-- company/
|   |   |           `-- ecommerce/
|   |   |               |-- EcommerceApplication.java
|   |   |               |-- config/
|   |   |               |-- controller/
|   |   |               |-- service/
|   |   |               |-- repository/
|   |   |               |-- entity/
|   |   |               |-- dto/
|   |   |               |-- mapper/
|   |   |               |-- exception/
|   |   |               |-- util/
|   |   |               `-- security/
|   |   |
|   |   `-- resources/
|   |       |-- application.yml
|   |       |-- application-dev.yml
|   |       |-- application-staging.yml
|   |       |-- application-prod.yml
|   |       `-- db/
|   |           `-- migration/
|   |               |-- V1__create_users_table.sql
|   |               |-- V2__create_products_table.sql
|   |               `-- V3__create_orders_table.sql
|   |
|   `-- test/
|       |-- java/
|       |   `-- com/
|       |       `-- company/
|       |           `-- ecommerce/
|       |               |-- controller/
|       |               |-- service/
|       |               |-- repository/
|       |               `-- integration/
|       `-- resources/
|           `-- application-test.yml
|
|-- pom.xml
|-- .mvn/
|   `-- wrapper/
|       `-- maven-wrapper.properties
|-- mvnw
|-- mvnw.cmd
|-- .gitignore
`-- README.md

Directory Purpose Reference

DirectoryPurpose
src/main/javaAll production source code
src/main/resourcesConfiguration files, SQL scripts, templates
src/test/javaTest source code (mirrors main structure)
src/test/resourcesTest-specific configuration and fixtures
db/migrationFlyway versioned migration scripts
.mvn/wrapperMaven wrapper ensures everyone uses the same Maven version

The Maven wrapper (mvnw, mvnw.cmd) is a critical addition. It ensures that CI/CD pipelines and all developers use the exact same Maven version, eliminating "works on my machine" build issues.


3. The pom.xml

The pom.xml is the heartbeat of your Maven project. It declares what your project needs and how it builds.

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
                             https://maven.apache.org/xsd/maven-4.0.0.xsd">
 
    <modelVersion>4.0.0</modelVersion>
 
    <!--
        The parent BOM (Bill of Materials) manages compatible versions
        for ALL Spring dependencies. You almost never need to specify
        versions for spring-* dependencies when this parent is present.
    -->
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.2.5</version>
        <relativePath/>
    </parent>
 
    <!-- Project coordinates - used as the artifact identifier -->
    <groupId>com.company</groupId>
    <artifactId>ecommerce-service</artifactId>
    <version>1.0.0-SNAPSHOT</version>
    <name>ecommerce-service</name>
    <description>E-Commerce Backend Service</description>
    <packaging>jar</packaging>
 
    <properties>
        <java.version>17</java.version>
        <mapstruct.version>1.5.5.Final</mapstruct.version>
        <lombok.version>1.18.30</lombok.version>
        <testcontainers.version>1.19.3</testcontainers.version>
    </properties>
 
    <dependencies>
 
        <!-- ===<mark class="obsidian-highlight"> CORE WEB </mark>=<mark class="obsidian-highlight"> -->
        <!-- Includes: Spring MVC, Tomcat, Jackson (JSON), Validation -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
 
        <!-- </mark>=<mark class="obsidian-highlight"> DATA LAYER </mark>=<mark class="obsidian-highlight"> -->
        <!-- Includes: Spring Data JPA, Hibernate, HikariCP connection pool -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>
 
        <!-- Bean Validation (Jakarta Validation API + Hibernate Validator) -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
        </dependency>
 
        <!-- </mark>=<mark class="obsidian-highlight"> SECURITY </mark>=<mark class="obsidian-highlight"> -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>
 
        <!-- </mark>=<mark class="obsidian-highlight"> DATABASE DRIVER </mark>=== -->
        <!--
            scope=runtime: Only needed at runtime, not compile time.
            This prevents accidental use of driver-specific classes in your code.
        -->
        <dependency>
            <groupId>com.mysql</groupId>
            <artifactId>mysql-connector-j</artifactId>
            <scope>runtime</scope>
        </dependency>
 
        <!-- ===<mark class="obsidian-highlight"> DATABASE MIGRATION </mark>=<mark class="obsidian-highlight"> -->
        <!-- flyway-mysql includes MySQL-specific support -->
        <dependency>
            <groupId>org.flywaydb</groupId>
            <artifactId>flyway-mysql</artifactId>
        </dependency>
 
        <!-- </mark>=<mark class="obsidian-highlight"> DEVELOPER PRODUCTIVITY </mark>=== -->
        <!--
            Lombok generates boilerplate: getters, setters, constructors,
            builders, equals/hashCode, toString, logging.
            optional=true means it is NOT included in the final jar.
        -->
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
 
        <!--
            MapStruct generates type-safe DTO <-> Entity mapping code at compile time.
            Much faster and safer than reflection-based mappers like ModelMapper.
        -->
        <dependency>
            <groupId>org.mapstruct</groupId>
            <artifactId>mapstruct</artifactId>
            <version>${mapstruct.version}</version>
        </dependency>
 
        <!-- ===<mark class="obsidian-highlight"> CONFIGURATION </mark>=== -->
        <!--
            optional=true: Generates metadata for @ConfigurationProperties.
            Enables IDE autocomplete for your custom application.yml properties.
        -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-configuration-processor</artifactId>
            <optional>true</optional>
        </dependency>
 
        <!-- ===<mark class="obsidian-highlight"> OBSERVABILITY </mark>=<mark class="obsidian-highlight"> -->
        <!-- Provides /actuator/health, /actuator/info, /actuator/metrics -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-actuator</artifactId>
        </dependency>
 
        <!-- </mark>=<mark class="obsidian-highlight"> TESTING </mark>=== -->
        <!-- Includes JUnit 5, Mockito, AssertJ, Spring Test, MockMvc -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
 
        <!-- Security test support -->
        <dependency>
            <groupId>org.springframework.security</groupId>
            <artifactId>spring-security-test</artifactId>
            <scope>test</scope>
        </dependency>
 
        <!--
            TestContainers: Spins up a REAL MySQL Docker container for integration tests.
            This is the gold standard for testing database code.
        -->
        <dependency>
            <groupId>org.testcontainers</groupId>
            <artifactId>mysql</artifactId>
            <scope>test</scope>
        </dependency>
 
        <dependency>
            <groupId>org.testcontainers</groupId>
            <artifactId>junit-jupiter</artifactId>
            <scope>test</scope>
        </dependency>
 
    </dependencies>
 
    <build>
        <plugins>
 
            <!-- Spring Boot Maven Plugin: creates executable fat jar -->
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <configuration>
                    <!-- Exclude Lombok from final jar - it is compile-time only -->
                    <excludes>
                        <exclude>
                            <groupId>org.projectlombok</groupId>
                            <artifactId>lombok</artifactId>
                        </exclude>
                    </excludes>
                </configuration>
            </plugin>
 
            <!--
                CRITICAL: Maven Compiler must be configured when using
                both Lombok AND MapStruct together.
                Lombok MUST appear BEFORE MapStruct in annotationProcessorPaths.
                If MapStruct runs first, it cannot see Lombok-generated methods
                and all mapping will fail silently.
            -->
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <configuration>
                    <source>17</source>
                    <target>17</target>
                    <annotationProcessorPaths>
                        <!-- Lombok FIRST -->
                        <path>
                            <groupId>org.projectlombok</groupId>
                            <artifactId>lombok</artifactId>
                            <version>${lombok.version}</version>
                        </path>
                        <!-- MapStruct SECOND -->
                        <path>
                            <groupId>org.mapstruct</groupId>
                            <artifactId>mapstruct-processor</artifactId>
                            <version>${mapstruct.version}</version>
                        </path>
                        <!-- Configuration processor THIRD -->
                        <path>
                            <groupId>org.springframework.boot</groupId>
                            <artifactId>spring-boot-configuration-processor</artifactId>
                        </path>
                    </annotationProcessorPaths>
                    <compilerArgs>
                        <!-- Makes MapStruct use Lombok builder if present -->
                        <arg>-Amapstruct.defaultComponentModel=spring</arg>
                    </compilerArgs>
                </configuration>
            </plugin>
 
        </plugins>
    </build>
 
</project>

Critical pom.xml Tips

Tip 1 - Lombok must be first in annotationProcessorPaths:
MapStruct generates mapping code by inspecting getter and setter methods. Lombok generates those methods. If MapStruct runs before Lombok, it sees no getters/setters and generates empty mappers. This is one of the most common setup errors in new Spring Boot projects.

Tip 2 - spring-boot-starter-parent manages all compatible versions:
You should not specify versions for Spring-managed dependencies. The parent BOM knows which versions of Hibernate, HikariCP, Jackson, and all other dependencies are compatible with each other. Override versions only when absolutely necessary and with caution.

Tip 3 - Use scope runtime for database drivers:
The MySQL driver should never be imported directly in your code. Marking it runtime enforces this and keeps your code independent of the database vendor.

Tip 4 - Use the Maven wrapper:
Always include mvnw and mvnw.cmd in your repository. This ensures CI/CD systems and all developers use the same Maven version without requiring a Maven installation on their machines.


4. Package Organization Strategies

Strategy 1: Layer-Based (Technical Layers)

All classes of the same technical type live together.

com.company.ecommerce
    |-- controller/
    |   |-- UserController.java
    |   |-- ProductController.java
    |   `-- OrderController.java
    |-- service/
    |   |-- UserService.java
    |   |-- ProductService.java
    |   `-- OrderService.java
    |-- repository/
    |   |-- UserRepository.java
    |   |-- ProductRepository.java
    |   `-- OrderRepository.java
    |-- entity/
    |   |-- User.java
    |   |-- Product.java
    |   `-- Order.java
    `-- dto/
        |-- UserDTO.java
        |-- ProductDTO.java
        `-- OrderDTO.java

Pros:

  • Simple and familiar to most Spring developers
  • Easy to understand the architecture at a glance
  • Good for small projects with 5-10 entities

Cons:

  • Each package grows very large in big applications (50+ classes in controller/)
  • Unrelated classes are grouped together (user and payment share the same package)
  • Hard to apply package-level access restrictions
  • Difficult to extract a domain into a microservice because related code is spread across all packages

Strategy 2: Domain-Based (Feature Packages)

All code for a single feature or domain lives together.

com.company.ecommerce
    |-- user/
    |   |-- UserController.java
    |   |-- UserService.java
    |   |-- UserRepository.java
    |   |-- User.java
    |   |-- UserDTO.java
    |   `-- UserMapper.java
    |-- product/
    |   |-- ProductController.java
    |   |-- ProductService.java
    |   |-- ProductRepository.java
    |   |-- Product.java
    |   `-- ProductDTO.java
    `-- order/
        |-- OrderController.java
        |-- OrderService.java
        |-- OrderRepository.java
        |-- Order.java
        `-- OrderDTO.java

Pros:

  • High domain cohesion - everything for a feature is in one place
  • Easy to extract domains into microservices
  • Aligns with Domain-Driven Design (DDD) bounded contexts
  • Package-level access control becomes meaningful

Cons:

  • Can lead to circular dependencies between domains if not carefully managed
  • Cross-cutting concerns (security, auditing) need a shared location
  • Shared entities like Address complicate the model

This is the approach used by most mature Spring Boot applications. It separates infrastructure (config, exception, util) from domain logic and maintains a clear layered architecture within each domain.

com.company.ecommerce
    |
    |-- common/                          # Cross-cutting concerns
    |   |-- config/
    |   |   |-- WebConfig.java
    |   |   |-- SecurityConfig.java
    |   |   |-- JpaConfig.java
    |   |   `-- AuditConfig.java
    |   |-- exception/
    |   |   |-- GlobalExceptionHandler.java
    |   |   |-- ResourceNotFoundException.java
    |   |   `-- BusinessException.java
    |   |-- util/
    |   |   `-- DateUtils.java
    |   `-- audit/
    |       |-- AuditableEntity.java     # Base class for all auditable entities
    |       `-- AuditorAwareImpl.java
    |
    |-- domain/                          # JPA entities and their repositories
    |   |-- user/
    |   |   |-- User.java
    |   |   |-- UserProfile.java
    |   |   |-- Address.java
    |   |   |-- Role.java
    |   |   |-- UserRepository.java
    |   |   |-- RoleRepository.java
    |   |   `-- AddressRepository.java
    |   |-- product/
    |   |   |-- Product.java
    |   |   |-- Category.java
    |   |   |-- ProductRepository.java
    |   |   `-- CategoryRepository.java
    |   `-- order/
    |       |-- Order.java
    |       |-- OrderItem.java
    |       |-- Payment.java
    |       |-- OrderStatus.java         # Enum
    |       |-- OrderRepository.java
    |       `-- PaymentRepository.java
    |
    |-- service/                         # Business logic (application layer)
    |   |-- UserService.java
    |   |-- ProductService.java
    |   |-- OrderService.java
    |   `-- PaymentService.java
    |
    |-- api/                             # Presentation layer
    |   |-- controller/
    |   |   |-- UserController.java
    |   |   |-- ProductController.java
    |   |   `-- OrderController.java
    |   |-- dto/
    |   |   |-- request/
    |   |   |   |-- CreateUserRequest.java
    |   |   |   |-- CreateOrderRequest.java
    |   |   |   `-- UpdateProductRequest.java
    |   |   `-- response/
    |   |       |-- UserResponse.java
    |   |       |-- OrderResponse.java
    |   |       `-- ProductResponse.java
    |   `-- mapper/
    |       |-- UserMapper.java
    |       |-- OrderMapper.java
    |       `-- ProductMapper.java
    |
    `-- infrastructure/                  # External service integrations
        |-- messaging/
        |   `-- OrderEventPublisher.java
        |-- email/
        |   `-- SesEmailService.java
        `-- storage/
            `-- S3StorageService.java

Why this structure wins in production:

  • common packages all cross-cutting concerns that do not belong to any domain
  • domain keeps entities close to their repositories (they always change together)
  • service acts as the orchestration layer between domains
  • api is the only layer that should know about HTTP, JSON, and REST conventions
  • infrastructure isolates all external dependencies, making them easy to mock in tests

5. The Main Application Class

package com.company.ecommerce;
 
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.transaction.annotation.EnableTransactionManagement;
 
import com.company.ecommerce.common.config.AppProperties;
 
/*
 * @SpringBootApplication is a meta-annotation that combines:
 *   @Configuration          - This class is a source of bean definitions
 *   @EnableAutoConfiguration - Auto-configure based on classpath
 *   @ComponentScan           - Scan this package and ALL sub-packages
 *
 * PLACEMENT MATTERS: This class must be in the ROOT package.
 * If placed in a sub-package, sibling packages will NOT be scanned.
 */
@SpringBootApplication
@EnableJpaAuditing(auditorAwareRef = "auditorAware")  // Enables @CreatedBy, @LastModifiedBy
@EnableAsync                                           // Enables @Async methods
@EnableTransactionManagement                           // Enables @Transactional
@EnableConfigurationProperties(AppProperties.class)    // Enables @ConfigurationProperties beans
public class EcommerceApplication {
 
    public static void main(String[] args) {
        SpringApplication.run(EcommerceApplication.class, args);
    }
}

What @SpringBootApplication Does Internally

@SpringBootApplication
        |
        +-- @Configuration
        |       Marks this class as a bean definition source.
        |       Any @Bean methods here produce Spring-managed beans.
        |
        +-- @EnableAutoConfiguration
        |       Looks at classpath, auto-configures relevant beans.
        |       Example: HikariCP on classpath -> creates DataSource bean.
        |       Example: Hibernate on classpath -> creates EntityManagerFactory.
        |
        `-- @ComponentScan
                Scans com.company.ecommerce.* recursively.
                Finds @Component, @Service, @Repository, @Controller, @RestController.
                Registers them as Spring beans.

Component Scan Scope - A Common Mistake

com.company.ecommerce.EcommerceApplication  <-- Main class is HERE
com.company.ecommerce.user.*                <-- Scanned (sub-package)
com.company.ecommerce.product.*             <-- Scanned (sub-package)
com.company.ecommerce.order.*               <-- Scanned (sub-package)

com.company.OTHER.*                         <-- NOT scanned (different root)

If your main class is at com.company.ecommerce.core.EcommerceApplication, then com.company.ecommerce.user is a sibling, not a sub-package, and it will NOT be scanned. This causes NoSuchBeanDefinitionException errors that are confusing to debug.


6. Configuration Management

application.yml - Base Configuration

YAML is strongly preferred over .properties because it supports nesting, reducing repetitive prefixes and making structure obvious.

# application.yml
# This file contains defaults and settings shared across ALL profiles.
# Profile-specific files override only what needs to change.
 
spring:
  application:
    name: ecommerce-service
 
  # ===<mark class="obsidian-highlight"> JPA / HIBERNATE </mark>===
  jpa:
    # NEVER use 'update' or 'create' in production.
    # 'validate' checks that your entities match the schema - great safety net.
    # 'none' when you fully trust Flyway to manage the schema.
    hibernate:
      ddl-auto: validate
 
    # CRITICAL: Disable Open Session In View.
    # When true (default), Hibernate session stays open during HTTP request processing
    # including serialization. This causes lazy loading outside service layer,
    # hidden N+1 queries, and connections held longer than necessary.
    open-in-view: false
 
    show-sql: false # Override to true in dev profile
 
    properties:
      hibernate:
        # MySQL dialect for Hibernate 6
        dialect: org.hibernate.dialect.MySQLDialect
 
        # Format SQL for readability (useful with show-sql=true in dev)
        format_sql: true
 
        # All datetimes stored as UTC in MySQL
        jdbc:
          time_zone: UTC
 
        # JDBC batch processing - improves bulk insert/update performance
        jdbc.batch_size: 25
        order_inserts: true # Group inserts of same type together for batching
        order_updates: true # Group updates of same type together for batching
 
        # Default batch fetch size - reduces N+1 queries
        default_batch_fetch_size: 25
 
  # ===<mark class="obsidian-highlight"> DATASOURCE </mark>===
  # Override url, username, password in profile-specific files.
  # NEVER hardcode credentials here.
  datasource:
    url: jdbc:mysql://localhost:3306/ecommerce?useSSL=false&serverTimezone=UTC&characterEncoding=UTF-8&rewriteBatchedStatements=true
    username: ${DB_USERNAME}
    password: ${DB_PASSWORD}
    driver-class-name: com.mysql.cj.jdbc.Driver
 
    # ===<mark class="obsidian-highlight"> HIKARICP CONNECTION POOL </mark>===
    hikari:
      pool-name: EcommerceHikariPool
      # Formula: (core_count * 2) + effective_spindle_count
      # For 4-core machine with SSD: (4 * 2) + 1 = 9, round to 10
      maximum-pool-size: 10
      minimum-idle: 2
      # How long a connection can sit idle before being removed
      idle-timeout: 300000 # 5 minutes in milliseconds
      # Maximum lifetime of a connection in the pool (must be < MySQL wait_timeout)
      max-lifetime: 1800000 # 30 minutes
      # How long to wait for a connection from the pool before throwing an exception
      connection-timeout: 30000 # 30 seconds
      validation-timeout: 5000 # 5 seconds
      # Log a warning if connection is held longer than this (useful for detecting leaks)
      leak-detection-threshold: 60000 # 1 minute
 
  # ===<mark class="obsidian-highlight"> FLYWAY MIGRATION </mark>=<mark class="obsidian-highlight">
  flyway:
    enabled: true
    locations: classpath:db/migration
    baseline-on-migrate: false # Set true ONLY for first migration on existing DB
    validate-on-migrate: true # Validate checksums before migrating
    out-of-order: false # Prevent out-of-order migrations in production
 
# </mark>=<mark class="obsidian-highlight"> ACTUATOR (Health, Metrics) </mark>=<mark class="obsidian-highlight">
management:
  endpoints:
    web:
      exposure:
        include: health,info,metrics,prometheus
  endpoint:
    health:
      show-details: when_authorized # Only show DB health details when authenticated
 
# </mark>=<mark class="obsidian-highlight"> LOGGING </mark>===
logging:
  level:
    com.company.ecommerce: INFO
    org.springframework.web: WARN
    org.hibernate.SQL: WARN
    org.hibernate.type.descriptor.sql.BasicBinder: WARN

application-dev.yml

# application-dev.yml
# Activated with: SPRING_PROFILES_ACTIVE=dev
 
spring:
  jpa:
    show-sql: true
    properties:
      hibernate:
        format_sql: true
 
  datasource:
    url: jdbc:mysql://localhost:3306/ecommerce_dev?useSSL=false&serverTimezone=UTC&characterEncoding=UTF-8
 
  flyway:
    out-of-order: true # Allow out-of-order in dev (not prod!)
 
logging:
  level:
    com.company.ecommerce: DEBUG
    # Show actual SQL query parameters (WARNING: exposes data in logs)
    org.hibernate.SQL: DEBUG
    org.hibernate.type.descriptor.sql.BasicBinder: TRACE

application-prod.yml

# application-prod.yml
# Activated with: SPRING_PROFILES_ACTIVE=prod
# Credentials come from AWS Parameter Store / Secrets Manager via environment variables
 
spring:
  jpa:
    show-sql: false
    hibernate:
      ddl-auto: validate # Validate, never modify
 
  datasource:
    # AWS RDS endpoint injected at runtime
    url: jdbc:mysql://${RDS_ENDPOINT}:3306/${RDS_DB_NAME}?useSSL=true&requireSSL=true&serverTimezone=UTC&characterEncoding=UTF-8&connectTimeout=5000&socketTimeout=30000&rewriteBatchedStatements=true
    username: ${RDS_USERNAME}
    password: ${RDS_PASSWORD}
 
    hikari:
      maximum-pool-size: 20 # Higher for production load
      minimum-idle: 5
      max-lifetime: 1800000
 
management:
  endpoint:
    health:
      show-details: never # Never expose internals publicly in production
 
logging:
  level:
    root: WARN
    com.company.ecommerce: WARN

7. Spring Profiles

Profiles allow you to have different beans, configurations, and properties per environment.

Activating Profiles

# Option 1: Environment variable (RECOMMENDED for production and Docker/Kubernetes)
SPRING_PROFILES_ACTIVE=prod java -jar app.jar
 
# Option 2: JVM system property
java -Dspring.profiles.active=prod -jar app.jar
 
# Option 3: In application.yml (use ONLY for local development default)
# spring:
#   profiles:
#     active: dev

Industry practice: Use environment variables to activate profiles in production. This is the Twelve-Factor App principle: configuration comes from the environment, not the code.

Profile-Specific Beans

package com.company.ecommerce.common.config;
 
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
 
@Configuration
public class EmailConfig {
 
    /*
     * In non-production environments, use a fake email service
     * that logs the email instead of sending it.
     * The ! prefix means "NOT prod profile".
     */
    @Bean
    @Profile("!prod")
    public EmailService mockEmailService() {
        return new MockEmailService();
    }
 
    /*
     * In production, use the real AWS SES implementation.
     */
    @Bean
    @Profile("prod")
    public EmailService awsSesEmailService(AppProperties appProperties) {
        return new SesEmailService(appProperties.getAws().getSesRegion());
    }
}

Profile Groups (Spring Boot 2.4+)

You can group multiple profiles so activating one activates several.

# application.yml
spring:
  profiles:
    group:
      prod: "prod,aws,security-strict"
      staging: "staging,aws,security-relaxed"

Now SPRING_PROFILES_ACTIVE=prod activates the prod, aws, and security-strict profiles simultaneously.


8. Type-Safe Configuration

Instead of scattering @Value("${some.property}") annotations throughout the codebase, centralize all custom properties in a type-safe class.

package com.company.ecommerce.common.config;
 
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Positive;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.validation.annotation.Validated;
 
/*
 * @ConfigurationProperties binds properties with prefix "app" to this class.
 * @Validated enables Bean Validation on the bound values at startup.
 * If jwt-secret is empty, the application FAILS TO START with a clear error message.
 * This is much better than failing at runtime when the first JWT operation is attempted.
 */
@Data
@Validated
@ConfigurationProperties(prefix = "app")
public class AppProperties {
 
    @NotBlank(message = "Application name must not be blank")
    private String name;
 
    @Valid
    private Security security = new Security();
 
    @Valid
    private Aws aws = new Aws();
 
    @Valid
    private Email email = new Email();
 
    @Data
    public static class Security {
 
        @NotBlank(message = "JWT secret must be configured")
        private String jwtSecret;
 
        @Positive
        private long jwtExpirationMs = 86400000L;  // 24 hours default
 
        private boolean requireHttps = true;
    }
 
    @Data
    public static class Aws {
        private String region = "us-east-1";
 
        @NotBlank(message = "S3 bucket name must be configured")
        private String s3BucketName;
 
        private String sqsOrderQueueUrl;
        private String sesRegion = "us-east-1";
    }
 
    @Data
    public static class Email {
        private String fromAddress = "noreply@company.com";
        private boolean enabled = true;
    }
}
# application.yml - Corresponding YAML
app:
  name: Ecommerce Service
  security:
    jwt-secret: ${JWT_SECRET} # From environment variable
    jwt-expiration-ms: 86400000
    require-https: true
  aws:
    region: us-east-1
    s3-bucket-name: ${S3_BUCKET_NAME}
    sqs-order-queue-url: ${SQS_QUEUE_URL}
  email:
    from-address: noreply@company.com
    enabled: true
// Usage in any Spring-managed class
@Service
@RequiredArgsConstructor
public class JwtTokenService {
 
    // Clean, type-safe injection - no magic strings
    private final AppProperties appProperties;
 
    public String generateToken(String username) {
        return Jwts.builder()
            .setSubject(username)
            .setExpiration(new Date(System.currentTimeMillis()
                + appProperties.getSecurity().getJwtExpirationMs()))
            .signWith(Keys.hmacShaKeyFor(
                appProperties.getSecurity().getJwtSecret().getBytes()))
            .compact();
    }
}

9. Multi-Module Maven Projects

For large applications with teams working on different areas, split the project into Maven modules.

When to Use Multi-Module

  • Team size is 10+ developers
  • You want to enforce layer boundaries (e.g., API layer cannot import entity classes directly)
  • You may eventually extract modules into separate services
  • Build times are slow (Maven can skip unchanged modules)

Multi-Module Layout

ecommerce-parent/
|-- pom.xml                          # Parent POM - declares modules
|
|-- ecommerce-domain/                # JPA entities, repositories, value objects
|   |-- src/main/java/...
|   `-- pom.xml
|
|-- ecommerce-service/               # Business logic services
|   |-- src/main/java/...            # Depends on: ecommerce-domain
|   `-- pom.xml
|
|-- ecommerce-api/                   # REST controllers, DTOs, mappers
|   |-- src/main/java/...            # Depends on: ecommerce-service
|   `-- pom.xml
|
|-- ecommerce-infrastructure/        # AWS S3, SES, SQS, external APIs
|   |-- src/main/java/...            # Depends on: ecommerce-domain
|   `-- pom.xml
|
`-- ecommerce-app/                   # Main application, assembles all modules
    |-- src/main/java/...            # Depends on: all modules
    `-- pom.xml

Parent pom.xml

<groupId>com.company</groupId>
<artifactId>ecommerce-parent</artifactId>
<version>1.0.0-SNAPSHOT</version>
<packaging>pom</packaging>  <!-- pom packaging for parent -->
 
<modules>
    <module>ecommerce-domain</module>
    <module>ecommerce-service</module>
    <module>ecommerce-api</module>
    <module>ecommerce-infrastructure</module>
    <module>ecommerce-app</module>
</modules>

Module pom.xml Example

<!-- ecommerce-service/pom.xml -->
<parent>
    <groupId>com.company</groupId>
    <artifactId>ecommerce-parent</artifactId>
    <version>1.0.0-SNAPSHOT</version>
</parent>
 
<artifactId>ecommerce-service</artifactId>
 
<dependencies>
    <!-- Service depends on Domain module -->
    <dependency>
        <groupId>com.company</groupId>
        <artifactId>ecommerce-domain</artifactId>
        <version>${project.version}</version>
    </dependency>
</dependencies>

10. Auto-Configuration Demystified

Spring Boot's auto-configuration is why you can add a dependency and it "just works". Here is what actually happens.

How Auto-Configuration Works

1. spring-boot-starter-data-jpa is added to pom.xml

2. Spring Boot reads META-INF/spring/
   org.springframework.boot.autoconfigure.AutoConfiguration.imports
   inside the starter jar

3. This file lists: JpaRepositoriesAutoConfiguration,
   DataSourceAutoConfiguration, HibernateJpaAutoConfiguration, etc.

4. Each auto-configuration class has @ConditionalOn... annotations:
   @ConditionalOnClass(DataSource.class)    - only if DataSource is on classpath
   @ConditionalOnMissingBean(DataSource.class) - only if user did NOT define one

5. Because HikariCP and Hibernate are on the classpath, Spring Boot creates:
   - DataSource (HikariCP)
   - EntityManagerFactory
   - JpaTransactionManager
   - JpaRepositories

Excluding Auto-Configuration

// Exclude when you want full control (e.g., multiple data sources)
@SpringBootApplication(exclude = {
    DataSourceAutoConfiguration.class,
    HibernateJpaAutoConfiguration.class
})
public class EcommerceApplication { }

Debugging Auto-Configuration

Add this to see what was auto-configured and why:

logging:
  level:
    org.springframework.boot.autoconfigure: DEBUG

Or use the Actuator endpoint: GET /actuator/conditions


11. Bean Lifecycle and Scopes

Bean Scopes

ScopeInstancesThread SafeUse Case
singletonOne per ApplicationContext (default)Must beStateless services, repositories, controllers
prototypeNew instance per requestYes (isolated)Stateful, non-shared beans
requestOne per HTTP requestYes (isolated)Request-specific data
sessionOne per HTTP sessionMust beUser session state

Industry rule: Keep all your beans singleton-scoped and stateless. This is the easiest path to scalability and thread safety.

Lifecycle Callbacks

package com.company.ecommerce.common.config;
 
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
 
@Component
@RequiredArgsConstructor
@Slf4j
public class ApplicationStartupValidator {
 
    private final AppProperties appProperties;
 
    /*
     * @PostConstruct: Called AFTER constructor and @Autowired injection.
     * Use for: validation, warm-up, initialization logic.
     * If this method throws an exception, the application fails to start.
     * This is ideal for catching misconfiguration early.
     */
    @PostConstruct
    public void validateConfiguration() {
        log.info("Starting {} - validating configuration...",
            appProperties.getName());
 
        if (appProperties.getSecurity().getJwtSecret().length() < 32) {
            throw new IllegalStateException(
                "JWT secret must be at least 32 characters for security");
        }
 
        log.info("Configuration validation passed.");
    }
 
    /*
     * @PreDestroy: Called BEFORE the bean is removed from the context.
     * Use for: releasing resources, flushing caches, graceful shutdown.
     */
    @PreDestroy
    public void onShutdown() {
        log.info("Application shutting down gracefully...");
    }
}

12. Dependency Injection Best Practices

Constructor Injection - The Only Right Way

// BAD - Field Injection
@Service
public class OrderService {
    @Autowired  // Hidden dependency, cannot be final, hard to test
    private OrderRepository orderRepository;
 
    @Autowired
    private ProductService productService;
}
// GOOD - Constructor Injection (manual)
@Service
public class OrderService {
    private final OrderRepository orderRepository;
    private final ProductService productService;
 
    // Dependencies are explicit, field is final (immutable), easy to test
    public OrderService(OrderRepository orderRepository,
                        ProductService productService) {
        this.orderRepository = orderRepository;
        this.productService = productService;
    }
}
// BEST - Constructor Injection with Lombok
@Service
@RequiredArgsConstructor  // Generates constructor for all final fields
@Slf4j                    // Generates: private static final Logger log = ...
public class OrderService {
 
    private final OrderRepository orderRepository;
    private final ProductService productService;
    private final UserService userService;
    private final AppProperties appProperties;
 
    // Clean, readable, all dependencies visible and final
}

Why Constructor Injection Wins

  1. All dependencies are visible - the constructor signature documents what the class needs
  2. Fields can be final - ensuring dependencies are never null and never changed after construction
  3. No reflection - works without Spring context, making unit tests trivial
  4. Circular dependency detection - Spring detects circular constructor injection at startup and throws a clear error, not a NullPointerException at runtime

13. Tips and Best Practices

Tip 1: Use application.yml over application.properties

YAML nesting is cleaner and less repetitive than flat properties files.

# application.properties - repetitive
spring.jpa.hibernate.ddl-auto=validate
spring.jpa.show-sql=false
spring.datasource.url=jdbc:mysql://...
spring.datasource.username=user
# application.yml - clear hierarchy
spring:
  jpa:
    hibernate:
      ddl-auto: validate
    show-sql: false
  datasource:
    url: jdbc:mysql://...
    username: user

Tip 2: Use Placeholder ${VAR_NAME} for all Secrets

Secrets never live in code or YAML files checked into Git. They come from environment variables, AWS Secrets Manager, or AWS Systems Manager Parameter Store.

spring:
  datasource:
    password: ${DB_PASSWORD} # Injected at runtime
 
app:
  security:
    jwt-secret: ${JWT_SECRET}

Tip 3: Set Sensible Health Check Timeouts for AWS RDS

AWS RDS can have brief connectivity blips during failover. Configure HikariCP to handle these gracefully:

spring:
  datasource:
    hikari:
      # Do not fail immediately on a brief RDS blip
      connection-timeout: 30000
      # Kick out connections that are no longer valid
      max-lifetime: 1800000 # Must be less than MySQL wait_timeout (28800 by default)
      # Prevent stale connections
      keepalive-time: 300000 # Send keepalive every 5 minutes

Tip 4: Verify Your Configuration at Startup

Use @PostConstruct validation or @ConfigurationProperties with @Validated to fail fast on misconfiguration.

Tip 5: Use Structured Logging in Production

Add Logstash or JSON logging for AWS CloudWatch or Elasticsearch integration:

<dependency>
    <groupId>net.logstash.logback</groupId>
    <artifactId>logstash-logback-encoder</artifactId>
    <version>7.4</version>
</dependency>

14. Anti-Patterns to Avoid

Anti-Pattern 1: Open Session In View (OSIV)

What it is: Spring Boot enables OSIV by default. It keeps the Hibernate session (and database connection) open for the entire HTTP request, including controller execution and HTTP response serialization.

Why it is dangerous:

  • Lazy-loaded collections can be triggered during JSON serialization, which happens outside your service layer
  • These lazy loads appear as "free" but actually hit the database, creating hidden N+1 problems
  • The database connection is held for the entire request duration, reducing pool throughput under load
  • It masks poor data fetching design, hiding problems until production load

Fix:

spring:
  jpa:
    open-in-view: false # Disable OSIV - forces you to be explicit about what you fetch

Anti-Pattern 2: Using ddl-auto=update in Production

# DANGEROUS - Hibernate will try to alter your production schema
spring:
  jpa:
    hibernate:
      ddl-auto: update  # NEVER do this in production
 
# SAFE
spring:
  jpa:
    hibernate:
      ddl-auto: validate  # Checks entities match schema. Use with Flyway.

Hibernate's update is unreliable: It cannot rename columns, cannot drop constraints safely, and will silently fail on many complex schema changes. Use Flyway for all schema changes.

Anti-Pattern 3: Putting the Main Class in a Sub-package

// BAD - only com.company.ecommerce.core.* will be scanned
package com.company.ecommerce.core;
 
@SpringBootApplication
public class EcommerceApplication { }
// GOOD - all sub-packages will be scanned
package com.company.ecommerce;
 
@SpringBootApplication
public class EcommerceApplication { }

Anti-Pattern 4: Mixing Business Logic into Configuration Classes

// BAD - @Configuration classes should configure beans, not do business logic
@Configuration
public class AppConfig {
    @Bean
    public OrderService orderService() {
        // Processing logic here is wrong
        OrderService service = new OrderService();
        service.processAllPendingOrders();  // WRONG
        return service;
    }
}

15. Real-World Challenges and Solutions

Challenge 1: Different database schemas per environment

Problem: Dev database has a relaxed schema. Staging has more constraints. Production has read replicas.

Solution: Use Flyway with profile-specific SQL dialects and maintain a V0__baseline.sql for existing databases.

spring:
  flyway:
    locations: classpath:db/migration,classpath:db/migration/${spring.profiles.active}

This allows profile-specific migration scripts alongside shared ones.

Challenge 2: Long startup times in production

Problem: The application takes 45+ seconds to start, causing slow deployments and failed health checks.

Solutions:

  • Use Spring Boot's lazy initialization (spring.main.lazy-initialization=true) for non-critical beans
  • Profile startup with -verbose:class to identify slow bean initialization
  • Defer expensive initialization to @PostConstruct or use ApplicationReadyEvent
spring:
  main:
    lazy-initialization: true # Beans initialized on first use, not at startup

Challenge 3: Configuration drifts between environments

Problem: Dev and production YAML files diverge over time, leading to bugs that only appear in production.

Solution: Use a centralized configuration service (AWS AppConfig or Spring Cloud Config Server). Define a canonical set of properties and override only what differs per environment.

Challenge 4: Bean initialization order issues

Problem: Bean A depends on Bean B, which depends on a database that is not yet ready.

Solution: Use @DependsOn, ApplicationRunner, or CommandLineRunner for startup logic that requires full context initialization.

@Component
public class DataInitializer implements ApplicationRunner {
 
    private final RoleRepository roleRepository;
 
    @Override
    public void run(ApplicationArguments args) {
        // This runs AFTER full application context is ready
        // Safe to access database here
        if (roleRepository.count() == 0) {
            roleRepository.save(new Role("ADMIN"));
            roleRepository.save(new Role("CUSTOMER"));
        }
    }
}

Continue to Part 2: Database Modelling and ID Strategies