Microservices Architecture & Design

API Design for Microservices

18 min Lesson 6 of 12

API Design for Microservices

When a microservice publishes an API it is not just writing code — it is publishing a contract. Every team that depends on that service trusts the contract to be stable. Breaking it silently is one of the fastest ways to bring down a distributed system at 2 AM. This lesson covers how to define contracts rigorously, how to evolve them without causing downtime, and how to think about backward compatibility as a first-class engineering concern.

What an API Contract Actually Is

An API contract is the complete, machine-readable description of what a service accepts and returns: the URL structure, HTTP methods, request bodies, response shapes, status codes, error formats, authentication requirements, and rate-limit headers. Contracts are more than documentation — they are the test oracle and the deployment gate.

In a Spring Boot 3 ecosystem the de-facto standard for REST contracts is OpenAPI 3 (formerly Swagger). Add springdoc-openapi-starter-webmvc-ui to your service and it generates the spec from your annotations automatically:

<!-- pom.xml --> <dependency> <groupId>org.springdoc</groupId> <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId> <version>2.5.0</version> </dependency>
// OrderController.java @RestController @RequestMapping("/api/v1/orders") @Tag(name = "Orders", description = "Order lifecycle management") public class OrderController { @Operation(summary = "Place a new order") @ApiResponse(responseCode = "201", description = "Order created", content = @Content(schema = @Schema(implementation = OrderResponse.class))) @ApiResponse(responseCode = "422", description = "Validation failed", content = @Content(schema = @Schema(implementation = ProblemDetail.class))) @PostMapping public ResponseEntity<OrderResponse> placeOrder( @Valid @RequestBody PlaceOrderRequest request) { // ... } }

The generated JSON spec at /v3/api-docs becomes the contract artifact. You publish it to a schema registry (Confluent, AWS Glue, or a plain Git repository) and consumer teams pin their integration tests to it.

Contract-first vs. code-first: In contract-first development you write the OpenAPI YAML first and generate stubs and client SDKs from it. Code-first (annotating existing controllers) is faster to start but risks drift. For internal services, code-first with strict CI linting is pragmatic. For public or partner-facing APIs, go contract-first.

API Versioning Strategies

All APIs change. The only question is how you expose that change to consumers. There are four mainstream strategies, each with trade-offs:

  • URI versioning/api/v1/orders, /api/v2/orders. Simple, highly visible, easy to route in an API gateway. The downside: you maintain N parallel route trees and clients must opt in explicitly.
  • Header versioningAccept: application/vnd.myapp.v2+json or X-API-Version: 2. Keeps URLs clean; harder to test in a browser or share as a link.
  • Query-parameter versioning/api/orders?version=2. Very discoverable but versions are cache-hostile and pollute URLs.
  • Content negotiation (media type). Most REST-theoretically correct, most operationally painful. Rarely used beyond large platform APIs.

