Designing APIs for Microservice Architectures

By Oleksandr Andrushchenko — Published on — Modified on
0 Likes
0 Dislikes
Designing APIs for Microservice Architectures
Designing APIs for Microservice Architectures

APIs are the contracts that allow microservices to evolve independently. A well-designed API exposes stable business capabilities while hiding internal schemas, implementation details, and deployment decisions.

In production systems, API design affects much more than endpoint naming. Service coupling, latency, backward compatibility, retries, idempotency, authorization, observability, and failure isolation all depend on the shape and behavior of the contract.

Table of Contents

API Boundaries in Microservices

An API is more than a transport interface. It defines what another service is allowed to depend on.

Every exposed field, endpoint, status code, event, and behavior can become part of another team's production system. The smaller and more stable that public surface remains, the easier it is for services to change independently.

Business Contracts, Not Database Contracts

A common mistake is designing APIs directly from database tables.

Database-shaped API

POST   /orders
GET    /orders/{id}
PATCH  /orders/{id}
DELETE /orders/{id}

PATCH /orders/{id}
{
  "status": "paid"
}

The interface allows callers to manipulate internal state directly. A client can potentially set an order to a state that violates domain rules.

A business-oriented contract exposes permitted operations instead:

POST /orders
POST /orders/{id}/cancel
POST /orders/{id}/confirm-payment
POST /orders/{id}/ship

The service remains responsible for deciding whether each state transition is valid.

An API should expose business behavior, not remote access to a service's tables.

Design for Service Autonomy

Consumers should depend on a stable external contract rather than internal domain objects. Otherwise seemingly local refactoring can require coordinated changes across many services.

Consider an internal Order model:

from dataclasses import dataclass
from datetime import datetime
from decimal import Decimal


@dataclass
class Order:
    id: str
    customer_id: str
    total: Decimal
    internal_state: str
    payment_reference: str | None
    created_at: datetime

Returning the object directly from an API leaks internal implementation details such as internal_state and payment_reference.

A separate response contract gives the service freedom to change its internal model:

from pydantic import BaseModel


class OrderResponse(BaseModel):
    id: str
    status: str
    total_amount: str

This separation creates an intentional compatibility boundary between application internals and consumers.

Designing Resource and Operation APIs

Microservice APIs usually contain a mixture of resource queries and domain operations. REST-style resource design works well for many reads, but important business behavior should not be forced into generic CRUD operations when doing so hides domain semantics.

Resource-Oriented APIs

Resources work well when the interaction represents retrieving, creating, or replacing a stable business representation.

Examples include:

GET  /orders/{order_id}
GET  /orders?customer_id=cus_42
POST /orders
GET  /products/{product_id}
GET  /shipments/{shipment_id}

HTTP methods should preserve familiar semantics. GET should not create side effects. PUT should represent replacement or an idempotent update when used. POST commonly represents creation or a business command.

Responses should also avoid unnecessary nested dependencies. An Order API does not need to embed complete Customer, Payment, Inventory, and Shipment objects simply because those relationships exist.

Business Operation APIs

Some workflows are better represented explicitly as business actions.

For example, updating an order status through a generic PATCH request:

{
  "status": "cancelled"
}

does not communicate why the state changes or what rules apply.

An explicit operation provides clearer semantics:

POST /orders/ord_7281/cancel

The request can include business information:

{
  "reason": "customer_request",
  "requested_by": "cus_381"
}

The service can validate whether cancellation is allowed, release reservations, emit events, and preserve an audit trail.

This approach makes important state transitions visible in the contract instead of treating them as arbitrary field updates.

Request and Response Design

Request models should contain the minimum information required to perform the operation. Fields controlled by the server should not be accepted from clients.

For example, an order creation request may contain:

{
  "customer_id": "cus_381",
  "items": [
    {
      "product_id": "prod_42",
      "quantity": 2
    },
    {
      "product_id": "prod_91",
      "quantity": 1
    }
  ],
  "shipping_address_id": "addr_18"
}

