API Concepts Every Software Engineer Should Know: comprehensive guide

This article has a short video:TikTok

1. Overview

Calling an API is easy — send a request, read JSON, done. But designing an API that other teams, or yourself six months from now, can still use without having to read the code to guess its behavior — that’s a different story. This article synthesizes core API concepts that any backend engineer should grasp: from HTTP fundamentals, architectural styles (REST, GraphQL, gRPC, webhook, WebSocket), to sustainable design, security, reliability, and documentation. By the end, you’ll have a concrete checklist to review your own API before making it public for others to use.

2. HTTP Fundamentals: Methods, Status Codes, Request/Response

Before talking about REST or GraphQL, you must get HTTP semantics right. This is where many APIs “look like REST” but are actually fundamentally wrong.

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

    private final OrderService orderService;

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

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

    @PostMapping
    ResponseEntity<Order> createOrder(@RequestBody CreateOrderRequest request) {
        Order created = orderService.create(request);
        return ResponseEntity.created(URI.create("/api/orders/" + created.id())).body(created);
    }

    @PutMapping("/{id}")
    ResponseEntity<Order> replaceOrder(@PathVariable Long id, @RequestBody Order order) {
        return ResponseEntity.ok(orderService.replace(id, order));
    }

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

Common mistakes:

  • GET must be safe (doesn’t change state) and idempotent. If your GET endpoint increments a counter or writes important logs, you’re violating an implicit contract with the client (and with proxies/CDN caches).
  • PUT replaces the entire resource, PATCH updates only the changed parts. Using PUT to update a single field is semantically wrong, even if the code works.
  • Status codes must reflect the actual meaning: 201 Created when creating (with Location header), 204 No Content when deletion succeeds with no body, 409 Conflict when there’s a state conflict — not just 400 or 500 for every error.

Note: Returning 200 OK for every response with a "success": false field in the body is a common anti-pattern. Clients, load balancers, and APM tools all rely on status codes to decide whether to retry or alert — hiding errors in the body breaks the entire chain.

3. Choosing an API Style: REST, GraphQL, gRPC, Webhook, WebSocket

Each architectural style solves a different problem. Choosing the wrong one isn’t a technical mistake — it’s a misunderstanding of the use case.

3.1. REST — The Default for CRUD over HTTP

Suitable when resources are clear (Order, Customer, Invoice) and you have diverse clients (web, mobile, third-party). The example in section 2 is a standard REST API.

3.2. GraphQL — When Clients Need Flexible Fields

type Order {
  id: ID!
  customer: Customer!
  total: Float!
  items: [OrderItem!]!
}

type Query {
  order(id: ID!): Order
}
@Controller
class OrderGraphQlController {

    private final OrderService orderService;

    OrderGraphQlController(OrderService orderService) {
        this.orderService = orderService;
    }

    @QueryMapping
    Order order(@Argument Long id) {
        return orderService.findById(id).orElse(null);
    }
}

Clients choose which fields they need ({ order(id: 1) { total } } instead of making 3 REST calls). In return, you lose HTTP caching by URL and must handle N+1 queries yourself (using DataLoader). GraphQL suits apps with complex UIs and many clients with different data needs — not suitable for simple APIs with few clients.

3.3. gRPC — When You Need High Performance Between Internal Services

service PaymentService {
  rpc Charge (ChargeRequest) returns (ChargeResponse);
}

message ChargeRequest {
  string order_id = 1;
  int64 amount_cents = 2;
}

gRPC uses HTTP/2 + Protobuf (binary, more compact than JSON) and supports bidirectional streaming. Great for service-to-service communication in microservices, but hard to debug manually (can’t read with plain curl) and not suitable for public APIs used by browsers.

3.4. Webhook — When the Receiver Needs Proactive Notifications

@PostMapping("/webhooks/payment-status")
ResponseEntity<Void> handlePaymentWebhook(@RequestBody PaymentStatusEvent event,
                                           @RequestHeader("X-Signature") String signature) {
    // Webhooks reverse the call direction: PaymentService server calls your server back when an event occurs.
    if (!signatureValidator.isValid(event, signature)) {
        return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
    }
    paymentEventHandler.handle(event);
    return ResponseEntity.ok().build();
}

