Taming OpenAPI Code Generation: How to Keep Your Models DRY Across Microservices

Fokion Sotiropoulos

The Problem: Duplicate Models Everywhere

If you've worked with OpenAPI (Swagger) generators in a microservices architecture, you've encountered this frustrating scenario:

  • API Design defines an error model (ErrorIssue) for serving errors to customers
  • Service A needs to use the ErrorIssue for consistent error responses
  • Service B is an internal service that wants to use ErrorIssue but can't reference it by default, so developers copy it over
  • Service C is a new service that also needs ErrorIssue for API consistency
  • Soon you have the same models copy-pasted across multiple specs, each potentially diverging over time

The result? Violated DRY principles, maintenance nightmares, and inconsistent data models across your services.

What Went Wrong?

Traditional OpenAPI Generator workflows treat each service's specification as an isolated unit:

# Typical approach - each service generates everything independently
openapi-generator generate -i service-a.yaml -g spring -o ./generated
openapi-generator generate -i service-b.yaml -g spring -o ./generated
openapi-generator generate -i service-c.yaml -g spring -o ./generated

This works for simple cases but breaks down when you need to share common schemas across services while maintaining type safety and avoiding code duplication. The rule was popularised by Martin Fowler in Refactoring and attributed to Don Roberts. Duplication is considered a bad practice in programming because it makes the code harder to maintain. When the rule encoded in a replicated piece of code changes, whoever maintains the code will have to change it in all places correctly.

Of course, someone might suggest writing those models by hand. While that's possible, you need strict workflows to ensure generated code stays synchronized with the OpenAPI specifications. More importantly, this doesn't solve the fundamental problem of OpenAPI spec reusability.

The Solution: Modular OpenAPI with Shared Dependencies

This solution establishes a single source of truth for shared schemas. The core idea: treat common models as versioned dependencies, leveraging OpenAPI’s $ref and Maven’s dependency management.

Architecture Overview

This solution creates strong Module Boundaries: Microservices reinforce modular structure, which is particularly important for larger teams. by separating concerns into distinct modules:

project/
├── common/
│   ├── specs/
│   │   └── openapi.json        # Shared schemas only
│   ├── package.json            # Redocly bundling
│   ├── generate.sh             # OpenAPI Generator + Maven install
│   └── pom.xml                 # Generated Maven project
└── service/
    ├── specs/
    │   └── openapi.json        # Service endpoints + imported schemas
    ├── package.json            # Redocly bundling
    ├── generate.sh             # OpenAPI Generator
    └── pom.xml                 # Depends on common models JAR

Both modules generate valid OpenAPI specifications and documentation using Redocly, with Bash scripts orchestrating the OpenAPI generator to build Maven projects.

Implementation Deep Dive

Step 1: Common Models Module

The common/specs/openapi.json contains shared schemas that every service needs. Error handling models are perfect examples since every service requires consistent error responses:

{
  "openapi": "3.0.3",
  "info": {
    "title": "Common Models",
    "version": "1.0.0"
  },
  "paths": {},
  "components": {
    "schemas": {
      "ErrorIssue": {
        "required": ["type", "code"],
        "type": "object",
        "description": "Detailed information regarding the issue...",
        "properties": {
          "type": {
            "type": "string",
            "description": "Category of the issue"
          },
          "code": {
            "type": "integer",
            "description": "Code that uniquely identifies the type of issue"
          },
          "parameter": {
            "type": "string",
            "description": "Identifies the parameter..."
          },
          "message": {
            "type": "string",
            "description": "Human readable description..."
          }
        }
      }
    }
  }
}

The OpenAPI Generator produces clean Java models with proper annotations:

// common/src/main/java/xyz/fokion/common/models/ErrorIssue.java
@JsonPropertyOrder({
  ErrorIssue.JSON_PROPERTY_TYPE,
  ErrorIssue.JSON_PROPERTY_CODE,
  ErrorIssue.JSON_PROPERTY_PARAMETER,
  ErrorIssue.JSON_PROPERTY_MESSAGE
})
public class ErrorIssue implements Serializable {
  @NotNull private String type;
  @NotNull private Integer code;
  private String parameter;
  private String message;
  // getters, setters, equals/hashCode, toString, toUrlQueryString(...)
}

Step 2: Service Module with Schema References

Services reference common schemas using relative paths in $ref, creating what DDD says that your application should be broken up into separate parts (DDD terms: bounded contexts or domain) and these bounded contexts should be isolated from each other so that each bounded context can focus on its particular business group.

