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:
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
.
<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:
# ============================================
# 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 runschema.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 openhttp://localhost:8080/h2-consolein a browser to inspect the database directly while the app is running.
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:
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:
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);
}
}
@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:
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:
-
@Entitytells 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+@GeneratedValuemark the primary key with auto-increment — matching ourschema.sql. -
We use
BigDecimalfor salary, notdouble. This is the correct type for monetary values —doublehas floating-point precision issues (e.g.,0.1 + 0.2doesn't equal0.3). -
The no-argument constructor is required by JPA. We make it
protectedso 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:
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; }
}
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.
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:
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)— returnsOptional<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.
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
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));
}
}
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:
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
).
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. -
@PathVariableextracts a value from the URL path (/api/employees/5→id = 5). -
@RequestParamextracts a value from the query string (?department=Engineering). -
@RequestBodytells Spring to read the request body and deserialize it from JSON into theEmployeeRequestobject. -
@Validtriggers validation on theEmployeeRequestfields before the method body executes. If validation fails,MethodArgumentNotValidExceptionis thrown and caught by ourGlobalExceptionHandler.
Running and Testing the API
Run the application with Maven:
./mvnw spring-boot:run
Once started, test the endpoints. You can use
curl
, or a GUI tool like
Postman
or
IntelliJ's HTTP Client
:
curl http://localhost:8080/api/employees
curl "http://localhost:8080/api/employees?department=Engineering"
curl http://localhost:8080/api/employees/1
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"}'
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"}'
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"}'
curl http://localhost:8080/api/employees/999
curl -X DELETE http://localhost:8080/api/employees/6
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
:
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
Then, create a profile-specific configuration file for production:
# 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:
./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
@GetMappingannotation 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
passwordcolumn to the entity later, you don't want it showing up in the API response. -
Don't use field injection
(
@Autowiredon fields). Use constructor injection. -
Don't catch exceptions in controllers
just to return a different status code. Use
@ControllerAdvicefor 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=updatein production. It can drop columns or change types unexpectedly. Usevalidateand 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: Add Search and Pagination
Extend the Employee API with two new features:
-
Search by name.
Add a
GET /api/employees/search?name=aliceendpoint that returns employees whose first or last name contains the search term (case-insensitive). Use a derived query method in the repository. -
Pagination.
Modify
GET /api/employeesto supportpageandsizequery parameters (e.g.,GET /api/employees?page=0&size=2). UsePageablefrom 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 aPageableparameter and return aPage<EmployeeResponse>. -
Create an
EmployeePageResponseDTO that contains:List<EmployeeResponse> content,int pageNumber,int totalPages,long totalElements. -
When both
nameandpageparameters are provided, search first, then paginate the results.
Solution
// 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)
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; }
}
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());
}
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);
}
curl "http://localhost:8080/api/employees?page=0&size=2"
curl "http://localhost:8080/api/employees/search?name=alice"
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. -
@ControllerAdviceprovides 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.