APIs Explained: How Backend Services Communicate

By Oleksandr Andrushchenko — Published on — Modified on

APIs Explained: How Backend Services Communicate
APIs Explained: How Backend Services Communicate

APIs are the contracts that allow backend systems, frontend applications, mobile apps, third-party integrations, and internal services to communicate. A good API hides implementation details while exposing a stable way to request data, trigger actions, and exchange information between systems.

In production systems, APIs are not only endpoints and JSON responses. They define boundaries between services, control coupling, enforce security, shape performance, and determine how systems evolve without breaking clients.

Table of Contents

What Is an API?

An Application Programming Interface is a defined way for one software system to interact with another. In backend systems, an API usually exposes operations over HTTP, messaging, RPC, or events.

The API is not the database, framework, or internal code structure. It is the external contract that other systems depend on.

API Consumer API Provider Typical Purpose
Frontend app Backend API Read and modify user-facing data.
Mobile app Backend API Perform authenticated user operations.
Internal service Another backend service Coordinate business workflows.
Third-party system Public API Integrate external platforms.

Why APIs Exist

APIs exist because systems need boundaries. Without a defined interface, every consumer depends directly on implementation details such as database tables, internal classes, storage layout, or deployment structure.

Key Point: a good API allows internal implementation to change while external behavior stays stable.

Service Boundaries

A backend service boundary separates ownership, data, deployment, and responsibility. The API is the controlled entry point across that boundary.

Frontend
  -> User API
       -> User database

Orders Service
  -> Payments API
       -> Payment provider

Admin Tool
  -> Reporting API
       -> Analytics database

Boundaries reduce accidental coupling. A client should not need to know how data is stored, which cache is used, or which internal services are called.

Contracts Between Systems

An API contract defines what requests are accepted, what responses are returned, which errors are possible, and what behavior clients can rely on.

Contract Part Example
Endpoint GET /v1/orders/{order_id}
Request Path parameters, query parameters, headers, body.
Response Status code, JSON body, headers.
Error behavior 404 when order does not exist.
Security Authentication token and required permissions.

How Backend Communication Works

Backend communication usually follows one of two models: synchronous communication, where the caller waits for a response, or asynchronous communication, where work is sent for later processing.

The choice affects latency, reliability, failure handling, and system coupling.

Request/Response Model

The request/response model is the most common API communication pattern. A client sends a request, the server processes it, and the client receives a response.

Client
  -> HTTP request
  -> API server
  -> Validate input
  -> Check permissions
  -> Read or write data
  -> HTTP response

This model is simple and direct. It works well for operations where the caller needs an immediate result.

Synchronous vs Asynchronous Communication

Synchronous APIs are easier to reason about but create tighter runtime coupling. If the provider is slow or unavailable, the caller is directly affected.

Asynchronous communication uses queues, events, or streams to decouple producers from consumers. It improves resilience but introduces eventual consistency.

Pattern Best For Trade-Off
Synchronous API Immediate reads, validation, user-facing actions. Caller waits for response.
Asynchronous message Email sending, file processing, notifications, imports. Result may be delayed.
Event System integration and decoupled workflows. Harder tracing and consistency model.

Rule of Thumb: use synchronous APIs when the caller needs an immediate answer. Use asynchronous communication when the work is slow, retryable, or not required before responding.

Common API Styles

Different API styles exist because systems have different communication needs. REST, GraphQL, gRPC, and webhooks solve different problems.

The best style depends on consumer type, latency requirements, payload shape, platform constraints, and operational maturity.

REST APIs

REST APIs model operations around resources and standard HTTP methods. They are widely used for web applications, mobile apps, public APIs, and internal services.

GET    /v1/orders
GET    /v1/orders/ord_123
POST   /v1/orders
PATCH  /v1/orders/ord_123
DELETE /v1/orders/ord_123

REST is a strong default when resources are clear and HTTP semantics fit the problem.

GraphQL APIs

GraphQL allows clients to request exactly the fields they need. It is useful when frontend clients need flexible data shapes from many related resources.

