Build CoreOrdered learning track

OpenAPI for Internal Service Communication

Learn Java Microservices Communication - Part 037

OpenAPI for internal Java microservices: contract-first mindset, operation design, reusable components, error modeling, idempotency, pagination, governance, linting, diffing, testing, documentation, and production communication policy.

11 min read2098 words
PrevNext
Lesson 3796 lesson track18–52 Build Core
#java#microservices#communication#http-api+4 more

Part 037 — OpenAPI for Internal Service Communication

OpenAPI is often treated as API documentation.

That is too small.

For internal microservices, OpenAPI should be treated as a communication contract artifact.

It answers:

What can this service be called for, what does it promise, what can fail, and how should consumers integrate safely?

A good OpenAPI file is not a pretty Swagger UI page.

It is a machine-readable boundary used for:

  • API review,
  • contract governance,
  • client generation,
  • mock/stub generation,
  • compatibility diff,
  • security review,
  • test generation,
  • documentation,
  • platform catalog,
  • gateway policy,
  • consumer onboarding,
  • deprecation and ownership.

The practical goal is simple:

Make service communication explicit enough that humans, tests, and tools can reason about it before production traffic does.


1. What OpenAPI Is and Is Not

OpenAPI describes HTTP APIs in a standard, language-agnostic way.

It can describe:

  • paths,
  • methods,
  • parameters,
  • headers,
  • request bodies,
  • response bodies,
  • status codes,
  • media types,
  • schemas,
  • examples,
  • security schemes,
  • tags,
  • operation metadata,
  • reusable components.

It cannot fully describe everything that matters in production communication.

It does not automatically capture:

  • real latency envelope,
  • retry safety,
  • idempotency storage behavior,
  • eventual consistency,
  • side effects,
  • downstream dependency graph,
  • exact authorization semantics,
  • business invariants,
  • partial failure policy,
  • operational runbook,
  • rollback strategy.

So the right posture is:

OpenAPI is necessary, not sufficient.

Use OpenAPI as the structured contract spine, then attach operational meaning through descriptions, examples, extensions, ADRs, tests, and runbooks.


2. Contract-First vs Code-First

There are two common workflows.

2.1 Code-first

The team writes Java controllers first, then generates OpenAPI from annotations/runtime scanning.

Example tools:

  • springdoc-openapi,
  • Swagger annotations,
  • MicroProfile OpenAPI.

Benefits:

  • fast for existing services,
  • less duplicate specification effort,
  • close to implementation,
  • useful for documentation.

Risks:

  • implementation leaks into contract,
  • accidental endpoint changes become contract changes,
  • generated spec may miss behavior,
  • schema may reflect internal DTOs,
  • status codes and errors may be incomplete,
  • review happens too late.

2.2 Contract-first

The team designs OpenAPI first, reviews it, then implements server/client against it.

Benefits:

  • API reviewed before implementation,
  • consumer expectations explicit,
  • compatibility easier to manage,
  • generated clients/stubs available earlier,
  • good fit for multi-team service boundaries.

Risks:

  • spec can drift from implementation,
  • over-design without feedback,
  • developers may see YAML as bureaucracy,
  • generated code can become awkward.

2.3 Production default

For mature internal microservices:

contract-first for public/internal platform APIs,
code-first with strict diff governance for smaller owned APIs,
but always contract-verified in CI.

The distinction is less important than this invariant:

The OpenAPI contract must be versioned, reviewed, tested, and kept in sync with runtime behavior.


3. Repository Layout

Recommended layout:

case-service/
  api/
    openapi/
      case-service-v1.yaml
      case-service-v2.yaml
    examples/
      create-escalation-request.json
      create-escalation-response.json
      validation-error.json
    changelog/
      2026-07-05-add-assignee-id.md
  src/
    main/
      java/
  build.gradle.kts
  docs/
    adr/
      0007-api-versioning-policy.md

For larger organizations:

api-contracts/
  case-service/
    v1/
      openapi.yaml
      examples/
      changelog/
    v2/
      openapi.yaml
  workflow-service/
  document-service/
  common/
    problem-details.yaml
    pagination.yaml
    headers.yaml

