Enterprise Java

Spring HATEOAS + OpenAPI 3.0: Why Links Don’t Appear in Swagger UI

You built a perfectly valid HAL response, opened Swagger, and the _links are simply… gone. Here is exactly why that happens and how to fix it for good.

If you have spent any time combining Spring HATEOAS with springdoc-openapi, you have almost certainly hit the wall: your REST endpoint returns a beautifully structured HAL payload at runtime, but Swagger UI shows something completely different — or worse, it shows the raw internals of EntityModel instead of your clean _links map. As a result, your API consumers get misleading documentation. So, let’s dig into why this happens and, more importantly, how to get past it.

The Problem in Plain Language

Before we jump into solutions, it is worth understanding what is actually going on under the hood. Spring HATEOAS serializes responses using custom Jackson modules that transform EntityModel<T> and CollectionModel<T> into the well-known HAL (Hypertext Application Language) format at runtime. The resulting JSON looks something like this:

{
  "id": 42,
  "name": "Laptop",
  "_links": {
    "self": { "href": "/api/products/42" },
    "category": { "href": "/api/categories/electronics" }
  }
}

That looks great in Postman or a browser. However, the springdoc schema generator runs at application startup — before any request is made — and it inspects the raw Java class structure. Since it does not invoke the Jackson serializers at that point, it sees EntityModel‘s internal field named links (a plain Java collection) rather than the HAL-mapped _links object. The gap between what the serializer produces and what the schema generator sees is the root cause of everything that follows.

springdoc reads class structure at startup. Spring HATEOAS serializes data at request time. These two worlds never directly talk to each other unless you wire them up.

The Three Most Common Symptoms

Depending on how your project is set up, the broken documentation typically shows up in one of three ways. Recognizing which one you are dealing with saves a lot of debugging time.

What Swagger ShowsWhat It Actually MeansSeverity
links as an array of full Link objectsspringdoc is reading the raw Java class, not HAL serializationMisleading
_links shown as array instead of object/mapSchema type mismatch — HAL expects an object keyed by rel nameWrong schema
No _links field at allMissing HATEOAS module dependency or incorrect return type hintInvisible

Additionally, you might also encounter _embedded showing up as content on CollectionModel responses, which breaks HAL consumers who rely on the underscore-prefixed convention. All of these issues share the same underlying cause, so fortunately they also share similar fixes.

Fix 1: Add the Correct Dependency

This one sounds obvious, but it catches a surprising number of teams. The standard springdoc-openapi-starter-webmvc-ui does not automatically activate HATEOAS-aware schema generation. You need to ensure you are also pulling in spring-boot-starter-hateoas, and more specifically that you are using the right springdoc starter for your context.

For a Spring Boot 3.x project using Spring MVC, your pom.xml should look like this:

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-hateoas</artifactId>
</dependency>

<dependency>
  <groupId>org.springdoc</groupId>
  <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
  <version>2.8.4</version>
</dependency>

When spring-boot-starter-hateoas is on the classpath, springdoc’s autoconfiguration detects it and activates OpenApiHateoasLinksCustomizer automatically — provided you are on springdoc 2.x. On version 1.x, you also needed a separate springdoc-openapi-hateoas artifact; that separate module was folded into the core starter in v2.

If you are still on Spring Boot 2 and springdoc 1.x, you explicitly need to add springdoc-openapi-hateoas as a separate dependency. Without it, HATEOAS schema support simply does not activate.

Fix 2: Return the Right Types from Your Controller

springdoc infers the schema entirely from the method return type. Consequently, if your controller returns ResponseEntity<?> with a wildcard, or if you are returning a raw domain object that you happen to wrap inside a EntityModel later, springdoc misses the HATEOAS wrapper entirely.

The fix is straightforward — always be explicit with your generics:

// Avoid — springdoc cannot infer the schema
@GetMapping("/products/{id}")
public ResponseEntity getProduct(@PathVariable Long id) {
    Product product = service.findById(id);
    return ResponseEntity.ok(EntityModel.of(product));
}