For microservices behind a Spring Cloud Gateway the most maintainable approach is URI versioning combined with gateway routing. The gateway maps /api/v2/** to a new service deployment while /api/v1/** continues to serve the old one:

# application.yml (Spring Cloud Gateway) spring: cloud: gateway: routes: - id: orders-v1 uri: lb://ORDER-SERVICE-V1 predicates: - Path=/api/v1/orders/** - id: orders-v2 uri: lb://ORDER-SERVICE-V2 predicates: - Path=/api/v2/orders/**
Start with v1 from day one, even before you need versioning. Retrofitting /api/v1 into a live service that shipped as /api/orders means coordinating a breaking change across every consumer at once. Adding /v1 upfront costs nothing and buys you unlimited future flexibility.

Backward Compatibility Rules

Backward compatibility means existing consumers keep working without any change on their side. The rules are simple to state but easy to violate accidentally:

Safe changes (non-breaking):

  • Adding a new optional field to a response body.
  • Adding a new optional query parameter.
  • Adding a new endpoint (new route, same version).
  • Relaxing a validation constraint (accepting more values than before).
  • Adding a new enum value — only if consumers are expected to handle unknown values gracefully.

Breaking changes (require a new version):

  • Removing or renaming a field.
  • Changing a field's type (e.g., int to string).
  • Making an optional field required.
  • Changing the semantics of an existing field without renaming it.
  • Removing an endpoint.
  • Changing authentication/authorization requirements.

Enforce this automatically. In CI, use a tool like openapi-diff or Optic to compare the new spec against the published baseline and fail the build on breaking changes:

# .github/workflows/api-compat.yml (simplified) - name: Check API compatibility run: | npx @useoptic/optic-ci compare \ --from https://registry.internal/specs/orders-v1-latest.json \ --to ./target/openapi.json \ --check breaking-changes

Designing for Consumers: Tolerant Reader & Postel's Law

Robustness in a distributed system demands that clients be tolerant readers: they must ignore fields they do not recognise and not crash on unknown enum values. This is Postel's Law applied to JSON APIs: "be conservative in what you send, liberal in what you accept."

In Jackson (which Spring Boot uses), configure tolerance globally so new response fields do not break old clients:

// JacksonConfig.java @Configuration public class JacksonConfig { @Bean public Jackson2ObjectMapperBuilderCustomizer tolerantReader() { return builder -> builder .featuresToDisable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) .featuresToEnable(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS); } }
FAIL_ON_UNKNOWN_PROPERTIES defaults to false in Spring Boot, but teams sometimes enable it for stricter validation. Enabling it is correct for your own service input — you want to know about unexpected fields in requests — but should never be enabled in a client that calls another microservice. An upstream service adding a new response field must not crash your service.

Versioning Internal vs External APIs

Not every API needs the same versioning discipline. Apply a risk-based approach:

  • Internal service-to-service APIs: you own all consumers and can coordinate changes. Tolerate more churn; use feature flags and deploy consumers first (expand-contract pattern).
  • APIs consumed by a mobile app: you cannot force a user to upgrade. Treat these as public APIs; maintain old versions for 12–24 months and sunset them with advance notice.
  • APIs exposed through an API gateway to third-party developers: even stricter. Publish a deprecation policy (90-day notice minimum), a sunset header, and a migration guide before removing anything.

Spring Boot makes it straightforward to emit Deprecation and Sunset HTTP headers on old endpoints using a filter or response advice:

// V1DeprecationFilter.java @Component @Order(Ordered.HIGHEST_PRECEDENCE) public class V1DeprecationFilter implements Filter { @Override public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException { HttpServletRequest httpReq = (HttpServletRequest) req; HttpServletResponse httpResp = (HttpServletResponse) res; if (httpReq.getRequestURI().startsWith("/api/v1/")) { // RFC 8594 deprecation headers httpResp.setHeader("Deprecation", "true"); httpResp.setHeader("Sunset", "Sat, 31 Dec 2025 23:59:59 GMT"); httpResp.setHeader("Link", "</api/v2/orders>; rel=\"successor-version\""); } chain.doFilter(req, res); } }

Error Contract Design

Errors are part of the contract too. Ad-hoc error responses ({"error": "something went wrong"}) force every consumer to write bespoke parsing logic. Use RFC 9457 Problem Details (application/problem+json), which Spring Framework 6 supports natively via ProblemDetail:

@RestControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(OrderNotFoundException.class) public ResponseEntity<ProblemDetail> handleNotFound(OrderNotFoundException ex) { ProblemDetail pd = ProblemDetail.forStatusAndDetail( HttpStatus.NOT_FOUND, "Order " + ex.getOrderId() + " does not exist" ); pd.setType(URI.create("https://api.myapp.com/errors/order-not-found")); pd.setTitle("Order Not Found"); pd.setProperty("orderId", ex.getOrderId()); return ResponseEntity.status(HttpStatus.NOT_FOUND).body(pd); } }

A standard error shape means consumer teams can write a single error-handling library and reuse it across all services in the platform.

Summary

API contracts are the load-bearing walls of a microservices architecture. Define them rigorously with OpenAPI 3, version them from the start using URI versioning, automate backward-compatibility checking in CI, and standardise error shapes with RFC 9457 Problem Details. Write clients as tolerant readers so safe changes on the provider side never cause cascading failures. These habits are what separate a maintainable platform from a distributed monolith held together by fear of change.