Masoud Hamad

Lab 6 Walkthrough — Spring Boot REST API: Students CRUD

Audience: Lecturer guiding a 3-hour lab. How to use: Read top to bottom. Every code block is meant to be copied verbatim into the editor or terminal. Each “STEP” is one paste-and-explain moment. End state: A working Spring Boot REST API that creates / reads / updates / deletes students, validates input, returns clean error JSON, and ships with Swagger UI.

The full finished project lives at examples/lab06_students_api/ — open it in IntelliJ or VS Code if you’d rather demo from a complete copy.


Table of contents

  1. What we are building (3 min)
  2. Prerequisites & sanity check (5 min)
  3. Step-by-step build (90–110 min)
    • STEP 1 — Generate the project from Spring Initializr
    • STEP 2 — Start a local Postgres with Docker
    • STEP 3 — Open the project and run the empty app
    • STEP 4 — Configure application.properties
    • STEP 5 — Create package structure
    • STEP 6 — Write the Student entity
    • STEP 7 — Write the StudentRepository
    • STEP 8 — Write the three DTOs
    • STEP 9 — Write custom exceptions
    • STEP 10 — Write the StudentService
    • STEP 11 — Write the StudentController
    • STEP 12 — Write the GlobalExceptionHandler
    • STEP 13 — Seed some data on startup
    • STEP 14 — First run + first request
  4. Test every endpoint (20 min)
  5. Swagger UI (5 min)
  6. What students must submit
  7. Common errors and fixes

1. What we are building (3 min)

A REST API that manages student records. By the end of the lab the API will support:

Method URL Purpose
GET /api/students List all students
GET /api/students?course=BCS Filter by course
GET /api/students/{id} Get one student
POST /api/students Create a student
PUT /api/students/{id} Replace a student
DELETE /api/students/{id} Delete a student

The architecture in one diagram

HTTP request
     │
     ▼
┌─────────────────────┐
│ StudentController   │  ← @RestController (thin: HTTP only)
└─────────────────────┘
     │
     ▼
┌─────────────────────┐
│ StudentService      │  ← @Service (business rules + transactions)
└─────────────────────┘
     │
     ▼
┌─────────────────────┐
│ StudentRepository   │  ← extends JpaRepository (CRUD comes free)
└─────────────────────┘
     │
     ▼
   H2 in-memory DB     ← students table

Plus a side-channel: GlobalExceptionHandler (@RestControllerAdvice) catches every exception thrown anywhere above and turns it into clean JSON.

Why these layers


2. Prerequisites & sanity check (5 min)

Run these four commands. All four must work before continuing.

java -version           # expect 21 (any modern JDK ≥ 17 works, but Initializr will assume 21)
mvn -version            # expect Apache Maven ≥ 3.9
docker --version        # we use Docker to run Postgres locally
curl --version          # any version

If any of those fail, fix it before proceeding — see the Setup Guide.


3. Step-by-step build

STEP 1 — Generate the project from Spring Initializr

Open https://start.spring.io and fill it in:

Field Value
Project Maven
Language Java
Spring Boot 3.3.4 (or latest 3.3.x)
Group tz.ac.suza.wt
Artifact students-api
Packaging Jar
Java 21

Click ADD DEPENDENCIES and tick all five:

Click GENERATE. A students-api.zip downloads.

unzip students-api.zip -d ~/Code/
cd ~/Code/students-api

💡 Teach moment. Initializr is just a UI over a Maven archetype. You can do the same thing with a single curl command — but the website is friendlier for class.

STEP 2 — Start a local Postgres with Docker

Spring needs a real database to talk to. We use PostgreSQL — the same database students will use in production. Docker means “no install ceremony”: one command and we have a running DB.

Create docker-compose.yml in the project root:

services:
  db:
    image: postgres:16-alpine
    container_name: students-api-db
    restart: unless-stopped
    environment:
      POSTGRES_DB: studentsdb
      POSTGRES_USER: studentsapi
      POSTGRES_PASSWORD: studentsapi
    ports:
      - "5432:5432"
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U studentsapi -d studentsdb"]
      interval: 5s
      retries: 10

