REST API Development
01 How do you create a REST API?
Solution and explanation
The layers
HTTP request → @RestController (validation, mapping)
→ @Service (business rules, @Transactional)
→ Repository (Spring Data JPA)
→ Database
DTOs carry data in and out; entities stay inside the service/repository layers.
Step 1: entity and repository
@Entity
@Table(name = "jobs")
public class Job {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
private String location;
private boolean open = true;
// getters and setters
}
public interface JobRepository extends JpaRepository<Job, Long> {
Page<Job> findByLocationIgnoreCaseAndOpenTrue(String location, Pageable pageable);
}
Step 2: DTOs
public record CreateJobRequest(@NotBlank @Size(max = 120) String title, @NotBlank String location) { }
public record JobResponse(Long id, String title, String location, boolean open) {
static JobResponse from(Job j) { return new JobResponse(j.getId(), j.getTitle(), j.getLocation(), j.isOpen()); }
}
Step 3: service
@Service
public class JobService {
private final JobRepository jobs;
public JobService(JobRepository jobs) { this.jobs = jobs; }
@Transactional
public JobResponse create(CreateJobRequest req) {
Job job = new Job();
job.setTitle(req.title());
job.setLocation(req.location());
return JobResponse.from(jobs.save(job));
}
@Transactional(readOnly = true)
public JobResponse get(Long id) {
return jobs.findById(id).map(JobResponse::from)
.orElseThrow(() -> new JobNotFoundException(id));
}
}
Step 4: controller
@RestController
@RequestMapping("/api/v1/jobs")
public class JobController {
private final JobService service;
public JobController(JobService service) { this.service = service; }
@PostMapping
public ResponseEntity<JobResponse> create(@Valid @RequestBody CreateJobRequest req) {
JobResponse created = service.create(req);
return ResponseEntity.created(URI.create("/api/v1/jobs/" + created.id())).body(created);
}
@GetMapping("/{id}")
public JobResponse get(@PathVariable Long id) { return service.get(id); }
}
Step 5: error handling
@RestControllerAdvice
public class ApiErrors {
@ExceptionHandler(JobNotFoundException.class)
ResponseEntity<Map<String, Object>> notFound(JobNotFoundException e) {
return ResponseEntity.status(404).body(Map.of("code", "JOB_NOT_FOUND", "message", e.getMessage()));
}
}
Trying it
POST /api/v1/jobs {"title":"Java Developer","location":"Pune"} → 201 Created, Location: /api/v1/jobs/1
GET /api/v1/jobs/1 → 200 OK
GET /api/v1/jobs/99 → 404 {"code":"JOB_NOT_FOUND",...}
Checklist for a production API
- Correct HTTP methods and status codes, DTOs instead of entities, validation, central error handling.
- Pagination for lists, versioning (/v1), authentication (Spring Security + JWT).
- OpenAPI documentation (springdoc), tests (@WebMvcTest, @SpringBootTest).
@RestController
@RequestMapping("/api/v1/jobs")
public class JobController {
@GetMapping
public Page<JobDto> index(
Pageable pageable
) {
return service.index(pageable);
}
}
Build a paginated jobs endpoint without returning JPA entities directly.
Quick check
Which statement is correct?
02 What are HTTP methods?
Solution and explanation
What they are
HTTP methods (verbs) tell the server what action the client wants on a resource. REST APIs map them to create, read, update and delete operations.
The main methods
- GET – read a resource or collection. Safe and idempotent. No body.
- POST – create a resource or trigger an action (send OTP, pay). Not idempotent.
- PUT – replace a resource completely. Idempotent.
- PATCH – update part of a resource. Usually not guaranteed idempotent.
- DELETE – remove a resource. Idempotent.
- HEAD – like GET but headers only (check existence/size).
- OPTIONS – what methods are allowed; used by browsers for CORS preflight.
Safe and idempotent
- Safe: does not change server state (GET, HEAD, OPTIONS).
- Idempotent: repeating the same request has the same effect as doing it once (GET, PUT, DELETE). Retrying a timed-out PUT is safe; retrying a POST may create duplicates.
Mapping for a candidate profile API
GET /api/v1/candidates list candidates
GET /api/v1/candidates/42 get one
POST /api/v1/candidates create → 201 Created
PUT /api/v1/candidates/42 replace the whole profile
PATCH /api/v1/candidates/42 change only phone/headline
DELETE /api/v1/candidates/42 delete → 204 No Content
@RestController
@RequestMapping("/api/v1/candidates")
class CandidateController {
@GetMapping List<CandidateDto> all() { ... }
@GetMapping("/{id}") CandidateDto one(@PathVariable Long id) { ... }
@PostMapping ResponseEntity<CandidateDto> create(@Valid @RequestBody NewCandidate c) { ... }
@PutMapping("/{id}") CandidateDto replace(@PathVariable Long id, @Valid @RequestBody NewCandidate c) { ... }
@PatchMapping("/{id}") CandidateDto patch(@PathVariable Long id, @RequestBody Map<String, Object> changes) { ... }
@DeleteMapping("/{id}") ResponseEntity<Void> delete(@PathVariable Long id) { ... }
}
Making POST safe to retry (payments)
Clients send an Idempotency-Key header; the server stores the first result for that key and returns it for repeats, so a retried "pay" request never charges twice.
Note: actions that are not CRUD (approve, cancel, refund) are usually modelled as POST /orders/{id}/cancel.
GET /api/jobs
POST /api/jobs
PUT /api/jobs/42
PATCH /api/jobs/42
DELETE /api/jobs/42
Design CRUD routes for job applications and identify which operations should be idempotent.
Quick check
Which statement is correct?
03 PUT versus PATCH
Solution and explanation
The difference
- PUT replaces the entire resource with the representation you send. Fields you omit are cleared (set to null/default). Idempotent.
- PATCH applies a partial change: only the fields you send are modified. Not guaranteed idempotent (e.g. "increment views").
Example: a candidate profile
Current state:
{"id": 42, "name": "Asha", "phone": "98100...", "city": "Pune", "headline": "Java Developer"}
PUT with only name and city:
PUT /api/v1/candidates/42
{"name": "Asha Rao", "city": "Bengaluru"}
Result: phone and headline become null, because PUT replaces everything.
PATCH with only city:
PATCH /api/v1/candidates/42
{"city": "Bengaluru"}
Result: only city changes; everything else stays.
Implementing PATCH in Spring
public record CandidatePatch(Optional<String> phone, Optional<String> city, Optional<String> headline) { }
@PatchMapping("/{id}")
@Transactional
public CandidateDto patch(@PathVariable Long id, @RequestBody CandidatePatch patch) {
Candidate c = candidates.findById(id).orElseThrow();
patch.phone().ifPresent(c::setPhone);
patch.city().ifPresent(c::setCity);
patch.headline().ifPresent(c::setHeadline);
return CandidateDto.from(c); // dirty checking saves the changes on commit
}
Implementing PUT
@PutMapping("/{id}")
@Transactional
public CandidateDto replace(@PathVariable Long id, @Valid @RequestBody CandidateRequest body) {
Candidate c = candidates.findById(id).orElseThrow();
c.setName(body.name()); c.setPhone(body.phone()); c.setCity(body.city()); c.setHeadline(body.headline());
return CandidateDto.from(c);
}
Which to use
- Forms that always submit the full object → PUT.
- Mobile apps editing one field, toggles, status changes → PATCH.
Note: JSON Merge Patch (application/merge-patch+json) and JSON Patch (application/json-patch+json, a list of operations) are standard PATCH formats for more complex needs.
@PatchMapping("/{id}")
public JobDto updateStatus(
@PathVariable Long id,
@RequestBody UpdateStatusRequest request
) {
return service.updateStatus(
id,
request
);
}
Implement a PATCH endpoint that changes only the application status.
Quick check
Which statement is correct?
04 What is ResponseEntity?
Solution and explanation
What it is
ResponseEntity<T> represents the whole HTTP response: status code, headers and body. Returning it from a controller gives you full control instead of the default 200 OK.
@PostMapping("/api/v1/applications")
public ResponseEntity<ApplicationDto> apply(@Valid @RequestBody ApplyRequest req) {
ApplicationDto created = service.apply(req);
return ResponseEntity
.created(URI.create("/api/v1/applications/" + created.id())) // 201 + Location header
.header("X-Request-Id", MDC.get("requestId"))
.body(created);
}
@GetMapping("/api/v1/applications/{id}")
public ResponseEntity<ApplicationDto> get(@PathVariable Long id) {
return service.find(id)
.map(ResponseEntity::ok) // 200 with body
.orElse(ResponseEntity.notFound().build()); // 404 without body
}
@DeleteMapping("/api/v1/applications/{id}")
public ResponseEntity<Void> withdraw(@PathVariable Long id) {
service.withdraw(id);
return ResponseEntity.noContent().build(); // 204
}
Useful builders
- ok(body), created(uri), accepted(), noContent(), badRequest(), notFound(), status(HttpStatus.CONFLICT).
- header(name, value), contentType(MediaType.APPLICATION_PDF), eTag(...), cacheControl(...).
File download example
@GetMapping("/api/v1/resumes/{id}/download")
public ResponseEntity<Resource> download(@PathVariable Long id) {
Resource file = storage.load(id);
return ResponseEntity.ok()
.contentType(MediaType.APPLICATION_PDF)
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"resume.pdf\"")
.body(file);
}
When you do not need it
Returning a plain object gives 200 automatically; @ResponseStatus(HttpStatus.CREATED) on the method changes the default status. Use ResponseEntity when status or headers depend on the outcome.
URI location =
URI.create(
"/api/v1/jobs/" + job.id()
);
return ResponseEntity
.created(location)
.body(job);
Return 201 Created with a Location header after creating a job.
Quick check
Which statement is correct?
05 Which HTTP status codes should APIs use?
Solution and explanation
Families
- 2xx success, 3xx redirection, 4xx client error (fix the request), 5xx server error (retry may help).
The codes you will use most
- 200 OK – successful GET/PUT/PATCH with a body.
- 201 Created – a POST created a resource; include a Location header.
- 202 Accepted – work queued for later (bulk import, report generation).
- 204 No Content – success without a body (DELETE).
- 400 Bad Request – malformed JSON, invalid parameters, validation failure (some APIs use 422 for validation).
- 401 Unauthorized – not authenticated (missing or expired token).
- 403 Forbidden – authenticated but not allowed.
- 404 Not Found – resource does not exist (or is hidden from this user).
- 405 Method Not Allowed – path exists, method does not.
- 409 Conflict – state conflict: duplicate email, applying twice, order already shipped.
- 415 Unsupported Media Type – wrong Content-Type.
- 422 Unprocessable Entity – well-formed but semantically invalid.
- 429 Too Many Requests – rate limit hit; include Retry-After.
- 500 Internal Server Error – unexpected bug; never expose stack traces.
- 502/503/504 – upstream service or gateway problems; clients may retry with backoff.
Mapping exceptions to codes in Spring
@RestControllerAdvice
public class ApiExceptionHandler {
@ExceptionHandler(ResourceNotFoundException.class)
ResponseEntity<ApiError> notFound(ResourceNotFoundException e) { return error(404, "NOT_FOUND", e.getMessage()); }
@ExceptionHandler(DuplicateApplicationException.class)
ResponseEntity<ApiError> conflict(DuplicateApplicationException e) { return error(409, "ALREADY_APPLIED", e.getMessage()); }
@ExceptionHandler(MethodArgumentNotValidException.class)
ResponseEntity<ApiError> invalid(MethodArgumentNotValidException e) { return error(400, "VALIDATION_FAILED", "Check the highlighted fields"); }
private ResponseEntity<ApiError> error(int status, String code, String message) {
return ResponseEntity.status(status).body(new ApiError(code, message, Instant.now()));
}
}
Note: the status code and body must agree – never return 200 with {"error": ...}; clients, proxies and monitoring rely on the status.
return ResponseEntity
.noContent()
.build();
Map five job-portal scenarios to appropriate HTTP status codes.
Quick check
Which statement is correct?
06 How do you validate API requests?
Solution and explanation
Bean Validation in Spring Boot
Add spring-boot-starter-validation, annotate DTO fields with constraints, and put @Valid on the @RequestBody (or @Validated on the controller for path/query parameters).
public record RegisterCandidateRequest(
@NotBlank @Size(max = 80) String name,
@NotBlank @Email String email,
@Pattern(regexp = "^[6-9]\\d{9}$", message = "must be a valid Indian mobile number") String phone,
@Min(0) @Max(50) Integer experienceYears,
@Past LocalDate dateOfBirth,
@NotEmpty List<@NotBlank String> skills) { }
@PostMapping("/api/v1/candidates")
public ResponseEntity<CandidateDto> register(@Valid @RequestBody RegisterCandidateRequest req) { ... }
Common constraints
@NotNull, @NotBlank, @NotEmpty, @Size, @Min/@Max, @Positive, @Email, @Pattern, @Past/@Future, @Digits, @DecimalMin. Use @Valid on nested objects and list elements.
Returning field errors clearly
@ExceptionHandler(MethodArgumentNotValidException.class)
ResponseEntity<Map<String, Object>> handle(MethodArgumentNotValidException ex) {
Map<String, String> fields = new LinkedHashMap<>();
ex.getBindingResult().getFieldErrors().forEach(e -> fields.put(e.getField(), e.getDefaultMessage()));
return ResponseEntity.badRequest().body(Map.of("code", "VALIDATION_FAILED", "errors", fields));
}
400 Bad Request
{"code":"VALIDATION_FAILED","errors":{"email":"must be a well-formed email address","phone":"must be a valid Indian mobile number"}}
Validating path and query parameters
@Validated
@RestController
class JobController {
@GetMapping("/api/v1/jobs")
List<JobDto> search(@RequestParam @Min(1) @Max(100) int size) { ... } // ConstraintViolationException
}
Custom validation
@Target(FIELD) @Retention(RUNTIME) @Constraint(validatedBy = PanValidator.class)
public @interface ValidPan { String message() default "invalid PAN"; Class<?>[] groups() default {}; Class<? extends Payload>[] payload() default {}; }
public class PanValidator implements ConstraintValidator<ValidPan, String> {
public boolean isValid(String v, ConstraintValidatorContext c) { return v == null || v.matches("[A-Z]{5}[0-9]{4}[A-Z]"); }
}
Note: validation of format belongs in DTOs; business rules (email already registered, job closed) belong in the service and usually return 409 or 422.
public record CreateUserRequest(
@NotBlank
String name,
@Email
String email
) {
}
@PostMapping
public UserDto create(
@Valid
@RequestBody
CreateUserRequest request
) {
return service.create(request);
}
Test blank names and malformed email addresses and inspect the validation response.
Quick check
Which statement is correct?
07 What is global exception handling?
Solution and explanation
What it is
Instead of try/catch in every controller, a single @RestControllerAdvice class handles exceptions thrown from any controller and turns them into consistent HTTP responses.
@RestControllerAdvice
public class GlobalExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<ApiError> notFound(ResourceNotFoundException ex) {
return build(HttpStatus.NOT_FOUND, "NOT_FOUND", ex.getMessage());
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiError> validation(MethodArgumentNotValidException ex) {
String msg = ex.getBindingResult().getFieldErrors().stream()
.map(e -> e.getField() + " " + e.getDefaultMessage()).collect(Collectors.joining("; "));
return build(HttpStatus.BAD_REQUEST, "VALIDATION_FAILED", msg);
}
@ExceptionHandler(AccessDeniedException.class)
public ResponseEntity<ApiError> forbidden(AccessDeniedException ex) {
return build(HttpStatus.FORBIDDEN, "FORBIDDEN", "You cannot access this resource");
}
@ExceptionHandler(Exception.class) // safety net
public ResponseEntity<ApiError> unexpected(Exception ex) {
log.error("Unhandled error", ex); // full stack trace only in logs
return build(HttpStatus.INTERNAL_SERVER_ERROR, "SERVER_ERROR", "Something went wrong");
}
private ResponseEntity<ApiError> build(HttpStatus status, String code, String message) {
return ResponseEntity.status(status).body(new ApiError(code, message, Instant.now()));
}
}
public record ApiError(String code, String message, Instant timestamp) { }
How Spring chooses a handler
The most specific matching @ExceptionHandler wins (ResourceNotFoundException before Exception). Handlers can also live in a single controller for local rules.
ProblemDetail (Spring 6 / Boot 3)
A standard error format (RFC 7807):
@ExceptionHandler(ResourceNotFoundException.class)
ProblemDetail notFound(ResourceNotFoundException ex) {
ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
pd.setTitle("Job not found");
return pd;
}
Benefits
Consistent error shape for clients, no duplicated try/catch, no stack traces leaking to users, one place for logging and monitoring.
@RestControllerAdvice
public class ApiErrors {
@ExceptionHandler(
JobNotFoundException.class
)
ResponseEntity<ApiError> missing(
JobNotFoundException ex
) {
return ResponseEntity
.status(404)
.body(
ApiError.of(
ex.getMessage()
)
);
}
}
Handle validation, not-found and conflict errors through one centralized advice class.
Quick check
Which statement is correct?
08 How do you create a custom exception?
Solution and explanation
Why custom exceptions
They give business failures a name (JobClosedException, InsufficientBalanceException) so the service layer can signal them clearly and the global handler can map each to the right status code and error code.
Step 1: define the exception
public class JobClosedException extends RuntimeException {
private final Long jobId;
public JobClosedException(Long jobId) {
super("Job " + jobId + " is no longer accepting applications");
this.jobId = jobId;
}
public Long getJobId() { return jobId; }
}
Extend RuntimeException (unchecked) for business errors: no throws clauses everywhere, and @Transactional rolls back on unchecked exceptions by default.
Step 2: throw it from the service
@Transactional
public ApplicationDto apply(Long jobId, Long candidateId) {
Job job = jobs.findById(jobId).orElseThrow(() -> new ResourceNotFoundException("Job", jobId));
if (!job.isOpen()) throw new JobClosedException(jobId);
if (applications.existsByJobIdAndCandidateId(jobId, candidateId)) throw new DuplicateApplicationException(jobId);
return ApplicationDto.from(applications.save(new Application(job, candidateId)));
}
Step 3: map it to HTTP
@ExceptionHandler(JobClosedException.class)
ResponseEntity<ApiError> closed(JobClosedException ex) {
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(new ApiError("JOB_CLOSED", ex.getMessage(), Instant.now()));
}
Or annotate the exception itself: @ResponseStatus(HttpStatus.CONFLICT).
A small hierarchy keeps handlers simple
public abstract class BusinessException extends RuntimeException {
private final String code; private final HttpStatus status;
protected BusinessException(String code, HttpStatus status, String message) { super(message); this.code = code; this.status = status; }
public String code() { return code; } public HttpStatus status() { return status; }
}
public class JobClosedException extends BusinessException {
public JobClosedException(Long id) { super("JOB_CLOSED", HttpStatus.CONFLICT, "Job " + id + " is closed"); }
}
@ExceptionHandler(BusinessException.class)
ResponseEntity<ApiError> business(BusinessException ex) {
return ResponseEntity.status(ex.status()).body(new ApiError(ex.code(), ex.getMessage(), Instant.now()));
}
Note: keep the original cause when wrapping technical errors: new PaymentFailedException("Gateway timeout", cause).
public class JobNotFoundException
extends RuntimeException {
public JobNotFoundException(Long id) {
super(
"Job " + id +
" was not found"
);
}
}
Throw JobNotFoundException when a requested job does not exist.
Quick check
Which statement is correct?
09 How should APIs return errors?
Solution and explanation
Principles
- Use the correct HTTP status code.
- Return a consistent JSON structure for every error.
- Include a stable, machine-readable code that clients can switch on.
- Give a human-readable message, plus field-level details for validation.
- Add a trace/request id so support can find the logs.
- Never expose stack traces, SQL or internal class names.
A consistent error body
{
"code": "VALIDATION_FAILED",
"message": "Some fields are invalid",
"status": 400,
"path": "/api/v1/candidates",
"timestamp": "2026-10-12T10:20:30Z",
"traceId": "8f2c1e7a9b",
"errors": [
{"field": "email", "message": "must be a well-formed email address"},
{"field": "experienceYears", "message": "must be less than or equal to 50"}
]
}
Error codes clients can rely on
- VALIDATION_FAILED (400), UNAUTHENTICATED (401), FORBIDDEN (403), NOT_FOUND (404), ALREADY_APPLIED (409), RATE_LIMITED (429), SERVER_ERROR (500).
A React or mobile app shows a friendly message per code and highlights fields from errors[].
Implementation in Spring
public record ApiError(String code, String message, int status, String path, Instant timestamp,
String traceId, List<FieldError> errors) {
public record FieldError(String field, String message) { }
}
Build it in @RestControllerAdvice for every exception type (see global exception handling), taking the path from HttpServletRequest and the trace id from MDC.
Payment API example
{"code": "PAYMENT_DECLINED", "message": "The bank declined the card", "status": 402,
"decline_reason": "insufficient_funds", "retryable": false}
Telling the client whether a retry makes sense prevents duplicate charges.
Note: Spring Boot 3's ProblemDetail follows RFC 7807 and is a good standard shape; extend it with your own properties.
public record ApiError(
Instant timestamp,
int status,
String code,
String message,
String path
) {
}
Create one structured response for a missing job and another for invalid request fields.
Quick check
Which statement is correct?
10 What is API versioning?
Solution and explanation
Why it is needed
Mobile apps and partner integrations cannot all update at the same moment. When you make a breaking change (renaming a field, changing a type, removing an endpoint), existing clients would fail. Versioning lets old and new contracts run side by side.
Breaking vs non-breaking changes
- Non-breaking: adding a new optional field or endpoint – no new version needed.
- Breaking: removing or renaming fields, changing types or meanings, new required inputs – needs a new version.
Strategies
- URI path (most common, easy to test and route):
@RestController @RequestMapping("/api/v1/jobs") class JobControllerV1 { ... }
@RestController @RequestMapping("/api/v2/jobs") class JobControllerV2 { ... }
- Request parameter: /api/jobs?version=2.
- Custom header: X-API-Version: 2
@GetMapping(value = "/api/jobs", headers = "X-API-Version=2")
public List<JobV2> listV2() { ... }
- Media type (content negotiation): Accept: application/vnd.ascendia.v2+json
@GetMapping(value = "/api/jobs", produces = "application/vnd.ascendia.v2+json")
public List<JobV2> listV2() { ... }
Example of a breaking change
v1 returns {"salary": "8-12 LPA"}; v2 returns {"salary": {"min": 800000, "max": 1200000, "currency": "INR"}}. Both versions read from the same service; only the DTO mapping differs.
Lifecycle
- Announce deprecation (Deprecation and Sunset headers, docs, emails).
- Monitor traffic per version; retire v1 after clients migrate.
- Keep the number of live versions small (usually 2).
Note: share business logic between versions – only controllers and DTOs should be version-specific.
@RequestMapping("/api/v1/jobs")
public class JobV1Controller {
}
Introduce a v2 DTO without silently breaking existing v1 consumers.
Quick check
Which statement is correct?
Spring Quiz: Lessons 1 – 10
Test what you have learned before continuing to the next learning block.
Which statement is correct?
Which statement is correct?
Which statement is correct?
Which statement is correct?
Which statement is correct?
Which statement is correct?
Which statement is correct?
Which statement is correct?
Which statement is correct?
Which statement is correct?