The trade-off is operational complexity. Query cost, authorization, caching, and observability require careful design.

gRPC APIs

gRPC uses strongly typed contracts and efficient binary communication. It is common for internal service-to-service communication where performance and strict schemas matter.

gRPC is less convenient for browser-first public APIs, but it can be excellent for internal backend systems.

Webhooks

Webhooks are reverse APIs. Instead of a client polling for changes, the provider sends an HTTP request to a client-controlled endpoint when an event happens.

Payment provider
  -> POST /webhooks/payments
  -> Application validates signature
  -> Application stores event
  -> Worker processes event

Production Note: webhook handlers should be idempotent because providers often retry delivery.

API Contract Design

API design is contract design. A good API contract is predictable, consistent, secure, and stable across versions.

Poor contract design creates long-term maintenance problems because clients build assumptions around every response field, error code, and behavior.

Resources, Actions, and Events

Resource-based APIs work well when the system exposes entities such as users, orders, invoices, payments, or projects. Action-based endpoints are useful when the operation does not fit clean CRUD behavior.

API Shape Example Best Fit
Resource GET /v1/orders/ord_123 Reading or modifying entities.
Action POST /v1/orders/ord_123/cancel Business operation on a resource.
Event order.created Asynchronous system notification.

Status Codes and Errors

Status codes should communicate the category of result. Error bodies should provide enough detail for clients to recover or display a useful message.

Status Code Meaning Example
200 Success Resource returned.
201 Created New order created.
400 Invalid request Validation failed.
401 Not authenticated Missing or invalid token.
403 Not authorized Authenticated but not allowed.
404 Not found Order does not exist.
409 Conflict State does not allow operation.
429 Rate limited Too many requests.
500 Server error Unexpected failure.

Backward Compatibility

APIs should evolve without breaking existing clients. Removing fields, changing data types, changing meanings, or tightening validation can break consumers.

Rule of Thumb: adding optional fields is usually safe. Removing or changing existing fields is usually breaking.

{
  "error": {
    "code": "order_not_found",
    "message": "Order was not found.",
    "request_id": "req_9d2a81"
  }
}

Stable error codes are important. Clients should not need to parse human-readable messages to make decisions.

Production API Concerns

Production APIs need more than clean endpoint names. They need security, timeouts, retries, rate limits, observability, and failure behavior that clients can handle.

These concerns should be part of the API design from the beginning.

Authentication and Authorization

Authentication identifies who is calling. Authorization decides whether that caller is allowed to perform the requested operation.

Request
  -> Verify token
  -> Load identity
  -> Check permission
  -> Execute operation
  -> Return response

Important: authentication alone is not enough. A valid user token does not automatically mean access to every resource.

Timeouts and Retries

Backend services should use explicit timeouts when calling other APIs. Without timeouts, one slow dependency can consume threads, connections, workers, or event-loop capacity.

Retries should be limited and should use backoff. Retrying every failed request immediately can amplify incidents.

Failure Retry? Reason
Network timeout Usually yes May be temporary.
HTTP 500 Sometimes Depends on operation idempotency.
HTTP 400 No Request is invalid.
HTTP 401 No Authentication problem.
HTTP 429 Yes, after delay Respect rate limit response.

Rate Limiting

Rate limiting protects APIs from abuse, accidental overload, noisy clients, and traffic spikes. It can be applied per user, token, IP address, organization, endpoint, or plan.

Rate limits should return clear responses and should not look like random server failures.

HTTP/1.1 429 Too Many Requests
Retry-After: 30

{
  "error": {
    "code": "rate_limit_exceeded",
    "message": "Too many requests. Retry later."
  }
}

Observability

APIs should be observable through logs, metrics, traces, and request identifiers. Without observability, production debugging becomes guesswork.

  • Request ID for tracing one request across services.
  • Latency metrics for user-facing performance.
  • Error rate for service health.
  • Status code distribution for client and server behavior.
  • Dependency timing for database and downstream API calls.

Ready-to-Use Example

This example shows a small production-oriented Orders API contract, a FastAPI implementation, and a client script with timeouts and error handling.

