Spring Boot project layout: single slice example

shopping-cart/
β”œβ”€ build.gradle / pom.xml
β”œβ”€ README.md
β”œβ”€ .editorconfig
β”œβ”€ .gitignore
└─ src
   β”œβ”€ main
   β”‚  β”œβ”€ java/com/edge/shopping_cart
   β”‚  β”‚  β”œβ”€ web/                     # HTTP edge (controllers, request/response DTOs, exception translators)
   β”‚  β”‚  β”‚  β”œβ”€ category/
   β”‚  β”‚  β”‚  β”‚  β”œβ”€ CategoryController.java
   β”‚  β”‚  β”‚  β”‚  β”œβ”€ dto/
   β”‚  β”‚  β”‚  β”‚  β”‚  β”œβ”€ CreateCategoryRequest.java
   β”‚  β”‚  β”‚  β”‚  β”‚  └─ CategoryResponse.java
   β”‚  β”‚  β”‚  β”‚  └─ mapper/            # MapStruct mappers AT THE EDGE
   β”‚  β”‚  β”‚  β”‚     └─ CategoryWebMapper.java
   β”‚  β”‚  β”‚  β”œβ”€ error/
   β”‚  β”‚  β”‚  β”‚  β”œβ”€ GlobalExceptionHandler.java   # @RestControllerAdvice β†’ ProblemDetail
   β”‚  β”‚  β”‚  β”‚  └─ ProblemTypes.java             # optional: catalog of problem "type" URIs / codes
   β”‚  β”‚  β”‚  └─ config/
   β”‚  β”‚  β”‚     └─ WebConfig.java                # CORS, Jackson tweaks, message converters if needed
   β”‚  β”‚  β”œβ”€ app/                      # Use-cases (application layer): orchestrates domain + repos
   β”‚  β”‚  β”‚  β”œβ”€ category/
   β”‚  β”‚  β”‚  β”‚  β”œβ”€ CreateCategoryUseCase.java
   β”‚  β”‚  β”‚  β”‚  β”œβ”€ UpdateCategoryUseCase.java
   β”‚  β”‚  β”‚  β”‚  β”œβ”€ DeleteCategoryUseCase.java
   β”‚  β”‚  β”‚  β”‚  └─ CategoryService.java         # @Service, @Transactional, implements use-cases
   β”‚  β”‚  β”‚  └─ exception/
   β”‚  β”‚  β”‚     β”œβ”€ CategoryNotFoundException.java
   β”‚  β”‚  β”‚     └─ DuplicateCategoryException.java
   β”‚  β”‚  β”œβ”€ domain/                  # Business objects + repository ports (interfaces)
   β”‚  β”‚  β”‚  β”œβ”€ category/
   β”‚  β”‚  β”‚  β”‚  β”œβ”€ Category.java
   β”‚  β”‚  β”‚  β”‚  └─ CategoryRepository.java      # Spring Data interface (port)
   β”‚  β”‚  β”‚  └─ common/
   β”‚  β”‚  β”‚     └─ BaseEntity.java              # optional: id, createdAt, updatedAt
   β”‚  β”‚  └─ infra/                   # Adapters: DB config, security, integrations (SMTP, S3, etc.)
   β”‚  β”‚     β”œβ”€ persistence/
   β”‚  β”‚     β”‚  └─ JpaConfig.java               # Naming strategies, batch size, etc. (if needed)
   β”‚  β”‚     β”œβ”€ security/
   β”‚  β”‚     β”‚  └─ SecurityConfig.java          # Only if/when you add security
   β”‚  β”‚     └─ mapping/
   β”‚  β”‚        └─ MapStructConfig.java         # Global MapStruct defaults (optional)
   β”‚  └─ resources
   β”‚     β”œβ”€ application.yml
   β”‚     β”œβ”€ application-local.yml              # local overrides
   β”‚     β”œβ”€ db/migration/                      # Flyway: V1__init.sql, V2__add_unique_category.sql, ...
   β”‚     └─ logback-spring.xml                 # sane logging (JSON or pattern)
   └─ test
      β”œβ”€ java/com/edge/shopping_cart
      β”‚  β”œβ”€ web/category/
      β”‚  β”‚  └─ CategoryControllerIT.java       # @SpringBootTest + @AutoConfigureMockMvc
      β”‚  β”œβ”€ app/category/
      β”‚  β”‚  └─ CategoryServiceTest.java        # @DataJpaTest or @ExtendWith(SpringExtension)
      β”‚  └─ domain/category/
      β”‚     └─ CategoryRepositoryIT.java       # @DataJpaTest
      └─ resources/
         └─ application-test.yml               # H2 or Testcontainers config

