Spring Boot project structure — package-by-feature, record DTOs, Flyway migrations, multi-profile config, actuator, proper test structure
84
76%
Does it follow best practices?
Impact
97%
4.04xAverage score across 5 eval scenarios
Passed
No known issues
Production-ready project layout for Spring Boot services (Java 17+).
Organize by business domain, not by technical layer. Each feature package is self-contained with its own controller, service, repository, DTOs, and entities. Shared infrastructure lives in top-level cross-cutting packages.
src/main/java/com/example/ordersvc/
├── OrderServiceApplication.java # @SpringBootApplication
├── config/
│ ├── SecurityConfig.java # @Configuration — Spring Security
│ ├── WebConfig.java # @Configuration — CORS, interceptors
│ ├── JacksonConfig.java # @Configuration — ObjectMapper customization
│ └── AsyncConfig.java # @Configuration — thread pool settings
├── order/
│ ├── OrderController.java # @RestController for /api/orders
│ ├── OrderService.java # Business logic
│ ├── OrderRepository.java # Spring Data JPA repository
│ ├── Order.java # @Entity
│ ├── OrderItem.java # @Entity
│ ├── OrderStatus.java # Enum
│ ├── dto/
│ │ ├── CreateOrderRequest.java # record — request DTO with validation
│ │ ├── OrderResponse.java # record — response DTO
│ │ └── OrderMapper.java # MapStruct @Mapper interface
│ └── exception/
│ └── OrderNotFoundException.java # Feature-specific exception
├── product/
│ ├── ProductController.java
│ ├── ProductService.java
│ ├── ProductRepository.java
│ ├── Product.java
│ └── dto/
│ ├── ProductResponse.java
│ └── ProductMapper.java
├── shared/
│ ├── exception/
│ │ ├── GlobalExceptionHandler.java # @RestControllerAdvice
│ │ ├── AppException.java # Base exception with HTTP status
│ │ └── NotFoundException.java
│ └── dto/
│ └── ErrorResponse.java # Consistent error envelope
└── infrastructure/
└── logging/ # (logback-spring.xml lives in resources)
src/main/resources/
├── application.yml # Shared defaults
├── application-dev.yml # Dev overrides (H2, debug logging)
├── application-prod.yml # Prod overrides (connection pool, info logging)
├── application-test.yml # Test overrides (in-memory DB, reduced logging)
├── db/
│ └── migration/
│ ├── V1__create_orders_table.sql
│ ├── V2__create_products_table.sql
│ └── V3__add_order_items.sql
├── logback-spring.xml # Profile-aware logging configuration
└── static/ # Frontend assets (if embedded)
src/test/java/com/example/ordersvc/
├── order/
│ ├── OrderControllerTest.java # @WebMvcTest — slice test
│ ├── OrderServiceTest.java # Unit test with mocks
│ └── OrderRepositoryTest.java # @DataJpaTest — repository slice
├── product/
│ └── ProductControllerTest.java
└── OrderServiceApplicationTests.java # @SpringBootTest — integrationPackage-by-layer (controller/, service/, repository/ at the top level)
scatters related code across the tree. Package-by-feature keeps each domain
concept together, makes navigation intuitive, and allows package-private
visibility to enforce boundaries.
Use Java records for all request and response DTOs. Records are immutable,
concise, and generate equals/hashCode/toString automatically.
// order/dto/CreateOrderRequest.java
public record CreateOrderRequest(
@NotBlank String customerEmail,
@NotEmpty List<OrderItemRequest> items
) {
public record OrderItemRequest(
@NotNull Long productId,
@Positive int quantity
) {}
}
// order/dto/OrderResponse.java
public record OrderResponse(
Long id,
String customerEmail,
String status,
int totalCents,
List<OrderItemResponse> items,
Instant createdAt
) {
public record OrderItemResponse(
Long productId,
String productName,
int quantity,
int unitPriceCents
) {}
}Do NOT expose JPA entities directly in API responses. Entities contain internal fields (audit columns, lazy proxies, bidirectional references) that must not leak to clients.
Use MapStruct to generate type-safe mapping code at compile time. Avoid manual
from() methods that drift out of sync.
// order/dto/OrderMapper.java
@Mapper(componentModel = "spring")
public interface OrderMapper {
OrderResponse toResponse(Order order);
List<OrderResponse> toResponseList(List<Order> orders);
}Add the MapStruct dependency and annotation processor:
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
<version>1.5.5.Final</version>
</dependency>If using Lombok alongside MapStruct, configure annotation processor ordering in the maven-compiler-plugin so Lombok runs before MapStruct.
All configuration classes classes go in config/. Never scatter them across feature
packages — they configure cross-cutting infrastructure.
// config/SecurityConfig.java
configuration classes
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.csrf(csrf -> csrf.ignoringRequestMatchers("/api/**"))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/public/**").permitAll()
.requestMatchers("/actuator/health").permitAll()
.anyRequest().authenticated())
.build();
}
}
// config/WebConfig.java
configuration classes
public class WebConfig implements WebMvcConfigurer {
@Value("${app.allowed-origins}")
private List<String> allowedOrigins;
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOrigins(allowedOrigins.toArray(String[]::new))
.allowedMethods("GET", "POST", "PUT", "DELETE");
}
}Split configuration by environment. Shared defaults in application.yml,
overrides in profile-specific files.
# application.yml — shared defaults
server:
port: ${PORT:8080}
shutdown: graceful
spring:
application:
name: order-service
jpa:
open-in-view: false # Disable OSIV — avoids lazy-loading in controllers
hibernate:
ddl-auto: validate # Never auto-create in prod — use Flyway
flyway:
enabled: true
locations: classpath:db/migration
app:
allowed-origins: ${ALLOWED_ORIGINS:http://localhost:3000}
management:
endpoints:
web:
exposure:
include: health,info,metrics,prometheus
endpoint:
health:
show-details: when-authorized# application-dev.yml
spring:
datasource:
url: jdbc:h2:mem:devdb
driver-class-name: org.h2.Driver
jpa:
hibernate:
ddl-auto: create-drop # OK for local dev only
flyway:
enabled: false # Skip migrations in dev
h2:
console:
enabled: true
logging:
level:
com.example: DEBUG
org.hibernate.SQL: DEBUG# application-prod.yml
spring:
datasource:
url: ${DATABASE_URL}
hikari:
maximum-pool-size: 20
minimum-idle: 5
logging:
level:
root: WARN
com.example: INFO# application-test.yml
spring:
datasource:
url: jdbc:h2:mem:testdb
jpa:
hibernate:
ddl-auto: create-drop
flyway:
enabled: falseUse Flyway (or Liquibase) for schema management. Never rely on ddl-auto
or schema.sql in production.
-- db/migration/V1__create_orders_table.sql
CREATE TABLE orders (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
customer_email VARCHAR(255) NOT NULL,
status VARCHAR(50) NOT NULL DEFAULT 'PENDING',
total_cents INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- db/migration/V2__create_order_items_table.sql
CREATE TABLE order_items (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
order_id BIGINT NOT NULL REFERENCES orders(id),
product_id BIGINT NOT NULL,
quantity INTEGER NOT NULL,
unit_price_cents INTEGER NOT NULL
);Migration files follow the naming convention V{number}__{description}.sql.
Migrations are immutable once applied — create new migration files for changes.
Use logback-spring.xml (not logback.xml) to enable Spring profile-aware
logging configuration.
<!-- src/main/resources/logback-spring.xml -->
<configuration>
<include resource="org/springframework/boot/logging/logback/defaults.xml"/>
<springProfile name="dev">
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{HH:mm:ss.SSS} %-5level [%thread] %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<root level="DEBUG">
<appender-ref ref="CONSOLE"/>
</root>
</springProfile>
<springProfile name="prod">
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder class="net.logstash.logback.encoder.LogstashEncoder"/>
</appender>
<root level="INFO">
<appender-ref ref="CONSOLE"/>
</root>
</springProfile>
</configuration>Always include Spring Boot Actuator for health checks, metrics, and operational visibility.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>Expose at minimum: health, info, metrics. In production, secure all
endpoints except /actuator/health behind authentication.
Test packages must mirror the main source tree exactly. This ensures package-private access works in tests and keeps test files easy to locate.
src/test/java/com/example/ordersvc/
├── order/
│ ├── OrderControllerTest.java # @WebMvcTest(OrderController.class)
│ ├── OrderServiceTest.java # Unit test — mock repository
│ └── OrderRepositoryTest.java # @DataJpaTest
├── product/
│ └── ProductControllerTest.java
└── OrderServiceApplicationTests.javaUse slice tests for speed:
@WebMvcTest for controller tests (no server startup)@DataJpaTest for repository tests (in-memory DB)@SpringBootTest only for full integration testsIf using Lombok, use @Builder and @Value for immutable classes. Prefer
Java records over Lombok @Data for DTOs. Never use @Data on JPA entities
(broken equals/hashCode with lazy proxies).
// Acceptable: Lombok on entities for boilerplate only
@Entity
@Getter @Setter @NoArgsConstructor
@Table(name = "orders")
public class Order {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// ...
}
// Preferred for DTOs: Java records (no Lombok needed)
public record OrderResponse(Long id, String status, int totalCents) {}@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(NotFoundException.class)
public ResponseEntity<ErrorResponse> handleNotFound(NotFoundException ex) {
return ResponseEntity.status(404)
.body(new ErrorResponse("NOT_FOUND", ex.getMessage()));
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidation(MethodArgumentNotValidException ex) {
String message = ex.getBindingResult().getFieldErrors().stream()
.map(e -> e.getField() + ": " + e.getDefaultMessage())
.collect(Collectors.joining(", "));
return ResponseEntity.badRequest()
.body(new ErrorResponse("VALIDATION_ERROR", message));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleUnexpected(Exception ex) {
log.error("Unexpected error", ex);
return ResponseEntity.status(500)
.body(new ErrorResponse("INTERNAL_ERROR", "An unexpected error occurred"));
}
}
public record ErrorResponse(String code, String message) {}@Configuration classes in dedicated config/ package@Configuration classes in a dedicated config package