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
- Designing Resource and Operation APIs
- API Versioning and Contract Evolution
- Reliability and Failure Handling
- Security and Observability
- Production Design Example
- Common Mistakes
- Production Checklist
- Conclusion
- More Articles to Read
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.
Comments (0)