Central contract repo gives governance.

Service-local contract gives stronger implementation ownership.

The right choice depends on org structure, but the invariant is:

Each service API contract must have an owner and release lifecycle.


4. A Minimal but Useful OpenAPI Skeleton

openapi: 3.2.0
info:
  title: Case Service Internal API
  version: 1.8.0
  description: >
    Internal API for case retrieval and case escalation commands.
    This contract is consumed by workflow-service, portal-service, and reporting-service.
servers:
  - url: https://case-service.internal.example.com
    description: Internal production route
tags:
  - name: Cases
    description: Case query operations
  - name: Escalations
    description: Case escalation command operations
paths: {}
components:
  schemas: {}
  responses: {}
  parameters: {}
  headers: {}
  securitySchemes: {}

Even this skeleton already defines:

  • API name,
  • contract version,
  • intended scope,
  • server route,
  • ownership structure through tags.

But it is not enough.

Production OpenAPI needs operational precision.


5. Operation Design

Every operation should be named intentionally.

Bad:

operationId: getUsingGET_1

Good:

operationId: getCaseById

Operation IDs matter because they often become:

  • generated method names,
  • metrics labels,
  • documentation anchors,
  • trace/span operation names,
  • governance identifiers,
  • compatibility records.

Recommended naming:

verb + domain object + qualifier

Examples:

getCaseById
searchCases
createCaseEscalation
cancelCaseEscalation
getOperationStatus

Avoid:

  • framework-generated names,
  • method names tied to controller class,
  • names that include version unless necessary,
  • names that describe transport instead of intent.

6. Operation Description Should Include Behavior

A production operation description should not merely say:

Creates a case escalation.

Better:

summary: Create case escalation
description: >
  Creates a new escalation request for a case.

  This operation is side-effecting and requires Idempotency-Key.
  Repeating the same request with the same Idempotency-Key returns the original outcome.
  If the same key is reused with a different request fingerprint, the service returns 409.

  The command writes a business audit record and publishes CaseEscalated after commit.
  The response is synchronous when validation and assignment are completed within the timeout budget.

This tells the consumer:

  • operation is side-effecting,
  • idempotency is required,
  • retry behavior is defined,
  • audit/event side effects exist,
  • timeout behavior is bounded.

That is real communication documentation.


7. Reusable Headers

Internal service communication often depends on headers.

Define them once.

components:
  parameters:
    CorrelationIdHeader:
      name: X-Correlation-Id
      in: header
      required: false
      schema:
        type: string
        maxLength: 128
      description: >
        Optional caller-provided correlation identifier. Trace propagation uses W3C traceparent;
        this value is for business/support correlation only.

    IdempotencyKeyHeader:
      name: Idempotency-Key
      in: header
      required: true
      schema:
        type: string
        minLength: 16
        maxLength: 128
        pattern: "^[A-Za-z0-9._:-]+$"
      description: >
        Required for side-effecting commands. The key is scoped by tenant, caller, operation,
        and API major version. Same key and same request fingerprint replays the original outcome.
        Same key with different request fingerprint returns 409.

Do not leave header semantics implicit.

Headers are part of the contract.


8. Trace and Baggage Headers

Usually you do not need to define every tracing header as required operation parameters.

Instrumentation handles:

  • traceparent,
  • tracestate,
  • baggage.

But you should document propagation policy.

Example extension:

x-observability:
  tracePropagation: w3c-trace-context
  baggagePolicy:
    allowedKeys:
      - tenant.id
      - caller.service
    forbiddenKeys:
      - authorization
      - cookie
      - personal_data

OpenAPI does not enforce this by itself.

But API governance tools can read extensions.


9. Error Modeling with Problem Details

Use a consistent error body.

components:
  schemas:
    Problem:
      type: object
      required:
        - type
        - title
        - status
      properties:
        type:
          type: string
          format: uri
          description: Stable error type URI.
        title:
          type: string
        status:
          type: integer
          format: int32
        detail:
          type: string
        instance:
          type: string
        extensions:
          type: object
          additionalProperties: true
          description: Service-specific machine-readable details.

