Erste REST API mit Spring Boot erstellen

1. Übersicht

Dieser Artikel zeigt dir, wie du eine komplette REST API mit Spring Boot erstellen kannst, anhand eines Beispiels für die Verwaltung von Order (Bestellungen). Danach wirst du in der Lage sein, ein Projekt aufzusetzen, einen Controller für CRUD-Operationen zu schreiben, Fehler nach HTTP-Standard zu behandeln und die API mit MockMvc zu testen.

2. Projektinitialisierung

Erstelle ein Projekt über Spring Initializr mit folgenden Dependencies:

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-validation</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

spring-boot-starter-web bietet eingebetteten Tomcat und Spring MVC an. spring-boot-starter-validation wird verwendet, um den Request-Body zu validieren (Abschnitt 5). Der Teil spring-boot-starter-test bringt JUnit 5, AssertJ und MockMvc mit.

Überprüfe, ob die Anwendung läuft:

@SpringBootApplication
public class OrderApiApplication {
    public static void main(String[] args) {
        SpringApplication.run(OrderApiApplication.class, args);
    }
}

Führe mvn spring-boot:run aus, und du wirst sehen, dass Tomcat auf Port 8080 startet.

3. Modell definieren

public class Order {
    private Long id;
    private String customerName;
    private BigDecimal amount;

    public Order(Long id, String customerName, BigDecimal amount) {
        this.id = id;
        this.customerName = customerName;
        this.amount = amount;
    }

    // Getter, Setter
}

Das ist ein einfaches Modell, noch nicht mit JPA verbunden. Das Ziel dieses Artikels ist, zuerst die REST-Schicht zu beherrschen, der Persistenz-Teil ist Stoff für einen anderen Artikel.

4. Service für temporäre Datenspeicherung schreiben

@Service
public class OrderService {

    private final Map<Long, Order> store = new ConcurrentHashMap<>();
    private final AtomicLong idSequence = new AtomicLong(0);

    public Order create(Order order) {
        long id = idSequence.incrementAndGet();
        Order saved = new Order(id, order.getCustomerName(), order.getAmount());
        store.put(id, saved);
        return saved;
    }

    public List<Order> findAll() {
        return new ArrayList<>(store.values());
    }

    public Optional<Order> findById(Long id) {
        return Optional.ofNullable(store.get(id));
    }

    public void deleteById(Long id) {
        store.remove(id);
    }
}

ConcurrentHashMap fungiert als “temporäre Datenbank”, damit du das Demo ausführen kannst, ohne eine DB zu konfigurieren. AtomicLong erzeugt aufsteigende IDs und ist sicher, wenn mehrere Threads gleichzeitig create aufrufen.

Hinweis: Diese Art der Datenspeicherung ist nur zum Lernen gedacht, die Daten gehen beim Neustart der App verloren. In der Produktion brauchst du Spring Data JPA oder ähnliches.

5. REST Controller schreiben

5.1. Endpoints zum Erstellen und Abrufen der Liste

@RestController
@RequestMapping("/api/orders")
public class OrderController {

    private final OrderService orderService;

    public OrderController(OrderService orderService) {
        this.orderService = orderService;
    }

    @PostMapping
    public ResponseEntity<Order> create(@Valid @RequestBody OrderRequest request) {
        Order order = new Order(null, request.customerName(), request.amount());
        Order saved = orderService.create(order);
        return ResponseEntity.status(HttpStatus.CREATED).body(saved);
    }

    @GetMapping
    public List<Order> findAll() {
        return orderService.findAll();
    }
}

@RestController kombiniert @Controller und @ResponseBody und gibt JSON statt einer View zurück. @RequestMapping("/api/orders") legt ein gemeinsames Präfix für alle Endpoints in dieser Klasse fest. @PostMapping empfängt den Body über @RequestBody, Spring konvertiert JSON automatisch in OrderRequest durch Jackson, das in spring-boot-starter-web bereits enthalten ist.

OrderRequest ist ein Record für die Validierung der Eingabe, getrennt von Order:

public record OrderRequest(
        @NotBlank String customerName,
        @NotNull @Positive BigDecimal amount
) {}

5.2. Endpoints zum Abrufen nach ID und zum Löschen

@GetMapping("/{id}")
public ResponseEntity<Order> findById(@PathVariable Long id) {
    return orderService.findById(id)
            .map(ResponseEntity::ok)
            .orElse(ResponseEntity.notFound().build());
}

@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable Long id) {
    orderService.deleteById(id);
    return ResponseEntity.noContent().build();
}

@PathVariable extrahiert den Wert {id} aus der URL. Wenn order nicht gefunden wird, gibt die API 404 Not Found zurück statt null — das ist ein häufig übersehener Punkt, wenn man neu bei REST APIs ist.