// Preferred — explicit type lets springdoc map the schema correctly
@GetMapping("/products/{id}")
public EntityModel<Product> getProduct(@PathVariable Long id) {
    Product product = service.findById(id);
    return EntityModel.of(product,
        linkTo(methodOn(ProductController.class).getProduct(id)).withSelfRel(),
        linkTo(methodOn(CategoryController.class).getCategory(product.getCategoryId())).withRel("category")
    );
}

Equally important: for collections, return CollectionModel<EntityModel<Product>> rather than List<EntityModel<Product>>. springdoc knows how to unwrap the nested generics and generate the correct _embedded structure when you are specific.

Annotating with @Operation and @ApiResponse

Sometimes the return type alone is not enough — especially when you use ResponseEntity for HTTP status control. In those cases, complement your method signature with a Swagger annotation to spell out exactly what the response looks like:

@Operation(summary = "Get a product by ID")
@ApiResponse(responseCode = "200",
    content = @Content(schema = @Schema(implementation = ProductEntityModel.class)))
@GetMapping("/products/{id}")
public ResponseEntity<EntityModel<Product>> getProduct(@PathVariable Long id) {
    // ...
}

This combination gives springdoc an unambiguous signal about the schema, so the generated documentation reliably reflects your actual HAL output.

Fix 3: Understand the _links vs links Naming Gap

Even after the above fixes, you may notice that Swagger still shows the field as links (no underscore) rather than _links. This is because, as reported in the springdoc issue tracker, the schema generator reads the Java field name rather than the HAL-serialized name.

springdoc 2.x with HATEOAS autoconfiguration should handle this automatically via its built-in OpenApiHateoasLinksCustomizer. However, if you still see plain links in the schema, you can register a customizer bean yourself to fix the field name:

@Configuration
public class OpenApiConfig {

    @Bean
    public OpenApiCustomizer hateoasLinksCustomizer() {
        return openApi -> openApi.getComponents()
            .getSchemas()
            .forEach((name, schema) -> {
                if (schema.getProperties() != null
                        && schema.getProperties().containsKey("links")) {
                    var linksSchema = schema.getProperties().remove("links");
                    schema.getProperties().put("_links", linksSchema);
                }
            });
    }
}

This walks the generated component schemas and renames the links property to _links everywhere it appears. As a result, your Swagger UI will finally mirror the actual HAL payload your API produces.

After the fix your Swagger schema should show _links as an object with string keys mapping to link objects — matching what HAL actually delivers at runtime.

How the Schema Should Look After Fixing

With all the above pieces in place, the OpenAPI schema for a EntityModel<Product> response should end up looking like this in the generated v3/api-docs output:

{
  "EntityModelProduct": {
    "type": "object",
    "properties": {
      "id": { "type": "integer", "format": "int64" },
      "name": { "type": "string" },
      "_links": {
        "type": "object",
        "additionalProperties": {
          "$ref": "#/components/schemas/Link"
        }
      }
    }
  },
  "Link": {
    "type": "object",
    "properties": {
      "href":      { "type": "string" },
      "templated": { "type": "boolean" }
    }
  }
}

Notice that _links is an object with additionalProperties referencing Link — not a flat array. That is the correct HAL representation and the one that will render properly in Swagger UI.

The Serialization Gap at a Glance

The chart below visualizes how the divergence between springdoc’s schema generation and Spring HATEOAS runtime serialization leads to documentation drift, and at which layer each fix operates.

Where Documentation Drift Happens

Comparison of what each layer produces vs. what Swagger receives — before and after fixes

Common Edge Cases to Watch Out For

PagedModel and pagination links

If you use PagedModel<EntityModel<T>>, springdoc should also show the pagination links (firstprevnextlast) in the schema. However, this only works when spring-boot-starter-hateoas is properly detected. If you do not see them, double-check that your return type uses PagedModel explicitly rather than Spring Data’s Page.