Then define reusable responses:

components:
  responses:
    BadRequest:
      description: Request is malformed or violates schema-level validation.
      content:
        application/problem+json:
          schema:
            $ref: "#/components/schemas/Problem"
          examples:
            invalidRequest:
              value:
                type: "https://errors.example.internal/invalid-request"
                title: "Invalid request"
                status: 400
                extensions:
                  code: "INVALID_REQUEST"
                  retryable: false

    Conflict:
      description: Request conflicts with current resource state or idempotency state.
      content:
        application/problem+json:
          schema:
            $ref: "#/components/schemas/Problem"

For each operation, document expected errors.

Bad:

responses:
  "500":
    description: Internal server error

Better:

responses:
  "201":
    description: Escalation created
  "400":
    $ref: "#/components/responses/BadRequest"
  "401":
    $ref: "#/components/responses/Unauthorized"
  "403":
    $ref: "#/components/responses/Forbidden"
  "404":
    $ref: "#/components/responses/CaseNotFound"
  "409":
    $ref: "#/components/responses/Conflict"
  "422":
    $ref: "#/components/responses/DomainValidationFailed"
  "429":
    $ref: "#/components/responses/RateLimited"
  "503":
    $ref: "#/components/responses/ServiceUnavailable"

Consumers build retry, fallback, and user messaging from errors.

So errors deserve first-class design.


10. Modeling Retriability

OpenAPI has no universal standard field for retryability.

Use error extensions and/or vendor extensions.

Example error body:

{
  "type": "https://errors.example.internal/dependency-timeout",
  "title": "Dependency timeout",
  "status": 503,
  "detail": "Assignment service did not respond within the configured deadline.",
  "extensions": {
    "code": "DEPENDENCY_TIMEOUT",
    "retryable": true,
    "retryAfterMillis": 1000
  }
}

OpenAPI schema:

components:
  schemas:
    ErrorExtensions:
      type: object
      properties:
        code:
          type: string
          example: DEPENDENCY_TIMEOUT
        retryable:
          type: boolean
        retryAfterMillis:
          type: integer
          format: int64
          minimum: 0

Operation extension:

x-retry-policy:
  clientRetryAllowed: true
  retryableStatuses:
    - 429
    - 503
  unsafeWithoutIdempotencyKey: true

This does not replace code review.

It makes review automatable.


11. Modeling Idempotent Commands

Example command operation:

paths:
  /v1/case-escalations:
    post:
      tags:
        - Escalations
      operationId: createCaseEscalation
      summary: Create case escalation
      parameters:
        - $ref: "#/components/parameters/IdempotencyKeyHeader"
        - $ref: "#/components/parameters/CorrelationIdHeader"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateCaseEscalationRequest"
            examples:
              fraudReview:
                value:
                  caseId: "CASE-100"
                  targetQueue: "FRAUD_REVIEW"
                  reasonCode: "SUSPICIOUS_ACTIVITY"
      responses:
        "201":
          description: Escalation created.
          headers:
            Location:
              schema:
                type: string
              description: URI of created escalation.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CreateCaseEscalationResponse"
        "409":
          $ref: "#/components/responses/Conflict"
      x-communication-policy:
        sideEffecting: true
        idempotencyRequired: true
        duplicateBehavior: replay-original-outcome
        sameKeyDifferentPayloadStatus: 409

The custom extension is not decorative.

It enables:

  • lint rule,
  • API review checklist,
  • generated docs,
  • test fixture generation,
  • consistency across services.

12. Modeling Queries

Query endpoints need stable pagination and filtering.

