Project Structure

Where code, configuration, migrations and tests live in a Spring Boot project, and why the main class must sit in the root package.

Syntaxsrc/main/java/com/example/orders/OrdersApplication.java src/main/java/com/example/orders/order/OrderController.java src/main/resources/application.properties src/test/java/com/example/orders/order/OrderControllerTest.java

A Boot project follows the standard Maven layout, which Gradle uses too. Code lives in src/main/java, files that end up on the classpath in src/main/resources, and tests mirror both under src/test. Build output goes to target/ (Maven) or build/ (Gradle), which never belongs in version control.

The root package rule

The main class, annotated with @SpringBootApplication, sits in the top package of your code, for example com.example.orders. Component scanning starts from that package and walks down. A controller in com.example.orders.order is found; one in com.example.shared is silently ignored, and the endpoint returns 404 with no error at start-up. That silence is why this mistake costs so much time.

Never use the default package (no package line). Scanning from it means scanning every jar on the classpath, and Boot warns about it for good reason.

Organise by feature

Put everything for one part of the domain in one package: order holds the controller, service, repository, entity and DTOs for orders. Classes can then be package-private, which keeps other features from reaching into them. Grouping by layer (controllers, services, repositories) forces everything to be public and spreads one change across four folders.

Resources

  • application.properties or application.yaml: configuration. Pick one format per project.
  • db/migration: Flyway scripts such as V1__create_orders.sql.
  • static/ and templates/: web assets and server-side templates. A pure JSON API usually has neither.

compose.yaml, the build file and the wrapper sit at the project root, not in resources.

The test resources trap

A file at src/test/resources/application.properties does not add to the main one; it replaces it, because both have the same classpath name and the test one comes first. Every property you set in main disappears from tests. Use application-test.properties with an active test profile, or @TestPropertySource, to override a few values instead.

Tests should mirror the package of the class they test. That lets them call package-private methods and keeps slice tests such as @WebMvcTest finding the main configuration by searching upwards from their own package.

Example

Example · text

orders/
  pom.xml
  mvnw, mvnw.cmd, .mvn/          wrapper: commit these
  compose.yaml                   local PostgreSQL for development
  src/
    main/
      java/com/example/orders/
        OrdersApplication.java   @SpringBootApplication, the root package
        order/
          OrderController.java
          OrderService.java
          OrderRepository.java
          Order.java             JPA entity
          OrderResponse.java     record DTO
        customer/
          ...
      resources/
        application.properties
        application-prod.properties
        db/migration/V1__create_orders.sql
    test/
      java/com/example/orders/
        OrdersApplicationTests.java
        order/OrderControllerTest.java
      resources/
        application-test.properties
  target/                        build output: never commit

When to use it

  • Diagnose a controller that returns 404 because it sits outside the main class's package.
  • Lay out a new service so each domain feature can be read and changed in one folder.
  • Override the database URL for tests without losing the rest of the main configuration.

More examples

One feature package, mostly package-private

Only the controller needs to be public, and even that is optional: Spring can create package-private beans. Keeping the service package-private means another feature cannot call it directly and must go through whatever public API you choose to expose, which keeps features from tangling as the code grows.

Example · java
import java.util.List;

import org.springframework.stereotype.Service;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

record OrderResponse(long id, String status) {
}

@Service
class OrderService {

    List<OrderResponse> openOrders() {
        return List.of(new OrderResponse(1, "OPEN"));
    }
}

@RestController
@RequestMapping("/orders")
public class OrderController {

    private final OrderService orders;

    OrderController(OrderService orders) {
        this.orders = orders;
    }

    @GetMapping
    List<OrderResponse> open() {
        return orders.openOrders();
    }
}

Overriding a few values for tests

Named application-test.properties, this file layers over the main configuration instead of hiding it. Activate it with @ActiveProfiles("test") on a test class. If you name it application.properties instead, the main file vanishes from the test classpath and tests run against defaults you never chose.

Example · properties
# src/test/resources/application-test.properties
# Loaded on top of the main application.properties when the test profile is active.
spring.jpa.show-sql=true
logging.level.com.example.orders=debug

A test in the mirrored package

Because the test lives in the same package as OrderService, it can construct the package-private class and call its package-private method directly. A plain unit test like this needs no Spring at all and runs in milliseconds; save the Spring test annotations for when you test wiring or HTTP behaviour.

Example · java
import static org.assertj.core.api.Assertions.assertThat;

import java.util.List;

import org.junit.jupiter.api.Test;

record OrderResponse(long id, String status) {
}

class OrderService {

    List<OrderResponse> openOrders() {
        return List.of(new OrderResponse(1, "OPEN"));
    }
}

class OrderServiceTest {

    @Test
    void returnsOnlyOpenOrders() {
        var service = new OrderService();

        assertThat(service.openOrders())
                .extracting(OrderResponse::status)
                .containsOnly("OPEN");
    }
}

Discussion

  • Be the first to comment on this lesson.