The goal is not to build a full application. The goal is to show the shape of a practical API contract and the operational details that should exist from the beginning.

OpenAPI Example

The OpenAPI snippet defines one resource endpoint and one action endpoint. It includes status codes, stable error response shape, and bearer authentication.

openapi: 3.0.3
info:
  title: Orders API
  version: "1.0"

paths:
  /v1/orders/{order_id}:
    get:
      summary: Get an order by ID
      description: Returns one order visible to the authenticated caller.
      security:
        - bearerAuth: []

      parameters:
        - name: order_id
          in: path
          required: true
          schema:
            type: string
          description: Stable public order identifier, not a database ID.

      responses:
        "200":
          description: Order found.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Order"

        "401":
          description: Missing or invalid authentication token.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

        "403":
          description: Caller is authenticated but cannot access this order.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

        "404":
          description: Order does not exist or is not visible to the caller.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /v1/orders/{order_id}/cancel:
    post:
      summary: Cancel an order
      description: Performs a business action on an existing order.
      security:
        - bearerAuth: []

      parameters:
        - name: order_id
          in: path
          required: true
          schema:
            type: string

      responses:
        "200":
          description: Order was canceled.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Order"

        "409":
          description: Order cannot be canceled in its current state.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT

  schemas:
    Order:
      type: object
      required:
        - id
        - status
        - total_cents
      properties:
        id:
          type: string
          example: ord_123
        status:
          type: string
          example: paid
        total_cents:
          type: integer
          example: 1299

    ErrorResponse:
      type: object
      required:
        - error
      properties:
        error:
          type: object
          required:
            - code
            - message
            - request_id
          properties:
            code:
              type: string
              example: order_not_found
            message:
              type: string
              example: Order was not found.
            request_id:
              type: string
              example: req_9d2a81

FastAPI Example

The FastAPI example shows stable error responses, request IDs, authorization checks, and clear status codes.

from fastapi import FastAPI, Header, HTTPException, Request
from pydantic import BaseModel
from uuid import uuid4

app = FastAPI(title="Orders API")


class OrderResponse(BaseModel):
    id: str
    status: str
    total_cents: int


class ApiError(Exception):
    def __init__(self, status_code: int, code: str, message: str):
        self.status_code = status_code
        self.code = code
        self.message = message


ORDERS = {
    "ord_123": {
        "id": "ord_123",
        "owner_id": "user_1",
        "status": "paid",
        "total_cents": 1299,
    }
}


@app.middleware("http")
async def add_request_id(request: Request, call_next):
    # Use caller-provided request ID when present, otherwise generate one.
    # This makes logs easier to connect across API gateway, app, and clients.
    request.state.request_id = request.headers.get("x-request-id", f"req_{uuid4().hex[:8]}")

    response = await call_next(request)
    response.headers["x-request-id"] = request.state.request_id
    return response


@app.exception_handler(ApiError)
async def handle_api_error(request: Request, exc: ApiError):
    # Keep a consistent error shape so clients can handle failures reliably.
    return {
        "status_code": exc.status_code,
        "content": {
            "error": {
                "code": exc.code,
                "message": exc.message,
                "request_id": request.state.request_id,
            }
        },
    }


def get_current_user_id(authorization: str | None) -> str:
    # This is simplified for article clarity.
    # Production code should verify JWT signature, issuer, audience, and expiration.
    if not authorization or not authorization.startswith("Bearer "):
        raise ApiError(401, "unauthenticated", "Authentication token is required.")

    return "user_1"


def load_order_for_user(order_id: str, user_id: str) -> dict:
    order = ORDERS.get(order_id)

    # Avoid leaking whether another user's order exists.
    # Returning 404 is often safer than returning 403 for invisible resources.
    if not order or order["owner_id"] != user_id:
        raise ApiError(404, "order_not_found", "Order was not found.")

    return order


@app.get("/v1/orders/{order_id}", response_model=OrderResponse)
def get_order(order_id: str, authorization: str | None = Header(default=None)):
    user_id = get_current_user_id(authorization)
    order = load_order_for_user(order_id, user_id)

    return OrderResponse(
        id=order["id"],
        status=order["status"],
        total_cents=order["total_cents"],
    )