paths:
  /v1/cases:
    get:
      tags:
        - Cases
      operationId: searchCases
      summary: Search cases
      parameters:
        - name: status
          in: query
          required: false
          schema:
            type: array
            items:
              $ref: "#/components/schemas/CaseStatus"
          style: form
          explode: true
        - name: pageSize
          in: query
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 200
            default: 50
        - name: pageToken
          in: query
          required: false
          schema:
            type: string
          description: >
            Opaque pagination token returned by the previous response.
            Consumers must not parse or construct this value.
      responses:
        "200":
          description: Search result page.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SearchCasesResponse"
      x-query-policy:
        pagination: cursor
        stableSort:
          - createdAt: desc
          - caseId: desc
        maxPageSize: 200

Response:

components:
  schemas:
    SearchCasesResponse:
      type: object
      required:
        - items
      properties:
        items:
          type: array
          items:
            $ref: "#/components/schemas/CaseSummary"
        nextPageToken:
          type: string
          nullable: true
          description: Opaque token for the next page, or null if no next page exists.

Documenting "opaque token" matters.

Otherwise consumers parse it and lock you into implementation details.


13. Schema Design Rules

13.1 Use explicit required fields

CreateCaseEscalationRequest:
  type: object
  required:
    - caseId
    - targetQueue
    - reasonCode
  properties:
    caseId:
      type: string
      pattern: "^CASE-[0-9]+$"
    targetQueue:
      type: string
    reasonCode:
      type: string

Do not rely on Java validation annotations alone.

The contract should say what is required.

13.2 Avoid leaking internal enums blindly

Bad:

CaseStatus:
  type: string
  enum:
    - DB_OPEN_STATE
    - DB_CLOSED_STATE

Good:

CaseStatus:
  type: string
  description: >
    External lifecycle status. Consumers must tolerate unknown values.
  enum:
    - OPEN
    - UNDER_REVIEW
    - CLOSED

Add a warning:

x-extensible-enum: true

Why?

Generated clients can break when new enum values appear.

13.3 Prefer domain-specific scalar formats carefully

CaseId:
  type: string
  pattern: "^CASE-[0-9]+$"
  example: "CASE-100"

Reusable scalar schemas are useful.

But over-modeling every string can make specs noisy.

Use reusable schemas for important domain identifiers, not every field.

13.4 Set bounds

Bad:

comment:
  type: string

Good:

comment:
  type: string
  minLength: 1
  maxLength: 2000

Bounds are communication safety.

They protect:

  • memory,
  • logs,
  • database columns,
  • UI rendering,
  • gateway limits,
  • abuse scenarios.

14. Examples Are Part of the Contract

Examples catch ambiguity faster than schemas.

For every important operation, include:

  • success example,
  • validation error example,
  • conflict example,
  • not found example,
  • rate limit/overload example,
  • idempotency replay example if relevant.

Example:

examples:
  keyReuseConflict:
    summary: Same Idempotency-Key reused with different payload
    value:
      type: "https://errors.example.internal/idempotency-key-reused"
      title: "Idempotency key reused"
      status: 409
      detail: "The idempotency key was previously used with a different request fingerprint."
      extensions:
        code: "IDEMPOTENCY_KEY_REUSED"
        retryable: false

Examples should be valid and tested.

Do not let examples become stale marketing copy.


15. Security Schemes

Internal does not mean unauthenticated.

Example:

components:
  securitySchemes:
    ServiceJwt:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: >
        Service-to-service JWT issued by internal identity provider.
        Token audience must be case-service.
security:
  - ServiceJwt: []

Operation-specific requirements:

paths:
  /v1/cases/{caseId}:
    get:
      security:
        - ServiceJwt: []
      x-authorization:
        requiredPermissions:
          - case:read
        tenantIsolation: required

OpenAPI can describe the authentication mechanism.

Authorization semantics often need extensions or documentation.


16. Vendor Extensions for Internal Governance

OpenAPI allows extension fields with x-.

Use them deliberately.

Possible extension namespace:

x-service:
  owner: case-platform
  slackChannel: "#case-platform"
  tier: critical
  dataClassification: confidential

x-communication-policy:
  timeoutBudgetMs: 500
  idempotencyRequired: true
  clientRetryAllowed: true
  maxRequestBytes: 32768
  maxResponseBytes: 1048576
  consistency: read-your-writes-not-guaranteed

