Cheatsheet Spring Boot
Framework Java para aplicações enterprise e APIs REST
Spring Boot
Setup e CLI
Create a Project
# Spring Initializr (start.spring.io) or CLI: spring init --build=maven \ --dependencies=web,jpa,mysql \ --java-version=21 my-app cd my-app ./mvnw spring-boot:run
spring init generates the project structure. --dependencies sets the initial starters. mvnw is the bundled Maven wrapper. Access it at localhost:8080.
Maven Commands
./mvnw spring-boot:run # run ./mvnw clean package # build JAR ./mvnw test # run tests java -jar target/app.jar # run JAR # Gradle alternative: ./gradlew bootRun ./gradlew bootJar
spring-boot:run starts in dev mode. clean package builds the executable JAR in target/. The JAR includes the embedded server — just java -jar.
Project Structure
src/main/java/com/app/ Application.java # main controller/ model/ repository/ service/ dto/ src/main/resources/ application.properties static/ templates/
Application.java is the entry point. Organization follows layers: controller, service, repository and model. Resources live in resources/.
Gradle Dependencies
plugins {
id 'java'
id 'org.springframework.boot' version '3.3.0'
id 'io.spring.dependency-management' version '1.1.5'
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
runtimeOnly 'com.mysql:mysql-connector-j'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}build.gradle is the Maven alternative. The org.springframework.boot plugin manages versions. runtimeOnly is for drivers only needed at runtime. testImplementation for test dependencies.
Main Class
@SpringBootApplication
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}@SpringBootApplication combines @Configuration, @EnableAutoConfiguration and @ComponentScan. The main method starts the embedded Tomcat server.
DevTools
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<scope>runtime</scope>
<optional>true</optional>
</dependency>
# application.properties:
spring.devtools.restart.enabled=true
spring.devtools.livereload.enabled=truedevtools enables automatic restart on code changes. Includes LiveReload for the browser. Marked optional so it is not included in the final JAR. Only useful in development.
Maven Dependencies
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
</dependency>
</dependencies>Each starter groups related dependencies. starter-web includes Tomcat and Jackson. starter-data-jpa includes Hibernate. mysql-connector-j is the JDBC driver.
Java Requirements
# Spring Boot 3.x requires: # - Java 17+ (21 recommended) # - Jakarta EE (not javax) # Check version: java -version # Maven wrapper update: ./mvnw wrapper:wrapper -Dmaven=3.9.6
Spring Boot 3 migrated from javax.* to jakarta.*. Requires Java 17 minimum. Use Java 21 for virtual threads. The maven-wrapper avoids a global Maven install.
Controllers REST
REST Controller
@RestController
@RequestMapping("/api/posts")
public class PostController {
private final PostService service;
public PostController(PostService service) {
this.service = service;
}
@GetMapping
public List<Post> list() {
return service.findAll();
}
}@RestController combines @Controller + @ResponseBody. @RequestMapping sets the base route. Injection is via constructor. Returns JSON automatically through Jackson.
ResponseEntity
@PostMapping
public ResponseEntity<Post> create(@RequestBody PostDTO dto) {
Post post = service.create(dto);
return ResponseEntity
.status(HttpStatus.CREATED)
.header("X-Post-Id", post.getId().toString())
.body(post);
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> remove(@PathVariable Long id) {
service.delete(id);
return ResponseEntity.noContent().build();
}ResponseEntity lets you control status, headers and body. HttpStatus.CREATED returns 201. noContent() returns 204 with no body. Use it when you need custom headers.
HTTP Methods
@GetMapping // GET /api/posts
@GetMapping("/{id}") // GET /api/posts/1
@PostMapping // POST /api/posts
@PutMapping("/{id}") // PUT /api/posts/1
@PatchMapping("/{id}") // PATCH /api/posts/1
@DeleteMapping("/{id}") // DELETE /api/posts/1Each annotation maps an HTTP verb. @GetMapping for reading, @PostMapping for creation. @PutMapping replaces everything, @PatchMapping updates partially. @DeleteMapping removes.
Global Exception Handler
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(NotFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
public ErrorResponse handleNotFound(NotFoundException ex) {
return new ErrorResponse(ex.getMessage());
}
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ErrorResponse handlisValidtion(MethodArgumentNotValidException ex) {
String msg = ex.getBindingResult().getFieldErrors()
.stream().map(e -> e.getDefaultMessage())
.collect(Collectors.joining(", "));
return new ErrorResponse(msg);
}
}@RestControllerAdvice intercepts exceptions from every controller. @ExceptionHandler defines which exception to handle. @ResponseStatus sets the HTTP code. Centralizes error handling.
Path and Body
@GetMapping("/{id}")
public Post show(@PathVariable Long id) {
return service.findById(id);
}
@PostMapping
public Post create(@RequestBody @Valid PostDTO dto) {
return service.create(dto);
}@PathVariable extracts values from the URL. @RequestBody deserializes the body JSON. @Valid triggers Bean Validation before entering the method.
Headers and Cookies
@GetMapping("/download")
public ResponseEntity<byte[]> download() {
byte[] data = service.generatePdf();
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_TYPE, "application/pdf")
.header(HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=report.pdf")
.body(data);
}
@GetMapping("/profile")
public String profile(@CookieValue("session") String token) {
return service.getProfile(token);
}HttpHeaders sets response headers. CONTENT_DISPOSITION forces download. @CookieValue reads request cookies. Use ResponseCookie to create cookies with options.
Request Params
@GetMapping
public List<Post> search(
@RequestParam String term,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
@RequestParam(required = false) String category
) {
return service.search(term, page, size, category);
}@RequestParam reads query strings (?term=x&page=0). defaultValue avoids errors when absent. required = false makes it optional. Ideal for filters and pagination.
Input Validation
public record PostDTO(
@NotBlank(message = "Title is required")
@Size(min = 5, max = 200)
String title,
@NotNull
@Positive
Integer categoryId
) {}
// In the controller:
@PostMapping
public Post create(@RequestBody @Valid PostDTO dto) { ... }@NotBlank rejects empty strings. @Size limits length. @Positive requires a value > 0. @Valid in the controller triggers validation. Errors produce 400 Bad Request.
Entities e JPA
Define an Entity
@Entity
@Table(name = "posts")
public class Post {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 200)
private String title;
@Column(columnDefinition = "TEXT")
private String content;
private LocalDateTime createdAt;
@PrePersist
void prePersist() {
this.createdAt = LocalDateTime.now();
}
}@Entity marks the class the a JPA table. @Id + @GeneratedValue defines the auto-increment key. @Column customizes the column. @PrePersist runs before insert.
ManyToMany Relationship
@Entity
public class Post {
@ManyToMany
@JoinTable(
name = "post_tags",
joinColumns = @JoinColumn(name = "post_id"),
inverseJoinColumns = @JoinColumn(name = "tag_id")
)
private Set<Tag> tags = new HashSet<>();
}
@Entity
public class Tag {
@ManyToMany(mappedBy = "tags")
private Set<Post> posts = new HashSet<>();
}@ManyToMany creates the join table automatically. @JoinTable defines its name and FKs. Use Set to avoid duplicates. Only one side is the owner — the other uses mappedBy.
Column Annotations
@Column(length = 200, nullable = false) private String title; @Column(unique = true) private String slug; @Lob private String largeText; @Enumerated(EnumType.STRING) private Status status; @Temporal(TemporalType.TIMESTAMP) private Date updatedAt;
@Column defines column constraints. @Lob for large texts (CLOB/BLOB). @Enumerated(STRING) stores the enum name, not the ordinal. @Temporal for legacy date types.
DTOs with Records
// Request DTO
public record CreatePostDTO(
@NotBlank String title,
@NotBlank String content,
Long authorId
) {}
// Response DTO
public record PostResponse(
Long id,
String title,
String authorName,
LocalDateTime createdAt
) {
public static PostResponse from(Post p) {
return new PostResponse(
p.getId(), p.getTitle(),
p.getAuthor().getName(), p.getCreatedAt());
}
}record (Java 16+) creates immutable DTOs without boilerplate. Separate request from response. The static from() method converts Entity to DTO. Never expose Entities directly in the API.
ManyToOne Relationship
@Entity
public class Comment {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String text;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "post_id", nullable = false)
private Post post;
}@ManyToOne means many comments to one post. FetchType.LAZY loads only when accessed (avoids N+1). @JoinColumn defines the FK in the table. Always prefer LAZY.
Bean Validation
public class UserDTO {
@NotBlank(message = "Name is required")
@Size(min = 2, max = 100)
private String name;
@Email(message = "Invalid email")
@NotBlank
private String email;
@Min(value = 18, message = "Minimum age: 18")
private int age;
@Pattern(regexp = "^[A-Z]{2}$")
private String country;
}@NotBlank rejects null and empty. @Email validates the format. @Min/@Max for numeric limits. @Pattern uses regex. The message is returned in the 400 error.
OneToMany Relationship
@Entity
public class Post {
@OneToMany(mappedBy = "post",
cascade = CascadeType.ALL,
orphanRemoval = true)
private List<Comment> comments = new ArrayList<>();
public void addComment(Comment c) {
comments.add(c);
c.setPost(this);
}
public void removeComment(Comment c) {
comments.remove(c);
c.setPost(null);
}
}@OneToMany is the inverse side of the relationship. mappedBy points to the owning field. cascade = ALL propagates operations. orphanRemoval deletes children removed from the list. Keep helper methods to sync both sides.
JPA Auditing
@Entity
@EntityListeners(AuditingEntityListener.class)
public class Post {
@CreatedDate
private LocalDateTime createdAt;
@LastModifiedDate
private LocalDateTime updatedAt;
@CreatedBy
private String createdBy;
}
// Enable:
@Configuration
@EnableJpaAuditing
public class AuditConfig {}@EnableJpaAuditing enables global auditing. @CreatedDate is filled on creation. @LastModifiedDate updates on every save. @CreatedBy requires an AuditorAware bean.
Repositories
Base Interface
@Repository
public interface PostRepository
extends JpaRepository<Post, Long> {
// Inherited methods:
// save(), findById(), findAll(),
// delete(), count(), existsById()
}JpaRepository provides full CRUD without code. The generic is <Entity, IdType>. @Repository is optional (Spring detects it automatically). Includes pagination and sorting.
Pagination and Sorting
// In the controller:
@GetMapping
public Page<Post> list(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
@RequestParam(defaultValue = "createdAt") String sort
) {
return repo.findAll(PageRequest.of(page, size,
Sort.by(sort).descending()));
}
// Response includes:
// content[], totalElements, totalPages, numberPageRequest.of() sets page and size. Sort.by() sorts by field. Page includes metadata: total, pages, current. Pagination starts at 0. Serializes straight to JSON.
Query Methods
public interface PostRepository
extends JpaRepository<Post, Long> {
List<Post> findByActiveTrue();
List<Post> findByTitleContaining(String term);
List<Post> findByAuthorNameAndActive(String name, boolean active);
List<Post> findByViewsGreaterThanOrderByCreatedAtDesc(int min);
long countByActiveTrue();
boolean existsBySlug(String slug);
Optional<Post> findBySlug(String slug);
}Spring generates the query from the method name. findBy + field + condition. Containing does a LIKE. OrderBy sorts. countBy and existsBy for aggregations. No manual SQL.
Specifications (Dynamic Filters)
public interface PostRepository extends
JpaRepository<Post, Long>,
JpaSpecificationExecutor<Post> {}
// Build a dynamic filter:
Specification<Post> spec = Specification.where(null);
if (title != null) {
spec = spec.and((root, q, cb) ->
cb.like(root.get("title"), "%" + title + "%"));
}
if (active != null) {
spec = spec.and((root, q, cb) ->
cb.equal(root.get("active"), active));
}
List<Post> results = repo.findAll(spec);JpaSpecificationExecutor enables dynamic filters. Specification builds predicates programmatically. Ideal for searches with multiple optional filters. Combine with and()/or().
@Query JPQL
@Query("SELECT p FROM Post p WHERE p.views > :min " +
"AND p.active = true ORDER BY p.createdAt DESC")
List<Post> findPopular(@Param("min") int min);
@Query("SELECT new com.app.dto.PostSummaryDTO(p.id, p.title) " +
"FROM Post p WHERE p.author.id = :authorId")
List<PostSummaryDTO> summariesByAuthor(@Param("authorId") Long id);@Query allows custom JPQL. :param are named parameters with @Param. JPQL uses class names, not tables. SELECT new projects directly into DTOs.
Projections
// Interface-based projection:
public interface PostSummary {
Long getId();
String getTitle();
String getAuthorName();
}
List<PostSummary> findByActiveTrue();
// Closed projection (JPQL):
@Query("SELECT p.id AS id, p.title AS title " +
"FROM Post p")
List<PostSummary> listSummaries();Projections return only the needed fields. The interface defines getters with aliases. Reduces traffic and memory vs. the full Entity. Use for listings and reports. Spring implements the interface automatically.
Native Query
@Query(value = "SELECT * FROM posts " +
"WHERE MATCH(title, content) AGAINST(:term)",
nativeQuery = true)
List<Post> fullTextSearch(@Param("term") String term);
@Modifying
@Query(value = "UPDATE posts SET views = views + 1 WHERE id = :id",
nativeQuery = true)
void incrementViews(@Param("id") Long id);nativeQuery = true uses direct DB SQL. Useful for DB-specific features (full-text, JSON). @Modifying is required for UPDATE/DELETE. Combine with @Transactional.
Services and DI
Service Layer
@Service
public class PostService {
private final PostRepository repo;
private final AuthorRepository authorRepo;
public PostService(PostRepository repo,
AuthorRepository authorRepo) {
this.repo = repo;
this.authorRepo = authorRepo;
}
public List<Post> findAll() {
return repo.findAll();
}
}@Service marks the business layer. Constructor injection is the recommended pattern. Spring creates the singleton automatically. Services orchestrate repositories and business rules.
@Component and @Bean
@Component // auto-detected by ComponentScan
public class SlugGenerator {
public String generate(String title) {
return title.toLowerCase().replaceAll("\s+", "-");
}
}
@Configuration
public class AppConfig {
@Bean
public RestTemplate restTemplate() {
return new RestTemplate();
}
@Bean
@Scope("prototype")
public Notification notification() {
return new Notification();
}
}@Component registers classes in the context. @Bean in @Configuration registers third-party objects. @Scope("prototype") creates a new instance on every injection. Beans are singletons by default.
CRUD in the Service
public Post create(CreatePostDTO dto) {
Author author = authorRepo.findById(dto.authorId())
.orElseThrow(() -> new NotFoundException("Author not found"));
Post post = new Post();
post.setTitle(dto.title());
post.setContent(dto.content());
post.setAuthor(author);
return repo.save(post);
}
public void delete(Long id) {
if (!repo.existsById(id)) {
throw new NotFoundException("Post not found");
}
repo.deleteById(id);
}orElseThrow throws an exception if not found. Validate business rules before persisting. save() does INSERT or UPDATE depending on the ID. Separate validation from persistence.
Events and Listeners
// Define the event:
public record PostCreatedEvent(Post post) {}
// Publish:
@Service
public class PostService {
private final ApplicationEventPublisher publisher;
public void create(PostDTO dto) {
Post post = repo.save(new Post(dto));
publisher.publishEvent(new PostCreatedEvent(post));
}
}
// Listen:
@Component
public class NotifySubscribers {
@EventListener
public void onPostCreated(PostCreatedEvent event) {
// send emails, notifications...
}
}ApplicationEventPublisher publishes events. @EventListener reacts in a decoupled way. Ideal for side effects (emails, logs). Use @Async to process in the background.
@Transactional
@Transactional
public Post createWithComments(CreatePostDTO dto) {
Post post = repo.save(new Post(dto));
for (String text : dto.comments()) {
commentRepo.save(new Comment(post, text));
}
return post;
// If it fails, everything is reverted (rollback)
}
@Transactional(readOnly = true)
public List<Post> list() {
return repo.findAll();
}@Transactional guarantees atomicity — all or nothing. If an exception occurs, it rolls back. readOnly = true optimizes reads. Put it in the service, not the controller.
Scheduled Tasks
@Configuration
@EnableScheduling
public class ScheduleConfig {}
@Component
public class ScheduledTasks {
@Scheduled(cron = "0 0 2 * * ?")
public void cleanExpiredTokens() {
tokenRepo.deleteByExpiresAtBefore(LocalDateTime.now());
}
@Scheduled(fixedRate = 60000) // every 60s
public void syncCache() {
cacheService.refresh();
}
}@EnableScheduling enables the scheduler. cron defines the expression (2 AM). fixedRate runs every X ms. Useful for cleanups, syncs and reports. Do not use across multiple instances without a lock.
Dependency Injection
// Recommended: constructor (implicit with a single constructor)
@Service
public class EmailService {
private final MailSender sender;
public EmailService(MailSender sender) {
this.sender = sender;
}
}
// Alternative: @Autowired on the field (avoid)
@Autowired
private MailSender sender;
// Qualifier for multiple implementations:
@Autowired
@Qualifier("smtp")
private MailSender sender;Constructor injection is testable and immutable. @Autowired on fields makes testing harder. @Qualifier picks among multiple beans of the same type. With one constructor, @Autowired is optional.
Settings
application.properties
server.port=8080 spring.application.name=my-app spring.datasource.url=jdbc:mysql://localhost:3306/my_db spring.datasource.username=root spring.datasource.password=secret spring.jpa.hibernate.ddl-auto=validate spring.jpa.show-sql=true spring.jpa.properties.hibernate.format_sql=true
application.properties centralizes configuration. ddl-auto=validate checks the schema without changing it. show-sql logs queries in dev. In production use validate or none, never update.
CORS
@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOrigins("http://localhost:3000")
.allowedMethods("GET", "POST", "PUT", "DELETE")
.allowedHeaders("*")
.allowCredentials(true)
.maxAge(3600);
}
}CORS allows requests from other domains. allowedOrigins restricts origins (never use * with credentials). maxAge caches the preflight. Essential for APIs consumed by separate frontends.
application.yml
spring:
datasource:
url: jdbc:mysql://localhost:3306/my_db
username: root
password: ${DB_PASSWORD}
jpa:
hibernate:
ddl-auto: validate
show-sql: false
server:
port: 8080
servlet:
context-path: /apiYAML format alternative to properties. ${DB_PASSWORD} reads from environment variables. context-path prefixes every route. Indentation defines hierarchy. Pick one format and stay consistent.
Environment Variables
# application.properties with placeholders:
spring.datasource.password=${DB_PASSWORD}
app.jwt.secret=${JWT_SECRET}
app.mail.api-key=${MAIL_API_KEY:default-key}
# .env (with spring-dotenv):
DB_PASSWORD=super_secret
JWT_SECRET=jwt-key-here
# Docker:
# docker run -e DB_PASSWORD=secret app${VAR} resolves from env, system properties or args. :default defines a fallback. Never commit secrets in properties. Use spring-dotenv for .env files in dev. In prod, use env vars or Vault.
Profiles
# application-dev.properties spring.datasource.url=jdbc:h2:mem:dev spring.jpa.show-sql=true # application-prod.properties spring.datasource.url=jdbc:mysql://prod-server/db spring.jpa.show-sql=false # Activate: spring.profiles.active=dev # Or via CLI: java -jar app.jar --spring.profiles.active=prod
Profiles separate configuration per environment. Files follow application-{profile}.properties. spring.profiles.active selects the active one. Ideal for dev, staging and prod. You can combine multiple profiles.
Conditional Beans
@Configuration
public class CacheConfig {
@Bean
@ConditionalOnProperty(name = "app.cache.enabled",
havingValue = "true")
public CacheManager cacheManager() {
return new ConcurrentMapCacheManager("posts");
}
@Bean
@ConditionalOnMissingBean
public Clock clock() {
return Clock.systemDefaultZone();
}
}@ConditionalOnProperty creates the bean only if the property exists. @ConditionalOnMissingBean avoids duplicates. @Profile("dev") restricts per environment. Useful for modular, testable configuration.
@Value and @ConfigurationProperties
// Individual value:
@Value("${app.upload.max-size:10MB}")
private String maxUpload;
// Typed group:
@ConfigurationProperties(prefix = "app.mail")
public record MailProperties(
String host,
int port,
String from
) {}
// Enable:
@Configuration
@EnableConfigurationProperties(MailProperties.class)
public class AppConfig {}@Value injects one property with a default (:10MB). @ConfigurationProperties maps a prefix onto a class. Safer and more testable than multiple @Value. Validate with @Validated.
Advanced and Deploy
Actuator (Health and Metrics)
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
# application.properties:
management.endpoints.web.exposure.include=health,metrics,info
management.endpoint.health.show-details=always
# Endpoints:
# GET /actuator/health
# GET /actuator/metrics/jvm.memory.used
# GET /actuator/infoActuator exposes monitoring endpoints. /health for load balancers and Kubernetes. /metrics integrates with Prometheus/Grafana. show-details shows DB, disk, etc. Protect it with Security.
Docker and Deploy
# Build: ./mvnw clean package -DskipTests # Dockerfile: FROM eclipse-temurin:21-jre-alpine WORKDIR /app COPY target/*.jar app.jar EXPOSE 8080 ENTRYPOINT ["java", "-jar", "app.jar"] # Build and run: docker build -t my-app . docker run -p 8080:8080 \ -e DB_PASSWORD=secret my-app
eclipse-temurin:21-jre-alpine is the lightweight base image. The JAR is self-sufficient with embedded Tomcat. -e passes environment variables. Use a multi-stage build to optimize. Publish to Docker Hub or ECR.
Swagger / OpenAPI
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.5.0</version>
</dependency>
# Access at:
# http://localhost:8080/swagger-ui.html
# http://localhost:8080/v3/api-docs
// Customize:
@Bean
public OpenAPI customOpenAPI() {
return new OpenAPI()
.info(new Info().title("Posts API").version("1.0"));
}springdoc-openapi generates automatic documentation. swagger-ui.html is the interactive interface. It detects endpoints, DTOs and validations. Annotate with @Operation and @Schema for extra details.
Flyway (Migrations)
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-mysql</artifactId>
</dependency>
# src/main/resources/db/migration/
# V1__create_posts.sql
# V2__add_slug_column.sql
# V3__create_tags_table.sql
spring.flyway.enabled=true
spring.jpa.hibernate.ddl-auto=validateFlyway manages versioned DB migrations. Files follow V{n}__description.sql. They run in order — never edit applied ones. ddl-auto=validate confirms the schema matches. Essential for teams and CI/CD.
Cache with @Cacheable
@Configuration
@EnableCaching
public class CacheConfig {}
@Service
public class PostService {
@Cacheable(value = "posts", key = "#id")
public Post findById(Long id) {
return repo.findById(id).orElseThrow();
}
@CacheEvict(value = "posts", key = "#post.id")
public Post update(Post post) {
return repo.save(post);
}
@CacheEvict(value = "posts", allEntries = true)
public void clearCache() {}
}@EnableCaching enables cache support. @Cacheable stores the result — subsequent calls do not execute. @CacheEvict invalidates on update. key uses SpEL. Use Redis in production with spring-boot-starter-data-redis.
Logging and SLF4J
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@Service
public class PostService {
private static final Logger log =
LoggerFactory.getLogger(PostService.class);
public Post create(PostDTO dto) {
log.info("Creating post: {}", dto.title());
try {
Post p = repo.save(new Post(dto));
log.debug("Post created with id={}", p.getId());
return p;
} catch (Exception e) {
log.error("Failed to create post", e);
throw e;
}
}
}
# application.properties:
logging.level.com.app=DEBUGSLF4J is the standard logging API. Use {} the a placeholder (avoids concatenation). log.info for events, log.error with the exception for the stacktrace. logging.level controls verbosity per package. Use Lombok's @Slf4j to simplify.
Async and @CompletableFuture
@Configuration
@EnableAsync
public class AsyncConfig {}
@Service
public class EmailService {
@Async
public CompletableFuture<Void> sendAsync(String to) {
// long-running operation (sending email)
mailSender.send(to, "Subject", "Body");
return CompletableFuture.completedFuture(null);
}
}
// In the controller:
emailService.sendAsync("user@mail.com");
return ResponseEntity.accepted().build(); // 202@EnableAsync enables asynchronous execution. @Async runs in a separate thread. The controller responds immediately with 202. CompletableFuture allows chaining results. Ideal for emails and notifications.
Security
Base Security Config
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(
HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.sessionManagement(sm ->
sm.sessionCreationPolicy(STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll()
.anyRequest().authenticated()
);
return http.build();
}
}@EnableWebSecurity enables Spring Security. csrf.disable() for stateless APIs. STATELESS creates no HTTP session. permitAll() opens public routes. Everything else requires authentication.
Login Endpoint
@RestController
@RequestMapping("/api/auth")
public class AuthController {
@PostMapping("/login")
public ResponseEntity<TokenResponse> login(
@RequestBody @Valid LoginRequest req) {
authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
req.email(), req.password()));
String token = jwtService.generateToken(req.email());
return ResponseEntity.ok(new TokenResponse(token));
}
@PostMapping("/register")
public ResponseEntity<Void> register(
@RequestBody @Valid RegisterRequest req) {
userService.create(req);
return ResponseEntity.status(HttpStatus.CREATED).build();
}
}AuthenticationManager validates credentials against the UserDetailsService. If it fails, it throws BadCredentialsException. The JWT token is returned to the client. Auth endpoints should be permitAll().
Password Encoder
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(12);
}
// Registration:
String hash = encoder.encode("password123");
user.setPassword(hash);
// Login:
boolean valid = encoder.matches("password123", user.getPassword());BCryptPasswordEncoder generates a hash with an automatic salt. The parameter 12 is the cost (strength). matches() compares plain text with the hash. Never store passwords in plain text. Each hash is unique even for the same password.
Rules per Role
http.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers(HttpMethod.DELETE, "/api/posts/**")
.hasAnyRole("ADMIN", "MODERATOR")
.anyRequest().authenticated()
);
// In the controller (method security):
@PreAuthorize("hasRole('ADMIN')")
public void deleteUser(Long id) { ... }
@PreAuthorize("#id == authentication.main.id")
public ProfileDTO getProfile(Long id) { ... }hasRole() checks the ROLE_* authority. @PreAuthorize protects individual methods. #id accesses parameters via SpEL. Enable it with @EnableMethodSecurity. Combine URL rules with method security.
JWT - Generate Token
@Service
public class JwtService {
@Value("${app.jwt.secret}")
private String secret;
public String generateToken(String email) {
return Jwts.builder()
.subject(email)
.issuedAt(new Date())
.expiration(new Date(
System.currentTimeMillis() + 86400000))
.signWith(getSigningKey())
.compact();
}
private Key getSigningKey() {
return Keys.hmacShaKeyFor(secret.getBytes());
}
}Jwts.builder() builds the JWT token. subject identifies the user. expiration sets the validity (24h here). hmacShaKeyFor uses an HMAC key. Keep the secret in an env variable, never in the code.
UserDetailsService
@Service
public class CustomUserDetailsService
implements UserDetailsService {
private final UserRepository repo;
@Override
public UserDetails loadUserByUsername(String email)
throws UsernameNotFoundException {
User user = repo.findByEmail(email)
.orElseThrow(() ->
new UsernameNotFoundException(email));
return org.springframework.security
.core.userdetails.User.builder()
.username(user.getEmail())
.password(user.getPassword())
.roles(user.getRole().name())
.build();
}
}UserDetailsService loads the user for authentication. loadUserByUsername is called by the AuthenticationManager. It returns UserDetails with roles. Spring Security compares the password using the encoder.
JWT - Validate (Filter)
@Component
public class JwtAuthFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(
HttpServletRequest req,
HttpServletResponse res,
FilterChain chain) throws Exception {
String token = extractToken(req);
if (token != null && jwtService.isValid(token)) {
String email = jwtService.getEmail(token);
var auth = new UsernamePasswordAuthenticationToken(
email, null, List.of());
SecurityContextHolder.getContext()
.setAuthentication(auth);
}
chain.doFilter(req, res);
}
}OncePerRequestFilter runs once per request. It extracts the token from the Authorization: Bearer header. If valid, it sets the authentication in the SecurityContext. Register the filter before UsernamePasswordAuthenticationFilter.
Testes
Integration Test
@SpringBootTest
class PostServiceTest {
@Autowired
private PostService service;
@Autowired
private PostRepository repo;
@Test
void createPost() {
var dto = new CreatePostDTO("Title", "Text", 1L);
Post post = service.create(dto);
assertNotNull(post.getId());
assertEquals("Title", post.getTitle());
assertTrue(repo.existsById(post.getId()));
}
}@SpringBootTest starts the full context. Use it for real integration tests. @Autowired injects real beans. assertNotNull and assertEquals are from JUnit 5. Slower but more realistic.
Testcontainers
@SpringBootTest
@Testcontainers
class IntegrationTest {
@Container
static MySQLContainer<?> mysql =
new MySQLContainer<>("mysql:8.0")
.withDatabaseName("test_db");
@DynamicPropertySource
static void config(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url",
mysql::getJdbcUrl);
registry.add("spring.datasource.username",
mysql::getUsername);
registry.add("spring.datasource.password",
mysql::getPassword);
}
}@Testcontainers spins up Docker containers for tests. MySQLContainer creates a real ephemeral MySQL. @DynamicPropertySource injects the dynamic URL. Requires Docker installed. More reliable tests than H2.
MockMvc (Test the API)
@WebMvcTest(PostController.class)
class PostControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private PostService service;
@Test
void listPosts() throws Exception {
when(service.findAll()).thenReturn(List.of(
new Post(1L, "Post 1")));
mockMvc.perform(get("/api/posts"))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].title")
.value("Post 1"));
}
}@WebMvcTest loads only the web layer. @MockBean replaces dependencies with mocks. MockMvc simulates HTTP requests without a server. jsonPath checks JSON fields. Fast and isolated.
Testing with Profiles
# src/test/resources/application-test.properties
spring.datasource.url=jdbc:h2:mem:test
spring.jpa.hibernate.ddl-auto=create-drop
spring.jpa.show-sql=true
// In the test:
@SpringBootTest
@ActiveProfiles("test")
class AppIntegrationTest {
@Test
void contextLoads() {
// If we get here, the context is OK
}
}@ActiveProfiles("test") uses the test config. create-drop creates and drops the schema on every run. In-memory H2 is fast for CI. Put it in src/test/resources/. Always keep test config separate from production.
Mockito (Unit Tests)
@ExtendWith(MockitoExtension.class)
class PostServiceUnitTest {
@Mock
private PostRepository repo;
@InjectMocks
private PostService service;
@Test
void createPostSuccess() {
when(repo.save(any())).thenAnswer(inv -> {
Post p = inv.getArgument(0);
p.setId(1L);
return p;
});
Post result = service.create(dto);
assertEquals(1L, result.getId());
verify(repo).save(any());
}
}@ExtendWith(MockitoExtension.class) enables Mockito. @Mock creates fake dependencies. @InjectMocks injects the mocks into the service. when/thenReturn defines behavior. verify confirms calls.
AssertJ and REST Tests
@Test
void createPostReturns201() throws Exception {
String json = """
{"title": "New Post", "content": "Text here"}
""";
mockMvc.perform(post("/api/posts")
.contentType(MediaType.APPLICATION_JSON)
.content(json))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.id").isNumber())
.andExpect(jsonPath("$.title").value("New Post"));
}
// With RestTemplate/WebTestClient:
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(body.getTitle()).contains("Post");mockMvc.perform() sends requests with a JSON body. jsonPath validates the response structure. AssertJ's assertThat is more readable. Text blocks (\"\"\") make inline JSON easier. Combine status + body assertions.
Data JPA Test
@DataJpaTest
class PostRepositoryTest {
@Autowired
private PostRepository repo;
@Autowired
private TestEntityManager em;
@Test
void findBySlug() {
em.persist(new Post("Test Post", "test-slug"));
Optional<Post> found =
repo.findBySlug("test-slug");
assertTrue(found.isPresent());
assertEquals("Test Post", found.get().getTitle());
}
}@DataJpaTest tests only the JPA layer with in-memory H2. TestEntityManager inserts test data. Each test rolls back automatically. Ideal for validating queries and constraints.