volumes:
  pgdata:

Start it:

docker compose up -d
docker compose ps        # status column should say "healthy" after ~5s

You now have a Postgres running at localhost:5432, with:

💡 Teach moment. The named volume pgdata keeps the data alive between restarts. docker compose down -v (with -v) deletes the volume too — handy for a clean slate, dangerous in production.

You can connect from the host with psql or DBeaver:

docker exec -it students-api-db psql -U studentsapi -d studentsdb
# inside psql:
\l        # list databases
\dt       # list tables (none yet)
\q

STEP 3 — Open the project in your editor

Open the folder in IntelliJ IDEA or VS Code. Wait for it to import the Maven project — that downloads Spring Boot and all its transitive dependencies (200 MB of jars, one-time).

💡 Teach moment. We can’t run the app yet — Spring needs a datasource URL and we haven’t given it one. We’ll do that in STEP 4 and start the app in STEP 14.

STEP 4 — Configure application.properties

Replace the contents of src/main/resources/application.properties with:

spring.application.name=students-api

# ----- PostgreSQL ---------------------------------------------------------
# Override these via env vars in production:
#   SPRING_DATASOURCE_URL=jdbc:postgresql://prod-host:5432/studentsdb
#   SPRING_DATASOURCE_USERNAME=...   SPRING_DATASOURCE_PASSWORD=...
spring.datasource.url=jdbc:postgresql://localhost:5432/studentsdb
spring.datasource.username=studentsapi
spring.datasource.password=studentsapi
spring.datasource.driver-class-name=org.postgresql.Driver

# ----- JPA / Hibernate ----------------------------------------------------
# 'update' is fine for development; for production use Flyway/Liquibase migrations
# and set ddl-auto=validate.
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect
spring.jpa.open-in-view=false

# ----- Swagger / OpenAPI --------------------------------------------------
springdoc.swagger-ui.path=/swagger-ui.html
springdoc.api-docs.path=/v3/api-docs

💡 Teach moment.

STEP 5 — Create package structure

Inside src/main/java/tz/ac/suza/wt/studentsapi/ create six new packages:

config/
controller/
dto/
entity/
exception/
repository/
service/

💡 Teach moment. We’re organising by layer, not by feature. Both are valid. By-layer is easier to learn; by-feature scales better. We pick by-layer here.

STEP 6 — Write the Student entity

Create entity/Student.java:

package tz.ac.suza.wt.studentsapi.entity;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

import java.time.Instant;

@Entity
@Table(name = "students")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Student {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, length = 100)
    private String name;

    @Column(nullable = false, unique = true, length = 150)
    private String email;

    @Column(nullable = false, length = 100)
    private String course;

    @Column(nullable = false)
    private Integer year;

    @Column(nullable = false, updatable = false)
    private Instant createdAt;
}

💡 Teach moment.

STEP 7 — Write the StudentRepository

Create repository/StudentRepository.java:

package tz.ac.suza.wt.studentsapi.repository;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import tz.ac.suza.wt.studentsapi.entity.Student;

import java.util.List;
import java.util.Optional;

@Repository
public interface StudentRepository extends JpaRepository<Student, Long> {

    List<Student> findByCourse(String course);

    Optional<Student> findByEmail(String email);

    boolean existsByEmail(String email);
}

💡 Teach moment. We didn’t write any SQL. Spring Data JPA reads our method names and generates the queries at runtime: findByCourse becomes SELECT * FROM students WHERE course = ?. This is called derived query methods. It’s magical the first time you see it.

STEP 8 — Write the three DTOs

We need three:

dto/StudentCreateDto.java

package tz.ac.suza.wt.studentsapi.dto;

import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;