x-observability:
  operationMetric: case.create_escalation
  traceSpanName: POST /v1/case-escalations
  cardinalityRisk: low

x-deprecation:
  status: active
  replacement: null
  sunsetDate: null

Do not add arbitrary extensions nobody reads.

Each extension should have at least one consumer:

  • linter,
  • documentation generator,
  • review checklist,
  • gateway config,
  • dashboard,
  • security scanner.

17. Linting OpenAPI

Linting turns API style into executable governance.

Example Spectral ruleset idea:

extends:
  - spectral:oas

rules:
  operation-operationId:
    description: Every operation must have operationId.
    given: $.paths[*][*]
    then:
      field: operationId
      function: truthy

  no-unsafe-post-without-idempotency:
    description: Side-effecting POST operations must require Idempotency-Key.
    given: $.paths[*].post
    then:
      function: schema
      functionOptions:
        schema:
          type: object
          required:
            - x-communication-policy

More useful lint rules:

RuleWhy
Every operation has operationIdStable tooling identity
Every operation has success and error responsesAvoid undocumented failures
POST command has idempotency policyRetry safety
Pagination endpoints define max page sizeCapacity safety
Schemas define max string lengthPayload safety
Error responses use application/problem+jsonConsistent error handling
No undocumented default response onlyConsumers need explicit behavior
Deprecated operation has replacement/sunsetLifecycle management
No raw object without schemaContract precision
No internal package/class namesPrevent implementation leakage

18. OpenAPI Diffing

Diffing detects accidental breaking changes.

CI should compare:

main branch OpenAPI
vs
pull request OpenAPI

Fail or require approval for:

  • removed path,
  • removed method,
  • removed response code,
  • request field made required,
  • response field removed,
  • schema type changed,
  • enum value removed,
  • operationId changed,
  • security requirement changed,
  • pagination token semantics changed.

Some semantic changes cannot be detected automatically.

For those, require an API change checklist.

Example PR checklist:

## API Change Checklist

- [ ] OpenAPI diff reviewed
- [ ] Breaking change classification documented
- [ ] Error/status code semantics unchanged or migration documented
- [ ] Idempotency behavior unchanged or migration documented
- [ ] Pagination behavior unchanged or migration documented
- [ ] Known consumers identified
- [ ] Consumer contract tests updated
- [ ] Deprecation/sunset plan included if needed

19. Contract Testing From OpenAPI

OpenAPI can drive multiple test types.

Test typePurpose
Request validationProvider rejects invalid requests
Response validationProvider returns schema-compliant responses
Stub generationConsumer tests against provider-like behavior
Example validationExamples are valid against schema
Compatibility diffNo accidental breaking change
Negative testingInvalid inputs produce documented errors
Fuzz/property testingExplore schema boundaries

Example test flow:

Do not confuse generated stub tests with real provider behavior.

Stubs validate consumer assumptions.

Provider tests validate implementation.

You need both.


20. Documentation Site vs Contract Source

Swagger UI or Redoc is not the source of truth.

The source of truth is the version-controlled OpenAPI document.

Documentation site should be generated from it.

If someone edits docs manually but not OpenAPI, drift starts.

If someone edits controller but not OpenAPI, drift starts.

A mature pipeline makes drift visible.


21. OpenAPI and Java Server Implementation

21.1 Controller boundary

Keep the controller close to contract.

@RestController
@RequestMapping("/v1/case-escalations")
public final class CaseEscalationController {
    private final CreateEscalationUseCase useCase;
    private final CaseEscalationApiMapper mapper;

    @PostMapping
    public ResponseEntity<CreateCaseEscalationResponse> create(
        @RequestHeader("Idempotency-Key") String idempotencyKey,
        @Valid @RequestBody CreateCaseEscalationRequest request
    ) {
        CreateEscalationCommand command = mapper.toCommand(request, idempotencyKey);
        CreateEscalationResult result = useCase.execute(command);

        return ResponseEntity
            .created(URI.create("/v1/case-escalations/" + result.escalationId()))
            .body(mapper.toResponse(result));
    }
}

