CtrlK
BlogDocsLog inGet started
Tessl Logo

springboot-project-starter

Scaffold a production-ready Spring Boot 3.3+ application with Java 21+, Spring Data JPA, Spring Security, Flyway migrations, and comprehensive testing.

77

Quality

72%

Does it follow best practices?

Impact

Pending

No eval scenarios have been run

SecuritybySnyk

Passed

No known issues

Optimize this skill with Tessl

npx tessl skill review --optimize ./backend-java/springboot-project-starter/SKILL.md
SKILL.md
Quality
Evals
Security

Spring Boot Project Starter

Scaffold a production-ready Spring Boot 3.3+ application with Java 21+, Spring Data JPA, Spring Security, Flyway migrations, and comprehensive testing.

Prerequisites

  • Java 21+ (Eclipse Temurin or Amazon Corretto recommended)
  • Maven 3.9+ or Gradle 8.5+
  • Docker (for Testcontainers and local database)
  • IDE with Lombok annotation processing enabled (if using Lombok)

Scaffold Command

# Via Spring Initializr CLI (curl)
curl -s https://start.spring.io/starter.zip \
  -d type=maven-project \
  -d language=java \
  -d bootVersion=3.3.5 \
  -d javaVersion=21 \
  -d groupId=com.example \
  -d artifactId=myapp \
  -d name=myapp \
  -d packageName=com.example.myapp \
  -d dependencies=web,data-jpa,security,validation,flyway,postgresql,testcontainers,lombok \
  -o myapp.zip && unzip myapp.zip -d myapp

# Via Spring Boot CLI (if installed)
spring init --boot-version=3.3.5 --java-version=21 \
  --dependencies=web,data-jpa,security,validation,flyway,postgresql,testcontainers,lombok \
  --groupId=com.example --artifactId=myapp --name=myapp myapp.zip

Project Structure

myapp/
├── src/
│   ├── main/
│   │   ├── java/com/example/myapp/
│   │   │   ├── MyappApplication.java
│   │   │   ├── config/
│   │   │   │   ├── SecurityConfig.java
│   │   │   │   └── WebConfig.java
│   │   │   ├── controller/
│   │   │   │   └── UserController.java
│   │   │   ├── dto/
│   │   │   │   ├── UserRequest.java
│   │   │   │   └── UserResponse.java
│   │   │   ├── entity/
│   │   │   │   └── User.java
│   │   │   ├── exception/
│   │   │   │   ├── GlobalExceptionHandler.java
│   │   │   │   └── ResourceNotFoundException.java
│   │   │   ├── mapper/
│   │   │   │   └── UserMapper.java
│   │   │   ├── repository/
│   │   │   │   └── UserRepository.java
│   │   │   └── service/
│   │   │       └── UserService.java
│   │   └── resources/
│   │       ├── application.yml
│   │       ├── application-dev.yml
│   │       ├── application-prod.yml
│   │       └── db/migration/
│   │           └── V1__create_users_table.sql
│   └── test/
│       └── java/com/example/myapp/
│           ├── MyappApplicationTests.java
│           ├── controller/
│           │   └── UserControllerTest.java
│           └── service/
│               └── UserServiceTest.java
├── pom.xml
└── docker-compose.yml

Key Conventions

  • Use Java records for DTOs (UserRequest, UserResponse).
  • Entity classes are mutable POJOs annotated with @Entity. Never expose entities directly in controllers.
  • MapStruct interfaces handle entity-to-DTO conversion. Annotate with @Mapper(componentModel = "spring").
  • Use constructor injection everywhere. No @Autowired on fields.
  • Validate request bodies with Bean Validation annotations (@Valid, @NotBlank, @Size, etc.).
  • Flyway migration files follow strict naming: V{version}__{description}.sql.
  • Profiles: dev for local development (H2 or Docker Postgres), prod for production.
  • Controllers return ResponseEntity<T> for explicit HTTP status control.
  • Service layer handles business logic; repositories are thin Spring Data interfaces.
  • Test slices: @WebMvcTest for controllers, @DataJpaTest for repositories, @SpringBootTest for integration.

Essential Patterns

JPA Entity

package com.example.myapp.entity;