public record StudentCreateDto(

        @NotBlank(message = "name is required")
        @Size(min = 2, max = 100, message = "name must be 2..100 characters")
        String name,

        @NotBlank(message = "email is required")
        @Email(message = "email must be a valid address")
        @Size(max = 150)
        String email,

        @NotBlank(message = "course is required")
        @Size(min = 2, max = 100)
        String course,

        @NotNull(message = "year is required")
        @Min(value = 1, message = "year must be between 1 and 4")
        @Max(value = 4, message = "year must be between 1 and 4")
        Integer year
) {}

dto/StudentUpdateDto.java

package tz.ac.suza.wt.studentsapi.dto;

import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;

public record StudentUpdateDto(

        @NotBlank @Size(min = 2, max = 100)
        String name,

        @NotBlank @Email @Size(max = 150)
        String email,

        @NotBlank @Size(min = 2, max = 100)
        String course,

        @NotNull @Min(1) @Max(4)
        Integer year
) {}

dto/StudentResponseDto.java

package tz.ac.suza.wt.studentsapi.dto;

import tz.ac.suza.wt.studentsapi.entity.Student;

import java.time.Instant;

public record StudentResponseDto(
        Long id,
        String name,
        String email,
        String course,
        Integer year,
        Instant createdAt
) {
    public static StudentResponseDto from(Student s) {
        return new StudentResponseDto(
                s.getId(), s.getName(), s.getEmail(),
                s.getCourse(), s.getYear(), s.getCreatedAt()
        );
    }
}

💡 Teach moment.

STEP 9 — Write custom exceptions

These let the service throw meaningful errors that the advice can catch later.

exception/StudentNotFoundException.java

package tz.ac.suza.wt.studentsapi.exception;

public class StudentNotFoundException extends RuntimeException {
    public StudentNotFoundException(Long id) {
        super("Student with id " + id + " was not found");
    }
}

exception/EmailAlreadyUsedException.java

package tz.ac.suza.wt.studentsapi.exception;

public class EmailAlreadyUsedException extends RuntimeException {
    public EmailAlreadyUsedException(String email) {
        super("Email '" + email + "' is already used by another student");
    }
}

💡 Teach moment. Custom exceptions read like English at the call site: throw new StudentNotFoundException(id); is much clearer than throw new RuntimeException("not found: " + id);. They also become the keys for our error mapping in STEP 11.

STEP 10 — Write the StudentService

Create service/StudentService.java:

package tz.ac.suza.wt.studentsapi.service;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import tz.ac.suza.wt.studentsapi.dto.StudentCreateDto;
import tz.ac.suza.wt.studentsapi.dto.StudentResponseDto;
import tz.ac.suza.wt.studentsapi.dto.StudentUpdateDto;
import tz.ac.suza.wt.studentsapi.entity.Student;
import tz.ac.suza.wt.studentsapi.exception.EmailAlreadyUsedException;
import tz.ac.suza.wt.studentsapi.exception.StudentNotFoundException;
import tz.ac.suza.wt.studentsapi.repository.StudentRepository;

import java.time.Instant;
import java.util.List;

@Service
@Transactional
public class StudentService {

    private final StudentRepository repository;

    public StudentService(StudentRepository repository) {
        this.repository = repository;
    }

    @Transactional(readOnly = true)
    public List<StudentResponseDto> findAll() {
        return repository.findAll().stream()
                .map(StudentResponseDto::from)
                .toList();
    }

    @Transactional(readOnly = true)
    public List<StudentResponseDto> findByCourse(String course) {
        return repository.findByCourse(course).stream()
                .map(StudentResponseDto::from)
                .toList();
    }

    @Transactional(readOnly = true)
    public StudentResponseDto findById(Long id) {
        Student s = repository.findById(id)
                .orElseThrow(() -> new StudentNotFoundException(id));
        return StudentResponseDto.from(s);
    }