Webhooks reverse the call direction: the PaymentService server calls your server back when an event occurs. Always verify the signature (HMAC) to avoid fake requests, and always return 200 quickly then process asynchronously — the sender usually retries if there’s a timeout or error.

3.5. WebSocket — When You Need Real-Time Bidirectional Communication

Use for chat, live tracking, continuously updating dashboards. Not suitable for simple request-response because maintaining the connection continuously uses far more resources than REST.

4. Sustainable Design: Naming, Pagination, Versioning, Error Responses

4.1. Consistent Naming

Use plural nouns for collections (/orders, not /order or /getOrders), and nested nouns to show relationships (/customers/{id}/orders). Avoid mixing camelCase and snake_case in the same API.

4.2. Pagination

@GetMapping
Page<Order> listOrders(@RequestParam(defaultValue = "0") int page,
                        @RequestParam(defaultValue = "20") int size) {
    return orderService.findAll(PageRequest.of(page, size));
}
GET /api/orders?page=1&size=20

Offset-based (page/size) is easy to understand but slow with large tables and prone to duplicate/missing records if data is inserted/deleted between page requests. Cursor-based (?after=order_1234) is more stable with large datasets, but more complex to implement “jump to page 10”.

4.3. Versioning

@RestController
@RequestMapping("/api/v2/orders")
class OrderControllerV2 { /* ... */ }

Versioning via URI path is easy to read and easy to route with a gateway. Versioning via header (Accept: application/vnd.myapp.v2+json) is cleaner for URLs but harder to debug in a browser. Which one you choose matters less than having a clear deprecation strategy: notify in advance, keep the old version running long enough, and return Deprecation and Sunset headers.

4.4. Standardized Error Responses

@ExceptionHandler(OrderNotFoundException.class)
ResponseEntity<ProblemDetail> handleNotFound(OrderNotFoundException ex) {
    ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
    problem.setProperty("orderId", ex.getOrderId());
    return ResponseEntity.status(HttpStatus.NOT_FOUND).body(problem);
}
{
  "type": "about:blank",
  "title": "Not Found",
  "status": 404,
  "detail": "Order 99 not found",
  "orderId": 99
}

ProblemDetail (built into Spring Boot 3.x, following RFC 7807) produces a uniform error format across the system — clients only need to parse one schema, not guess which field contains the error message.

5. Security: API Key, OAuth2, JWT, Scope

5.1. API Key — Simple, Suitable for Low-Trust Service-to-Service with Low Risk

@Component
class ApiKeyFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain)
            throws ServletException, IOException {
        String apiKey = req.getHeader("X-Api-Key");
        if (apiKey == null || !apiKeyRepository.isValid(apiKey)) {
            res.setStatus(HttpStatus.UNAUTHORIZED.value());
            return;
        }
        chain.doFilter(req, res);
    }
}

An API key can’t distinguish who is calling on behalf of whom — it only authenticates the app, not the user. Don’t use it to protect personal data of end users.

5.2. OAuth2 + JWT — When You Need Scoped Authorization

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: https://auth.nonladev.com
@PreAuthorize("hasAuthority('SCOPE_orders:write')")
@PostMapping
ResponseEntity<Order> createOrder(@RequestBody CreateOrderRequest request) {
    return ResponseEntity.ok(orderService.create(request));
}

JWT carries claims (user ID, scope, exp) and can be self-verified using a public key without asking the auth server on each request — this is why it scales better than traditional sessions. But JWTs can’t be easily revoked before expiration: if a user’s account is locked, old tokens remain valid until exp. Common solution: use short exp (5–15 minutes) with refresh tokens, or check a blacklist at the gateway layer.

Note: Scope only says “what this token is allowed to do”, it doesn’t replace checking business rules (for example: user A has orders:write but no permission to edit user B’s orders). Always check ownership at the service layer.

6. Reliability: Timeout, Retry, Idempotency, Rate Limit, Cache

6.1. Timeout and Retry