import jakarta.persistence.*;
import java.time.Instant;

@Entity
@Table(name = "users")
public class User {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, unique = true)
    private String email;

    @Column(nullable = false)
    private String name;

    @Column(nullable = false, updatable = false)
    private Instant createdAt;

    private Instant updatedAt;

    @PrePersist
    void onCreate() {
        this.createdAt = Instant.now();
    }

    @PreUpdate
    void onUpdate() {
        this.updatedAt = Instant.now();
    }

    // Constructors
    protected User() {}

    public User(String email, String name) {
        this.email = email;
        this.name = name;
    }

    // Getters and setters
    public Long getId() { return id; }
    public String getEmail() { return email; }
    public void setEmail(String email) { this.email = email; }
    public String getName() { return name; }
    public void setName(String name) { this.name = name; }
    public Instant getCreatedAt() { return createdAt; }
    public Instant getUpdatedAt() { return updatedAt; }
}

DTOs (Java Records)

package com.example.myapp.dto;

import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;

public record UserRequest(
    @NotBlank(message = "Name is required")
    @Size(min = 2, max = 100)
    String name,

    @NotBlank(message = "Email is required")
    @Email(message = "Email must be valid")
    String email
) {}
package com.example.myapp.dto;

import java.time.Instant;

public record UserResponse(
    Long id,
    String name,
    String email,
    Instant createdAt
) {}

MapStruct Mapper

package com.example.myapp.mapper;

import com.example.myapp.dto.UserRequest;
import com.example.myapp.dto.UserResponse;
import com.example.myapp.entity.User;
import org.mapstruct.Mapper;
import org.mapstruct.MappingTarget;

@Mapper(componentModel = "spring")
public interface UserMapper {
    User toEntity(UserRequest request);
    UserResponse toResponse(User user);
    void updateEntity(UserRequest request, @MappingTarget User user);
}

Spring Data Repository

package com.example.myapp.repository;

import com.example.myapp.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;

public interface UserRepository extends JpaRepository<User, Long> {
    Optional<User> findByEmail(String email);
    boolean existsByEmail(String email);
}

Service Layer

package com.example.myapp.service;

import com.example.myapp.dto.UserRequest;
import com.example.myapp.dto.UserResponse;
import com.example.myapp.entity.User;
import com.example.myapp.exception.ResourceNotFoundException;
import com.example.myapp.mapper.UserMapper;
import com.example.myapp.repository.UserRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;

@Service
public class UserService {

    private final UserRepository userRepository;
    private final UserMapper userMapper;

    public UserService(UserRepository userRepository, UserMapper userMapper) {
        this.userRepository = userRepository;
        this.userMapper = userMapper;
    }

    @Transactional(readOnly = true)
    public List<UserResponse> findAll() {
        return userRepository.findAll()
            .stream()
            .map(userMapper::toResponse)
            .toList();
    }

    @Transactional(readOnly = true)
    public UserResponse findById(Long id) {
        return userRepository.findById(id)
            .map(userMapper::toResponse)
            .orElseThrow(() -> new ResourceNotFoundException("User", id));
    }

    @Transactional
    public UserResponse create(UserRequest request) {
        User user = userMapper.toEntity(request);
        return userMapper.toResponse(userRepository.save(user));
    }

    @Transactional
    public UserResponse update(Long id, UserRequest request) {
        User user = userRepository.findById(id)
            .orElseThrow(() -> new ResourceNotFoundException("User", id));
        userMapper.updateEntity(request, user);
        return userMapper.toResponse(userRepository.save(user));
    }

    @Transactional
    public void delete(Long id) {
        if (!userRepository.existsById(id)) {
            throw new ResourceNotFoundException("User", id);
        }
        userRepository.deleteById(id);
    }
}

REST Controller

package com.example.myapp.controller;

import com.example.myapp.dto.UserRequest;
import com.example.myapp.dto.UserResponse;
import com.example.myapp.service.UserService;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;

@RestController
@RequestMapping("/api/v1/users")
public class UserController {

    private final UserService userService;

    public UserController(UserService userService) {
        this.userService = userService;
    }

