Spring Boot is the most popular way to build Java backends. In this tutorial you build a small Course API with Create, Read, Update and Delete (CRUD) endpoints using Spring Web and Spring Data JPA.
Project setup
Go to start.spring.io and choose:
- Project: Maven, Language: Java 21, latest stable Spring Boot 3.x
- Dependencies: Spring Web, Spring Data JPA, Validation, H2 Database
H2 is an in-memory database, so you can run everything without installing a database. Switch to PostgreSQL or MySQL later by changing the dependency and application.properties.
The layers
| Layer | Responsibility |
|---|---|
| Entity | Maps a Java class to a database table |
| Repository | Database access (Spring generates the code) |
| Service | Business rules |
| Controller | HTTP endpoints and status codes |
Step 1: Entity
@Entity
public class Course {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@NotBlank
private String title;
@Min(1)
private int durationWeeks;
// getters and setters
}Step 2: Repository
public interface CourseRepository extends JpaRepository<Course, Long> {
List<Course> findByTitleContainingIgnoreCase(String text);
}Spring Data creates the SQL for save, findById, findAll, deleteById and even the custom findByTitleContainingIgnoreCase from the method name.
Step 3: Service
@Service
public class CourseService {
private final CourseRepository repo;
public CourseService(CourseRepository repo) { this.repo = repo; }
public List<Course> all() { return repo.findAll(); }
public Course get(Long id) {
return repo.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Course not found"));
}
public Course create(Course c) { return repo.save(c); }
public Course update(Long id, Course input) {
Course c = get(id);
c.setTitle(input.getTitle());
c.setDurationWeeks(input.getDurationWeeks());
return repo.save(c);
}
public void delete(Long id) { repo.delete(get(id)); }
}Constructor injection (no @Autowired needed) makes the class easy to unit test.
Step 4: Controller
@RestController
@RequestMapping("/api/courses")
public class CourseController {
private final CourseService service;
public CourseController(CourseService service) { this.service = service; }
@GetMapping
public List<Course> list() { return service.all(); }
@GetMapping("/{id}")
public Course get(@PathVariable Long id) { return service.get(id); }
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public Course create(@Valid @RequestBody Course c) { return service.create(c); }
@PutMapping("/{id}")
public Course update(@PathVariable Long id, @Valid @RequestBody Course c) { return service.update(id, c); }
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void delete(@PathVariable Long id) { service.delete(id); }
}@Valid triggers the @NotBlank and @Min checks; invalid input returns 400 Bad Request automatically.
Step 5: Test with curl
curl -X POST localhost:8080/api/courses -H "Content-Type: application/json" \
-d '{"title":"Playwright with AI","durationWeeks":8}'
curl localhost:8080/api/courses
curl -X PUT localhost:8080/api/courses/1 -H "Content-Type: application/json" \
-d '{"title":"Playwright + AI","durationWeeks":10}'
curl -X DELETE localhost:8080/api/courses/1 -iREST status codes to remember
| Code | Meaning | When |
|---|---|---|
| 200 | OK | Successful GET or PUT |
| 201 | Created | Successful POST |
| 204 | No Content | Successful DELETE |
| 400 | Bad Request | Validation failed |
| 404 | Not Found | ID does not exist |
Interview questions
- `@Controller` vs `@RestController`?
@RestController=@Controller+@ResponseBody, so return values are written as JSON. - PUT vs PATCH? PUT replaces the whole resource; PATCH updates only the given fields.
- Why use DTOs? To avoid exposing entity internals and to control exactly what the API accepts and returns.
Next steps
Add DTOs, global exception handling with @RestControllerAdvice, and pagination with Pageable. Learn security, microservices and deployment in the Java + Spring Boot + AI course.