{
  "openapi": "3.0.3",
  "info": {
    "title": "Items Service API",
    "version": "1.0.0"
  },
  "paths": {
    "/items": {
      "get": {
        "responses": {
          "200": {
            "content": {
              "application/json;charset=UTF-8": {
                "schema": {
                  "$ref": "#/components/schemas/ItemsResponse"
                }
              }
            },
            "description": "Success"
          },
          "500": {
            "content": {
              "application/json;charset=UTF-8": {
                "schema": {
                  "$ref": "../common/specs/openapi.json#/components/schemas/ErrorIssue"
                }
              }
            },
            "description": "Internal Server Error"
          }
        }
      }
    }
  },
  "components": {
    "schemas": {
      "ItemsResponse": {
        "type": "object",
        "properties": {
          "items": {
            "type": "array",
            "items": {"$ref": "#/components/schemas/Item"}
          }
        }
      },
      "Item": {
        "type": "object",
        "properties": {
          "id": {"type": "string"},
          "name": {"type": "string"}
        }
      }
    }
  }
}

Notice the ../common/specs/openapi.json#/components/schemas/ErrorIssue reference. This can be a relative path or public URL—the key is establishing a single source of truth.

Step 3: Build Automation Magic

The critical piece is the OpenAPI Generator configuration that handles import mapping and selective generation:

docker run --rm \
  --user $(id -u):$(id -g) \
  -v ${PWD}:/local openapitools/openapi-generator-cli generate \
  -i /local/specs/openapi.json \
  -g spring \
  -c /local/ci/generator/config.json \
  --import-mappings=$(./ci/util.sh common/specs/openapi.json ${PACKAGE_NAME} import) \
  --openapi-generator-ignore-list "README.md,docs/*.md,src/main/java/org/openapitools/*,gradle*,*.gradle,git_push.sh,build.sbt,.travis.yml,.github,api,.gitignore,$(./ci/util.sh common/specs/openapi.json ${PACKAGE_NAME} ignore)" \
  -o /local

The --import-mappings parameter tells the generator which types to import rather than generate:

ErrorIssue=xyz.fokion.common.models.ErrorIssue

The --openapi-generator-ignore-list prevents generation of files that should come from dependencies:

docs/*.md
.gitignore
gradle*
src/xyz/fokion/service/models/ErrorIssue.java  # Skip generating this
git_push.sh
...

Finally, replace the generated pom.xml with a customized version that includes the common models dependency:

# Replace pom.xml with dependency-aware version
cp ci/generator/pom.xml pom.xml
awk -v ver="$VERSION" 'NR==8 && /<version>version<\/version>/ {gsub(/<version>version<\/version>/, "<version>" ver "</version>")} {print}' pom.xml > pom.xml.tmp && mv pom.xml.tmp pom.xml
awk -v ver="$SPRING_VERSION" '/<spring-boot\.version>version<\/spring-boot\.version>/ {gsub(/<spring-boot\.version>version<\/spring-boot\.version>/, "<spring-boot.version>" ver "</spring-boot.version>")} {print}' pom.xml > pom.xml.tmp && mv pom.xml.tmp pom.xml
awk -v ver="$COMMON_VERSION" '/<common-models\.version>version<\/common-models\.version>/ {gsub(/<common-models\.version>version<\/common-models\.version>/, "<common-models.version>" ver "</common-models.version>")} {print}' pom.xml > pom.xml.tmp && mv pom.xml.tmp pom.xml

mvn install

The Profound Benefits

BenefitImpact
DRY ComplianceEliminates duplicate schemas; changes propagate via dependency updates.
Type SafetyCompile-time validation catches breaking changes early.
Modular BoundariesExplicit dependencies prevent accidental coupling.
Polyglot SupportGenerate models for any stack (Java, TypeScript, etc.) from the same spec.
DocumentationShared models inherit uniform validation, serialization, and docs.
Consistency

TL;DR

  • common module defines shared schemas and publishes a Java models artifact locally
  • service module defines endpoints, imports common schemas, and generates Spring server interface
  • Small scripts wrap OpenAPI Generator and keep POMs, versions, and mappings in sync

Try it yourself at github.com/fokion/oas-example:

When to Apply This Pattern

This approach transcends code reuse—it’s about scalable, maintainable architecture. By centralizing shared contracts and enforcing dependencies, you:

  • Well-defined domains with cross-service models where certain models naturally belong to multiple services
  • Mature DevOps practices for dependency management and versioning
  • Polyglot teams needing consistent contracts across tech stacks.

This isn't just about avoiding duplicate code—it's about creating sustainable architecture that can evolve with your organization while maintaining the discipline necessary for long-term success.