Java and Spring Boot to Build an API
Building an API is one of those skills that quietly unlocks a lot of doors. It lets your frontend talk to your backend, your mobile app share data with your server, your microservices exchange information, and your whole product become something real and usable. Java and Spring Boot have become one of the most trusted combinations for this job because they give you structure, speed, reliability, and a clean way to grow from a small project into something that can survive real traffic and real users.
What makes Spring Boot so attractive is not only that it simplifies Java development, but that it helps you stay focused on the actual business problem instead of fighting configuration. You do not need to spend half the day wiring beans by hand or manually assembling a pile of XML files. You describe the behavior you want, add a few dependencies, and Spring Boot handles a large part of the plumbing for you. That is a big deal when the goal is to deliver a working API quickly without creating a mess you will regret six months later.
This article walks through how to build a REST API using Java and Spring Boot in a practical, human way. We will start with the basic idea of an API, create a project, design a small domain, build endpoints, add validation and error handling, and then move into more advanced concerns like pagination, security, testing, and documentation. The examples will focus on a simple “Book API” because books are easy to understand, but the same patterns apply to products, users, orders, blog posts, courses, or anything else you want to manage through an API.
What an API really is
An API, or Application Programming Interface, is a way for one piece of software to talk to another. In web development, people usually mean a REST API, which exposes data over HTTP using familiar methods like GET, POST, PUT, PATCH, and DELETE. A frontend can call /api/books to list books, /api/books/1 to retrieve one book, /api/books with a POST request to create a new book, and so on.
The beauty of REST APIs is that they are predictable. A client does not need to know how your server stores the data internally. It only needs to know the URL, the HTTP method, and the shape of the JSON response. That separation gives you freedom on the backend to change databases, refactor services, or reorganize your code without breaking the client as long as the contract remains stable.
Why Java and Spring Boot are a strong choice
Java is still a solid option for backend systems because it is mature, widely supported, and excellent for building applications that need structure and long-term maintainability. Spring Boot adds a layer of convenience on top of the Spring ecosystem and removes much of the ceremony that older Java projects used to have.
There are several reasons developers keep coming back to this stack. Spring Boot makes it easy to create production-ready applications quickly. It provides sensible defaults, built-in dependency injection, automatic configuration, and starter packages for common tasks like web development, database access, security, and testing. It also integrates well with JPA, Hibernate, validation libraries, messaging systems, caching tools, and observability platforms.
In real projects, that matters. A lot. You do not just need code that works on your laptop. You need code that can be tested, deployed, monitored, and maintained by real people. Spring Boot gives you a strong foundation for all of that.
Setting up the project
The easiest way to start is through Spring Initializr, which creates a ready-to-use project skeleton. You can choose Maven or Gradle, pick Java, select your Spring Boot version, and include the dependencies you need.
For a simple API, a good starting set of dependencies is:
Spring Web
Spring Data JPA
Validation
H2 Database or PostgreSQL Driver
Lombok, if you like it
Spring Boot DevTools
Spring Boot Test
A typical Maven pom.xml might look like this:
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>book-api</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>book-api</name>
<description>Book API with Spring Boot</description>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.5</version>
<relativePath/>
</parent>
<properties>
<java.version>17</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
That is enough to get a real API moving. No giant setup ritual. No unnecessary complexity. Just a clean beginning.
Designing the API before writing code
Before you write a single controller, it helps to think about the API contract. In a real project, good API design prevents confusion later. For a book API, a simple resource might have these fields:
idtitleauthorisbndescriptionpricepublishedDate
From there, you can imagine the main endpoints:
GET /api/books→ list booksGET /api/books/{id}→ fetch one bookPOST /api/books→ create a bookPUT /api/books/{id}→ replace a bookPATCH /api/books/{id}→ update part of a bookDELETE /api/books/{id}→ delete a book
That is the kind of foundation that can later become a larger system. Once you know the shape of your resource and the operations you need, the implementation becomes much easier.
Creating the entity
In Spring Boot with JPA, an entity is a Java class that maps to a database table. Let us create a Book entity.
package com.example.bookapi.book;
import jakarta.persistence.*;
import java.math.BigDecimal;
import java.time.LocalDate;
@Entity
@Table(name = "books")
public class Book {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String title;
@Column(nullable = false)
private String author;
@Column(unique = true, nullable = false)
private String isbn;
@Column(length = 2000)
private String description;
@Column(nullable = false, precision = 10, scale = 2)
private BigDecimal price;
private LocalDate publishedDate;
public Book() {
}
public Book(Long id, String title, String author, String isbn, String description, BigDecimal price, LocalDate publishedDate) {
this.id = id;
this.title = title;
this.author = author;
this.isbn = isbn;
this.description = description;
this.price = price;
this.publishedDate = publishedDate;
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getAuthor() {
return author;
}
public void setAuthor(String author) {
this.author = author;
}
public String getIsbn() {
return isbn;
}
public void setIsbn(String isbn) {
this.isbn = isbn;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public BigDecimal getPrice() {
return price;
}
public void setPrice(BigDecimal price) {
this.price = price;
}
public LocalDate getPublishedDate() {
return publishedDate;
}
public void setPublishedDate(LocalDate publishedDate) {
this.publishedDate = publishedDate;
}
}
This entity is simple, but it teaches an important point: your database model is not your API model. In many beginner projects, people expose entities directly to the outside world. That works for tiny demos, but it usually creates problems later. A better habit is to use DTOs, which keep your API stable even if your internal model changes.
Creating DTOs for request and response
DTO stands for Data Transfer Object. It is a class that represents the data you send or receive through the API. Using DTOs gives you control over the API contract and helps you validate input properly.
Here is a request DTO for creating a book:
package com.example.bookapi.book.dto;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import java.math.BigDecimal;
import java.time.LocalDate;
public class BookRequest {
@NotBlank(message = "Title is required")
private String title;
@NotBlank(message = "Author is required")
private String author;
@NotBlank(message = "ISBN is required")
private String isbn;
private String description;
@NotNull(message = "Price is required")
@DecimalMin(value = "0.0", inclusive = false, message = "Price must be greater than zero")
private BigDecimal price;
private LocalDate publishedDate;
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getAuthor() {
return author;
}
public void setAuthor(String author) {
this.author = author;
}
public String getIsbn() {
return isbn;
}
public void setIsbn(String isbn) {
this.isbn = isbn;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public BigDecimal getPrice() {
return price;
}
public void setPrice(BigDecimal price) {
this.price = price;
}
public LocalDate getPublishedDate() {
return publishedDate;
}
public void setPublishedDate(LocalDate publishedDate) {
this.publishedDate = publishedDate;
}
}
And here is a response DTO:
package com.example.bookapi.book.dto;
import java.math.BigDecimal;
import java.time.LocalDate;
public class BookResponse {
private Long id;
private String title;
private String author;
private String isbn;
private String description;
private BigDecimal price;
private LocalDate publishedDate;
public BookResponse() {
}
public BookResponse(Long id, String title, String author, String isbn, String description, BigDecimal price, LocalDate publishedDate) {
this.id = id;
this.title = title;
this.author = author;
this.isbn = isbn;
this.description = description;
this.price = price;
this.publishedDate = publishedDate;
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getAuthor() {
return author;
}
public void setAuthor(String author) {
this.author = author;
}
public String getIsbn() {
return isbn;
}
public void setIsbn(String isbn) {
this.isbn = isbn;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public BigDecimal getPrice() {
return price;
}
public void setPrice(BigDecimal price) {
this.price = price;
}
public LocalDate getPublishedDate() {
return publishedDate;
}
public void setPublishedDate(LocalDate publishedDate) {
this.publishedDate = publishedDate;
}
}
Repository layer
The repository layer is where Spring Data JPA really starts to save time. Instead of writing a lot of SQL or boilerplate DAO code, you can extend a JPA repository interface and get common database operations automatically.
package com.example.bookapi.book;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface BookRepository extends JpaRepository<Book, Long> {
Optional<Book> findByIsbn(String isbn);
boolean existsByIsbn(String isbn);
}
That is already useful. JpaRepository gives you methods like findAll, findById, save, deleteById, and pagination support. With a few custom method names, Spring can generate queries for you.
Service layer
The service layer contains the business logic. This is where you keep the code that should not live in the controller or repository. It makes your code easier to test and easier to change later.
package com.example.bookapi.book;
import com.example.bookapi.book.dto.BookRequest;
import com.example.bookapi.book.dto.BookResponse;
import com.example.bookapi.exception.ResourceNotFoundException;
import com.example.bookapi.exception.DuplicateResourceException;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
@Service
public class BookService {
private final BookRepository bookRepository;
public BookService(BookRepository bookRepository) {
this.bookRepository = bookRepository;
}
public BookResponse createBook(BookRequest request) {
if (bookRepository.existsByIsbn(request.getIsbn())) {
throw new DuplicateResourceException("Book with ISBN already exists");
}
Book book = new Book();
book.setTitle(request.getTitle());
book.setAuthor(request.getAuthor());
book.setIsbn(request.getIsbn());
book.setDescription(request.getDescription());
book.setPrice(request.getPrice());
book.setPublishedDate(request.getPublishedDate());
Book savedBook = bookRepository.save(book);
return toResponse(savedBook);
}
public BookResponse getBookById(Long id) {
Book book = bookRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Book not found with id: " + id));
return toResponse(book);
}
public Page<BookResponse> getAllBooks(Pageable pageable) {
return bookRepository.findAll(pageable).map(this::toResponse);
}
public BookResponse updateBook(Long id, BookRequest request) {
Book book = bookRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Book not found with id: " + id));
book.setTitle(request.getTitle());
book.setAuthor(request.getAuthor());
book.setIsbn(request.getIsbn());
book.setDescription(request.getDescription());
book.setPrice(request.getPrice());
book.setPublishedDate(request.getPublishedDate());
Book updatedBook = bookRepository.save(book);
return toResponse(updatedBook);
}
public void deleteBook(Long id) {
if (!bookRepository.existsById(id)) {
throw new ResourceNotFoundException("Book not found with id: " + id);
}
bookRepository.deleteById(id);
}
private BookResponse toResponse(Book book) {
return new BookResponse(
book.getId(),
book.getTitle(),
book.getAuthor(),
book.getIsbn(),
book.getDescription(),
book.getPrice(),
book.getPublishedDate()
);
}
}
The service is where things start to feel real. Notice how we check for duplicate ISBNs, how we throw meaningful exceptions, and how we keep the controller free from business decisions. This is exactly the kind of organization that makes a codebase pleasant to work with.
Custom exceptions
Good APIs do not just fail. They fail clearly.
package com.example.bookapi.exception;
public class ResourceNotFoundException extends RuntimeException {
public ResourceNotFoundException(String message) {
super(message);
}
}
package com.example.bookapi.exception;
public class DuplicateResourceException extends RuntimeException {
public DuplicateResourceException(String message) {
super(message);
}
}
You may feel tempted to throw generic exceptions everywhere, but that becomes painful very quickly. A meaningful exception tells both your team and your API client what went wrong.
Global exception handling
Instead of writing try-catch blocks inside every controller method, you can use a global exception handler. This makes the API cleaner and the error responses consistent.
package com.example.bookapi.exception;
import org.springframework.http.HttpStatus;
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.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<Map<String, Object>> handleNotFound(ResourceNotFoundException ex) {
Map<String, Object> error = new HashMap<>();
error.put("timestamp", LocalDateTime.now());
error.put("status", HttpStatus.NOT_FOUND.value());
error.put("error", "Not Found");
error.put("message", ex.getMessage());
return new ResponseEntity<>(error, HttpStatus.NOT_FOUND);
}
@ExceptionHandler(DuplicateResourceException.class)
public ResponseEntity<Map<String, Object>> handleDuplicate(DuplicateResourceException ex) {
Map<String, Object> error = new HashMap<>();
error.put("timestamp", LocalDateTime.now());
error.put("status", HttpStatus.CONFLICT.value());
error.put("error", "Conflict");
error.put("message", ex.getMessage());
return new ResponseEntity<>(error, HttpStatus.CONFLICT);
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Map<String, Object>> handleValidation(MethodArgumentNotValidException ex) {
Map<String, Object> error = new HashMap<>();
error.put("timestamp", LocalDateTime.now());
error.put("status", HttpStatus.BAD_REQUEST.value());
error.put("error", "Validation Failed");
Map<String, String> validationErrors = new HashMap<>();
ex.getBindingResult().getFieldErrors().forEach(fieldError ->
validationErrors.put(fieldError.getField(), fieldError.getDefaultMessage())
);
error.put("details", validationErrors);
return new ResponseEntity<>(error, HttpStatus.BAD_REQUEST);
}
@ExceptionHandler(Exception.class)
public ResponseEntity<Map<String, Object>> handleGeneric(Exception ex) {
Map<String, Object> error = new HashMap<>();
error.put("timestamp", LocalDateTime.now());
error.put("status", HttpStatus.INTERNAL_SERVER_ERROR.value());
error.put("error", "Internal Server Error");
error.put("message", "Something went wrong");
return new ResponseEntity<>(error, HttpStatus.INTERNAL_SERVER_ERROR);
}
}
This kind of centralized error handling is one of those habits that separates a hobby project from something polished. Clients appreciate consistency, and you will appreciate not repeating yourself.
Building the controller
Now the controller. This is the part that handles HTTP requests and responses.
package com.example.bookapi.book;
import com.example.bookapi.book.dto.BookRequest;
import com.example.bookapi.book.dto.BookResponse;
import jakarta.validation.Valid;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/books")
public class BookController {
private final BookService bookService;
public BookController(BookService bookService) {
this.bookService = bookService;
}
@PostMapping
public ResponseEntity<BookResponse> createBook(@Valid @RequestBody BookRequest request) {
BookResponse response = bookService.createBook(request);
return new ResponseEntity<>(response, HttpStatus.CREATED);
}
@GetMapping("/{id}")
public ResponseEntity<BookResponse> getBookById(@PathVariable Long id) {
BookResponse response = bookService.getBookById(id);
return ResponseEntity.ok(response);
}
@GetMapping
public ResponseEntity<Page<BookResponse>> getAllBooks(Pageable pageable) {
Page<BookResponse> books = bookService.getAllBooks(pageable);
return ResponseEntity.ok(books);
}
@PutMapping("/{id}")
public ResponseEntity<BookResponse> updateBook(@PathVariable Long id, @Valid @RequestBody BookRequest request) {
BookResponse response = bookService.updateBook(id, request);
return ResponseEntity.ok(response);
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteBook(@PathVariable Long id) {
bookService.deleteBook(id);
return ResponseEntity.noContent().build();
}
}
This controller is intentionally thin. It does not contain business rules, and that is a good thing. Controllers should coordinate requests, not become the place where every part of your application logic gets dumped.
Running the application
A simple Spring Boot main class might look like this:
package com.example.bookapi;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class BookApiApplication {
public static void main(String[] args) {
SpringApplication.run(BookApiApplication.class, args);
}
}
Now configure application.properties or application.yml. Here is a simple application.yml using H2:
spring:
datasource:
url: jdbc:h2:mem:bookdb
driverClassName: org.h2.Driver
username: sa
password: password
jpa:
hibernate:
ddl-auto: update
show-sql: true
properties:
hibernate:
format_sql: true
h2:
console:
enabled: true
For development, H2 is convenient because it is lightweight and easy to start. For production, you would usually switch to PostgreSQL, MySQL, or another serious database.
Testing the API with sample requests
Once the application is running, you can test the API with Postman, curl, or any HTTP client.
Create a book
POST /api/books
Content-Type: application/json
{
"title": "Clean Code",
"author": "Robert C. Martin",
"isbn": "9780132350884",
"description": "A handbook of agile software craftsmanship.",
"price": 39.99,
"publishedDate": "2008-08-01"
}
Response
{
"id": 1,
"title": "Clean Code",
"author": "Robert C. Martin",
"isbn": "9780132350884",
"description": "A handbook of agile software craftsmanship.",
"price": 39.99,
"publishedDate": "2008-08-01"
}
That moment, when the first clean JSON response comes back from your own code, is always satisfying. It is a small victory, but it matters. It means the stack is alive and talking.
Get a book by ID
GET /api/books/1
List books with pagination
GET /api/books?page=0&size=10&sort=title,asc
Spring automatically turns those query parameters into a Pageable object. That is one of the quiet superpowers of Spring Data.
Adding pagination, sorting, and filtering
In real APIs, you rarely want to return everything at once. Pagination keeps responses efficient and avoids overwhelming the client.
Spring Boot makes this easy with Pageable, but you should still think about the user experience. Returning a page object with metadata helps clients know how many records exist and which page they are on.
For filtering, you can add repository methods like:
Page<Book> findByAuthorContainingIgnoreCase(String author, Pageable pageable);
And in the service:
public Page<BookResponse> searchByAuthor(String author, Pageable pageable) {
return bookRepository.findByAuthorContainingIgnoreCase(author, pageable)
.map(this::toResponse);
}
Then expose an endpoint:
@GetMapping("/search")
public ResponseEntity<Page<BookResponse>> searchByAuthor(
@RequestParam String author,
Pageable pageable) {
return ResponseEntity.ok(bookService.searchByAuthor(author, pageable));
}
That gives users a way to search by author without pulling the whole database into memory.
Validation matters more than people think
Validation is not decoration. It is one of the first lines of defense for your API. If a client sends junk data, your API should reject it in a clear and predictable way.
You already saw annotations like @NotBlank, @NotNull, and @DecimalMin. You can also use:
@Email@Size@Pattern@Past@Future
For example:
@NotBlank(message = "Title is required")
@Size(max = 200, message = "Title cannot exceed 200 characters")
private String title;
It might seem small, but validation saves time, protects your data, and reduces bugs in surprising ways. Every invalid request you reject early is a problem you do not have to debug later.
Updating resources properly
When you update a resource with PUT, the idea is usually to replace the whole resource. Some teams use PATCH for partial updates. The difference matters because the API should be explicit about what it expects.
For a partial update, you can create another DTO with optional fields:
package com.example.bookapi.book.dto;
import java.math.BigDecimal;
import java.time.LocalDate;
public class BookPatchRequest {
private String title;
private String author;
private String isbn;
private String description;
private BigDecimal price;
private LocalDate publishedDate;
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getAuthor() {
return author;
}
public void setAuthor(String author) {
this.author = author;
}
public String getIsbn() {
return isbn;
}
public void setIsbn(String isbn) {
this.isbn = isbn;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public BigDecimal getPrice() {
return price;
}
public void setPrice(BigDecimal price) {
this.price = price;
}
public LocalDate getPublishedDate() {
return publishedDate;
}
public void setPublishedDate(LocalDate publishedDate) {
this.publishedDate = publishedDate;
}
}
And then carefully apply only the fields provided:
public BookResponse patchBook(Long id, BookPatchRequest request) {
Book book = bookRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Book not found with id: " + id));
if (request.getTitle() != null) {
book.setTitle(request.getTitle());
}
if (request.getAuthor() != null) {
book.setAuthor(request.getAuthor());
}
if (request.getIsbn() != null) {
book.setIsbn(request.getIsbn());
}
if (request.getDescription() != null) {
book.setDescription(request.getDescription());
}
if (request.getPrice() != null) {
book.setPrice(request.getPrice());
}
if (request.getPublishedDate() != null) {
book.setPublishedDate(request.getPublishedDate());
}
return toResponse(bookRepository.save(book));
}
This kind of careful update logic may not feel glamorous, but it is exactly the sort of detail that keeps APIs dependable.
A note on mapping
In the examples above, the service manually converts between entity and DTO. That is perfectly fine for a small project. In larger systems, many teams use mapper libraries like MapStruct to reduce repetitive conversion code.
Manual mapping keeps things obvious. Mapper libraries keep things concise. Both are valid. The right choice depends on the size of the project and the preferences of the team. What matters most is that your boundaries stay clear.
Introducing Swagger or OpenAPI
Once your API starts growing, documentation becomes important. Good documentation makes onboarding easier, helps frontend developers move faster, and reduces the number of questions people ask you.
Spring Boot integrates well with OpenAPI through tools like springdoc-openapi. With the right dependency, you can generate interactive documentation automatically.
A typical dependency might be:
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.6.0</version>
</dependency>
Then your API documentation can appear in a browser with Swagger UI. That means your endpoints become not only usable but visible and testable in one place. For many teams, this is a huge quality-of-life improvement.
Adding logging
Logging is one of those things people ignore until something breaks. Then it becomes the only thing they care about.
At minimum, you should log important events like failures, suspicious input, and major state changes. Spring Boot includes logging support out of the box. For example:
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
@Service
public class BookService {
private static final Logger logger = LoggerFactory.getLogger(BookService.class);
public BookResponse createBook(BookRequest request) {
logger.info("Creating book with title: {}", request.getTitle());
...
}
}
Be careful not to log sensitive data. Logging is useful, but it should still be respectful of privacy and security.
Securing the API
An API without security can become a liability very quickly. Even if your first version is public, you should think about authentication and authorization early.
Spring Security is the standard tool for this. A complete security setup can get large, so here is a simplified example of securing endpoints with HTTP Basic or JWT-style authentication.
A basic security configuration might look like this:
package com.example.bookapi.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/books/**").authenticated()
.anyRequest().permitAll()
)
.httpBasic(Customizer.withDefaults());
return http.build();
}
}
This is not the final word on security, but it shows the direction. In production, you would often use JWT tokens, OAuth2, sessionless authentication, role-based authorization, and better password storage practices.
Security is one of those topics that rewards careful thinking. The goal is not just to lock the door. The goal is to make sure the right people get in and the wrong people do not.
Using profiles for different environments
A project often behaves differently in development, testing, and production. Spring profiles let you separate configuration by environment.
For example:
application-dev.yml
spring:
datasource:
url: jdbc:h2:mem:bookdb
driver-class-name: org.h2.Driver
application-prod.yml
spring:
datasource:
url: jdbc:postgresql://localhost:5432/bookdb
username: bookuser
password: secret
jpa:
hibernate:
ddl-auto: validate
This keeps your development experience easy while making production safer and more controlled. It also avoids the common mistake of using the same settings everywhere and then wondering why the app behaves differently after deployment.
Writing tests
Testing is where a lot of good intentions are either confirmed or exposed. It is easy to say your API works. It is more convincing to prove it.
Unit test example
A simple service test using Mockito might look like this:
package com.example.bookapi.book;
import com.example.bookapi.book.dto.BookRequest;
import com.example.bookapi.book.dto.BookResponse;
import com.example.bookapi.exception.DuplicateResourceException;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import java.math.BigDecimal;
import java.time.LocalDate;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.when;
class BookServiceTest {
@Test
void shouldCreateBookSuccessfully() {
BookRepository repository = Mockito.mock(BookRepository.class);
BookService service = new BookService(repository);
BookRequest request = new BookRequest();
request.setTitle("Domain-Driven Design");
request.setAuthor("Eric Evans");
request.setIsbn("9780321125217");
request.setDescription("Tackling complexity in the heart of software.");
request.setPrice(new BigDecimal("49.99"));
request.setPublishedDate(LocalDate.of(2003, 8, 30));
when(repository.existsByIsbn(request.getIsbn())).thenReturn(false);
when(repository.save(Mockito.any(Book.class))).thenAnswer(invocation -> {
Book book = invocation.getArgument(0);
book.setId(1L);
return book;
});
BookResponse response = service.createBook(request);
assertNotNull(response);
assertEquals("Domain-Driven Design", response.getTitle());
assertEquals(1L, response.getId());
}
@Test
void shouldRejectDuplicateIsbn() {
BookRepository repository = Mockito.mock(BookRepository.class);
BookService service = new BookService(repository);
BookRequest request = new BookRequest();
request.setTitle("Test");
request.setAuthor("Author");
request.setIsbn("123");
request.setPrice(new BigDecimal("10.00"));
when(repository.existsByIsbn("123")).thenReturn(true);
assertThrows(DuplicateResourceException.class, () -> service.createBook(request));
}
}
Integration test example
An integration test gives you more confidence because it checks the endpoint behavior more closely:
package com.example.bookapi.book;
import com.example.bookapi.book.dto.BookRequest;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest
@AutoConfigureMockMvc
class BookControllerIntegrationTest {
@Autowired
private MockMvc mockMvc;
@Test
void shouldReturnCreatedWhenBookIsValid() throws Exception {
String json = """
{
"title": "Refactoring",
"author": "Martin Fowler",
"isbn": "9780201485677",
"description": "Improving the design of existing code.",
"price": 45.50,
"publishedDate": "1999-07-08"
}
""";
mockMvc.perform(post("/api/books")
.contentType(MediaType.APPLICATION_JSON)
.content(json))
.andExpect(status().isCreated());
}
}
Tests are not just for catching bugs. They also help you think more clearly about the design of the code.
Common mistakes beginners make
There are a few traps that appear again and again when people build their first API with Spring Boot.
One mistake is exposing JPA entities directly as API responses. It feels quicker at first, but it usually becomes messy later. Another common mistake is putting all business logic inside controllers. That makes the code hard to test and hard to reuse. A third mistake is ignoring validation and exception handling until the end, then trying to patch them in after the API already exists.
There is also a tendency to overcomplicate things too soon. Not every API needs microservices, event sourcing, distributed tracing, and twelve layers of abstraction on day one. Sometimes the best path is a clean monolith with sensible structure and good boundaries. That is not boring. That is mature.
Moving from H2 to a real database
H2 is great for development, but production usually needs a stronger database. PostgreSQL is a very common choice with Spring Boot because it is reliable, capable, and integrates smoothly.
To switch, replace the H2 dependency with the PostgreSQL driver:
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
And update the datasource configuration:
spring:
datasource:
url: jdbc:postgresql://localhost:5432/bookdb
username: bookuser
password: bookpassword
driver-class-name: org.postgresql.Driver
jpa:
hibernate:
ddl-auto: update
Be careful with ddl-auto. It is convenient in development, but production environments usually need migrations instead of automatic schema changes. Tools like Flyway or Liquibase are a better long-term solution.
Using migrations
Database migration tools let you version your schema changes. That means your database evolves in a controlled, repeatable way.
A Flyway migration file might look like this:
CREATE TABLE books (
id BIGSERIAL PRIMARY KEY,
title VARCHAR(255) NOT NULL,
author VARCHAR(255) NOT NULL,
isbn VARCHAR(255) NOT NULL UNIQUE,
description VARCHAR(2000),
price NUMERIC(10,2) NOT NULL,
published_date DATE
);
That kind of discipline pays off later. It keeps deployments safer and makes it easier to understand how your database changed over time.
Improving the API over time
A good API is rarely perfect in its first version. That is okay. The goal is not to make it flawless on the first day. The goal is to build something clean enough to improve.
Over time, you may want to add:
authentication and authorization
search endpoints
file uploads
caching
rate limiting
role-based access
audit logging
soft deletes
API versioning
async processing
notifications
external service integration
Spring Boot can handle all of those. The trick is to add complexity only when the project actually needs it. Good engineering is often about timing, not just technique.
A simple versioned API approach
When your API grows, versioning helps protect existing clients. One common pattern is to include the version in the URL:
@RequestMapping("/api/v1/books")
Then later you can introduce:
@RequestMapping("/api/v2/books")
This way, changes do not surprise older clients. Versioning is not glamorous, but it is a practical kindness to the people using your API.
Useful production considerations
When your API is heading toward real usage, think about the little things that become big things later. Timeouts matter. Logging matters. Response shape matters. Consistent error messages matter. Monitoring matters. Health checks matter. Configuration management matters. Even the way you name your endpoints matters, because naming can either reduce confusion or create it.
Spring Boot has built-in support for actuator endpoints, which can expose health and metrics information. That is extremely useful once the application is deployed and you need to know whether it is alive, healthy, and performing well.
You should also think about:
how your app handles slow requests
how it behaves under load
whether responses are cached
whether you need rate limits for public endpoints
how secrets are stored
whether CORS settings are correct for browsers
These details are often invisible during development, but they shape the real user experience.
A human approach to API development
There is something very human about building APIs well. Behind every endpoint, there is usually a person trying to get work done. A frontend developer waiting for data. A mobile app showing a user’s account. A reporting tool generating numbers. A client trying to complete a task without friction.
That is why clarity matters so much. A well-designed API feels calm. It answers requests predictably, returns meaningful errors, and behaves the same way today and tomorrow. That kind of reliability is not accidental. It comes from thoughtful design, clean code, and a willingness to care about the small details.
Spring Boot helps a lot, but it does not replace judgment. You still decide how to structure your code, how strict your validation should be, when to introduce abstractions, and how to keep the project healthy as it grows. The framework gives you the tools. The craft comes from how you use them.
Final thoughts
Java and Spring Boot are an excellent combination for building APIs because they balance power and simplicity. You can start small, move quickly, and still build something that scales in a maintainable way. The patterns shown in this article—entities, DTOs, repositories, services, controllers, validation, exception handling, pagination, testing, and security—form a strong foundation for almost any backend API.
If you keep the layers clean, validate your input, return consistent errors, and test the important paths, you will already be ahead of many real-world APIs. And perhaps the most important lesson is this: build the API as if another person will need to understand it tomorrow, because they will. Sometimes that person is your teammate. Sometimes it is future you. Either way, clean code is a gift that keeps paying back.