Rules:

  • controller DTOs match OpenAPI schemas,
  • controller maps into application commands,
  • domain model does not depend on OpenAPI generated classes,
  • errors are mapped consistently to Problem Details.

21.2 Generated server interfaces

Some teams generate server interfaces from OpenAPI.

This can be useful for contract-first enforcement.

But avoid letting generated code dominate application architecture.

Recommended:

Generated API interface
        |
Controller adapter implements interface
        |
Application use case
        |
Domain

Not:

Generated model everywhere in domain and database

22. OpenAPI and Java Client Implementation

Generated clients can help, but they should be wrapped.

Owned adapter responsibilities:

  • map generated DTO to domain DTO,
  • map generated exceptions to domain exception taxonomy,
  • enforce timeout/retry/idempotency policy,
  • attach headers,
  • emit telemetry,
  • hide generated client churn.

Never let generated OpenAPI model become your internal domain model.

Generated code is an integration detail.


23. Common OpenAPI Anti-Patterns

23.1 Spec as afterthought

The service is already deployed, then someone generates a spec.

Result:

  • undocumented status codes,
  • incomplete errors,
  • internal models exposed,
  • no consumer review,
  • no compatibility guarantee.

23.2 One giant shared spec for all services

This can become unreviewable.

Prefer service-owned specs with reusable shared components.

23.3 Every field nullable

This destroys contract precision.

If everything is nullable, consumers cannot reason.

23.4 No examples

Schemas show shape.

Examples show intent.

You need both.

23.5 default response only

Bad:

responses:
  default:
    description: Error

Consumers need to know whether a failure is:

  • validation,
  • auth,
  • not found,
  • conflict,
  • rate limit,
  • overload,
  • dependency failure.

23.6 Enum without unknown handling

OpenAPI enum looks precise, but generated clients may fail when provider adds values.

Document extensibility or avoid strict enums when values evolve frequently.

23.7 OpenAPI says one thing, runtime does another

This is the worst failure.

A wrong spec is worse than no spec because it creates false confidence.


24. Production OpenAPI Review Checklist

Before approving an internal API:

Identity

  • Does every operation have stable operationId?
  • Are tags meaningful?
  • Is owner/contact known?
  • Is API major version clear?

Request/response

  • Are required fields explicit?
  • Are string lengths bounded?
  • Are array sizes bounded?
  • Are examples present?
  • Are unknown enum values considered?

HTTP semantics

  • Are methods appropriate?
  • Are status codes intentional?
  • Are redirects avoided unless needed?
  • Are cacheability and idempotency clear?

Failure

  • Are errors Problem Details?
  • Are retryable vs non-retryable failures clear?
  • Are 409/412/422 semantics separated?
  • Are overload/rate-limit responses documented?

Reliability

  • Are side effects documented?
  • Is Idempotency-Key required for unsafe commands?
  • Is duplicate behavior documented?
  • Are timeout budgets described?

Query

  • Is pagination stable?
  • Is pageToken opaque?
  • Are filter/sort fields documented?
  • Are max page sizes bounded?

Security

  • Is auth mechanism declared?
  • Are permission requirements documented?
  • Is tenant isolation documented?
  • Is sensitive data classified?

Lifecycle

  • Is deprecation policy present?
  • Are breaking changes classified?
  • Is diff/lint enforced?
  • Are known consumers identified?

25. The Real Lesson

OpenAPI is not a YAML chore.

It is a shared executable memory of how services communicate.

Used poorly, it becomes stale documentation.

Used well, it becomes:

design artifact
+ contract artifact
+ testing artifact
+ governance artifact
+ documentation artifact
+ migration artifact

For internal Java microservices, that is the difference between:

"Here is an endpoint."

and:

"Here is a service boundary that consumers can safely build on."

References

Lesson Recap

You just completed lesson 37 in build core. Use the series map if you want to review the broader track, or continue directly into the next lesson while the context is still warm.

Continue The Track

Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.