Custom RepresentationModel subclasses

When you extend RepresentationModel<T> directly to create a custom representation, springdoc may generate the _links field as an array instead of a map — especially on subtypes using @Schema(allOf = ...). This is a known issue tracked in the springdoc repository. The workaround is to annotate the _links property explicitly with @Schema to override the generated type:

public class ProductModel extends RepresentationModel<ProductModel> {

    private String name;
    private String sku;

    @Schema(description = "HAL links map",
            additionalPropertiesSchema = @Schema(ref = "#/components/schemas/Link"))
    @JsonProperty("_links")
    public Links getLinks() {
        return super.getLinks();
    }
}

HalConfiguration and single-link arrays

If you have configured HalConfiguration to render single links as arrays rather than objects, your runtime payload changes shape — and the default springdoc schema will be wrong again. In that case, you need a custom OpenApiCustomizer that also adjusts the _links additionalProperties to accept an array of Link.

Dependency Compatibility at a Glance

Getting the right dependency versions together is half the battle. The following table summarizes the tested combinations that produce correct HATEOAS documentation:

Spring Bootspringdoc-openapiHATEOAS module needed?Status
3.2.x – 3.5.x2.3.x – 2.8.xNo separate module — built inFully supported
2.7.x1.6.x – 1.8.xYes — springdoc-openapi-hateoasSupported (EOL)
3.x with springfoxn/a (springfox)springfox does not support HATEOAS wellAvoid
AnyAnyMissing spring-boot-starter-hateoasLinks invisible

A Realistic Adoption Timeline

To give you a sense of how teams typically progress from broken to fully fixed documentation, the chart below shows an approximate timeline of issue discovery and resolution effort based on real reports in the springdoc and Spring HATEOAS repositories.

Developer Hours to Full Resolution — Typical Progression

Averaged across reported GitHub issues and community posts. Each bar represents cumulative effort at that stage.

Quick Diagnostic Checklist

Before you reach for a custom OpenApiCustomizer, it is worth running through this quick list to rule out the simpler causes first. In most cases, one of the first three items is all you need.

  • Is spring-boot-starter-hateoas in your pom.xml or build.gradle?
  • Are you on springdoc 2.x (not springfox) with Spring Boot 3.x?
  • Does your controller return an explicit generic type like EntityModel<Product> rather than ResponseEntity<?>?
  • Have you checked /v3/api-docs directly (not just Swagger UI) to see the raw generated schema?
  • If using @Schema(subTypes), are your subtypes annotated correctly without shadowing _links?

The official springdoc documentation covers the HATEOAS integration in detail. The Spring HATEOAS reference guide explains how serializers and link discoverers work under the hood — both are worth a read if you are building a production-grade hypermedia API.

What We Learned

The root of the Spring HATEOAS + Swagger problem is a timing mismatch: springdoc reads your class structure at startup, while Spring HATEOAS transforms it with custom Jackson serializers at request time. As a result, the two worlds produce different schemas unless you explicitly bridge them.

Throughout this article, we walked through the three most common symptoms — wrong field names, array vs. object mismatches, and invisible links.

We then covered the exact fixes: adding spring-boot-starter-hateoas, using explicit generic return types in controllers, registering an OpenApiCustomizer to correct field names, and handling edge cases like PagedModel and custom RepresentationModel subclasses. With all these pieces in place, your Swagger UI documentation will accurately reflect what your HAL responses deliver at runtime — which is the whole point.

Eleftheria Drosopoulou

Eleftheria is an Experienced Business Analyst with a robust background in the computer software industry. Proficient in Computer Software Training, Digital Marketing, HTML Scripting, and Microsoft Office, they bring a wealth of technical skills to the table. Additionally, she has a love for writing articles on various tech subjects, showcasing a talent for translating complex concepts into accessible content.
Subscribe
Notify of
guest

This site uses Akismet to reduce spam. Learn how your comment data is processed.

0 Comments
Oldest
Newest Most Voted
Back to top button