Why this matters
- Building CRUD from scratch cements the layered pattern before adding validation, error handling, and security.
- This is the reference implementation every later BookStore article extends.
- Interviewers often ask candidates to sketch a CRUD API — having built one makes the answer concrete.
ControllerHTTP in/out — @RestController
ServiceBusiness rules — @Service
RepositoryDatabase — @Repository
DatabasePostgreSQL / H2
Step 1: Entity
@Entity
@Table(name = "books")
public class Book {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String title;
@Column(nullable = false, unique = true)
private String isbn;
@Column(nullable = false)
private BigDecimal price;
protected Book() {} // JPA requires no-arg constructor
public Book(String title, String isbn, BigDecimal price) {
this.title = title;
this.isbn = isbn;
this.price = price;
}
// getters and setters
}
Step 2: Repository
public interface BookRepository extends JpaRepository<Book, Long> {
Optional<Book> findByIsbn(String isbn);
List<Book> findByTitleContainingIgnoreCase(String keyword);
}
Spring Data generates the implementation. JpaRepository provides save, findById, findAll, deleteById out of the box.
Step 3: Service
@Service
@Transactional(readOnly = true)
public class BookService {
private final BookRepository repository;
public BookService(BookRepository repository) {
this.repository = repository;
}
public List<Book> findAll() {
return repository.findAll();
}
public Book findById(Long id) {
return repository.findById(id)
.orElseThrow(() -> new BookNotFoundException(id));
}
@Transactional
public Book create(String title, String isbn, BigDecimal price) {
Book book = new Book(title, isbn, price);
return repository.save(book);
}
@Transactional
public Book update(Long id, String title, BigDecimal price) {
Book book = findById(id);
book.setTitle(title);
book.setPrice(price);
return repository.save(book);
}
@Transactional
public void delete(Long id) {
if (!repository.existsById(id)) {
throw new BookNotFoundException(id);
}
repository.deleteById(id);
}
}
Step 4: Controller
@RestController
@RequestMapping("/api/books")
public class BookController {
private final BookService bookService;
public BookController(BookService bookService) {
this.bookService = bookService;
}
@GetMapping
public List<BookResponse> list() {
return bookService.findAll().stream()
.map(BookResponse::from)
.toList();
}
@GetMapping("/{id}")
public BookResponse get(@PathVariable Long id) {
return BookResponse.from(bookService.findById(id));
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public BookResponse create(@RequestBody CreateBookRequest request) {
Book book = bookService.create(
request.title(), request.isbn(), request.price());
return BookResponse.from(book);
}
@PutMapping("/{id}")
public BookResponse update(@PathVariable Long id,
@RequestBody UpdateBookRequest request) {
Book book = bookService.update(id, request.title(), request.price());
return BookResponse.from(book);
}
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void delete(@PathVariable Long id) {
bookService.delete(id);
}
}
Step 5: Configuration
spring:
datasource:
url: jdbc:h2:mem:bookstore
driver-class-name: org.h2.Driver
jpa:
hibernate:
ddl-auto: create-drop
show-sql: true
h2:
console:
enabled: true
Add spring-boot-starter-data-jpa and H2 to the POM for local development.
Test the API
# Create
curl -X POST http://127.0.0.1:8080/api/books \
-H "Content-Type: application/json" \
-d '{"title":"Clean Code","isbn":"978-0132350884","price":38.50}'
# List
curl http://127.0.0.1:8080/api/books
# Get by ID
curl http://127.0.0.1:8080/api/books/1
# Update
curl -X PUT http://127.0.0.1:8080/api/books/1 \
-H "Content-Type: application/json" \
-d '{"title":"Clean Code (2nd Ed)","price":42.00}'
# Delete
curl -X DELETE http://127.0.0.1:8080/api/books/1
Request flow summary
- HTTP request hits
BookController. - Controller calls
BookService. - Service applies business logic and calls
BookRepository. - Repository executes SQL via Hibernate.
- Result flows back: entity → service → DTO → JSON response.
Each hop adds one layer of responsibility — no layer skips another.
Quick recall
Everything you need if you only revisit this box.
- CRUD stack: Entity → Repository → Service → Controller, each in its own package.
JpaRepositoryprovides standard CRUD; derived query methods handle custom lookups.- Service layer owns transactions (
@Transactional) and business exceptions. - Controller maps entities to DTOs and sets HTTP status codes.
- Test with curl against H2 in-memory for fast local iteration.
- Replace
ddl-auto: create-dropwith Flyway before deploying to production.
Test yourself
Answer these before moving on — recall is what makes it stick.