The client should not normally provide fields such as:

  • calculated order total
  • internal order status
  • payment status
  • inventory reservation status
  • server timestamps

Those values are controlled by the service or another authoritative domain.

Responses should be explicit and predictable:

{
  "id": "ord_7281",
  "status": "pending",
  "total_amount": {
    "currency": "USD",
    "value": "149.90"
  },
  "created_at": "2026-08-10T16:22:41Z"
}

Avoid interfaces where omitted fields, null values, and empty values have undocumented or inconsistent meanings.

API Versioning and Contract Evolution

Independent deployment requires producers and consumers to tolerate different software versions running at the same time.

Backward compatibility is therefore more important than version-number syntax. A versioned endpoint still creates coordinated deployments if every small change forces consumers to upgrade.

Backward-Compatible Changes

Common compatible changes include adding optional response fields, introducing new endpoints, adding optional request properties, and expanding enum behavior when consumers are designed to tolerate unknown values.

For example, adding a field is usually safe:

{
  "id": "ord_7281",
  "status": "pending",
  "total_amount": "149.90",
  "currency": "USD"
}

An older consumer can continue reading the fields it understands.

Compatibility depends on consumer behavior. A client that rejects unknown fields makes otherwise additive server changes breaking changes.

Contracts should therefore specify how clients handle:

  • unknown response fields
  • new enum values
  • missing optional fields
  • null values
  • additional error metadata

Breaking Changes

Breaking changes include removing fields, changing field meaning, changing data types, making optional fields required, or altering endpoint behavior in ways existing consumers do not expect.

When breaking changes cannot be avoided, both versions may need to coexist temporarily:

/v1/orders/{id}
/v2/orders/{id}

Consumer A ------> v1
Consumer B ------> v1
Consumer C ------> v2

Migration

Consumer A ------> v2
Consumer B ------> v2
Consumer C ------> v2

Then retire v1

Old versions should have an explicit migration and retirement strategy. Permanent support for every historical contract eventually makes implementation and testing expensive.

Consumer contract tests can help detect accidental incompatibilities before deployment.

Reliability and Failure Handling

A correct API contract must describe failure behavior as carefully as success behavior. Every remote call can time out, return after the caller has given up, fail temporarily, or complete while its response is lost.

Timeouts and Retries

Callers should use explicit deadlines derived from an end-to-end latency budget.

If an API request has a 700 ms target and invokes three downstream services, each dependency cannot independently wait several seconds.

Request latency budget: 700 ms

Gateway               30 ms
Order logic            50 ms
Inventory API         100 ms
Pricing API            80 ms
Database               60 ms
Serialization          20 ms
Network/headroom      360 ms

Retries should be limited to failures likely to be transient. Retrying validation errors, authentication failures, or permanent business rejections only increases traffic.

Retries should normally include:

  • maximum attempts
  • exponential backoff
  • jitter
  • overall request deadline
  • idempotency protection for side effects

Idempotency

Side-effecting APIs require special handling because a timeout does not tell the caller whether the operation completed.

Consider payment creation:

Order Service ------> Payment Service
                         |
                         | charge succeeds
                         v
                    Payment Provider

Response is lost

Order Service sees timeout

Blindly retrying can create another charge.

An idempotency key identifies one logical operation:

import httpx


async def create_payment(
    order_id: str,
    amount: int,
    idempotency_key: str,
) -> dict:
    async with httpx.AsyncClient(timeout=2.0) as client:
        response = await client.post(
            "http://payment-service/v1/payments",
            headers={
                # All retries for this logical payment use the same key.
                "Idempotency-Key": idempotency_key,
            },
            json={
                "order_id": order_id,
                "amount": amount,
            },
        )

        response.raise_for_status()
        return response.json()

The server stores the key together with the operation result. A repeated request returns the existing outcome instead of executing the side effect again.

Error Contracts

Errors should be machine-readable and stable enough for callers to distinguish business failures from technical failures.

A useful structure might be:

