Skip to content
ExponaBLOG
JAVA, SPRING FRAMEWORK & SPRING BOOT · DAY 022 / 100

Spring Boot tutorial: your first REST API for production

A clean CRUD endpoint with the patterns you will use for every feature.

Expona Admin8 min read

Why your first REST API matters

Building a REST API might seem straightforward—expose a few endpoints, query a database, return JSON. But the difference between a prototype and production code is pattern discipline. A Spring Boot tutorial for your first REST API for production should teach you the layering, validation, error handling, and HTTP semantics that scale across hundreds of features. Today we're building a minimal CRUD endpoint using patterns you'll repeat for every feature your team ships.

The controller—keep it thin

The controller's job is mechanical: map HTTP to a service call. Nothing more. No business logic, no database queries, no transaction management. A thin controller is a testable controller, and a testable controller is one you can refactor without breaking half your codebase.

Here's a production-ready controller for a product resource:

java
@RestController
@RequestMapping("/api/products")
public class ProductController {
  
  private final ProductService service;
  
  public ProductController(ProductService service) {
    this.service = service;
  }
  
  @GetMapping("/{id}")
  public ProductDto get(@PathVariable long id) {
    return service.get(id);
  }
  
  @GetMapping
  public List<ProductDto> list() {
    return service.list();
  }
  
  @PostMapping
  public ResponseEntity<ProductDto> create(@Valid @RequestBody CreateProductRequest req) {
    ProductDto product = service.create(req);
    return ResponseEntity.created(
      URI.create("/api/products/" + product.id())
    ).body(product);
  }
  
  @PutMapping("/{id}")
  public ProductDto update(@PathVariable long id, @Valid @RequestBody UpdateProductRequest req) {
    return service.update(id, req);
  }
  
  @DeleteMapping("/{id}")
  public ResponseEntity<Void> delete(@PathVariable long id) {
    service.delete(id);
    return ResponseEntity.noContent().build();
  }
}

Notice the structure: constructor injection of ProductService, method parameters bound by @PathVariable and @RequestBody, responses wrapped in ResponseEntity. The controller doesn't know or care whether the product comes from PostgreSQL, a cache, or a third-party API. That's the service's concern.

The @Valid annotation on the request body triggers Spring's validation framework. If validation fails, Spring returns 400 automatically—you don't write that code.

The service—where business rules live

The service layer contains your business logic and transaction boundaries. It's where you enforce invariants, calculate derived values, and coordinate multiple operations atomically.

For our product example:

java
@Service
public class ProductService {
  
  private final ProductRepository repo;
  
  public ProductService(ProductRepository repo) {
    this.repo = repo;
  }
  
  public ProductDto get(long id) {
    return repo.findById(id)
      .map(ProductDto::fromEntity)
      .orElseThrow(() -> new ResourceNotFoundException("Product not found"));
  }
  
  public List<ProductDto> list() {
    return repo.findAll().stream()
      .map(ProductDto::fromEntity)
      .toList();
  }
  
  @Transactional
  public ProductDto create(CreateProductRequest req) {
    if (repo.existsByCode(req.code())) {
      throw new DuplicateCodeException("Product code already exists");
    }
    Product product = new Product(req.name(), req.code(), req.price());
    Product saved = repo.save(product);
    return ProductDto.fromEntity(saved);
  }
  
  @Transactional
  public ProductDto update(long id, UpdateProductRequest req) {
    Product product = repo.findById(id)
      .orElseThrow(() -> new ResourceNotFoundException("Product not found"));
    product.setName(req.name());
    product.setPrice(req.price());
    Product saved = repo.save(product);
    return ProductDto.fromEntity(saved);
  }
  
  @Transactional
  public void delete(long id) {
    Product product = repo.findById(id)
      .orElseThrow(() -> new ResourceNotFoundException("Product not found"));
    repo.delete(product);
  }
}

Key observations: the @Transactional annotation ensures each operation commits or rolls back as a unit. The service validates business rules (duplicate code check) before attempting the write. It throws domain-specific exceptions that the framework or a global error handler can translate to HTTP status codes. The service doesn't know about HTTP—it only knows about products and invariants.

Create with 201—return Created with a Location header

HTTP semantics matter in production. When you create a resource, return 201 Created with a Location header pointing to the newly created resource. Clients rely on this to retrieve the full state immediately after creation without an extra round-trip.

We already saw this in the controller, but let's examine it closely:

java
@PostMapping
public ResponseEntity<ProductDto> create(@Valid @RequestBody CreateProductRequest req) {
  ProductDto product = service.create(req);
  return ResponseEntity.created(
    URI.create("/api/products/" + product.id())
  ).body(product);
}

ResponseEntity.created(URI) sets the status to 201 and the Location header to your URI. The body contains the full product representation—typically the same DTO you'd return from a GET. This means the client has all the information it needs without making a second request.

For other operations:

  • 200 OK for successful GET, PUT, or PATCH that returns a body.
  • 204 No Content for successful DELETE or operations that don't return a body.
  • 400 Bad Request for invalid input (validation errors).
  • 404 Not Found when a resource doesn't exist.
  • 409 Conflict for business rule violations like duplicate codes.

Spring Boot's default error handling maps common exceptions to these codes, but you'll often write a @RestControllerAdvice class to standardize error response formats.

