Spring Boot: Building a REST API from Scratch

Learn Spring Boot by building a complete REST API with CRUD operations, dependency injection, Spring Data JPA, validation, and error handling. This tutorial assumes you've completed the JDBC and Exception Handling tutorials.

What Spring Boot Is (and Isn't)

Spring is a framework that provides infrastructure for Java applications — dependency injection, transaction management, web request handling, and more. The original Spring (pre-2013) required extensive XML configuration files that were verbose, error-prone, and hard to debug.

Spring Boot (released 2014) solved this by introducing auto-configuration : it inspects your classpath and automatically configures the components you need. Add a database driver JAR? Spring Boot configures a data source. Add a web starter? Spring Boot starts an embedded Tomcat server. You override the defaults only when you need something different.

What Spring Boot is not :

  • It's not a language — it's a framework that runs on standard Java.
  • It's not always the right choice — for a simple script or a learning exercise, plain Java (as shown in every other tutorial on this site) is simpler and more instructive.
  • It's not magic — it does a lot automatically, but understanding what it's doing (which this tutorial will teach you) is essential for debugging problems.

Prerequisites

This tutorial assumes you understand: Java classes and objects, exception handling (especially try-with-resources), basic SQL, and the concepts from the JDBC tutorial (connections, queries, PreparedStatement). You don't need to have used annotations before — we'll explain each one.

Setting Up the Project

Unlike the other tutorials on this site, Spring Boot projects use Maven (or Gradle) because they require external dependencies. The easiest way to create a Spring Boot project is Spring Initializr at start.spring.io .

Select these options:

  • Project: Maven
  • Language: Java
  • Spring Boot: 3.2.x (or the latest stable 3.x)
  • Group: com.example
  • Artifact: employee-api
  • Packaging: Jar
  • Java: 17

Click "Add Dependencies" and select:

  • Spring Web — REST controller support, embedded Tomcat
  • Spring Data JPA — database access with repositories
  • H2 Database — in-memory database for development (no setup required)
  • Validation — request validation with annotations like @NotNull

Click "Generate", download the zip, extract it, and open it in your IDE. Here's the project structure you'll see:

Project Structure
employee-api/
├── pom.xml                                    // Maven build file (dependencies)
├── src/
│   ├── main/
│   │   ├── java/com/example/employeeapi/
│   │   │   ├── EmployeeApiApplication.java     // Entry point
│   │   │   ├── controller/
│   │   │   ├── service/
│   │   │   ├── repository/
│   │   │   ├── model/
│   │   │   └── dto/
│   │   └── resources/
│   │       └── application.properties          // Configuration
│   └── test/
│       └── java/com/example/employeeapi/
│           └── EmployeeApiApplicationTests.java

The package convention matters: Spring Boot only scans for components (controllers, services, etc.) in the package of your main class and its sub-packages. That's why all your code goes under com.example.employeeapi .

pom.xml (key dependencies)
<dependencies>
    <!-- REST API: controllers, request mapping, embedded Tomcat -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

    <!-- Database: JPA entities, repository interfaces -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>

    <!-- In-memory database for development (no installation needed) -->
    <dependency>
        <groupId>com.h2database</groupId>
        <artifactId>h2</artifactId>
        <scope>runtime</scope>
    </dependency>

    <!-- Request validation: @NotNull, @Size, etc. -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-validation</artifactId>
    </dependency>

    <!-- Testing -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

Application Configuration

Spring Boot reads configuration from src/main/resources/application.properties . For our project:

src/main/resources/application.properties
# ============================================
# Server Configuration
# ============================================
server.port=8080

# ============================================
# H2 Database Configuration
# ============================================
spring.datasource.url=jdbc:h2:mem:employee_db
spring.datasource.driverClassName=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=

# Create the database schema from schema.sql on startup
spring.sql.init.mode=always

# JPA / Hibernate settings
spring.jpa.hibernate.ddl-auto=none
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true

# Enable the H2 console at http://localhost:8080/h2-console
spring.h2.console.enabled=true
spring.h2.console.path=/h2-console

# ============================================
# Logging
# ============================================
logging.level.com.example.employeeapi=DEBUG
logging.level.org.springframework.web=INFO
logging.level.org.hibernate.SQL=DEBUG

Key settings explained:

  • jdbc:h2:mem:employee_db — H2 runs entirely in memory. When the application stops, all data is gone. This is perfect for learning and testing.
  • spring.sql.init.mode=always — tells Spring to run schema.sql (which we'll create next) every time the app starts.
  • spring.jpa.hibernate.ddl-auto=none — prevents Hibernate from auto-creating tables. We control the schema ourselves with SQL, which is more explicit and teaches you what's actually happening.
  • spring.h2.console.enabled=true — lets you open http://localhost:8080/h2-console in a browser to inspect the database directly while the app is running.
src/main/resources/schema.sql
CREATE TABLE employees (
    id         BIGINT AUTO_INCREMENT PRIMARY KEY,
    first_name VARCHAR(50) NOT NULL,
    last_name  VARCHAR(50) NOT NULL,
    email      VARCHAR(100) NOT NULL UNIQUE,
    salary     DECIMAL(10,2),
    department VARCHAR(50) NOT NULL
);

INSERT INTO employees (first_name, last_name, email, salary, department)
VALUES
    ('Alice',   'Johnson',  'alice@example.com',   75000.00, 'Engineering'),
    ('Bob',     'Smith',    'bob@example.com',     68000.00, 'Marketing'),
    ('Carol',   'Williams', 'carol@example.com',   82000.00, 'Engineering'),
    ('David',   'Brown',    'david@example.com',   71000.00, 'Sales'),
    ('Eve',     'Davis',    'eve@example.com',     95000.00, 'Engineering');

The Main Class

Spring Initializr generates this for you. You rarely need to change it:

src/main/java/com/example/employeeapi/EmployeeApiApplication.java
package com.example.employeeapi;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class EmployeeApiApplication {

    public static void main(String[] args) {
        SpringApplication.run(EmployeeApiApplication.class, args);
    }
}

The @SpringBootApplication annotation is actually three annotations combined:

  • @Configuration — marks this class as a source of bean definitions.
  • @EnableAutoConfiguration — tells Spring to configure itself based on your classpath.
  • @ComponentScan — tells Spring to find and register all annotated components ( @Controller , @Service , @Repository , etc.) in this package and sub-packages.

Understanding Dependency Injection

Dependency injection (DI) is the core idea behind Spring. Instead of objects creating their own dependencies, Spring creates the objects and injects the dependencies into them.

Compare the two approaches:

Without DI (what you did in the JDBC tutorial)
public class EmployeeService {

    // The service CREATES its own dependency
    private final EmployeeDAO dao = new EmployeeDAO(
            "jdbc:mysql://localhost:3306/company_db",
            "root", "password"
    );

    public Employee findById(int id) {
        return dao.findById(id);
    }
}
With DI (Spring Boot approach)
@Service
public class EmployeeService {

    private final EmployeeRepository repository;

    // Spring INJECTS the dependency through the constructor
    public EmployeeService(EmployeeRepository repository) {
        this.repository = repository;
    }

    public Employee findById(Long id) {
        return repository.findById(id)
                .orElseThrow(() -> new ResourceNotFoundException(
                        "Employee", "id", id));
    }
}

Why is the second version better?

  • No hardcoded configuration — the database URL, credentials, and connection pool are in application.properties , not in Java code.
  • Easy to test — you can inject a mock repository in tests without touching a real database.
  • Easy to swap implementations — switch from H2 to MySQL by changing one line in application.properties , zero Java code changes.
  • Clear dependencies — the constructor tells you exactly what this class needs.

Use Constructor Injection, Not Field Injection

You'll see tutorials that use @Autowired on fields directly. Don't do this — it makes the class harder to test and hides its dependencies. Always use constructor injection (as shown above). With a single constructor, Spring doesn't even require the @Autowired annotation — it's implied.

The Entity (Model)

A JPA entity maps a Java class to a database table. Each field maps to a column:

src/main/java/com/example/employeeapi/model/Employee.java
package com.example.employeeapi.model;

import jakarta.persistence.*;
import java.math.BigDecimal;

@Entity
@Table(name = "employees")
public class Employee {

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

    @Column(name = "first_name", nullable = false, length = 50)
    private String firstName;

    @Column(name = "last_name", nullable = false, length = 50)
    private String lastName;

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

    @Column(precision = 10, scale = 2)
    private BigDecimal salary;

    @Column(nullable = false, length = 50)
    private String department;

    // JPA requires a no-argument constructor
    protected Employee() {}

    public Employee(String firstName, String lastName, String email,
                   BigDecimal salary, String department) {
        this.firstName = firstName;
        this.lastName = lastName;
        this.email = email;
        this.salary = salary;
        this.department = department;
    }

    // Getters and setters
    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public String getFirstName() { return firstName; }
    public void setFirstName(String firstName) { this.firstName = firstName; }
    public String getLastName() { return lastName; }
    public void setLastName(String lastName) { this.lastName = lastName; }
    public String getEmail() { return email; }
    public void setEmail(String email) { this.email = email; }
    public BigDecimal getSalary() { return salary; }
    public void setSalary(BigDecimal salary) { this.salary = salary; }
    public String getDepartment() { return department; }
    public void setDepartment(String department) { this.department = department; }
}

Key points about this entity:

  • @Entity tells JPA this class maps to a database table.
  • @Table(name = "employees") specifies the table name explicitly. Without it, JPA defaults to the class name.
  • @Id + @GeneratedValue mark the primary key with auto-increment — matching our schema.sql .
  • We use BigDecimal for salary, not double . This is the correct type for monetary values — double has floating-point precision issues (e.g., 0.1 + 0.2 doesn't equal 0.3 ).
  • The no-argument constructor is required by JPA. We make it protected so it's not used by application code.

The DTO (Data Transfer Object)

In a real application, you don't send your entity class directly to clients. The entity is tied to the database structure — if you add a column, the API response changes. A DTO decouples the API from the database:

src/main/java/com/example/employeeapi/dto/EmployeeRequest.java
package com.example.employeeapi.dto;

import jakarta.validation.constraints.*;
import java.math.BigDecimal;

/**
 * DTO for creating or updating an employee.
 * The validation annotations are checked automatically when
 * @Valid is used on the controller method parameter.
 */
public class EmployeeRequest {

    @NotBlank(message = "First name is required")
    @Size(max = 50, message = "First name must be at most 50 characters")
    private String firstName;

    @NotBlank(message = "Last name is required")
    @Size(max = 50, message = "Last name must be at most 50 characters")
    private String lastName;

    @NotBlank(message = "Email is required")
    @Email(message = "Email must be a valid email address")
    @Size(max = 100, message = "Email must be at most 100 characters")
    private String email;

    @NotNull(message = "Salary is required")
    @DecimalMin(value = "0.0", message = "Salary must be zero or positive")
    private BigDecimal salary;

    @NotBlank(message = "Department is required")
    @Size(max = 50, message = "Department must be at most 50 characters")
    private String department;

    // Getters and setters
    public String getFirstName() { return firstName; }
    public void setFirstName(String firstName) { this.firstName = firstName; }
    public String getLastName() { return lastName; }
    public void setLastName(String lastName) { this.lastName = lastName; }
    public String getEmail() { return email; }
    public void setEmail(String email) { this.email = email; }
    public BigDecimal getSalary() { return salary; }
    public void setSalary(BigDecimal salary) { this.salary = salary; }
    public String getDepartment() { return department; }
    public void setDepartment(String department) { this.department = department; }
}
src/main/java/com/example/employeeapi/dto/EmployeeResponse.java
package com.example.employeeapi.dto;

import java.math.BigDecimal;

/**
 * DTO for API responses. Contains exactly what the client needs,
 * nothing more. No validation annotations needed here.
 */
public class EmployeeResponse {

    private Long id;
    private String firstName;
    private String lastName;
    private String email;
    private BigDecimal salary;
    private String department;

    // Static factory method — clearer than a constructor with 6 parameters
    public static EmployeeResponse fromEntity(Employee employee) {
        EmployeeResponse response = new EmployeeResponse();
        response.id = employee.getId();
        response.firstName = employee.getFirstName();
        response.lastName = employee.getLastName();
        response.email = employee.getEmail();
        response.salary = employee.getSalary();
        response.department = employee.getDepartment();
        return response;
    }

    // Getters only — response DTOs should be immutable
    public Long getId() { return id; }
    public String getFirstName() { return firstName; }
    public String getLastName() { return lastName; }
    public String getEmail() { return email; }
    public BigDecimal getSalary() { return salary; }
    public String getDepartment() { return department; }
}

Why Separate Request and Response DTOs?

The request DTO has validation ( @NotBlank , @Email ) that doesn't belong on the response. The response DTO has an id that doesn't belong on the request (it's auto-generated). If you used a single DTO for both, you'd need @JsonIgnore on the id for requests and @JsonProperty(access = WRITE_ONLY) on the password field — messy and error-prone. Separate DTOs are cleaner.

Ad placeholder
Advertisement

The Repository

Spring Data JPA provides a remarkable feature: you write a Java interface with method names that follow a naming convention, and Spring generates the implementation at runtime — including the SQL. Compare this to the EmployeeDAO you wrote by hand in the JDBC tutorial:

src/main/java/com/example/employeeapi/repository/EmployeeRepository.java
package com.example.employeeapi.repository;

import com.example.employeeapi.model.Employee;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

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

@Repository
public interface EmployeeRepository extends JpaRepository<Employee, Long> {

    // Spring generates the SQL from the method name.
    // This becomes: SELECT * FROM employees WHERE department = ?
    List<Employee> findByDepartment(String department);

    // This becomes: SELECT * FROM employees WHERE email = ?
    Optional<Employee> findByEmail(String email);

    // This becomes: SELECT * FROM employees WHERE email = ? AND id != ?
    Optional<Employee> findByEmailAndIdNot(String email, Long id);

    // This becomes: SELECT * FROM employees WHERE last_name LIKE '%?%'
    List<Employee> findByLastNameContainingIgnoreCase(String lastName);

    // No custom methods needed for findById, findAll, save, deleteById —
    // JpaRepository provides all of these out of the box.
}

The JpaRepository<Employee, Long> generic parameters are: the entity type and the ID type. It provides these methods without you writing any code:

  • save(entity) — INSERT or UPDATE (it checks if the ID is null)
  • findById(id) — returns Optional<Employee>
  • findAll() — returns all rows
  • deleteById(id) — deletes by ID
  • count() — returns the total row count
  • And about 15 more utility methods

What's Happening Underneath

Spring Data JPA uses Hibernate as its default JPA implementation. When you call findByDepartment("Engineering") , Hibernate parses the method name, generates SQL like SELECT * FROM employees WHERE department = ? , creates a PreparedStatement (exactly like the ones you wrote in the JDBC tutorial ), sets the parameter, executes the query, and maps each row to an Employee object. You get all of that from a one-line interface method.

The Service Layer

The service layer contains your business logic. It sits between the controller (HTTP handling) and the repository (database access). This separation means the same business logic can be called from a REST API, a scheduled job, or a message listener without duplication.

src/main/java/com/example/employeeapi/service/EmployeeService.java
package com.example.employeeapi.service;

import com.example.employeeapi.dto.EmployeeRequest;
import com.example.employeeapi.dto.EmployeeResponse;
import com.example.employeeapi.model.Employee;
import com.example.employeeapi.repository.EmployeeRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;
import java.util.stream.Collectors;

@Service
public class EmployeeService {

    private final EmployeeRepository repository;

    public EmployeeService(EmployeeRepository repository) {
        this.repository = repository;
    }

    public List<EmployeeResponse> getAllEmployees() {
        return repository.findAll().stream()
                .map(EmployeeResponse::fromEntity)
                .collect(Collectors.toList());
    }

    public List<EmployeeResponse> getByDepartment(String department) {
        return repository.findByDepartment(department).stream()
                .map(EmployeeResponse::fromEntity)
                .collect(Collectors.toList());
    }

    public EmployeeResponse getById(Long id) {
        Employee employee = repository.findById(id)
                .orElseThrow(() -> new ResourceNotFoundException(
                        "Employee", "id", id));
        return EmployeeResponse.fromEntity(employee);
    }

    @Transactional
    public EmployeeResponse createEmployee(EmployeeRequest request) {
        // Business rule: check for duplicate email before inserting
        if (repository.findByEmail(request.getEmail()).isPresent()) {
            throw new DuplicateEmailException(
                    "Email already exists: " + request.getEmail());
        }

        Employee employee = new Employee(
                request.getFirstName(),
                request.getLastName(),
                request.getEmail(),
                request.getSalary(),
                request.getDepartment()
        );

        Employee saved = repository.save(employee);
        return EmployeeResponse.fromEntity(saved);
    }

    @Transactional
    public EmployeeResponse updateEmployee(Long id, EmployeeRequest request) {
        Employee employee = repository.findById(id)
                .orElseThrow(() -> new ResourceNotFoundException(
                        "Employee", "id", id));

        // Business rule: if email is being changed, check for duplicates
        if (!employee.getEmail().equals(request.getEmail())) {
            if (repository.findByEmailAndIdNot(request.getEmail(), id).isPresent()) {
                throw new DuplicateEmailException(
                        "Email already exists: " + request.getEmail());
            }
        }

        employee.setFirstName(request.getFirstName());
        employee.setLastName(request.getLastName());
        employee.setEmail(request.getEmail());
        employee.setSalary(request.getSalary());
        employee.setDepartment(request.getDepartment());

        Employee updated = repository.save(employee);
        return EmployeeResponse.fromEntity(updated);
    }

    @Transactional
    public void deleteEmployee(Long id) {
        if (!repository.existsById(id)) {
            throw new ResourceNotFoundException(
                    "Employee", "id", id);
        }
        repository.deleteById(id);
    }
}

Notice the @Transactional annotation on write methods. This is the Spring equivalent of the manual transaction management you did in the JDBC tutorial ( setAutoCommit(false) commit() / rollback() ). Spring handles it automatically: if the method returns normally, it commits; if it throws an exception, it rolls back.

Custom Exceptions

src/main/java/com/example/employeeapi/exception/ResourceNotFoundException.java
package com.example.employeeapi.exception;

import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;

/**
 * Thrown when a requested resource (employee, etc.) is not found.
 * The @ResponseStatus annotation tells Spring to return 404 Not Found
 * when this exception reaches the controller layer.
 */
@ResponseStatus(HttpStatus.NOT_FOUND)
public class ResourceNotFoundException extends RuntimeException {

    public ResourceNotFoundException(String resource, String field, Object value) {
        super(String.format("%s not found with %s: '%s'", resource, field, value));
    }
}
src/main/java/com/example/employeeapi/exception/DuplicateEmailException.java
package com.example.employeeapi.exception;

import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;

@ResponseStatus(HttpStatus.CONFLICT)  // 409 Conflict — the right status for duplicates
public class DuplicateEmailException extends RuntimeException {

    public DuplicateEmailException(String message) {
        super(message);
    }
}

The @ResponseStatus annotation is a simple approach: it tells Spring "when this exception escapes the controller, return this HTTP status code." For more control over the response body (like returning a structured JSON error), we'll add a global exception handler next.

Global Error Handler

A @ControllerAdvice class catches exceptions from all controllers in one place. This is where we handle validation errors and format error responses consistently:

src/main/java/com/example/employeeapi/exception/GlobalExceptionHandler.java
package com.example.employeeapi.exception;

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

@RestControllerAdvice
public class GlobalExceptionHandler {

    /**
     * Handles validation failures (e.g., @NotBlank, @Email on DTO fields).
     * Spring throws MethodArgumentNotValidException when @Valid fails.
     */
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<Map<String, Object>> handleValidationErrors(
            MethodArgumentNotValidException ex) {

        Map<String, Object> body = new HashMap<>();
        body.put("timestamp", LocalDateTime.now().toString());
        body.put("status", 400);
        body.put("error", "Validation Failed");

        // Collect all field-level errors into a list
        List<String> errors = ex.getBindingResult()
                .getFieldErrors().stream()
                .map(error -> error.getField() + ": " + error.getDefaultMessage())
                .collect(java.util.stream.Collectors.toList());

        body.put("errors", errors);

        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(body);
    }

    /**
     * Handles duplicate email errors.
     */
    @ExceptionHandler(DuplicateEmailException.class)
    public ResponseEntity<Map<String, Object>> handleDuplicateEmail(
            DuplicateEmailException ex) {

        Map<String, Object> body = new HashMap<>();
        body.put("timestamp", LocalDateTime.now().toString());
        body.put("status", 409);
        body.put("error", "Conflict");
        body.put("message", ex.getMessage());

        return ResponseEntity.status(HttpStatus.CONFLICT).body(body);
    }

    /**
     * Handles resource not found errors.
     */
    @ExceptionHandler(ResourceNotFoundException.class)
    public ResponseEntity<Map<String, Object>> handleNotFound(
            ResourceNotFoundException ex) {

        Map<String, Object> body = new HashMap<>();
        body.put("timestamp", LocalDateTime.now().toString());
        body.put("status", 404);
        body.put("error", "Not Found");
        body.put("message", ex.getMessage());

        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(body);
    }
}

The REST Controller

The controller receives HTTP requests, calls the service layer, and returns HTTP responses. Spring converts your return values to JSON automatically using the Jackson library (included in spring-boot-starter-web ).

src/main/java/com/example/employeeapi/controller/EmployeeController.java
package com.example.employeeapi.controller;

import com.example.employeeapi.dto.EmployeeRequest;
import com.example.employeeapi.dto.EmployeeResponse;
import com.example.employeeapi.service.EmployeeService;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@RestController
@RequestMapping("/api/employees")
public class EmployeeController {

    private final EmployeeService employeeService;

    public EmployeeController(EmployeeService employeeService) {
        this.employeeService = employeeService;
    }

    // GET /api/employees
    // GET /api/employees?department=Engineering
    @GetMapping
    public List<EmployeeResponse> getAll(
            @RequestParam(required = false) String department) {

        if (department != null) {
            return employeeService.getByDepartment(department);
        }
        return employeeService.getAllEmployees();
    }

    // GET /api/employees/5
    @GetMapping("/{id}")
    public EmployeeResponse getById(@PathVariable Long id) {
        return employeeService.getById(id);
    }

    // POST /api/employees
    // Request body is validated by @Valid before reaching the method
    @PostMapping
    public ResponseEntity<EmployeeResponse> create(
            @Valid @RequestBody EmployeeRequest request) {

        EmployeeResponse created = employeeService.createEmployee(request);
        return ResponseEntity.status(HttpStatus.CREATED).body(created);
    }

    // PUT /api/employees/5
    @PutMapping("/{id}")
    public EmployeeResponse update(@PathVariable Long id,
                                  @Valid @RequestBody EmployeeRequest request) {
        return employeeService.updateEmployee(id, request);
    }

    // DELETE /api/employees/5
    @DeleteMapping("/{id}")
    @ResponseStatus(HttpStatus.NO_CONTENT)
    public void delete(@PathVariable Long id) {
        employeeService.deleteEmployee(id);
    }
}

Key annotations explained:

  • @RestController = @Controller + @ResponseBody . It tells Spring "every method in this class returns data to be written directly to the HTTP response body" (as JSON, not as a view name).
  • @RequestMapping("/api/employees") sets the base URL path for all methods in this class.
  • @GetMapping , @PostMapping , etc. map HTTP methods to Java methods.
  • @PathVariable extracts a value from the URL path ( /api/employees/5 id = 5 ).
  • @RequestParam extracts a value from the query string ( ?department=Engineering ).
  • @RequestBody tells Spring to read the request body and deserialize it from JSON into the EmployeeRequest object.
  • @Valid triggers validation on the EmployeeRequest fields before the method body executes. If validation fails, MethodArgumentNotValidException is thrown and caught by our GlobalExceptionHandler .

Running and Testing the API

Run the application with Maven:

Terminal
./mvnw spring-boot:run

Once started, test the endpoints. You can use curl , or a GUI tool like Postman or IntelliJ's HTTP Client :

Get all employees
curl http://localhost:8080/api/employees
Response (200 OK)
[ {"id":1,"firstName":"Alice","lastName":"Johnson","email":"alice@example.com","salary":75000.00,"department":"Engineering"}, {"id":2,"firstName":"Bob","lastName":"Smith","email":"bob@example.com","salary":68000.00,"department":"Marketing"}, {"id":3,"firstName":"Carol","lastName":"Williams","email":"carol@example.com","salary":82000.00,"department":"Engineering"}, {"id":4,"firstName":"David","lastName":"Brown","email":"david@example.com","salary":71000.00,"department":"Sales"}, {"id":5,"firstName":"Eve","lastName":"Davis","email":"eve@example.com","salary":95000.00,"department":"Engineering"} ]
Filter by department
curl "http://localhost:8080/api/employees?department=Engineering"
Get a single employee
curl http://localhost:8080/api/employees/1
Response (200 OK)
{"id":1,"firstName":"Alice","lastName":"Johnson","email":"alice@example.com","salary":75000.00,"department":"Engineering"}
Create a new employee
curl -X POST http://localhost:8080/api/employees \
  -H "Content-Type: application/json" \
  -d '{"firstName":"Frank","lastName":"Miller","email":"frank@example.com","salary":72000.00,"department":"Sales"}'
Response (201 Created)
{"id":6,"firstName":"Frank","lastName":"Miller","email":"frank@example.com","salary":72000.00,"department":"Sales"}
Test validation — missing required field
curl -X POST http://localhost:8080/api/employees \
  -H "Content-Type: application/json" \
  -d '{"firstName":"","lastName":"Miller","email":"not-an-email","salary":72000.00,"department":"Sales"}'
Response (400 Bad Request)
{ "timestamp": "2025-07-15T14:30:00.123", "status": 400, "error": "Validation Failed", "errors": [ "firstName: First name is required", "email: Email must be a valid email address" ] }
Test duplicate email
curl -X POST http://localhost:8080/api/employees \
  -H "Content-Type: application/json" \
  -d '{"firstName":"Alice","lastName":"Two","email":"alice@example.com","salary":50000.00,"department":"Marketing"}'
Response (409 Conflict)
{ "timestamp": "2025-07-15T14:31:00.456", "status": 409, "error": "Conflict", "message": "Email already exists: alice@example.com" }
Get non-existent employee
curl http://localhost:8080/api/employees/999
Response (404 Not Found)
{ "timestamp": "2025-07-15T14:32:00.789", "status": 404, "error": "Not Found", "message": "Employee not found with id: '999'" }
Delete an employee
curl -X DELETE http://localhost:8080/api/employees/6
Response (204 No Content — empty body)
Ad placeholder
Advertisement

Switching to MySQL (for Production)

The entire point of the configuration file approach is that switching databases requires zero Java code changes . To use MySQL instead of H2:

First, add the MySQL driver to pom.xml :

pom.xml — add MySQL dependency
<dependency>
    <groupId>com.mysql</groupId>
    <artifactId>mysql-connector-j</artifactId>
    <scope>runtime</scope>
</dependency>

Then, create a profile-specific configuration file for production:

src/main/resources/application-prod.properties
# Override just the database settings for production
spring.datasource.url=jdbc:mysql://localhost:3306/company_db
spring.datasource.username=app_user
spring.datasource.password=${DB_PASSWORD}
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver

# Don't initialize schema in production — it already exists
spring.sql.init.mode=never

# Let Hibernate manage the schema in production (optional)
spring.jpa.hibernate.ddl-auto=validate

# Disable the H2 console in production
spring.h2.console.enabled=false

# Lower logging level in production
logging.level.com.example.employeeapi=INFO
logging.level.org.hibernate.SQL=WARN

Run with the production profile:

Terminal
./mvnw spring-boot:run -Dspring-boot.run.profiles=prod \
  -DDB_PASSWORD=your_actual_password

Profiles

Spring Boot loads application.properties first, then overlays profile-specific files on top. application-prod.properties only needs to contain the properties that differ from the defaults. The ${DB_PASSWORD} syntax reads from an environment variable — you should never put production passwords in properties files. In deployment, you'd set this environment variable on your server or in your container orchestration platform.

Best Practices

Layer Separation

  • Controller handles HTTP only — no business logic, no database access. It delegates to the service.
  • Service handles business logic — no HTTP concepts, no SQL. It delegates to the repository.
  • Repository handles data access — no business rules, no HTTP concepts.
  • If you find yourself writing a SQL query in a controller or an @GetMapping annotation in a repository, the layering is wrong.

HTTP Status Codes

  • 200 OK — successful GET, PUT, or DELETE
  • 201 Created — successful POST (new resource created)
  • 204 No Content — successful DELETE with no response body
  • 400 Bad Request — validation failure, malformed JSON
  • 404 Not Found — resource doesn't exist
  • 409 Conflict — duplicate unique constraint
  • 500 Internal Server Error — unexpected exception (don't use this for business logic errors)

What to Avoid

  • Don't return entities directly. Always map through a DTO. If you add a password column to the entity later, you don't want it showing up in the API response.
  • Don't use field injection ( @Autowired on fields). Use constructor injection.
  • Don't catch exceptions in controllers just to return a different status code. Use @ControllerAdvice for consistent error handling.
  • Don't put business logic in controllers. A controller method should be 3–5 lines: receive request, call service, return response.
  • Don't use spring.jpa.hibernate.ddl-auto=update in production. It can drop columns or change types unexpectedly. Use validate and manage schema changes with migration tools (Flyway or Liquibase).

What Spring Boot Replaces (and Doesn't)

Comparing the EmployeeDAO from the JDBC tutorial with the Spring Boot version shows what you gain and what you give up:

Aspect Plain JDBC Spring Boot
CRUD operations Write each SQL statement by hand JpaRepository provides them
Connection management Manual try-with-resources Auto-configured connection pool (HikariCP)
Transactions Manual commit/rollback @Transactional annotation
HTTP handling Not included Built-in embedded Tomcat + controllers
JSON serialization Not included Automatic via Jackson
Validation Manual if-then checks Declarative with annotations
Setup complexity Zero — just add the driver JAR Maven/Gradle project + many concepts to learn

When to Use Plain JDBC vs. Spring Boot

  • Use plain JDBC for learning, scripts, batch processing tools, and any situation where you need full control over every SQL statement and don't need HTTP endpoints.
  • Use Spring Boot for web applications, REST APIs, microservices, and any project that needs HTTP handling, dependency injection, or will grow beyond a few classes.
  • Spring Boot uses JDBC under the hood — everything you learned in the JDBC tutorial applies directly to understanding what Spring Data JPA is doing.
Exercise

Exercise: Add Search and Pagination

Extend the Employee API with two new features:

  1. Search by name. Add a GET /api/employees/search?name=alice endpoint that returns employees whose first or last name contains the search term (case-insensitive). Use a derived query method in the repository.
  2. Pagination. Modify GET /api/employees to support page and size query parameters (e.g., GET /api/employees?page=0&size=2 ). Use Pageable from Spring Data. Return a response that includes the list of employees, the current page number, total pages, and total elements.

Requirements:

  • Add a searchByName(String name) method to the repository (or two methods if needed).
  • Change getAllEmployees() in the service to accept a Pageable parameter and return a Page<EmployeeResponse> .
  • Create an EmployeePageResponse DTO that contains: List<EmployeeResponse> content , int pageNumber , int totalPages , long totalElements .
  • When both name and page parameters are provided, search first, then paginate the results.

Solution

EmployeeRepository.java — add these methods
// Search by first name OR last name (case-insensitive)
List<Employee> findByFirstNameContainingIgnoreCaseOrLastNameContainingIgnoreCase(
        String firstName, String lastName);

// Paginated version of findAll — provided by JpaRepository
// No method needed, just call: repository.findAll(pageable)
EmployeePageResponse.java
package com.example.employeeapi.dto;

import java.util.List;

public class EmployeePageResponse {
    private List<EmployeeResponse> content;
    private int pageNumber;
    private int totalPages;
    private long totalElements;

    public static EmployeePageResponse fromPage(
            org.springframework.data.domain.Page<Employee> page) {
        EmployeePageResponse response = new EmployeePageResponse();
        response.content = page.getContent().stream()
                .map(EmployeeResponse::fromEntity)
                .collect(java.util.stream.Collectors.toList());
        response.pageNumber = page.getNumber();
        response.totalPages = page.getTotalPages();
        response.totalElements = page.getTotalElements();
        return response;
    }

    public List<EmployeeResponse> getContent() { return content; }
    public int getPageNumber() { return pageNumber; }
    public int getTotalPages() { return totalPages; }
    public long getTotalElements() { return totalElements; }
}
EmployeeService.java — add these methods
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;

public EmployeePageResponse getAllEmployees(int page, int size) {
    Pageable pageable = PageRequest.of(page, size, Sort.by("id"));
    Page<Employee> result = repository.findAll(pageable);
    return EmployeePageResponse.fromPage(result);
}

public List<EmployeeResponse> searchByName(String name) {
    return repository
            .findByFirstNameContainingIgnoreCaseOrLastNameContainingIgnoreCase(
                    name, name)
            .stream()
            .map(EmployeeResponse::fromEntity)
            .collect(Collectors.toList());
}
EmployeeController.java — updated GET mapping
import org.springframework.data.domain.PageRequest;

// GET /api/employees?page=0&size=2
// GET /api/employees?department=Engineering&page=0&size=2
// GET /api/employees/search?name=alice
@GetMapping
public Object getAll(
        @RequestParam(required = false) String department,
        @RequestParam(defaultValue = "0") int page,
        @RequestParam(defaultValue = "10") int size,
        @RequestParam(required = false) String name) {

    if (name != null) {
        return employeeService.searchByName(name);
    }

    if (department != null) {
        // For department filter, return as a list (no pagination for simplicity)
        return employeeService.getByDepartment(department);
    }

    return employeeService.getAllEmployees(page, size);
}

// Separate endpoint to keep URLs clean
@GetMapping("/search")
public List<EmployeeResponse> search(
        @RequestParam String name) {
    return employeeService.searchByName(name);
}
Test: Paginated request
curl "http://localhost:8080/api/employees?page=0&size=2"
Response (200 OK)
{ "content": [ {"id":1,"firstName":"Alice","lastName":"Johnson","email":"alice@example.com","salary":75000.00,"department":"Engineering"}, {"id":2,"firstName":"Bob","lastName":"Smith","email":"bob@example.com","salary":68000.00,"department":"Marketing"} ], "pageNumber": 0, "totalPages": 3, "totalElements": 5 }
Test: Search
curl "http://localhost:8080/api/employees/search?name=alice"
Response (200 OK)
[{"id":1,"firstName":"Alice","lastName":"Johnson","email":"alice@example.com","salary":75000.00,"department":"Engineering"}]
Ad placeholder
Advertisement

Summary

  • Spring Boot auto-configures a Spring application based on your classpath. Add the right dependencies, and most things just work.
  • Dependency injection means Spring creates and wires your objects. You declare dependencies in constructors, and Spring provides the implementations.
  • The layered architecture (Controller → Service → Repository) separates concerns and makes each layer testable and replaceable independently.
  • Spring Data JPA generates repository implementations from interface method names — you write the method signature, Spring writes the SQL.
  • DTOs decouple your API contract from your database schema. Never expose entity classes directly in API responses.
  • @Valid + validation annotations provide declarative request validation that runs before your method code executes.
  • @ControllerAdvice provides centralized exception handling across all controllers.
  • Profiles let you switch configuration between environments (dev, test, prod) without code changes.
  • Spring Boot uses JDBC underneath — everything you learned about connections, PreparedStatements, and transactions in the JDBC tutorial still applies.

Next Steps

  • Spring Security — Add authentication and authorization to your API. This is the most common next step after building a basic REST API.
  • Testing with MockMvc — Write automated tests for your controllers without starting an actual HTTP server.
  • Database Migrations with Flyway — Manage schema changes in versioned SQL scripts that run automatically on startup.
  • DTO Mapping with MapStruct — Eliminate manual DTO-to-entity mapping code with a code generator.