    public StudentResponseDto create(StudentCreateDto dto) {
        if (repository.existsByEmail(dto.email())) {
            throw new EmailAlreadyUsedException(dto.email());
        }
        Student s = Student.builder()
                .name(dto.name())
                .email(dto.email())
                .course(dto.course())
                .year(dto.year())
                .createdAt(Instant.now())
                .build();
        return StudentResponseDto.from(repository.save(s));
    }

    public StudentResponseDto update(Long id, StudentUpdateDto dto) {
        Student s = repository.findById(id)
                .orElseThrow(() -> new StudentNotFoundException(id));

        if (!s.getEmail().equals(dto.email())
                && repository.existsByEmail(dto.email())) {
            throw new EmailAlreadyUsedException(dto.email());
        }

        s.setName(dto.name());
        s.setEmail(dto.email());
        s.setCourse(dto.course());
        s.setYear(dto.year());
        return StudentResponseDto.from(s);
    }

    public void delete(Long id) {
        if (!repository.existsById(id)) {
            throw new StudentNotFoundException(id);
        }
        repository.deleteById(id);
    }
}

💡 Teach moment.

STEP 11 — Write the StudentController

Create controller/StudentController.java:

package tz.ac.suza.wt.studentsapi.controller;

import jakarta.validation.Valid;
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.RestController;
import tz.ac.suza.wt.studentsapi.dto.StudentCreateDto;
import tz.ac.suza.wt.studentsapi.dto.StudentResponseDto;
import tz.ac.suza.wt.studentsapi.dto.StudentUpdateDto;
import tz.ac.suza.wt.studentsapi.service.StudentService;

import java.net.URI;
import java.util.List;

@RestController
@RequestMapping("/api/students")
public class StudentController {

    private final StudentService service;

    public StudentController(StudentService service) {
        this.service = service;
    }

    @GetMapping
    public ResponseEntity<List<StudentResponseDto>> list(
            @RequestParam(required = false) String course) {
        List<StudentResponseDto> students = (course == null)
                ? service.findAll()
                : service.findByCourse(course);
        return ResponseEntity.ok(students);
    }

    @GetMapping("/{id}")
    public ResponseEntity<StudentResponseDto> getOne(@PathVariable Long id) {
        return ResponseEntity.ok(service.findById(id));
    }

    @PostMapping
    public ResponseEntity<StudentResponseDto> create(
            @Valid @RequestBody StudentCreateDto dto) {
        StudentResponseDto saved = service.create(dto);
        URI location = URI.create("/api/students/" + saved.id());
        return ResponseEntity.created(location).body(saved);
    }

    @PutMapping("/{id}")
    public ResponseEntity<StudentResponseDto> update(
            @PathVariable Long id,
            @Valid @RequestBody StudentUpdateDto dto) {
        return ResponseEntity.ok(service.update(id, dto));
    }

    @DeleteMapping("/{id}")
    public ResponseEntity<Void> delete(@PathVariable Long id) {
        service.delete(id);
        return ResponseEntity.noContent().build();
    }
}

💡 Teach moment.

STEP 12 — Write the GlobalExceptionHandler

This is the single most important pattern in the lab. Create exception/GlobalExceptionHandler.java:

package tz.ac.suza.wt.studentsapi.exception;

import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.HttpStatus;
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 java.net.URI;
import java.util.LinkedHashMap;
import java.util.Map;

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(StudentNotFoundException.class)
    public ResponseEntity<ProblemDetail> handleNotFound(
            StudentNotFoundException ex, HttpServletRequest req) {
        ProblemDetail body = ProblemDetail
                .forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
        body.setTitle("Resource not found");
        body.setInstance(URI.create(req.getRequestURI()));
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(body);
    }

    @ExceptionHandler(EmailAlreadyUsedException.class)
    public ResponseEntity<ProblemDetail> handleConflict(
            EmailAlreadyUsedException ex, HttpServletRequest req) {
        ProblemDetail body = ProblemDetail
                .forStatusAndDetail(HttpStatus.CONFLICT, ex.getMessage());
        body.setTitle("Email conflict");
        body.setInstance(URI.create(req.getRequestURI()));
        return ResponseEntity.status(HttpStatus.CONFLICT).body(body);
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ProblemDetail> handleValidation(
            MethodArgumentNotValidException ex, HttpServletRequest req) {
        Map<String, String> fieldErrors = new LinkedHashMap<>();
        ex.getBindingResult().getFieldErrors().forEach(fe ->
                fieldErrors.put(fe.getField(), fe.getDefaultMessage()));

        ProblemDetail body = ProblemDetail
                .forStatusAndDetail(HttpStatus.BAD_REQUEST, "Validation failed");
        body.setTitle("Bad request");
        body.setInstance(URI.create(req.getRequestURI()));
        body.setProperty("errors", fieldErrors);
        return ResponseEntity.badRequest().body(body);
    }
}