{
  "error": {
    "code": "INSUFFICIENT_INVENTORY",
    "message": "Requested quantity is not available",
    "details": {
      "product_id": "prod_42",
      "requested": 4,
      "available": 2
    },
    "trace_id": "6c91b11a8f2048a9"
  }
}

The error code is more important for programmatic behavior than human-readable text.

Callers can then distinguish:

Failure Typical Meaning Retry?
Validation failure Request violates contract No
Business rejection Operation is valid but cannot be performed Usually no
Authentication failure Identity cannot be verified No without credential change
Rate limit Caller exceeded allowed capacity Possibly after delay
Dependency unavailable Temporary infrastructure failure Possibly
Timeout Outcome may be unknown Only with safe retry semantics

Security and Observability

Internal APIs still require explicit security and observability. A private network is not an authorization model, and distributed failures cannot be diagnosed reliably without consistent request metadata.

Authentication and Authorization

Authentication determines which caller is making a request. Authorization determines whether that caller can perform the requested operation.

These checks should be enforced at the appropriate domain boundary.

For example, an API gateway may validate an external access token, while the Order Service still determines whether the authenticated customer is allowed to cancel a specific order.

Client
  |
  | JWT
  v
API Gateway
  |
  | authenticated identity
  v
Order Service
  |
  | authorize:
  | customer owns order?
  | order cancellable?
  v
Domain operation

Service-to-service authentication may use workload identity, short-lived tokens, mutual TLS, or platform-managed identities.

Authorization should follow least privilege. A Reporting Service that only reads order summaries should not receive credentials capable of cancelling orders.

Tracing and API Metrics

Every API should expose enough telemetry to understand latency, failures, traffic, and dependency behavior.

Useful API metrics include:

  • request rate
  • success and error rate
  • p50, p95, and p99 latency
  • timeout rate
  • retry rate
  • rate-limit rejection rate
  • request and response size
  • dependency latency

Trace context should propagate across service calls so one business request can be followed through the distributed system.

async def call_inventory(
    client,
    order_id: str,
    trace_id: str,
):
    return await client.post(
        "http://inventory-service/v1/reservations",
        headers={
            # Keep request context consistent across service boundaries.
            "X-Trace-ID": trace_id,
        },
        json={
            "order_id": order_id,
        },
    )

Logs should contain the same trace identifier together with service name, operation, result, latency, and relevant domain identifiers.

Production Design Example

Consider an Order Service that owns order creation, cancellation, and lifecycle state while Inventory and Payments remain separate services.

The API should expose Ordering capabilities without allowing external consumers to manipulate internal state directly.

Order API Design

A practical external contract may include:

POST /v1/orders
GET  /v1/orders/{order_id}
GET  /v1/orders?customer_id={customer_id}

POST /v1/orders/{order_id}/cancel

Order creation:

POST /v1/orders

{
  "customer_id": "cus_381",
  "items": [
    {
      "product_id": "prod_42",
      "quantity": 2
    }
  ],
  "shipping_address_id": "addr_18"
}

The service validates its own domain rules and coordinates with other services where necessary.

                         Client
                           |
                    POST /v1/orders
                           |
                           v
                     Order Service
                      /         \
                     /           \
             Inventory API     Pricing API
                     \           /
                      \         /
                       v       v
                    Validate Order
                           |
                      Local Commit
                           |
                           v
                      OrderCreated
                           |
                     Message Broker
                      /          \
                     v            v
                Payments     Fulfillment

The synchronous path contains only dependencies required before the API can return a meaningful result. Work that does not need to complete immediately moves to asynchronous processing.

The initial response might be:

{
  "id": "ord_7281",
  "status": "pending",
  "total_amount": {
    "currency": "USD",
    "value": "149.90"
  },
  "created_at": "2026-08-10T16:22:41Z"
}

A pending state communicates that order creation succeeded while downstream processing may still be in progress.

Cancellation is modeled as a domain operation:

POST /v1/orders/ord_7281/cancel

{
  "reason": "customer_request"
}

