Part 1: Spring Boot Project Structure
Navigation: Index | Part 1 | Part 2 | Part 3 | Part 4 | Part 5 | Part 6
Table of Contents
- Why Project Structure Matters
- Maven Directory Layout
- The pom.xml - Your Project Blueprint
- Package Organization Strategies
- The Main Application Class
- Configuration Management with application.yml
- Spring Profiles - Environment Management
- Type-Safe Configuration with @ConfigurationProperties
- Multi-Module Maven Projects
- Spring Boot Auto-Configuration Demystified
- Bean Lifecycle and Scopes
- Dependency Injection Best Practices
- Tips and Best Practices
- Anti-Patterns to Avoid
- 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
| Directory | Purpose |
|---|---|
src/main/java | All production source code |
src/main/resources | Configuration files, SQL scripts, templates |
src/test/java | Test source code (mirrors main structure) |
src/test/resources | Test-specific configuration and fixtures |
db/migration | Flyway versioned migration scripts |
.mvn/wrapper | Maven 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
Strategy 3: Hybrid (Recommended for Production)
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:
commonpackages all cross-cutting concerns that do not belong to any domaindomainkeeps entities close to their repositories (they always change together)serviceacts as the orchestration layer between domainsapiis the only layer that should know about HTTP, JSON, and REST conventionsinfrastructureisolates 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: WARNapplication-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: TRACEapplication-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: WARN7. 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: devIndustry 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: DEBUGOr use the Actuator endpoint: GET /actuator/conditions
11. Bean Lifecycle and Scopes
Bean Scopes
| Scope | Instances | Thread Safe | Use Case |
|---|---|---|---|
| singleton | One per ApplicationContext (default) | Must be | Stateless services, repositories, controllers |
| prototype | New instance per request | Yes (isolated) | Stateful, non-shared beans |
| request | One per HTTP request | Yes (isolated) | Request-specific data |
| session | One per HTTP session | Must be | User 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
- All dependencies are visible - the constructor signature documents what the class needs
- Fields can be
final- ensuring dependencies are never null and never changed after construction - No reflection - works without Spring context, making unit tests trivial
- 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: userTip 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 minutesTip 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 fetchAnti-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:classto identify slow bean initialization - Defer expensive initialization to
@PostConstructor use ApplicationReadyEvent
spring:
main:
lazy-initialization: true # Beans initialized on first use, not at startupChallenge 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