Teste mit curl:

curl -X POST http://localhost:8080/api/orders \
  -H "Content-Type: application/json" \
  -d '{"customerName":"Nguyen Van A","amount":150000}'
HTTP/1.1 201 Created
Content-Type: application/json

{"id":1,"customerName":"Nguyen Van A","amount":150000}

6. Zentralisierte Fehlerbehandlung

Ohne Behandlung werden Validierungsfehler in einer schwer lesbaren Standard-JSON-Response zurückgegeben. Verwende @RestControllerAdvice, um sie zu standardisieren:

@RestControllerAdvice
public class ApiExceptionHandler {

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<Map<String, String>> handleValidation(
            MethodArgumentNotValidException ex) {
        Map<String, String> errors = new HashMap<>();
        ex.getBindingResult().getFieldErrors()
                .forEach(error -> errors.put(error.getField(), error.getDefaultMessage()));
        return ResponseEntity.badRequest().body(errors);
    }
}

Diese Klasse fängt alle MethodArgumentNotValidException, die von @Valid geworfen werden, sammelt die Fehler pro Feld und gibt eine 400 Bad Request mit leserlichem Body zurück:

{
  "amount": "muss größer als 0 sein",
  "customerName": "darf nicht leer sein"
}

Hinweis: Lass die Standard-Exception von Spring nicht in der Produktion sichtbar werden — sie könnte den Stack-Trace offenlegen und die Sicherheit beeinträchtigen.

7. Tests für den Controller schreiben

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>
@WebMvcTest(OrderController.class)
class OrderControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @MockBean
    private OrderService orderService;

    @Test
    void shouldReturn201WhenCreateOrderSuccessfully() throws Exception {
        Order saved = new Order(1L, "Tran Thi B", new BigDecimal("200000"));
        given(orderService.create(any(Order.class))).willReturn(saved);

        mockMvc.perform(post("/api/orders")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("""
                                {"customerName":"Tran Thi B","amount":200000}
                                """))
                .andExpect(status().isCreated())
                .andExpect(jsonPath("$.id").value(1))
                .andExpect(jsonPath("$.customerName").value("Tran Thi B"));
    }

    @Test
    void shouldReturn404WhenOrderNotFound() throws Exception {
        given(orderService.findById(99L)).willReturn(Optional.empty());

        mockMvc.perform(get("/api/orders/99"))
                .andExpect(status().isNotFound());
    }
}

@WebMvcTest lädt nur die Web-Schicht (Controller, Filter, Validierung) und initialisiert nicht den gesamten Context, daher läuft der Test schnell. @MockBean ersetzt den echten OrderService durch ein Mock, damit du den Controller unabhängig von der Service-Logik testen kannst. Die beiden Test-Cases prüfen sowohl den erfolgreichen als auch den Fehlerfall — eine Gewohnheit, die du für jeden Endpoint haben solltest.

Überprüfe zusätzlich mit AssertJ auf der Service-Schicht:

@Test
void shouldGenerateIncrementalIdWhenCreatingMultipleOrders() {
    OrderService service = new OrderService();

    Order first = service.create(new Order(null, "A", BigDecimal.TEN));
    Order second = service.create(new Order(null, "B", BigDecimal.ONE));

    assertThat(first.getId()).isEqualTo(1L);
    assertThat(second.getId()).isEqualTo(2L);
}

8. Häufige Fehler

  • @RequestBody vergessen: Spring wird JSON nicht an das Objekt binden, Felder bleiben immer null ohne klare Fehlermeldung.
  • null zurückgeben statt ResponseEntity.notFound(): Der Client erhält 200 OK mit null als Body, was leicht zu Frontend-Bugs führt.
  • Eingabe nicht validieren: API akzeptiert negative Beträge, leere Namen… was zu Logic-Fehlern in niedrigeren Schichten führt, die schwer zu verfolgen sind.
  • @Controller statt @RestController verwenden: Du musst @ResponseBody manuell bei jeder Methode hinzufügen, was leicht vergessen wird.

Hinweis: Wenn deine API mehrere Clients bedient (Mobile, Web, Partner), solltest du von Anfang an Versionierung berücksichtigen (z. B. /api/v1/orders), um Breaking Changes später zu vermeiden.

9. Fazit

Du hast eine grundlegende REST API mit Spring Boot aufgebaut: von der Model, zum Service, zum Controller, zur zentralisierten Fehlerbehandlung bis zum Testen mit MockMvc. Der nächste Schritt sollte sein, ConcurrentHashMap durch Spring Data JPA zu ersetzen, damit Daten wirklich persistiert werden, und weitere Exceptions wie EntityNotFoundException in @RestControllerAdvice zu behandeln.

spring-bootrest-apijavaspring-web