    @GetMapping
    public ResponseEntity<List<UserResponse>> findAll() {
        return ResponseEntity.ok(userService.findAll());
    }

    @GetMapping("/{id}")
    public ResponseEntity<UserResponse> findById(@PathVariable Long id) {
        return ResponseEntity.ok(userService.findById(id));
    }

    @PostMapping
    public ResponseEntity<UserResponse> create(@Valid @RequestBody UserRequest request) {
        return ResponseEntity.status(HttpStatus.CREATED).body(userService.create(request));
    }

    @PutMapping("/{id}")
    public ResponseEntity<UserResponse> update(
            @PathVariable Long id,
            @Valid @RequestBody UserRequest request) {
        return ResponseEntity.ok(userService.update(id, request));
    }

    @DeleteMapping("/{id}")
    public ResponseEntity<Void> delete(@PathVariable Long id) {
        userService.delete(id);
        return ResponseEntity.noContent().build();
    }
}

Spring Security Config

package com.example.myapp.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        return http
            .csrf(csrf -> csrf.disable())
            .sessionManagement(session ->
                session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/v1/auth/**").permitAll()
                .requestMatchers("/actuator/health").permitAll()
                .anyRequest().authenticated()
            )
            .build();
    }
}

Global Exception Handler

package com.example.myapp.exception;

import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.util.Map;
import java.util.stream.Collectors;

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(ResourceNotFoundException.class)
    public ProblemDetail handleNotFound(ResourceNotFoundException ex) {
        return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ProblemDetail handleValidation(MethodArgumentNotValidException ex) {
        Map<String, String> errors = ex.getBindingResult().getFieldErrors().stream()
            .collect(Collectors.toMap(
                fe -> fe.getField(),
                fe -> fe.getDefaultMessage() != null ? fe.getDefaultMessage() : "Invalid",
                (a, b) -> a
            ));
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(
            HttpStatus.BAD_REQUEST, "Validation failed");
        problem.setProperty("errors", errors);
        return problem;
    }
}

Flyway Migration

-- V1__create_users_table.sql
CREATE TABLE users (
    id         BIGSERIAL    PRIMARY KEY,
    email      VARCHAR(255) NOT NULL UNIQUE,
    name       VARCHAR(100) NOT NULL,
    created_at TIMESTAMPTZ  NOT NULL DEFAULT now(),
    updated_at TIMESTAMPTZ
);

application.yml (default)

spring:
  application:
    name: myapp
  profiles:
    active: dev
  jpa:
    open-in-view: false
    hibernate:
      ddl-auto: validate
    properties:
      hibernate:
        format_sql: true
  flyway:
    enabled: true
    locations: classpath:db/migration

server:
  port: 8080
  shutdown: graceful

management:
  endpoints:
    web:
      exposure:
        include: health,info,metrics

application-dev.yml

spring:
  datasource:
    url: jdbc:postgresql://localhost:5432/myapp_dev
    username: postgres
    password: postgres
  jpa:
    properties:
      hibernate:
        format_sql: true

logging:
  level:
    com.example.myapp: DEBUG
    org.springframework.security: DEBUG
    org.hibernate.SQL: DEBUG
    org.hibernate.orm.jdbc.bind: TRACE

application-prod.yml

spring:
  datasource:
    url: ${DATABASE_URL}
    username: ${DATABASE_USERNAME}
    password: ${DATABASE_PASSWORD}

logging:
  level:
    com.example.myapp: INFO
    org.hibernate.SQL: WARN

JUnit 5 + Mockito Service Test

package com.example.myapp.service;

import com.example.myapp.dto.UserRequest;
import com.example.myapp.dto.UserResponse;
import com.example.myapp.entity.User;
import com.example.myapp.exception.ResourceNotFoundException;
import com.example.myapp.mapper.UserMapper;
import com.example.myapp.repository.UserRepository;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.time.Instant;
import java.util.Optional;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;

@ExtendWith(MockitoExtension.class)
class UserServiceTest {

    @Mock UserRepository userRepository;
    @Mock UserMapper userMapper;
    @InjectMocks UserService userService;

    @Test
    void findById_existingUser_returnsResponse() {
        User user = new User("alice@example.com", "Alice");
        UserResponse response = new UserResponse(1L, "Alice", "alice@example.com", Instant.now());

        when(userRepository.findById(1L)).thenReturn(Optional.of(user));
        when(userMapper.toResponse(user)).thenReturn(response);

        UserResponse result = userService.findById(1L);

        assertThat(result.name()).isEqualTo("Alice");
        verify(userRepository).findById(1L);
    }

    @Test
    void findById_missingUser_throwsNotFound() {
        when(userRepository.findById(99L)).thenReturn(Optional.empty());

        assertThatThrownBy(() -> userService.findById(99L))
            .isInstanceOf(ResourceNotFoundException.class);
    }

    @Test
    void create_validRequest_savesAndReturns() {
        UserRequest request = new UserRequest("Bob", "bob@example.com");
        User entity = new User("bob@example.com", "Bob");
        UserResponse response = new UserResponse(2L, "Bob", "bob@example.com", Instant.now());

        when(userMapper.toEntity(request)).thenReturn(entity);
        when(userRepository.save(entity)).thenReturn(entity);
        when(userMapper.toResponse(entity)).thenReturn(response);

        UserResponse result = userService.create(request);

        assertThat(result.email()).isEqualTo("bob@example.com");
        verify(userRepository).save(any(User.class));
    }
}

Controller Slice Test

package com.example.myapp.controller;

import com.example.myapp.dto.UserResponse;
import com.example.myapp.service.UserService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.bean.MockBean;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.web.servlet.MockMvc;
import java.time.Instant;
import java.util.List;

import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

@WebMvcTest(UserController.class)
class UserControllerTest {

    @Autowired MockMvc mockMvc;
    @MockBean UserService userService;

    @Test
    @WithMockUser
    void findAll_returnsOkWithUsers() throws Exception {
        when(userService.findAll()).thenReturn(List.of(
            new UserResponse(1L, "Alice", "alice@example.com", Instant.now())
        ));

        mockMvc.perform(get("/api/v1/users"))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$[0].name").value("Alice"));
    }
}

First Steps After Scaffold

  1. Ensure PostgreSQL is running and create the database: createdb myapp_dev
  2. Update src/main/resources/application-dev.yml with your database credentials
  3. Run ./mvnw compile to generate MapStruct sources and verify the project compiles
  4. Start the application: ./mvnw spring-boot:run -Dspring-boot.run.profiles=dev (Flyway runs migrations automatically on startup)
  5. Verify the health endpoint: curl http://localhost:8080/actuator/health

Common Commands

# Run the application (dev profile)
./mvnw spring-boot:run -Dspring-boot.run.profiles=dev

# Run all tests
./mvnw test

# Run a specific test class
./mvnw test -Dtest=UserServiceTest

# Package as JAR
./mvnw clean package -DskipTests

# Run the packaged JAR
java -jar target/myapp-0.0.1-SNAPSHOT.jar --spring.profiles.active=prod

# Build Docker image (with Spring Boot's built-in Buildpack support)
./mvnw spring-boot:build-image -Dspring-boot.build-image.imageName=myapp:latest

# Check for dependency updates
./mvnw versions:display-dependency-updates

# Generate MapStruct sources (happens automatically during compile)
./mvnw compile

Integration Notes

  • Database: Pair with a Docker Compose file for local PostgreSQL. Testcontainers handles DB lifecycle in integration tests automatically.
  • Frontend: CORS is not configured by default. Add a WebMvcConfigurer bean or @CrossOrigin on controllers when pairing with a frontend skill (React, Angular, etc.).
  • API Documentation: Add springdoc-openapi-starter-webmvc-ui dependency for auto-generated Swagger UI at /swagger-ui.html.
  • Observability: Add micrometer-registry-prometheus for Prometheus metrics and spring-boot-starter-actuator (already included) for health checks.
  • Containerization: The built-in spring-boot:build-image goal produces an OCI image without a Dockerfile. For custom Dockerfiles, use a multi-stage build with Eclipse Temurin as the base.
  • CI/CD: The ./mvnw wrapper ensures reproducible builds without requiring Maven on the CI server.
Repository
achreftlili/deep-dev-skills
Last updated
Created

Is this your skill?

If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.