Why this works (and where each thing lives)

  • web/ is the outermost HTTP shell:

  • Controllers, request/response DTOs, @RestControllerAdvice, and edge mappers (MapStruct).

  • Only talks in HTTP types and DTOs; never leaks JPA entities back to clients.
  • app/ holds the use cases and application rules:

  • *UseCase interfaces + CategoryService implementation.

  • Throws domain/application exceptions. Mark methods @Transactional.
  • domain/ is the boring, stable core:

  • Entities and repository interfaces (ports).

  • Keep it persistence-agnostic in spirit; Spring Data lives here fine because it’s an interface.
  • infra/ is wiring and adapters:

  • JPA config, security, external systems (email, S3, payment, Kafka).

  • When you add an external integration, create a subpackage per integration.
  • resources/db/migration/ is Flyway:

  • SQL scripts define schema truth. The entity should match the migration, not the other way around.

  • test/ mirrors main/:

  • Unit tests for app/ services, slice tests for repos (@DataJpaTest), and integration tests for controllers.

Category example (where your files go)

  • web/category/dto/CreateCategoryRequest.java
  • web/category/dto/CategoryResponse.java
  • web/category/mapper/CategoryWebMapper.java
  • web/category/CategoryController.java
  • app/category/CreateCategoryUseCase.java, UpdateCategoryUseCase.java, CategoryService.java
  • app/exception/DuplicateCategoryException.java, CategoryNotFoundException.java
  • domain/category/Category.java, CategoryRepository.java
  • web/error/GlobalExceptionHandler.java (translates exceptions β†’ ProblemDetail)

MapStruct: edge mappers live at the edge

Keep mappers with the layer that owns the types:

  • Web mappers in web/.../mapper convert Request ↔ Command and Entity ↔ Response.
  • If you ever add persistence DTOs (e.g., raw projections), add a mapper in infra/mapping/ for that boundary.

Optional global config:

// infra/mapping/MapStructConfig.java
@Configuration
@MapperConfig(
  componentModel = "spring",
  unmappedTargetPolicy = ReportingPolicy.ERROR,
  nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE
)
public class MapStructConfig {}

Then each mapper: @Mapper(config = MapStructConfig.class).

ProblemDetail handler location

  • Put GlobalExceptionHandler in web/error/.
  • It depends on HTTP concerns (ProblemDetail, HttpStatus), so it’s an edge concern.

Configuration split

  • application.yml β†’ defaults.
  • application-local.yml β†’ dev overrides (DB URL, logging).
  • application-test.yml β†’ tests (H2 or Testcontainers).
  • Use Spring profiles (local, test, prod).

Flyway starter pack

src/main/resources/db/migration/V1__init.sql

CREATE TABLE category (
  id   BIGSERIAL PRIMARY KEY,
  name TEXT NOT NULL UNIQUE
);

V2__trim_ci_unique_index.sql (optional hardening)

-- Postgres example: case/trim-insensitive index
CREATE UNIQUE INDEX uq_category_name_norm ON category (lower(btrim(name)));

Testing layout (pragmatic)

  • Controller IT: @SpringBootTest + MockMvc, assert Location header and JSON body.
  • Service tests: plain JUnit + Mockito or @DataJpaTest if you want real DB behavior.
  • Repository tests: @DataJpaTest to lock in query behavior/constraints.

Package names vs. folders

Java packages mirror folders 1:1. Keep them short and semantic:

  • com.edge.shopping_cart.web.category
  • com.edge.shopping_cart.app.category
  • com.edge.shopping_cart.domain.category
  • com.edge.shopping_cart.infra.persistence

TL;DR rules of placement

  • HTTP stuff (controllers, ProblemDetail, web mappers) β†’ web/
  • Use cases + transactions + domain exceptions β†’ app/
  • Entities + repository interfaces β†’ domain/
  • DB/security/external adapters/config β†’ infra/
  • Schema β†’ resources/db/migration/
  • Tests mirror main