@Bean
RestClient paymentClient() {
    return RestClient.builder()
            .baseUrl("https://payment.internal")
            .requestFactory(ClientHttpRequestFactorySettings.DEFAULTS
                    .withConnectTimeout(Duration.ofSeconds(2))
                    .withReadTimeout(Duration.ofSeconds(3))
                    .buildRequestFactory())
            .build();
}
<dependency>
    <groupId>io.github.resilience4j</groupId>
    <artifactId>resilience4j-spring-boot3</artifactId>
    <version>2.2.0</version>
</dependency>
@Retry(name = "paymentService", fallbackMethod = "chargeFallback")
Charge charge(ChargeRequest request) {
    return paymentClient.post().uri("/charge").body(request).retrieve().body(Charge.class);
}

No timeout = threads hang indefinitely when downstream dies, pulling your entire service down in a domino effect. Careless retries can cause request storms that break downstream even harder — always combine retry with exponential backoff and circuit breaker.

6.2. Idempotency Key for Financial Transactions

@PostMapping("/charges")
ResponseEntity<Charge> charge(@RequestHeader("Idempotency-Key") String key,
                               @RequestBody ChargeRequest request) {
    return idempotencyStore.findByKey(key)
            .map(existing -> ResponseEntity.ok(existing))
            .orElseGet(() -> {
                Charge charge = paymentService.charge(request);
                idempotencyStore.save(key, charge);
                return ResponseEntity.ok(charge);
            });
}

When a client resends the same request (due to timeout or network glitch) with the same Idempotency-Key, they get the old result back and aren’t charged twice. This is the most important concept for any API with financial side-effects.

@Test
void shouldReturnSameChargeForDuplicateIdempotencyKey() {
    ChargeRequest request = new ChargeRequest("order-1", 10_000L);
    Charge first = client.charge("idem-key-1", request);
    Charge second = client.charge("idem-key-1", request);

    assertThat(second).isEqualTo(first);
    assertThat(paymentGateway.chargeCallCount()).isEqualTo(1);
}

6.3. Rate Limit and Cache

@Bean
Bucket4jConfiguration rateLimitConfig() {
    return Bucket4jConfiguration.builder()
            .capacity(100)
            .refillGreedy(100, Duration.ofMinutes(1))
            .build();
}
@Cacheable(value = "orders", key = "#id")
Order findById(Long id) {
    return orderRepository.findById(id).orElseThrow();
}

Rate limit protects the system from being overwhelmed by a faulty client (accidentally or intentionally). Cache reduces load on downstream but you must consider TTL and cache invalidation — stale cache for in-flight order data can cause serious state misunderstanding, which is worse than no cache.

7. Documentation, Observability, and Contract Testing

<dependency>
    <groupId>org.springdoc</groupId>
    <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
    <version>2.6.0</version>
</dependency>
@Operation(summary = "Create a new order", responses = {
        @ApiResponse(responseCode = "201", description = "Order created successfully")
})
@PostMapping
ResponseEntity<Order> createOrder(@RequestBody CreateOrderRequest request) { /* ... */ }

OpenAPI specs auto-generated from annotations prevent specs from “drifting” from actual code — the classic problem when docs are hand-written and code changes get forgotten.

Contract testing (Spring Cloud Contract, Pact) ensures consumers and providers don’t break their contracts when deploying independently:

@Test
void providerShouldHonorOrderContract() {
    Order order = orderService.findById(1L).orElseThrow();

    assertThat(order.id()).isEqualTo(1L);
    assertThat(order.status()).isIn("PENDING", "PAID", "CANCELLED");
}

Combined with observability (structured logging, trace IDs across services via the traceparent header, latency/error rate metrics per endpoint), your operations team has enough information to debug production rather than guessing through scattered logs.

8. Conclusion

API design doesn’t come down to choosing the right framework — it lies in a series of small decisions: which method, which status code, what pagination style, who’s allowed to call with which scope, when to stop retrying. Each decision individually looks simple, but together they determine whether your API is easy to use or a headache for every integrating team. Master the concepts above — HTTP semantics, choosing the right architecture for your use case, layered security, reliability patterns, and docs in sync with code — and you’ll have the foundation to design APIs that people actually trust to use long-term.

system-designapispring-bootrestsecurity