Lessons
1 · Java Foundations
2 · Object-Oriented Java
3 · Core Java
4 · Collections and Generics
5 · Functional Java
6 · The JVM and Concurrency
7 · Tools and Craft
- 21File I/O and Serialization
- 22Debugging: Errors, Stack Traces, the Debugger and Logging
- 23Maven: What It Does and How to Use It
- 24Git for Java Projects: Commits, Branches, Merging and Pull Requests
- 25Testing with JUnit and Mockito: Unit Tests, Mocks and Their Limits
- 26Project 1: Build an Expense Tracker CLI in Plain Java
8 · Databases and JDBC
9 · Web and Spring
10 · Persistence with JPA
11 · Secure, Tested, Production-Ready
- 36Security Fundamentals and Spring Security: Passwords, JWT, Access Control, CORS and Secrets
- 37Testing Spring Boot Applications: Slice Tests, MockMvc, Real Databases and the Test Pyramid
- 38Production Practices: Configuration, Logging, Docker and Health Checks for Spring Boot
- 39Project 3: The Job Application Tracker API, Assembled and Reviewed
Lesson 33 · Web and Spring
Building a REST API with Spring Boot: Controllers, Services, DTOs, Validation and Errors
Build the first version of the Job Application Tracker API: a layered Spring Boot application with controller, service, repository, request and response DTOs, Bean Validation, paging and filtering, problem-details errors and tests, then exercise every endpoint over real HTTP.
For
Learners who know Spring Boot basics and REST design and want to build a clean, layered API with correct status codes and error handling.
Level
Before you start
Spring Boot foundations (article 32), REST and HTTP (article 30), dependency injection (article 31) and testing with fakes (article 25). Verified with Spring Boot 4.1.1 on JDK 21 and JDK 25.
On this page
Key takeaways
- A layered design gives each class one job: the controller handles HTTP, the service holds the rules, the repository stores data. Dependencies point inward, so the service never sees an HTTP type.
- Use separate request and response records (DTOs) instead of exposing the domain object. They control exactly which fields a client can send and see, and they stop a client from setting fields such as the status or id.
- Bean Validation annotations on a request record, activated by @Valid, reject malformed input at the edge before it reaches your rules. Business rules, such as which status changes are allowed, belong in the service.
- A single @RestControllerAdvice class turns every failure into the same RFC 9457 problem-details JSON with the right status: 400 for invalid input, 404 for a missing resource, 409 for a rule conflict, and a generic 500 that never leaks internals.
- Test each layer for what it owns: plain unit tests for the service, and a MockMvc slice test for the HTTP behaviour.
Now we put it together. In this article you build the first version of the Job Application Tracker API, the backend that Project 3 completes: a service for keeping track of the jobs you have applied to, their stages, and your notes. This version stores its data in memory, so that the whole HTTP layer, validation and error handling can be built and tested first. In the next articles you will replace the storage with a real database, add relationships, security and production setup, and the code you write here will barely change. That is the payoff of layering.
Everything here was built as a real Maven project, its 18 tests pass on JDK 21 and JDK 25, and the HTTP session near the end is real output from the packaged jar.
What you will learn
#- How to organise a Spring Boot API into controller, service and repository layers.
- How to use request and response DTOs, and why.
- How to validate input and report errors consistently.
- How to test the layers separately.
What we are building
#A user tracks job applications. Each has a company, a position, a stage in the hiring process, and optional details.
Method and path | Purpose | Success | Errors |
|---|---|---|---|
| Create an application |
|
|
| Read one |
|
|
| List, with filters and paging |
|
|
| Replace the editable fields |
|
|
| Move to another stage |
|
|
| Delete |
|
|
The business rules, which the service will enforce:
- A new application starts as
APPLIEDif the user says when they applied, otherwise asWISHLIST. - The stages are
WISHLIST,APPLIED,INTERVIEWING,OFFER,REJECTEDandWITHDRAWN. Moves are limited: for exampleWISHLISTcan only go toAPPLIEDorWITHDRAWN, andREJECTEDandWITHDRAWNare final. - The status changes only through the status endpoint, never through the ordinary update. This makes the stage rule impossible to bypass.
The names stay the same through the rest of the course: JobApplication, JobApplicationRequest and JobApplicationResponse (the DTOs), JobApplicationService, JobApplicationRepository and JobApplicationController.
The layers
#HTTP request | v+-------------------------+ knows about: HTTP, JSON, status codes, DTOs| JobApplicationController|+-------------------------+ | calls v+-------------------------+ knows about: the rules, the domain objects| JobApplicationService |+-------------------------+ | calls v+-------------------------+ knows about: storage| JobApplicationRepository| (an interface; in memory now, a database next)+-------------------------+Each layer has a single responsibility, and dependencies point downwards only: the controller uses the service, the service uses the repository, never the other way. The service does not import anything from org.springframework.web and does not know it is being called from HTTP, so it could equally be called from a message queue or a test. The repository interface is the same idea as ExpenseRepository in Projects 1 and 2.
Project setup
#The pom.xml is that of the foundations project with a different name and description (the dependencies are the same: spring-boot-starter-webmvc, spring-boot-starter-validation and the test starter):
--- a/pom.xml+++ b/pom.xml@@ -13,7 +13,8 @@ <groupId>com.example</groupId>- <artifactId>foundations</artifactId>+ <artifactId>jobtracker</artifactId> <version>0.0.1-SNAPSHOT</version>- <name>foundations</name>+ <name>jobtracker</name>+ <description>Job Application Tracker API</description> <properties>The main class and settings follow article 32. TrackerSettings holds the paging limits, with validated defaults, and application.properties sets them:
// File: src/main/java/com/example/jobtracker/JobTrackerApplication.javapackage com.example.jobtracker;import org.springframework.boot.SpringApplication;import org.springframework.boot.autoconfigure.SpringBootApplication;import org.springframework.boot.context.properties.ConfigurationPropertiesScan;@SpringBootApplication@ConfigurationPropertiesScanpublic class JobTrackerApplication { public static void main(String[] args) { SpringApplication.run(JobTrackerApplication.class, args); }}// File: src/main/java/com/example/jobtracker/TrackerSettings.javapackage com.example.jobtracker;import jakarta.validation.constraints.Max;import jakarta.validation.constraints.Min;import org.springframework.boot.context.properties.ConfigurationProperties;import org.springframework.boot.context.properties.bind.DefaultValue;import org.springframework.validation.annotation.Validated;/** Paging limits, read from properties that start with "tracker." and checked at start-up. */@ConfigurationProperties(prefix = "tracker")@Validatedpublic record TrackerSettings( @DefaultValue("20") @Min(1) @Max(100) int defaultPageSize, @DefaultValue("100") @Min(1) @Max(1000) int maxPageSize) {}tracker.default-page-size=20tracker.max-page-size=50The Clock bean puts the decision of what "now" means in one place, so a test can substitute a fixed clock (article 8):
// File: src/main/java/com/example/jobtracker/ClockConfig.javapackage com.example.jobtracker;import java.time.Clock;import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.Configuration;@Configurationpublic class ClockConfig { /** One place that decides what "now" means, so tests can replace it with a fixed clock. */ @Bean Clock clock() { return Clock.systemUTC(); }}The domain
#The status enum carries the transition rules itself. A rule that belongs to the concept lives with the concept, and switch over an enum has to cover every constant (article 9):
// File: src/main/java/com/example/jobtracker/ApplicationStatus.javapackage com.example.jobtracker;import java.util.EnumSet;import java.util.Set;/** Where an application is in the process, and which moves between stages are allowed. */public enum ApplicationStatus { WISHLIST, APPLIED, INTERVIEWING, OFFER, REJECTED, WITHDRAWN; /** The stages this one may move to. REJECTED and WITHDRAWN are final. */ public Set<ApplicationStatus> allowedNext() { return switch (this) { case WISHLIST -> EnumSet.of(APPLIED, WITHDRAWN); case APPLIED -> EnumSet.of(INTERVIEWING, REJECTED, WITHDRAWN); case INTERVIEWING -> EnumSet.of(OFFER, REJECTED, WITHDRAWN); case OFFER -> EnumSet.of(REJECTED, WITHDRAWN); case REJECTED, WITHDRAWN -> EnumSet.noneOf(ApplicationStatus.class); }; } public boolean canMoveTo(ApplicationStatus next) { return allowedNext().contains(next); }}JobApplication is the domain object, a plain class for now. (Next article it becomes a JPA entity with a few annotations, and the rest of the code does not change.)
// File: src/main/java/com/example/jobtracker/JobApplication.javapackage com.example.jobtracker;import java.time.Instant;import java.time.LocalDate;/** The domain object: one job the user is pursuing. Plain Java for now. */public class JobApplication { private Long id; private String company; private String position; private ApplicationStatus status; private LocalDate appliedOn; private String jobUrl; private String notes; private Instant createdAt; private Instant updatedAt; public Long getId() { return id; } public void setId(Long id) { this.id = id; } public String getCompany() { return company; } public void setCompany(String company) { this.company = company; } public String getPosition() { return position; } public void setPosition(String position) { this.position = position; } public ApplicationStatus getStatus() { return status; } public void setStatus(ApplicationStatus status) { this.status = status; } public LocalDate getAppliedOn() { return appliedOn; } public void setAppliedOn(LocalDate appliedOn) { this.appliedOn = appliedOn; } public String getJobUrl() { return jobUrl; } public void setJobUrl(String jobUrl) { this.jobUrl = jobUrl; } public String getNotes() { return notes; } public void setNotes(String notes) { this.notes = notes; } public Instant getCreatedAt() { return createdAt; } public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; } public Instant getUpdatedAt() { return updatedAt; } public void setUpdatedAt(Instant updatedAt) { this.updatedAt = updatedAt; }}Two domain exceptions describe what can go wrong in the service's language, with no HTTP in sight. The status-change message explains why the move is not allowed, because the client developer will read it:
// File: src/main/java/com/example/jobtracker/ApplicationNotFoundException.javapackage com.example.jobtracker;public class ApplicationNotFoundException extends RuntimeException { public ApplicationNotFoundException(Long id) { super("No job application with id " + id); }}// File: src/main/java/com/example/jobtracker/InvalidStatusChangeException.javapackage com.example.jobtracker;public class InvalidStatusChangeException extends RuntimeException { public InvalidStatusChangeException(ApplicationStatus from, ApplicationStatus to) { super("An application cannot move from " + from + " to " + to + (from.allowedNext().isEmpty() ? " because " + from + " is final" : ". Allowed next stages: " + from.allowedNext())); }}The repository
#The repository interface uses the method names that Spring Data JPA will use in the next article (save, findById, findAll, deleteById), so replacing the implementation will not touch the service. The in-memory implementation is a @Repository bean holding a ConcurrentHashMap and an AtomicLong for ids:
// File: src/main/java/com/example/jobtracker/JobApplicationRepository.javapackage com.example.jobtracker;import java.util.List;import java.util.Optional;/** Where applications are stored. The method names match Spring Data's, which the next articles use. */public interface JobApplicationRepository { /** Inserts a new application (the id is null) or updates an existing one, and returns the stored object. */ JobApplication save(JobApplication application); Optional<JobApplication> findById(Long id); List<JobApplication> findAll(); void deleteById(Long id);}// File: src/main/java/com/example/jobtracker/InMemoryJobApplicationRepository.javapackage com.example.jobtracker;import java.util.ArrayList;import java.util.List;import java.util.Map;import java.util.Optional;import java.util.concurrent.ConcurrentHashMap;import java.util.concurrent.atomic.AtomicLong;import org.springframework.stereotype.Repository;/** A temporary store that keeps everything in memory, so the web layer can be built and tested first. */@Repositorypublic class InMemoryJobApplicationRepository implements JobApplicationRepository { private final Map<Long, JobApplication> byId = new ConcurrentHashMap<>(); private final AtomicLong nextId = new AtomicLong(1); @Override public JobApplication save(JobApplication application) { if (application.getId() == null) { application.setId(nextId.getAndIncrement()); } byId.put(application.getId(), application); return application; } @Override public Optional<JobApplication> findById(Long id) { return Optional.ofNullable(byId.get(id)); } @Override public List<JobApplication> findAll() { return new ArrayList<>(byId.values()); } @Override public void deleteById(Long id) { byId.remove(id); }}DTOs and validation
#A DTO (data transfer object) is a class whose only job is to carry data across a boundary. Here the boundary is HTTP. You could expose JobApplication directly as the JSON shape, and you should not, for three reasons:
- Control over what clients can set. If the request body were a
JobApplication, a client could send"id": 5,"status": "OFFER"or"createdAt": "..."and the framework would set them. This is called mass assignment, and is a classic vulnerability. The request record simply has nostatus,idor timestamp fields. - Control over what clients can see. Internal fields can be hidden or renamed without changing the database, and the reverse.
- Independent evolution. The API contract and the storage shape can change separately.
Records are ideal DTOs: immutable and with no boilerplate (article 9). The request carries Bean Validation annotations, which are declarative rules checked when the controller parameter is marked @Valid:
// File: src/main/java/com/example/jobtracker/JobApplicationRequest.javapackage com.example.jobtracker;import jakarta.validation.constraints.NotBlank;import jakarta.validation.constraints.PastOrPresent;import jakarta.validation.constraints.Size;import java.time.LocalDate;/** What a client sends to create or replace an application. The status is deliberately not part of it. */public record JobApplicationRequest( @NotBlank @Size(max = 100) String company, @NotBlank @Size(max = 100) String position, @PastOrPresent LocalDate appliedOn, @Size(max = 500) String jobUrl, @Size(max = 2000) String notes) {}// File: src/main/java/com/example/jobtracker/StatusChangeRequest.javapackage com.example.jobtracker;import jakarta.validation.constraints.NotNull;public record StatusChangeRequest(@NotNull ApplicationStatus status) {}// File: src/main/java/com/example/jobtracker/JobApplicationResponse.javapackage com.example.jobtracker;import java.time.Instant;import java.time.LocalDate;/** What the API returns. It is a separate type from JobApplication, so the stored shape can change freely. */public record JobApplicationResponse( Long id, String company, String position, ApplicationStatus status, LocalDate appliedOn, String jobUrl, String notes, Instant createdAt, Instant updatedAt) { static JobApplicationResponse from(JobApplication a) { return new JobApplicationResponse(a.getId(), a.getCompany(), a.getPosition(), a.getStatus(), a.getAppliedOn(), a.getJobUrl(), a.getNotes(), a.getCreatedAt(), a.getUpdatedAt()); }}// File: src/main/java/com/example/jobtracker/PageResponse.javapackage com.example.jobtracker;import java.util.List;/** One page of results, with enough information for a client to ask for the next one. */public record PageResponse<T>(List<T> items, int page, int size, long totalItems, int totalPages) {}The annotations you will use most:
Annotation | Checks |
|---|---|
| The value is present |
| A string is not null, empty or only spaces |
| Length of a string or size of a collection |
| Numeric bounds ( |
| The string matches a regular expression |
| Looks like an email address |
| Dates and times |
| On a field or parameter: validate the nested object too |
Notice what is not a Bean Validation concern. "The status may only move to certain stages" depends on the current state of a stored object, so it is a business rule in the service. Validation at the edge checks the shape of the input: is it present, is it the right length and format. The service checks the meaning in context. The database adds a third layer of constraints (article 27). All three are needed, because they protect against different things.
The service
#The service is where the rules live. Read it as the specification of the application: creation and the default stage, filtering and paging, replacing fields without touching the status, and the guarded status change. It depends only on the repository interface, the clock and the settings, all injected through the constructor (article 31):
// File: src/main/java/com/example/jobtracker/JobApplicationService.javapackage com.example.jobtracker;import java.time.Clock;import java.time.Instant;import java.util.Comparator;import java.util.List;import java.util.Locale;import org.springframework.stereotype.Service;/** The rules of the application. It knows nothing about HTTP and nothing about how data is stored. */@Servicepublic class JobApplicationService { private final JobApplicationRepository repository; private final Clock clock; private final TrackerSettings settings; public JobApplicationService(JobApplicationRepository repository, Clock clock, TrackerSettings settings) { this.repository = repository; this.clock = clock; this.settings = settings; } /** A new application starts as APPLIED if the user says when they applied, otherwise as WISHLIST. */ public JobApplicationResponse create(JobApplicationRequest request) { JobApplication application = new JobApplication(); copyFields(request, application); application.setStatus(request.appliedOn() == null ? ApplicationStatus.WISHLIST : ApplicationStatus.APPLIED); Instant now = Instant.now(clock); application.setCreatedAt(now); application.setUpdatedAt(now); return JobApplicationResponse.from(repository.save(application)); } public JobApplicationResponse get(Long id) { return JobApplicationResponse.from(find(id)); } /** Filters by status and by part of the company name, then returns one page, oldest first. */ public PageResponse<JobApplicationResponse> list(ApplicationStatus status, String company, Integer page, Integer size) { int pageSize = size == null ? settings.defaultPageSize() : Math.min(size, settings.maxPageSize()); int pageNumber = page == null ? 0 : page; String needle = company == null ? null : company.toLowerCase(Locale.ROOT); List<JobApplication> matching = repository.findAll().stream() .filter(a -> status == null || a.getStatus() == status) .filter(a -> needle == null || a.getCompany().toLowerCase(Locale.ROOT).contains(needle)) .sorted(Comparator.comparing(JobApplication::getId)) .toList(); List<JobApplicationResponse> items = matching.stream() .skip((long) pageNumber * pageSize) .limit(pageSize) .map(JobApplicationResponse::from) .toList(); int totalPages = (int) Math.ceil(matching.size() / (double) pageSize); return new PageResponse<>(items, pageNumber, pageSize, matching.size(), totalPages); } /** Replaces the editable fields. The status is not editable here, see changeStatus. */ public JobApplicationResponse replace(Long id, JobApplicationRequest request) { JobApplication application = find(id); copyFields(request, application); application.setUpdatedAt(Instant.now(clock)); return JobApplicationResponse.from(repository.save(application)); } public JobApplicationResponse changeStatus(Long id, ApplicationStatus next) { JobApplication application = find(id); if (!application.getStatus().canMoveTo(next)) { throw new InvalidStatusChangeException(application.getStatus(), next); } application.setStatus(next); application.setUpdatedAt(Instant.now(clock)); return JobApplicationResponse.from(repository.save(application)); } public void delete(Long id) { find(id); // throws if it does not exist repository.deleteById(id); } private JobApplication find(Long id) { return repository.findById(id).orElseThrow(() -> new ApplicationNotFoundException(id)); } private static void copyFields(JobApplicationRequest request, JobApplication application) { application.setCompany(request.company().strip()); application.setPosition(request.position().strip()); application.setAppliedOn(request.appliedOn()); application.setJobUrl(request.jobUrl()); application.setNotes(request.notes()); }}Points worth noticing:
listfilters and pages in memory with the Stream API (article 16). That is acceptable for a stand-in repository. With a database you will push filtering and paging into SQL, as article 27 explained, and only this method has to change. The page size is clamped tomaxPageSize, so a client cannot ask for a million rows.replaceimplementsPUTsemantics: the request replaces the editable fields, so a field left out of the request becomesnull. You will see this in the session below.changeStatusasks the enum whether the move is allowed and throws a domain exception if not.deletecallsfindfirst, so deleting something that does not exist is a404, not a silent success.Instant.now(clock)uses the injected clock, neverInstant.now().
The controller
#The controller is deliberately thin. Its job is HTTP translation: it binds the request to Java types, validates, calls the service, and shapes the response. No rule lives here.
// File: src/main/java/com/example/jobtracker/JobApplicationController.javapackage com.example.jobtracker;import jakarta.validation.Valid;import jakarta.validation.constraints.Min;import java.net.URI;import org.springframework.http.HttpStatus;import org.springframework.http.ResponseEntity;import org.springframework.web.bind.annotation.DeleteMapping;import org.springframework.web.bind.annotation.GetMapping;import org.springframework.web.bind.annotation.PathVariable;import org.springframework.web.bind.annotation.PostMapping;import org.springframework.web.bind.annotation.PutMapping;import org.springframework.web.bind.annotation.RequestBody;import org.springframework.web.bind.annotation.RequestMapping;import org.springframework.web.bind.annotation.RequestParam;import org.springframework.web.bind.annotation.ResponseStatus;import org.springframework.web.bind.annotation.RestController;import org.springframework.web.util.UriComponentsBuilder;/** The HTTP layer: translate requests into service calls and results into responses. No rules live here. */@RestController@RequestMapping("/api/applications")public class JobApplicationController { private final JobApplicationService service; public JobApplicationController(JobApplicationService service) { this.service = service; } @PostMapping public ResponseEntity<JobApplicationResponse> create(@Valid @RequestBody JobApplicationRequest request, UriComponentsBuilder uriBuilder) { JobApplicationResponse created = service.create(request); URI location = uriBuilder.path("/api/applications/{id}").buildAndExpand(created.id()).toUri(); return ResponseEntity.created(location).body(created); } @GetMapping("/{id}") public JobApplicationResponse get(@PathVariable Long id) { return service.get(id); } @GetMapping public PageResponse<JobApplicationResponse> list( @RequestParam(required = false) ApplicationStatus status, @RequestParam(required = false) String company, @RequestParam(required = false) @Min(0) Integer page, @RequestParam(required = false) @Min(1) Integer size) { return service.list(status, company, page, size); } @PutMapping("/{id}") public JobApplicationResponse replace(@PathVariable Long id, @Valid @RequestBody JobApplicationRequest request) { return service.replace(id, request); } @PostMapping("/{id}/status") public JobApplicationResponse changeStatus(@PathVariable Long id, @Valid @RequestBody StatusChangeRequest request) { return service.changeStatus(id, request.status()); } @DeleteMapping("/{id}") @ResponseStatus(HttpStatus.NO_CONTENT) public void delete(@PathVariable Long id) { service.delete(id); }}The annotations, and what they do:
Annotation | Meaning |
|---|---|
| A controller whose return values are written to the response body (as JSON). It is |
| The common path prefix for all methods in this class |
| Map an HTTP method, and optionally a sub-path, to a method |
| Takes |
| Takes a query parameter ( |
| Reads the request body as JSON into the parameter's type |
| Runs Bean Validation on the body. A failure never reaches the method body |
| Sets the status code, here |
Details worth understanding:
createreturnsResponseEntitybecause it needs to control the status (201) and a header (Location). Spring injects aUriComponentsBuilderthat already knows the server's address from the current request, so theLocationis a complete URL without hard-coding the host.- Query parameters bind to types.
statusis anApplicationStatus, so?status=HIREDfails conversion with a400automatically.@Minonpageandsizerejects negatives, using Spring's built-in validation of method parameters. - Returning a record is enough for
200. Spring converts it to JSON with Jackson (article 30). - A constructor with one parameter needs no
@Autowired.
Error handling
#Without help, each failure would produce a different default response. One @RestControllerAdvice class gives them all the same shape, the problem details format from article 30 (RFC 9457). It extends Spring's ResponseEntityExceptionHandler, which already converts the standard web exceptions (unreadable JSON, wrong method, wrong media type, failed type conversion) into problem details, and adds handlers for ours:
// File: src/main/java/com/example/jobtracker/ApiExceptionHandler.javapackage com.example.jobtracker;import java.util.Map;import java.util.TreeMap;import org.slf4j.Logger;import org.slf4j.LoggerFactory;import org.springframework.http.HttpHeaders;import org.springframework.http.HttpStatus;import org.springframework.http.HttpStatusCode;import org.springframework.http.ProblemDetail;import org.springframework.http.ResponseEntity;import org.springframework.web.bind.MethodArgumentNotValidException;import org.springframework.web.bind.annotation.ExceptionHandler;import org.springframework.web.bind.annotation.RestControllerAdvice;import org.springframework.web.context.request.WebRequest;import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;/** * Turns every failure into the same JSON shape (RFC 9457 problem details). * The base class already handles the standard web exceptions: unreadable JSON, wrong method, wrong media type. */@RestControllerAdvicepublic class ApiExceptionHandler extends ResponseEntityExceptionHandler { private static final Logger log = LoggerFactory.getLogger(ApiExceptionHandler.class); @ExceptionHandler(ApplicationNotFoundException.class) ProblemDetail notFound(ApplicationNotFoundException e) { ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, e.getMessage()); problem.setTitle("Application not found"); return problem; } @ExceptionHandler(InvalidStatusChangeException.class) ProblemDetail invalidStatusChange(InvalidStatusChangeException e) { ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, e.getMessage()); problem.setTitle("Invalid status change"); return problem; } /** Adds the list of invalid fields to the standard 400 response. */ @Override protected ResponseEntity<Object> handleMethodArgumentNotValid(MethodArgumentNotValidException ex, HttpHeaders headers, HttpStatusCode status, WebRequest request) { Map<String, String> errors = new TreeMap<>(); ex.getBindingResult().getFieldErrors() .forEach(error -> errors.merge(error.getField(), String.valueOf(error.getDefaultMessage()), (first, second) -> first + "; " + second)); ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, "The request has invalid fields"); problem.setTitle("Validation failed"); problem.setProperty("errors", errors); return handleExceptionInternal(ex, problem, headers, status, request); } /** The last resort: log the details for us, tell the client nothing about the internals. */ @ExceptionHandler(Exception.class) ProblemDetail unexpected(Exception e) { log.error("Unexpected error", e); ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR, "An unexpected error occurred"); problem.setTitle("Internal error"); return problem; }}- Domain exceptions map to statuses here, in one place: not found is
404, an illegal status move is409. - Validation failures are overridden to add an
errorsobject that lists each invalid field with its message, which is what a front end needs to highlight the right inputs. - The catch-all
Exceptionhandler logs the full details (for you) and returns a generic message (for the client). It is the one rule that must never be skipped: internal details such as stack traces, SQL and class names must not reach the client.
Tests
#Each layer is tested for what it owns, in the way article 25 described.
The service has no dependency on Spring, so a plain unit test with the in-memory repository and a fixed clock is enough. It runs in milliseconds and covers the rules: default stages, filtering ignoring case, paging and the page-size limit, PUT leaving the status alone, allowed and forbidden status moves, and deletion:
// File: src/test/java/com/example/jobtracker/JobApplicationServiceTest.javapackage com.example.jobtracker;import static org.junit.jupiter.api.Assertions.assertEquals;import static org.junit.jupiter.api.Assertions.assertThrows;import java.time.Clock;import java.time.Instant;import java.time.LocalDate;import java.time.ZoneOffset;import java.util.List;import org.junit.jupiter.api.BeforeEach;import org.junit.jupiter.api.Test;/** A plain unit test: no Spring, an in-memory repository and a fixed clock. */class JobApplicationServiceTest { private static final Clock CLOCK = Clock.fixed(Instant.parse("2026-03-15T10:00:00Z"), ZoneOffset.UTC); private JobApplicationService service; @BeforeEach void setUp() { service = new JobApplicationService(new InMemoryJobApplicationRepository(), CLOCK, new TrackerSettings(2, 3)); } private static JobApplicationRequest request(String company, LocalDate appliedOn) { return new JobApplicationRequest(company, "Java Developer", appliedOn, null, null); } @Test void anApplicationWithoutAnAppliedDateStartsAsWishlist() { JobApplicationResponse created = service.create(request("Acme", null)); assertEquals(ApplicationStatus.WISHLIST, created.status()); assertEquals(Instant.parse("2026-03-15T10:00:00Z"), created.createdAt()); assertEquals(1L, created.id()); } @Test void anApplicationWithAnAppliedDateStartsAsApplied() { JobApplicationResponse created = service.create(request("Acme", LocalDate.of(2026, 3, 1))); assertEquals(ApplicationStatus.APPLIED, created.status()); } @Test void gettingAnUnknownIdThrows() { assertThrows(ApplicationNotFoundException.class, () -> service.get(99L)); } @Test void listFiltersByStatusAndByCompanyIgnoringCase() { service.create(request("Acme Corp", null)); service.create(request("Globex", LocalDate.of(2026, 3, 1))); service.create(request("ACME Labs", LocalDate.of(2026, 3, 2))); List<String> applied = service.list(ApplicationStatus.APPLIED, null, null, null).items().stream() .map(JobApplicationResponse::company).toList(); List<String> acme = service.list(null, "acme", null, null).items().stream() .map(JobApplicationResponse::company).toList(); assertEquals(List.of("Globex", "ACME Labs"), applied); assertEquals(List.of("Acme Corp", "ACME Labs"), acme); } @Test void listReturnsPagesAndLimitsThePageSize() { for (int i = 1; i <= 7; i++) { service.create(request("Company " + i, null)); } PageResponse<JobApplicationResponse> defaultPage = service.list(null, null, null, null); PageResponse<JobApplicationResponse> lastPage = service.list(null, null, 3, 2); PageResponse<JobApplicationResponse> hugeSize = service.list(null, null, 0, 100); assertEquals(2, defaultPage.items().size()); // the default page size is 2 assertEquals(7, defaultPage.totalItems()); assertEquals(4, defaultPage.totalPages()); assertEquals(1, lastPage.items().size()); // page 3 of size 2 holds only item 7 assertEquals(3, hugeSize.size()); // 100 was reduced to the maximum of 3 } @Test void replaceChangesTheFieldsButNotTheStatus() { long id = service.create(request("Acme", LocalDate.of(2026, 3, 1))).id(); JobApplicationResponse replaced = service.replace(id, request(" Acme Ltd ", null)); assertEquals("Acme Ltd", replaced.company()); assertEquals(ApplicationStatus.APPLIED, replaced.status()); } @Test void statusFollowsTheAllowedMoves() { long id = service.create(request("Acme", null)).id(); service.changeStatus(id, ApplicationStatus.APPLIED); service.changeStatus(id, ApplicationStatus.INTERVIEWING); JobApplicationResponse offer = service.changeStatus(id, ApplicationStatus.OFFER); assertEquals(ApplicationStatus.OFFER, offer.status()); } @Test void aFinalStatusCannotChange() { long id = service.create(request("Acme", LocalDate.of(2026, 3, 1))).id(); service.changeStatus(id, ApplicationStatus.REJECTED); InvalidStatusChangeException e = assertThrows(InvalidStatusChangeException.class, () -> service.changeStatus(id, ApplicationStatus.APPLIED)); assertEquals("An application cannot move from REJECTED to APPLIED because REJECTED is final", e.getMessage()); } @Test void skippingAStageIsRejected() { long id = service.create(request("Acme", null)).id(); assertThrows(InvalidStatusChangeException.class, () -> service.changeStatus(id, ApplicationStatus.OFFER)); } @Test void deleteRemovesTheApplicationAndADeleteOfAMissingOneThrows() { long id = service.create(request("Acme", null)).id(); service.delete(id); assertThrows(ApplicationNotFoundException.class, () -> service.get(id)); assertThrows(ApplicationNotFoundException.class, () -> service.delete(id)); }}The controller is tested with @WebMvcTest, a slice test that starts only the web layer (controllers, the advice, JSON conversion, validation) and no service or repository. The service is replaced by a Mockito mock with @MockitoBean, so these tests check what only the HTTP layer is responsible for: status codes, headers, JSON shapes and error mapping. MockMvc performs requests without a real network. Article 37 goes deeper.
// File: src/test/java/com/example/jobtracker/JobApplicationControllerTest.javapackage com.example.jobtracker;import static org.mockito.ArgumentMatchers.any;import static org.mockito.BDDMockito.given;import static org.mockito.Mockito.verifyNoInteractions;import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;import java.time.Instant;import org.junit.jupiter.api.Test;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;import org.springframework.http.MediaType;import org.springframework.test.context.bean.override.mockito.MockitoBean;import org.springframework.test.web.servlet.MockMvc;/** Tests only the HTTP layer: the service is a mock, so these tests check requests, responses and errors. */@WebMvcTest(JobApplicationController.class)class JobApplicationControllerTest { @Autowired MockMvc mvc; @MockitoBean JobApplicationService service; private static JobApplicationResponse sample(long id) { return new JobApplicationResponse(id, "Acme", "Java Developer", ApplicationStatus.WISHLIST, null, null, null, Instant.parse("2026-03-15T10:00:00Z"), Instant.parse("2026-03-15T10:00:00Z")); } @Test void createReturns201WithALocationHeaderAndTheNewResource() throws Exception { given(service.create(any())).willReturn(sample(7)); mvc.perform(post("/api/applications").contentType(MediaType.APPLICATION_JSON) .content(""" {"company": "Acme", "position": "Java Developer"} """)) .andExpect(status().isCreated()) .andExpect(header().string("Location", "http://localhost/api/applications/7")) .andExpect(jsonPath("$.id").value(7)) .andExpect(jsonPath("$.status").value("WISHLIST")); } @Test void invalidFieldsReturn400WithAProblemThatListsThem() throws Exception { mvc.perform(post("/api/applications").contentType(MediaType.APPLICATION_JSON) .content(""" {"company": " ", "position": "Java Developer", "appliedOn": "2999-01-01"} """)) .andExpect(status().isBadRequest()) .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_PROBLEM_JSON)) .andExpect(jsonPath("$.title").value("Validation failed")) .andExpect(jsonPath("$.errors.company").exists()) .andExpect(jsonPath("$.errors.appliedOn").exists()); verifyNoInteractions(service); } @Test void anUnknownIdReturns404() throws Exception { given(service.get(99L)).willThrow(new ApplicationNotFoundException(99L)); mvc.perform(get("/api/applications/99")) .andExpect(status().isNotFound()) .andExpect(jsonPath("$.detail").value("No job application with id 99")); } @Test void anIllegalStatusChangeReturns409() throws Exception { given(service.changeStatus(1L, ApplicationStatus.OFFER)) .willThrow(new InvalidStatusChangeException(ApplicationStatus.WISHLIST, ApplicationStatus.OFFER)); mvc.perform(post("/api/applications/1/status").contentType(MediaType.APPLICATION_JSON) .content("{\"status\": \"OFFER\"}")) .andExpect(status().isConflict()) .andExpect(jsonPath("$.title").value("Invalid status change")); } @Test void malformedJsonReturns400() throws Exception { mvc.perform(post("/api/applications").contentType(MediaType.APPLICATION_JSON).content("{\"company\":")) .andExpect(status().isBadRequest()); } @Test void anUnknownStatusValueInTheBodyReturns400() throws Exception { mvc.perform(post("/api/applications/1/status").contentType(MediaType.APPLICATION_JSON) .content("{\"status\": \"HIRED\"}")) .andExpect(status().isBadRequest()); } @Test void aNegativePageReturns400() throws Exception { mvc.perform(get("/api/applications").param("page", "-1")) .andExpect(status().isBadRequest()); } @Test void anUnsupportedMethodReturns405AndAWrongContentTypeReturns415() throws Exception { mvc.perform(patch("/api/applications")).andExpect(status().isMethodNotAllowed()); mvc.perform(post("/api/applications").contentType(MediaType.TEXT_PLAIN).content("hello")) .andExpect(status().isUnsupportedMediaType()); }}mvn clean verify reports Tests run: 18, Failures: 0, Errors: 0, Skipped: 0 (10 for the service and 8 for the controller). Notice what the controller tests prove: that invalid input gets a 400 and never reaches the service (verifyNoInteractions), that the Location header is built correctly, and that every failure has the problem-details content type.
The API in action
#Build the jar with mvn clean verify, start it with java -jar target/jobtracker-0.0.1-SNAPSHOT.jar, and send real requests. This session used --server.port=18081 so as not to clash with anything, and the two timestamps in each response are replaced by <now> because they change on every run. The output is identical on JDK 21 and JDK 25. For each request the log shows the status line, then Location and Content-Type when present, then the body:
$ curl -i -X POST http://localhost:18081/api/applications -H 'Content-Type: application/json' -d '{"company":"Acme","position":"Java Developer"}'HTTP/1.1 201 Location: http://localhost:18081/api/applications/1Content-Type: application/json{"id":1,"company":"Acme","position":"Java Developer","status":"WISHLIST","appliedOn":null,"jobUrl":null,"notes":null,"createdAt":"<now>","updatedAt":"<now>"}$ curl -i -X POST http://localhost:18081/api/applications -H 'Content-Type: application/json' -d '{"company":"Globex","position":"Backend Engineer","appliedOn":"2026-09-20","jobUrl":"https://example.com/jobs/42"}'HTTP/1.1 201 Location: http://localhost:18081/api/applications/2Content-Type: application/json{"id":2,"company":"Globex","position":"Backend Engineer","status":"APPLIED","appliedOn":"2026-09-20","jobUrl":"https://example.com/jobs/42","notes":null,"createdAt":"<now>","updatedAt":"<now>"}$ curl -i -X POST http://localhost:18081/api/applications -H 'Content-Type: application/json' -d '{"company":"Initech","position":"Platform Engineer","appliedOn":"2026-09-25"}'HTTP/1.1 201 Location: http://localhost:18081/api/applications/3Content-Type: application/json{"id":3,"company":"Initech","position":"Platform Engineer","status":"APPLIED","appliedOn":"2026-09-25","jobUrl":null,"notes":null,"createdAt":"<now>","updatedAt":"<now>"}$ curl -i http://localhost:18081/api/applications/1HTTP/1.1 200 Content-Type: application/json{"id":1,"company":"Acme","position":"Java Developer","status":"WISHLIST","appliedOn":null,"jobUrl":null,"notes":null,"createdAt":"<now>","updatedAt":"<now>"}$ curl -i 'http://localhost:18081/api/applications?status=APPLIED'HTTP/1.1 200 Content-Type: application/json{"items":[{"id":2,"company":"Globex","position":"Backend Engineer","status":"APPLIED","appliedOn":"2026-09-20","jobUrl":"https://example.com/jobs/42","notes":null,"createdAt":"<now>","updatedAt":"<now>"},{"id":3,"company":"Initech","position":"Platform Engineer","status":"APPLIED","appliedOn":"2026-09-25","jobUrl":null,"notes":null,"createdAt":"<now>","updatedAt":"<now>"}],"page":0,"size":20,"totalItems":2,"totalPages":1}$ curl -i 'http://localhost:18081/api/applications?company=ac&size=1'HTTP/1.1 200 Content-Type: application/json{"items":[{"id":1,"company":"Acme","position":"Java Developer","status":"WISHLIST","appliedOn":null,"jobUrl":null,"notes":null,"createdAt":"<now>","updatedAt":"<now>"}],"page":0,"size":1,"totalItems":1,"totalPages":1}$ curl -i -X PUT http://localhost:18081/api/applications/1 -H 'Content-Type: application/json' -d '{"company":"Acme Corp","position":"Java Developer","notes":"Referred by Sam"}'HTTP/1.1 200 Content-Type: application/json{"id":1,"company":"Acme Corp","position":"Java Developer","status":"WISHLIST","appliedOn":null,"jobUrl":null,"notes":"Referred by Sam","createdAt":"<now>","updatedAt":"<now>"}$ curl -i -X POST http://localhost:18081/api/applications/1/status -H 'Content-Type: application/json' -d '{"status":"APPLIED"}'HTTP/1.1 200 Content-Type: application/json{"id":1,"company":"Acme Corp","position":"Java Developer","status":"APPLIED","appliedOn":null,"jobUrl":null,"notes":"Referred by Sam","createdAt":"<now>","updatedAt":"<now>"}$ curl -i -X PUT http://localhost:18081/api/applications/1 -H 'Content-Type: application/json' -d '{"company":"Acme Corp","position":"Java Developer","status":"OFFER"}'HTTP/1.1 200 Content-Type: application/json{"id":1,"company":"Acme Corp","position":"Java Developer","status":"APPLIED","appliedOn":null,"jobUrl":null,"notes":null,"createdAt":"<now>","updatedAt":"<now>"}$ curl -i -X POST http://localhost:18081/api/applications/1/status -H 'Content-Type: application/json' -d '{"status":"OFFER"}'HTTP/1.1 409 Content-Type: application/problem+json{"detail":"An application cannot move from APPLIED to OFFER. Allowed next stages: [INTERVIEWING, REJECTED, WITHDRAWN]","instance":"/api/applications/1/status","status":409,"title":"Invalid status change"}$ curl -i -X POST http://localhost:18081/api/applications -H 'Content-Type: application/json' -d '{"company":"","position":"x","appliedOn":"2999-01-01"}'HTTP/1.1 400 Content-Type: application/problem+json{"detail":"The request has invalid fields","instance":"/api/applications","status":400,"title":"Validation failed","errors":{"appliedOn":"must be a date in the past or in the present","company":"must not be blank"}}$ curl -i -X POST http://localhost:18081/api/applications -H 'Content-Type: application/json' -d '{"company":'HTTP/1.1 400 Content-Type: application/problem+json{"detail":"Failed to read request","instance":"/api/applications","status":400,"title":"Bad Request"}$ curl -i http://localhost:18081/api/applications/99HTTP/1.1 404 Content-Type: application/problem+json{"detail":"No job application with id 99","instance":"/api/applications/99","status":404,"title":"Application not found"}$ curl -i 'http://localhost:18081/api/applications?status=HIRED'HTTP/1.1 400 Content-Type: application/problem+json{"detail":"Failed to convert 'status' with value: 'HIRED'","instance":"/api/applications","status":400,"title":"Bad Request"}$ curl -i 'http://localhost:18081/api/applications?page=-1'HTTP/1.1 400 Content-Type: application/problem+json{"detail":"Validation failure","instance":"/api/applications","status":400,"title":"Bad Request"}$ curl -i -X PATCH http://localhost:18081/api/applications/1HTTP/1.1 405 Content-Type: application/problem+json{"detail":"Method 'PATCH' is not supported.","instance":"/api/applications/1","status":405,"title":"Method Not Allowed"}$ curl -i -X POST http://localhost:18081/api/applications -H 'Content-Type: text/plain' -d 'hello'HTTP/1.1 415 Content-Type: application/problem+json{"detail":"Content-Type 'text/plain;charset=UTF-8' is not supported.","instance":"/api/applications","status":415,"title":"Unsupported Media Type"}$ curl -i -X DELETE http://localhost:18081/api/applications/1HTTP/1.1 204 $ curl -i -X DELETE http://localhost:18081/api/applications/1HTTP/1.1 404 Content-Type: application/problem+json{"detail":"No job application with id 1","instance":"/api/applications/1","status":404,"title":"Application not found"}Walk through it:
- Three
POSTs. Each returned201and aLocation. The first has noappliedOn, so its status isWISHLIST. The other two have one, so they start asAPPLIED. The ids come from the repository. - The filter and paging requests returned a
PageResponse: the list underitems, pluspage,size,totalItemsandtotalPages. Case-insensitivecompany=acmatched "Acme".size=1shows the size echoed back. PUTreplaced the fields. Look atnotes: the request had nonotes, so it was cleared. This isPUTsemantics (replace everything), and the reason to offerPATCHwhen partial updates matter.- The status endpoint moved the application
WISHLISTtoAPPLIED, and then refusedAPPLIEDtoOFFERwith409and a problem whosedetaillists the allowed next stages. - Mass assignment is blocked: the
PUTrequest that included"status":"OFFER"returnedstatusAPPLIED. The request record has nostatuscomponent, so Jackson ignored the extra field. - Validation errors came back as
400withapplication/problem+jsonand anerrorsobject naming both bad fields:company(blank) andappliedOn(in the future). - Malformed JSON, an unknown enum value in the query, a negative page,
PATCHandtext/plaineach produced the right status (400,400,400,405,415) and the same problem-details shape, with generic messages that reveal nothing internal. DELETEreturned204and, repeated,404.
Review: what is still limited
#Limitation | Consequence | Where it is fixed |
|---|---|---|
The data is in memory | A restart loses everything | Article 34, a PostgreSQL database with JPA |
The service changes objects held by the repository directly, and several requests may run at once | A race is possible. It is hidden by the single-threaded demonstration, and an in-memory stand-in is not a place for real concurrency | A database with transactions (articles 34 and 35) |
| Slow with many rows | SQL filtering and paging |
No authentication, anyone can call everything | Not safe for real use | |
Nothing detects two clients editing the same record | A lost update | Optimistic locking, article 35 |
Mapping between the domain object and DTOs is hand-written | Repetitive, but explicit and easy to follow | A mapper library only if the volume justifies it |
Common mistakes
#- Putting business rules in the controller, so they cannot be reused or tested without HTTP.
- Returning the domain or entity object from the controller, exposing internal fields and enabling mass assignment.
- Skipping
@Valid, so invalid input reaches the service asnulls. Remember that Jackson accepts missing fields. - A
try/catchin every controller method instead of one@RestControllerAdvice. - Wrong status codes:
200with an error body,500for client mistakes,400for a missing resource. - Leaking internals in error messages: stack traces, SQL, or exception class names.
- Unbounded lists: no paging, or no maximum page size.
- Mixing HTTP types into the service, such as
ResponseEntityorHttpServletRequest, which couples the rules to the web layer. - Calling
Instant.now()directly in the service, which makes time-dependent behaviour impossible to test.
Exercises
#Exercise 1
Add a field
#Add a location (a city, up to 100 characters, optional) to applications. List every file you would change, from the request to the response.
Exercise 2: Choose the layer
#For each rule, name the layer that should enforce it: (a) company must not be blank, (b) an application in REJECTED cannot change, (c) page must not be negative, (d) company names are unique per user, (e) the response must have status 201.
Exercise 3: Why 409?
#The status-change conflict returns 409 instead of 400. Argue for that choice.
Exercise 4: A new endpoint
#You want GET /api/applications/count?status=APPLIED returning how many applications have that status. Sketch the controller method and the service method, and say what status codes are possible.
Exercise 5: Spot the vulnerability
#A teammate changes create to accept a JobApplication as the @RequestBody "to save mapping code". What can a client now do that it could not before?
Show solutionHide solution
JobApplicationRequest (a new @Size(max = 100) String location component), JobApplication (field, getter, setter), JobApplicationService.copyFields (copy it), JobApplicationResponse (a new component and the mapping in from). Then the tests: the service test (a created application keeps its location), and the controller test (a location over 100 characters gets a 400 with errors.location). The controller itself and the repository do not change, which is the benefit of the layers. In the next article the database table also needs a new column.
Exercise 2
Show solutionHide solution
(a) Bean Validation on the request DTO (@NotBlank): shape of the input, enforced at the edge. (b) The service (and the enum): it depends on the current state of a stored application. (c) Parameter validation in the controller (@Min(0)), also at the edge. (d) The service for a friendly error, and a database UNIQUE constraint for the guarantee, because only the database can prevent two simultaneous requests from both succeeding. (e) The controller, since status codes are HTTP, which the service must not know about.
Exercise 3
Show solutionHide solution
The request is well-formed and valid on its own, since OFFER is a legitimate status. It is refused because of the current state of the resource: moving from APPLIED straight to OFFER conflicts with the stage rules. 400 signals a malformed or invalid request that the client should fix by changing the request itself. 409 Conflict signals that the request cannot be applied to the resource as it is now, and might succeed later, for example after moving to INTERVIEWING first. The detail message tells the client which moves are allowed. (Some APIs use 422 for this. What matters is to choose one rule and apply it consistently.)
Exercise 4
Show solutionHide solution
Controller: @GetMapping("/count") public long count(@RequestParam(required = false) ApplicationStatus status) { return service.count(status); }, which returns 200 with a number (or a small record such as {"count": 3}, which is better because it can grow). Service: public long count(ApplicationStatus status) that filters repository.findAll() in memory, or with a database, repository.countByStatus(status). Possible codes: 200, and 400 for an invalid status value. There is no 404, because a count can be zero. One trap to avoid: the mapping /count must not be confused with /{id}. Spring prefers the literal path to the variable one, so GET /api/applications/count goes to the new method, but a test should prove it.
Exercise 5
Show solutionHide solution
The client can set any field of the domain object: the id (overwriting someone else's record, depending on how save works), the status (skipping the stage rules entirely), and the timestamps (backdating records). That is mass assignment. The request DTO exists to define exactly the fields a client may set, and the session above showed a request with "status":"OFFER" being ignored. The "saved" mapping code is the security boundary.
Where to go next
#- Next: JPA and Hibernate: The Persistence Model replaces the in-memory repository with a PostgreSQL database, leaving the controller and service unchanged.
- Then: Security Fundamentals and Spring Security puts authentication in front of these endpoints.
- For more on the tests: Testing Spring Boot Applications.
Sources & further reading
- Technology
- Software Engineering
- Web Development
- #Bean Validation
- #DTO
- #Java
- #Java backend
- #Java interview
- #JPA
- #learn Java
- #REST API
- #Spring Boot
- #SQL
Connected lessons
References
- Testing Spring Boot Applications: Slice Tests, MockMvc, Real Databases and the Test Pyramid
- Strings, StringBuilder, Text Blocks and Time
- JPA and Hibernate: Entities, the Persistence Context, Spring Data and Flyway Migrations
Show all 10 →
- HTTP, JSON and REST APIs: How Web Backends Talk
- Streams in Practice (and When Not to Use Them)
- SQL and PostgreSQL Fundamentals for Java Developers
- Dependency Injection and Inversion of Control: The Idea Behind Spring
- Security Fundamentals and Spring Security: Passwords, JWT, Access Control, CORS and Secrets
- Spring Boot Foundations: Starters, Auto-Configuration, Properties and Profiles
- Enums, Records and Sealed Types
Linked from 4 guides
Other pages in the library that point here.
Keep reading
More in Technology →Guide · Technology
Heaps and Priority Queues
Guide · Technology
Dynamic Programming Practice: From 1D to 2D
Course · Technology
DSA From Zero to Job-Ready: A Practical Guide with Java

