PrepZone Logo
PrepZone

Build a CRUD API Step by Step

Complete BookStore CRUD — entity, repository, service and controller wired together.

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
Each layer has one job. Dependencies point inward — controllers never touch the database directly.

Step 1: Entity

Java
@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

Java
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

Java
@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

Java
@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

Java
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

Java
# 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

  1. HTTP request hits BookController.
  2. Controller calls BookService.
  3. Service applies business logic and calls BookRepository.
  4. Repository executes SQL via Hibernate.
  5. 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.
  • JpaRepository provides 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-drop with Flyway before deploying to production.

Test yourself

Answer these before moving on — recall is what makes it stick.