@app.post("/v1/orders/{order_id}/cancel", response_model=OrderResponse)
def cancel_order(order_id: str, authorization: str | None = Header(default=None)):
    user_id = get_current_user_id(authorization)
    order = load_order_for_user(order_id, user_id)

    # Business-state conflicts should use 409 instead of generic 400 or 500.
    if order["status"] not in {"created", "paid"}:
        raise ApiError(409, "order_not_cancelable", "Order cannot be canceled.")

    order["status"] = "canceled"

    return OrderResponse(
        id=order["id"],
        status=order["status"],
        total_cents=order["total_cents"],
    )

Production Note: real exception handlers should return proper framework response objects. The important design point is the stable error shape, not the specific framework syntax.

Client Script Example

API clients should set timeouts, send request IDs, and handle status codes intentionally.

import requests
from uuid import uuid4

API_BASE_URL = "https://api.example.com"
TOKEN = "replace-with-real-token"

request_id = f"req_{uuid4().hex[:8]}"

try:
    response = requests.get(
        f"{API_BASE_URL}/v1/orders/ord_123",
        headers={
            # Bearer token authenticates the caller.
            "Authorization": f"Bearer {TOKEN}",

            # Request ID allows support and logs to trace this call.
            "X-Request-ID": request_id,
        },

        # Always set timeouts. Infinite waits can exhaust workers or threads.
        timeout=3,
    )

    if response.status_code == 200:
        order = response.json()
        print(order)

    elif response.status_code == 404:
        print("Order does not exist or is not visible to this caller.")

    elif response.status_code == 429:
        print("Rate limited. Retry later using server guidance.")

    elif response.status_code >= 500:
        print("Server error. Retry only if the operation is safe to retry.")

    else:
        print(f"Unexpected API response: {response.status_code}", response.text)

except requests.Timeout:
    # Timeout handling is required for production API clients.
    print(f"Request timed out. request_id={request_id}")

except requests.RequestException as exc:
    # Network errors should be handled separately from application errors.
    print(f"Request failed. request_id={request_id}. error={exc}")

Common Mistakes

Most API mistakes come from treating endpoints as simple controller methods instead of long-term contracts between systems.

  • Exposing database structure directly through API responses.
  • Changing response fields without considering existing clients.
  • Using generic errors that clients cannot handle programmatically.
  • Returning 500 for validation or business-state problems.
  • Skipping authorization checks after authentication succeeds.
  • Calling slow downstream services without timeouts.
  • Retrying non-idempotent operations blindly.
  • Doing slow work synchronously when a queue would be safer.
  • Ignoring rate limits until the API is overloaded.
  • Missing request IDs, making production debugging difficult.

Production Checklist

A production API should be stable, observable, secure, and predictable under failure.

  • Define API contracts explicitly with OpenAPI, Protobuf, or another schema format.
  • Use stable resource identifiers instead of leaking internal database IDs when possible.
  • Return consistent error responses with stable error codes.
  • Separate authentication and authorization.
  • Use timeouts for downstream calls.
  • Retry only safe operations with backoff.
  • Apply rate limits to protect the service.
  • Use request IDs across logs, responses, and downstream calls.
  • Monitor latency, error rate, traffic, and dependency failures.
  • Keep APIs backward-compatible across releases.
  • Move slow or retryable work to queues.
  • Document status codes and error behavior.

Conclusion

APIs are the communication contracts between backend systems, clients, and integrations. They define how data is requested, how actions are triggered, how failures are reported, and how systems remain decoupled.

A production API is not only a URL and a JSON response. It is a long-lived boundary that must handle security, compatibility, failure, performance, observability, and evolution.

Key Takeaway: APIs should be designed as stable production contracts: clear boundaries, predictable responses, safe errors, explicit security, controlled retries, useful observability, and backward-compatible evolution.

Continue with API articles that go deeper into REST design, authentication, rate limiting, pagination, versioning, and reliability.

Comments (0)