💡 Teach moment.

A successful POST will now look like:

{
  "id": 4,
  "name": "Asha Juma",
  "email": "asha@example.com",
  "course": "BCS",
  "year": 2,
  "createdAt": "2026-06-15T08:00:00Z"
}

A POST with a bad email returns:

{
  "type":     "about:blank",
  "title":    "Bad request",
  "status":   400,
  "detail":   "Validation failed",
  "instance": "/api/students",
  "errors": {
    "email": "email must be a valid address"
  }
}

STEP 13 — Seed some data on startup

So the API isn’t empty on first run, create config/DataSeeder.java:

package tz.ac.suza.wt.studentsapi.config;

import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import tz.ac.suza.wt.studentsapi.entity.Student;
import tz.ac.suza.wt.studentsapi.repository.StudentRepository;

import java.time.Instant;

@Configuration
public class DataSeeder {

    @Bean
    CommandLineRunner seed(StudentRepository repository) {
        return args -> {
            if (repository.count() > 0) return;

            repository.save(Student.builder()
                    .name("Asha Juma").email("asha@example.com")
                    .course("BCS").year(2).createdAt(Instant.now()).build());

            repository.save(Student.builder()
                    .name("Hassan Ali").email("hassan@example.com")
                    .course("BITAM").year(3).createdAt(Instant.now()).build());

            repository.save(Student.builder()
                    .name("Mwajuma Said").email("mwajuma@example.com")
                    .course("BCS").year(1).createdAt(Instant.now()).build());
        };
    }
}

💡 Teach moment. A CommandLineRunner bean runs once, right after Spring finishes wiring. Perfect for seeding demo data.

STEP 14 — First run + first request

mvn spring-boot:run

In another terminal:

curl -s http://localhost:8080/api/students | jq

You should see the three seeded students.

🎉 You have a working REST API.


4. Test every endpoint

Open VS Code, install the REST Client extension, then create requests.http:

@host = http://localhost:8080
@base = /api/students

### 1. List all students
GET 
Accept: application/json


### 2. Create a new student (201 Created)
POST 
Content-Type: application/json

{
  "name": "Salma Khamis",
  "email": "salma@example.com",
  "course": "BITAM",
  "year": 2
}


### 3. Get one student by id
GET /1
Accept: application/json


### 4. Filter by course (query param)
GET ?course=BCS
Accept: application/json


### 5. Update an existing student (200 OK)
PUT /1
Content-Type: application/json

{
  "name": "Asha Juma",
  "email": "asha.juma@example.com",
  "course": "BCS",
  "year": 3
}


### 6. Delete a student (204 No Content)
DELETE /2


### 7. NEGATIVE TEST — invalid email (expect 400 with field errors)
POST 
Content-Type: application/json

{
  "name": "Bad Email",
  "email": "not-an-email",
  "course": "BCS",
  "year": 2
}


### 8. NEGATIVE TEST — student not found (expect 404)
GET /9999


### 9. NEGATIVE TEST — duplicate email (expect 409)
POST 
Content-Type: application/json

{
  "name": "Duplicate",
  "email": "asha@example.com",
  "course": "BCS",
  "year": 1
}