Request and response DTOs

DTOs (Data Transfer Objects) decouple your HTTP contract from your domain model. The entity might have internal fields or state that shouldn't be exposed. DTOs also let you accept different shapes for create vs. update operations.

java
public record ProductDto(
  long id,
  String name,
  String code,
  BigDecimal price
) {
  public static ProductDto fromEntity(Product entity) {
    return new ProductDto(
      entity.getId(),
      entity.getName(),
      entity.getCode(),
      entity.getPrice()
    );
  }
}

public record CreateProductRequest(
  @NotBlank String name,
  @NotBlank String code,
  @Positive BigDecimal price
) {}

public record UpdateProductRequest(
  @NotBlank String name,
  @Positive BigDecimal price
) {}

Records are perfect for DTOs—immutable, concise, and Spring recognizes them for binding and validation. The @NotBlank, @Positive, and other annotations from jakarta.validation.constraints tell Spring's validation framework what to enforce before your service method runs.

The repository—data access

Spring Data JPA handles the boilerplate. For most CRUD operations, a simple interface is all you need:

java
@Repository
public interface ProductRepository extends JpaRepository<Product, Long> {
  boolean existsByCode(String code);
  Optional<Product> findByCode(String code);
}

JpaRepository provides findById, findAll, save, and delete out of the box. You add custom queries by declaring method names that Spring interprets, or write @Query methods for complex logic.

Run it

With Spring Boot, you're ready to test immediately:

java
./mvnw spring-boot:run

Spring Boot starts an embedded Tomcat and prints the address—usually http://localhost:8080. If you've included springdoc-openapi-starter-webmvc-ui in your dependencies, navigate to /swagger-ui.html to see an interactive API explorer generated from your @RestController annotations.

Test with curl:

java
curl -X POST http://localhost:8080/api/products \
  -H "Content-Type: application/json" \
  -d '{"name":"Laptop","code":"LAPTOP-001","price":999.99}'

curl -X GET http://localhost:8080/api/products/1

The POST returns 201 with a Location header. The GET returns 200 with the full product. That's the rhythm of production REST APIs.

Entity and domain model

The JPA entity represents your domain model—the actual product concept, with behavior and invariants:

java
@Entity
@Table(name = "products")
public class Product {
  
  @Id
  @GeneratedValue(strategy = GenerationType.IDENTITY)
  private long id;
  
  @Column(nullable = false)
  private String name;
  
  @Column(nullable = false, unique = true)
  private String code;
  
  @Column(nullable = false)
  private BigDecimal price;
  
  public Product() {}
  
  public Product(String name, String code, BigDecimal price) {
    this.name = name;
    this.code = code;
    this.price = price;
  }
  
  // getters and setters
}

The @Column annotations define schema constraints that match your business rules. The constructor enforces required fields. When the service retrieves or saves a product, it works with this entity, unaware of HTTP.

Configuration and dependencies

Your pom.xml should include:

java
<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>org.postgresql</groupId>
  <artifactId>postgresql</artifactId>
  <version>42.7.0</version>
  <scope>runtime</scope>
</dependency>

<dependency>
  <groupId>org.springdoc</groupId>
  <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
  <version>2.0.2</version>
</dependency>

In application.yml, configure your database:

java
spring:
  datasource:
    url: jdbc:postgresql://localhost:5432/myapp
    username: postgres
    password: password
  jpa:
    hibernate:
      ddl-auto: validate
    properties:
      hibernate:
        dialect: org.hibernate.dialect.PostgreSQLDialect
        format_sql: true
        use_sql_comments: true

Set ddl-auto: validate in production—you manage schema migrations separately. In development, update is convenient but risky.

Common mistakes

  • Logic in controllers. Controllers balloon with conditional checks, loops, and database calls. Move it to the service layer where it's testable and reusable.
  • Forgetting @Transactional. Without it, each repository method opens and closes its own transaction. Multi-step operations become non-atomic and vulnerable to race conditions.
  • Returning entities from endpoints. Your entity evolves for database reasons (indexes, lazy-loading strategies, internal state). Your API contract shouldn't break when you refactor the model. Use DTOs and map explicitly.
  • Ignoring HTTP status codes. Returning 200 for a deletion or 500 for a validation error confuses clients and breaks API contracts. Invest in a consistent error strategy early.
  • No validation. Assume the client is wrong. Use Bean Validation annotations and let Spring handle the 400 responses. Your service shouldn't waste cycles validating bad input.
  • Constructor injection neglect. Autowiring by field makes testing harder and hides dependencies. Use constructor injection so dependencies are explicit and easy to mock.

Key takeaways

  • A Spring Boot tutorial for your first REST API for production establishes patterns you repeat across the codebase. Thin controllers, thick services, explicit DTOs.
  • Layer responsibilities: controller handles HTTP mapping, service enforces business rules and transactions, repository abstracts data access.
  • Return proper HTTP status codes—201 Created with a Location header for resource creation, 204 No Content for deletion, 400 for validation errors, 404 for missing resources.
  • Use constructor injection and Bean Validation annotations to keep code testable and concise.
  • DTOs decouple your API contract from your domain model, preventing unnecessary breakage when your entities evolve.
  • Let Spring's @Transactional handle transaction boundaries and Spring's validation framework handle input constraints.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles