- Create and run a Spring Boot project
- Explain the
@RestController,@Service,@Repositorylayers and dependency injection - Write a CRUD API with correct HTTP statuses and test it with curl
A mobile app — say, an electronic school diary — gets grades from a server: the app sends an HTTP request (GET /api/students), and the server returns JSON. Such a server is called a REST API. In the Java world the most popular way to write one is Spring Boot: with a few annotations ordinary classes become a web server, and JSON, databases and configuration are wired up automatically.
Creating the project
- 1Open start.spring.io
This is the official project generator (IntelliJ IDEA Ultimate has the same wizard). Choose Maven or Gradle for Project, Java for Language and 21 for Java.
- 2Fill in the metadata
Group
com.educora, Artifactschool, NameSchoolApi. The package will becom.educora.schooland the main classSchoolApiApplication. - 3Add dependencies
Press ADD DEPENDENCIES and choose Spring Web. Real projects usually also add Spring Data JPA, a database driver and Validation.
- 4Generate and open
GENERATE downloads a ZIP archive. Unzip it, open the folder in your IDE and run the main class.
package com.educora.school;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class SchoolApiApplication {
public static void main(String[] args) {
SpringApplication.run(SchoolApiApplication.class, args);
}
}@SpringBootApplication and a one-line main@SpringBootApplication combines three things: it makes the class a configuration source and switches on the search for components in the package (component scan) and auto-configuration. The latter looks at the classpath: if Spring Web is there, an embedded Tomcat server starts on port 8080 and Jackson is wired in for JSON. You do not have to install a separate server — the app is a JAR with an ordinary main method.
./mvnw spring-boot:run... Tomcat started on port 8080 (http) with context path '/' ... Started SchoolApiApplication in 1.9 seconds (process running for 2.2)
./gradlew bootRunLayers and dependency injection
| Layer | Annotation | Job |
|---|---|---|
| Controller | @RestController | receives HTTP requests and returns JSON |
| Service | @Service | business rules: validation, calculations |
| Repository | @Repository | stores and reads data |
Spring creates the classes marked with these annotations itself — they are called beans — and keeps them in the IoC container (usually one instance of each class). If a class's constructor needs another bean, Spring passes it in automatically: this is called dependency injection. As a result, classes do not create each other with new, and in a test a dependency is easy to replace with a mock — just like in the JUnit lesson.
@RestController
public class StudentController {
// the controller builds its own dependencies:
// hard to test, and every class gets its own copy
private final StudentService service =
new StudentService(new StudentRepository(), 100);
}@RestController
public class StudentController {
private final StudentService service;
// Spring creates StudentService once and passes it in
public StudentController(StudentService service) {
this.service = service;
}
}A CRUD API for students
Now let's build a full CRUD API for students: Create, Read, Update, Delete. For simplicity the repository keeps the data in memory, in a ConcurrentHashMap: requests arrive on parallel threads, so a thread-safe collection was chosen.
public record Student(Long id, String name, int score) {}
@Repository
public class StudentRepository {
private final Map<Long, Student> students = new ConcurrentHashMap<>();
private final AtomicLong nextId = new AtomicLong(1);
public List<Student> findAll() {
return students.values().stream().sorted(Comparator.comparing(Student::id)).toList();
}
public Optional<Student> findById(long id) {
return Optional.ofNullable(students.get(id));
}
public Student save(Student s) {
long id = s.id() != null ? s.id() : nextId.getAndIncrement();
Student saved = new Student(id, s.name(), s.score());
students.put(id, saved);
return saved;
}
public boolean deleteById(long id) {
return students.remove(id) != null;
}
}@Service
public class StudentService {
private final StudentRepository repository;
private final int maxScore;
public StudentService(StudentRepository repository,
@Value("${school.max-score:100}") int maxScore) {
this.repository = repository;
this.maxScore = maxScore;
}
public List<Student> all() { return repository.findAll(); }
public Student get(long id) {
return repository.findById(id).orElseThrow(() ->
new ResponseStatusException(HttpStatus.NOT_FOUND, "Student " + id + " not found"));
}
public Student save(Long id, Student s) {
if (s.score() < 0 || s.score() > maxScore)
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Score must be 0-" + maxScore);
if (id != null) get(id); // 404 if we update a missing student
return repository.save(new Student(id, s.name(), s.score()));
}
public void delete(long id) {
if (!repository.deleteById(id)) throw new ResponseStatusException(HttpStatus.NOT_FOUND);
}
}school.max-score from application.properties@RestController
@RequestMapping("/api/students")
public class StudentController {
private final StudentService service;
public StudentController(StudentService service) {
this.service = service;
}
@GetMapping
public List<Student> all() { return service.all(); }
@GetMapping("/{id}")
public Student one(@PathVariable long id) { return service.get(id); }
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public Student create(@RequestBody Student student) { return service.save(null, student); }
@PutMapping("/{id}")
public Student update(@PathVariable long id, @RequestBody Student student) {
return service.save(id, student);
}
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void delete(@PathVariable long id) { service.delete(id); }
}| Request | What it does | Success status |
|---|---|---|
GET /api/students | all students | 200 OK |
GET /api/students/{id} | one student; 404 if missing | 200 OK |
POST /api/students | creates a student | 201 Created |
PUT /api/students/{id} | updates a student | 200 OK |
DELETE /api/students/{id} | deletes a student | 204 No Content |
The table shows the main REST conventions: the URL names a resource with a plural noun (/api/students), and the HTTP method says what to do. That is why paths like /api/getStudents or /api/deleteStudent?id=2 are considered bad style. GET must never change data, PUT replaces the resource as a whole, and the status code reports the result — the client knows what happened without reading the response text.
@RequestMapping sets the common path for the whole controller. @PathVariable turns the {id} in the URL into a parameter, and @RequestBody turns the JSON body of the request into a Student object; the Jackson library turns the response object back into JSON. ResponseStatusException lets you return the right HTTP status — such as 404 or 400 — from anywhere. @Value("${school.max-score:100}") reads a setting from the configuration; the 100 after the colon is the default value.
spring.application.name=school-api
server.port=8080
school.max-score=100src/main/resources/application.properties — the app's settingsSettings can be changed without touching the code: java -jar school.jar --server.port=9090 or the SERVER_PORT environment variable. For different environments there are profiles: for example, the application-dev.properties file is activated with --spring.profiles.active=dev.
Testing with curl
While the server is running, open a second terminal. curl is a command-line tool for sending HTTP requests: -X sets the method, -H a header and -d the body. First let's create two students and read the list:
curl -X POST localhost:8080/api/students -H 'Content-Type: application/json' -d '{"name":"Aysel","score":95}'
curl -X POST localhost:8080/api/students -H 'Content-Type: application/json' -d '{"name":"Murad","score":78}'
curl localhost:8080/api/students
curl localhost:8080/api/students/1{"id":1,"name":"Aysel","score":95}
{"id":2,"name":"Murad","score":78}
[{"id":1,"name":"Aysel","score":95},{"id":2,"name":"Murad","score":78}]
{"id":1,"name":"Aysel","score":95}idNow updating, deleting and the error cases. -w '%{http_code}' prints only the status code, and -s -o /dev/null hides the body and progress information:
curl -X PUT localhost:8080/api/students/2 -H 'Content-Type: application/json' -d '{"name":"Murad","score":84}'
curl -s -o /dev/null -w '%{http_code}\n' -X DELETE localhost:8080/api/students/2
curl -s -o /dev/null -w '%{http_code}\n' localhost:8080/api/students/2
curl -s -o /dev/null -w '%{http_code}\n' -X POST localhost:8080/api/students \
-H 'Content-Type: application/json' -d '{"name":"Elvin","score":120}'{"id":2,"name":"Murad","score":84}
204
404
400@SpringBootTest
@AutoConfigureMockMvc
class StudentControllerTest {
@Autowired
MockMvc mvc;
@Test
void createsStudent() throws Exception {
mvc.perform(post("/api/students")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"name\":\"Aysel\",\"score\":95}"))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.name").value("Aysel"));
}
}MockMvc imitates an HTTP request without starting a real server (imports omitted)Key points
- Spring Boot:
@SpringBootApplication+ auto-configuration + embedded Tomcat; start it with./mvnw spring-boot:run. - Layers:
@RestController→@Service→@Repository; Spring creates the beans and passes them to constructors. @GetMapping,@PostMapping,@PutMapping,@DeleteMapping,@PathVariable,@RequestBody; Jackson handles the JSON conversion.- Correct statuses: 200, 201, 204, 400, 404 — with
@ResponseStatusandResponseStatusException. - Settings live in
application.propertiesand are read with@Value; the API is tested with curl orMockMvc.
Check yourself
10 questions. Every correct answer earns XP.