Click Send Request above each block. Demonstrate all four success codes (200, 201, 204) and all three error codes (400, 404, 409) live.

Same tests with curl

curl -i -X POST http://localhost:8080/api/students \
     -H 'Content-Type: application/json' \
     -d '{"name":"Salma","email":"salma@example.com","course":"BCS","year":2}'

curl -i http://localhost:8080/api/students/1

curl -i -X DELETE http://localhost:8080/api/students/2

curl -i -X POST http://localhost:8080/api/students \
     -H 'Content-Type: application/json' \
     -d '{"name":"Bad","email":"not-an-email","course":"BCS","year":2}'

5. Swagger UI

We already added springdoc-openapi to pom.xml. If you generated through Initializr without it, add this dependency now:

<dependency>
    <groupId>org.springdoc</groupId>
    <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
    <version>2.6.0</version>
</dependency>

Restart the app, then open:

http://localhost:8080/swagger-ui.html

You should see every endpoint listed. Click any one → Try it out → fill in the body → Execute. Live request + response with no extra tools.

💡 Teach moment. Swagger UI is generated from the same controller code. No separate spec file. When the API changes, the docs change.


6. What students must submit

Per the lab brief, the deliverables are:

  1. A GitHub repo containing the project (accepted via GitHub Classroom).
  2. requests.http in the repo root, with the 5 CRUD requests + 1 negative test.
  3. Screenshot of Swagger UI embedded in the README.md.

Rubric (10 marks)

Criterion Marks
Entity + repository 1
Controller + service 3
Validation + error handling 2
Correct HTTP status codes (201, 204) 1
Tested via requests.http 2
Swagger UI live 1
Total 10

7. Common errors and fixes

“cannot find symbol: variable log / builder / getters”

Lombok plugin not installed in the IDE.

Port 8080 already in use

# macOS / Linux
lsof -ti:8080 | xargs kill -9
# Windows PowerShell
Get-Process -Id (Get-NetTCPConnection -LocalPort 8080).OwningProcess | Stop-Process

Or change the port in application.properties:

server.port=8081

MethodArgumentNotValidException reaches the browser as a 500

You forgot @Valid on the controller method. Add it:

public ResponseEntity<...> create(@Valid @RequestBody StudentCreateDto dto) { ... }

POST returns 200 instead of 201

You used ResponseEntity.ok(...). Switch to:

return ResponseEntity.created(URI.create("/api/students/" + saved.id())).body(saved);

DELETE returns 200 with a body

You returned the deleted object. Use noContent():

return ResponseEntity.noContent().build();

“Validation failed for query”

Your derived query method has a typo. findByCourse is fine; findByCorse will fail at startup with a clear error message — read it carefully.

Connection refused to Postgres

The DB container isn’t running.

docker compose ps                 # should say "healthy"
docker compose up -d              # start it
docker compose logs db --tail 30  # check for errors

If port 5432 is already taken (a system Postgres is running), either stop that one or change the host port:

ports:
  - "5433:5432"      # then use jdbc:postgresql://localhost:5433/studentsdb

relation "students" does not exist

ddl-auto isn’t generating the table. Confirm:

spring.jpa.hibernate.ddl-auto=update

Then restart the app — Hibernate prints create table students (...) in the log when it works.

Want to inspect the DB?

docker exec -it students-api-db psql -U studentsapi -d studentsdb
\dt                    # list tables
SELECT * FROM students;
\q

Or use DBeaver / pgAdmin pointed at localhost:5432, db studentsdb, user studentsapi.

Tests fail with “port already in use”

Spring Boot tests start a context but on a random port by default. If you wrote @SpringBootTest(webEnvironment = RANDOM_PORT) and still hit this, kill any leftover Java processes.


Next lab

Lab 7 — Authentication & Security: signup, login, JWT, role-based access, rate limiting. We’ll add spring-boot-starter-security to this same project and protect every endpoint behind a token.