The Order Service decides whether cancellation is currently valid. Consumers do not need to understand internal status-transition rules.

This design keeps the contract aligned with domain ownership while preserving room for the internal implementation to change.

Common Mistakes

API design problems become expensive in microservices because every weak contract can create long-lived coupling between independently deployed systems.

Mistake Why It Causes Problems Better Approach
Exposing database schemas directly Consumers become coupled to persistence structures and internal migrations. Design contracts around domain capabilities and consumer needs.
Using generic PATCH for every state transition Business rules become implicit and callers can attempt invalid state changes. Expose explicit domain operations for meaningful transitions.
Returning internal domain objects Refactoring internal models becomes an external compatibility problem. Maintain dedicated API request and response models.
Requiring coordinated upgrades Independent services lose deployment autonomy. Prefer additive backward-compatible contract evolution.
Versioning every small change Many permanent API versions increase testing and maintenance cost. Version only when compatibility cannot reasonably be preserved.
Allowing unbounded remote calls Slow dependencies consume resources and cause cascading failures. Assign explicit deadlines from the end-to-end latency budget.
Retrying side effects without idempotency Lost responses can produce duplicate payments, orders, or reservations. Use stable idempotency keys for retryable business operations.
Returning inconsistent error structures Consumers cannot reliably automate recovery or distinguish failure types. Standardize machine-readable error codes and metadata.
Embedding entire related domains in responses Payloads become large and consumers become transitively coupled to several services. Return owned data and explicit references or purpose-built projections.
Trusting internal network location for authorization A compromised or misconfigured workload can access capabilities outside its responsibility. Authenticate workloads and authorize operations with least privilege.
Ignoring consumer behavior during evolution Even additive server changes can break strict clients or enum handling. Define compatibility expectations and test representative consumers.
Monitoring only aggregate HTTP status codes Latency, timeouts, retries, and dependency degradation remain hidden. Measure operation-level latency, failure categories, saturation, and downstream behavior.

Production Checklist

Microservice APIs should be reviewed as long-lived contracts rather than implementation details.

  • Define domain ownership: expose operations only for state and behavior owned by the service.
  • Separate API and persistence models: prevent internal schema changes from becoming contract changes.
  • Use explicit business operations: represent important state transitions clearly instead of relying on arbitrary field updates.
  • Keep responses focused: avoid returning unrelated domain data that creates transitive coupling.
  • Design backward compatibility: prefer additive changes that allow old and new deployments to coexist.
  • Document enum evolution: require consumers to tolerate unknown future values where appropriate.
  • Set latency budgets: configure explicit timeouts for every downstream call.
  • Bound retry behavior: retry only transient failures and remain within the overall request deadline.
  • Add idempotency: protect retryable side-effecting operations from duplicate execution.
  • Standardize errors: expose stable machine-readable error codes and useful trace identifiers.
  • Authenticate every caller: use trusted workload or user identity instead of network location.
  • Authorize domain actions: enforce permissions at the service responsible for the business operation.
  • Propagate trace context: preserve correlation across gateways, services, and downstream dependencies.
  • Measure API behavior: monitor request volume, latency percentiles, failures, timeouts, retries, and rate limits.
  • Test contract compatibility: validate changes against real consumer expectations before deployment.

Conclusion

APIs are the stability boundaries between independently deployed microservices. Effective contracts expose business capabilities, protect domain ownership, evolve compatibly, and define failure behavior as carefully as successful responses.

Good API design reduces coordination between teams and allows internal implementations to evolve without forcing changes across the service graph. Poor API design turns independent deployments into a distributed monolith connected through fragile contracts.

Key Takeaway

Design microservice APIs around stable business capabilities rather than internal data structures. Keep contracts small, backward-compatible, idempotent where necessary, observable, and explicit about ownership and failure behavior.

Author

Enjoyed this article?

Support Oleksandr Andrushchenko

Buy me a coffee

This helps Oleksandr Andrushchenko continue creating useful content

Related articles

Comments (0)