CtrlK
BlogDocsLog inGet started
Tessl Logo

tessl-labs/springboot-project-structure

Spring Boot project structure — package-by-feature, record DTOs, Flyway migrations, multi-profile config, actuator, proper test structure

84

4.04x
Quality

76%

Does it follow best practices?

Impact

97%

4.04x

Average score across 5 eval scenarios

SecuritybySnyk

Passed

No known issues

Overview
Quality
Evals
Security
Files
name:
springboot-project-structure
description:
Spring Boot project structure best practices — package-by-feature layout, configuration classes in dedicated config package, multi-profile application.yml, record DTOs, MapStruct mapping, Flyway migrations, logback-spring.xml, actuator endpoints, and test directory mirroring. Use when building any Spring Boot service.
keywords:
spring boot project structure, spring boot package layout, spring boot package by feature, spring boot configuration, spring boot dto records, mapstruct, flyway migrations, logback spring, actuator, java project structure
license:
MIT

Spring Boot Project Structure

Production-ready project layout for Spring Boot services (Java 17+).


Package-by-Feature Layout

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 — integration

Why package-by-feature, not package-by-layer

Package-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.


Record DTOs (Java 17+)

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.


MapStruct for Entity-DTO Mapping

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.


Configuration Classes in Dedicated Config Package

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");
    }
}

Multi-Profile application.yml

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: false

Flyway Database Migrations

Use 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.


Logging Configuration (logback-spring.xml)

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>

Actuator Endpoints

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 Directory Mirrors Main

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.java

Use slice tests for speed:

  • @WebMvcTest for controller tests (no server startup)
  • @DataJpaTest for repository tests (in-memory DB)
  • @SpringBootTest only for full integration tests

Lombok Usage (if adopted)

If 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) {}

Global Exception Handler

@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) {}

Checklist

  • Packages organized by feature (order/, product/) not by layer
  • @Configuration classes in dedicated config/ package
  • Record types for all request/response DTOs (Java 17+)
  • JPA entities never exposed directly in API responses
  • MapStruct (or manual mapper classes) for entity-DTO conversion
  • application.yml with separate profiles for dev/prod/test
  • spring.jpa.open-in-view set to false
  • Flyway or Liquibase for database migrations (not ddl-auto or schema.sql)
  • logback-spring.xml with profile-aware configuration
  • Actuator health/metrics endpoints enabled and secured
  • Test directory mirrors main source tree
  • Slice tests (@WebMvcTest, @DataJpaTest) preferred over full @SpringBootTest
  • Global exception handler with @RestControllerAdvice
  • Graceful shutdown enabled (server.shutdown=graceful)

Verifiers

  • springboot-package-by-feature -- Organize packages by feature domain, not by technical layer
  • springboot-config-package -- Place @Configuration classes in a dedicated config package
  • springboot-record-dtos -- Use Java record types for DTOs, never expose entities in API responses
  • springboot-profile-config -- Use multi-profile application.yml with dev/prod/test separation
  • springboot-flyway-migrations -- Use Flyway or Liquibase for database migrations
  • springboot-test-structure -- Mirror main source tree in test directory with slice tests
  • springboot-exception-handler -- Global exception handler with @RestControllerAdvice
Workspace
tessl-labs
Visibility
Public
Created
Last updated
Publish Source
CLI
Badge
tessl-labs/springboot-project-structure badge