Adopt the server‑side pieces in your own Spring Boot service (MVC + Springdoc)
Purpose: Copy the minimal set of classes/config from customer-service
into your own microservice so that:
- You return
ServiceResponse<T>
from controllers. - The OpenAPI spec auto-registers wrapper schemas (e.g.,
ServiceResponseCustomerDto
) with vendor extensions. - A client module can later generate thin, generics-aware wrappers.
Scope: Spring MVC (WebMVC) + Springdoc. No WebFlux.
1) Result overview
After this guide, your service will:
- Expose CRUD (or your own endpoints) returning
ServiceResponse<T>
. - Publish Swagger UI and
/v3/api-docs(.yaml)
with composed wrapper schemas. -
Include vendor extensions:
x-api-wrapper: true
x-api-wrapper-datatype: <YourDto>
- (optional)
x-class-extra-annotation: "@..."
2) Add dependencies (pom.xml)
<dependencies>
<!-- Web + Bean Validation -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- Springdoc (OpenAPI 3.1 + Swagger UI) -->
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.8.13</version>
</dependency>
<!-- optional: configuration processor for metadata hints -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
</dependency>
<!-- test (optional) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
Add your usual build plugins (compiler, surefire/failsafe, jacoco) as you prefer.
Note: Make sure all
common.openapi
configuration classes are inside your main Spring Boot application’s component scan (same base package or a sub-package). If you place them elsewhere, adjust@SpringBootApplication(scanBasePackages=...)
to include their package.
3) Create the generic response envelope
common/api/response/ServiceResponse.java
package
<your.base>.common.api.response;
import java.util.Collections;
import java.util.List;
import org.springframework.http.HttpStatus;
public record ServiceResponse<T>(int status, String message, T data, List<ErrorDetail> errors) {
public static <T> ServiceResponse<T> ok(T data) {
return new ServiceResponse<>(HttpStatus.OK.value(), "OK", data, Collections.emptyList());
}
public static <T> ServiceResponse<T> of(HttpStatus status, String message, T data) {
return new ServiceResponse<>(status.value(), message, data, Collections.emptyList());
}
public static <T> ServiceResponse<T> error(HttpStatus status, String message) {
return new ServiceResponse<>(status.value(), message, null, Collections.emptyList());
}
public static <T> ServiceResponse<T> error(HttpStatus status, String message, List<ErrorDetail> errors) {
return new ServiceResponse<>(status.value(), message, null, errors != null ? errors : Collections.emptyList());
}
}
common/api/response/ErrorDetail.java
package <your.base>.common.api.response;
public record ErrorDetail(String errorCode, String message) {
}
Note: Ensure ServiceResponse and ErrorDetail are in a package visible to both controllers and OpenAPI config ( e.g., common.api.response). If you place them in a different package, make sure springdoc picks them up in schema generation.
You can keep your existing error model; only the field names
status
,message
,data
,errors
are used by the OpenAPI base envelope below.
4) OpenAPI base envelope + vendor extensions
Create the following under common/openapi/
.
OpenApiSchemas.java
package
<your.base>.common.openapi;
import <your.base>.common.api.response.ServiceResponse;
public final class OpenApiSchemas {
// Base envelope schema names
public static final String SCHEMA_SERVICE_RESPONSE = ServiceResponse.class.getSimpleName();
public static final String SCHEMA_SERVICE_RESPONSE_VOID = SCHEMA_SERVICE_RESPONSE + "Void";
// Common property keys
public static final String PROP_STATUS = "status";
public static final String PROP_MESSAGE = "message";
public static final String PROP_ERRORS = "errors";
public static final String PROP_ERROR_CODE = "errorCode";
public static final String PROP_DATA = "data";
// Vendor extension keys
public static final String EXT_API_WRAPPER = "x-api-wrapper";
public static final String EXT_API_WRAPPER_DATATYPE = "x-api-wrapper-datatype";
public static final String EXT_CLASS_EXTRA_ANNOTATION = "x-class-extra-annotation";
private OpenApiSchemas() {
}
}
SwaggerResponseCustomizer.java
— defines the base ServiceResponse
envelope as a schema once.
package
<your.base>.common.openapi;
import static <your.base>.common.openapi.OpenApiSchemas.*;
import io.swagger.v3.oas.models.media.ArraySchema;
import io.swagger.v3.oas.models.media.IntegerSchema;
import io.swagger.v3.oas.models.media.ObjectSchema;
import io.swagger.v3.oas.models.media.StringSchema;
import org.springdoc.core.customizers.OpenApiCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class SwaggerResponseCustomizer {
@Bean
public OpenApiCustomizer responseEnvelopeSchemas() {
return openApi -> {
if (!openApi.getComponents().getSchemas().containsKey(SCHEMA_SERVICE_RESPONSE)) {
openApi.getComponents().addSchemas(
SCHEMA_SERVICE_RESPONSE,
new ObjectSchema()
.addProperty(PROP_STATUS, new IntegerSchema().format("int32"))
.addProperty(PROP_MESSAGE, new StringSchema())
.addProperty(PROP_ERRORS, new ArraySchema().items(
new ObjectSchema()
.addProperty(PROP_ERROR_CODE, new StringSchema())
.addProperty(PROP_MESSAGE, new StringSchema()))));
}
if (!openApi.getComponents().getSchemas().containsKey(SCHEMA_SERVICE_RESPONSE_VOID)) {
openApi.getComponents().addSchemas(
SCHEMA_SERVICE_RESPONSE_VOID,
new ObjectSchema()
.addProperty(PROP_STATUS, new IntegerSchema().format("int32"))
.addProperty(PROP_MESSAGE, new StringSchema())
.addProperty(PROP_DATA, new ObjectSchema())
.addProperty(PROP_ERRORS, new ArraySchema().items(
new ObjectSchema()
.addProperty(PROP_ERROR_CODE, new StringSchema())
.addProperty(PROP_MESSAGE, new StringSchema()))));
}
};
}
}
ApiResponseSchemaFactory.java
— builds a composed wrapper per concrete T
(e.g., CustomerDto
).
package
<your.base>.common.openapi;
import static <your.base>.common.openapi.OpenApiSchemas.*;
import io.swagger.v3.oas.models.media.ComposedSchema;
import io.swagger.v3.oas.models.media.ObjectSchema;
import io.swagger.v3.oas.models.media.Schema;
import java.util.List;
public final class ApiResponseSchemaFactory {
private ApiResponseSchemaFactory() {
}
public static Schema<?> createComposedWrapper(String dataRefName) {
return createComposedWrapper(dataRefName, null);
}
public static Schema<?> createComposedWrapper(String dataRefName, String classExtraAnnotation) {
var schema = new ComposedSchema();
schema.setAllOf(List.of(
new Schema<>().$ref("#/components/schemas/" + SCHEMA_SERVICE_RESPONSE),
new ObjectSchema().addProperty(PROP_DATA, new Schema<>().$ref("#/components/schemas/" + dataRefName))
));
schema.addExtension(EXT_API_WRAPPER, true);
schema.addExtension(EXT_API_WRAPPER_DATATYPE, dataRefName);
if (classExtraAnnotation != null && !classExtraAnnotation.isBlank()) {
schema.addExtension(EXT_CLASS_EXTRA_ANNOTATION, classExtraAnnotation);
}
return schema;
}
}
ResponseTypeIntrospector.java
— unwraps return types until it finds ServiceResponse<T>
and extracts T
.
package
<your.base>.common.openapi.introspector;
import <your.base>.common.api.response.ServiceResponse;
import java.lang.reflect.Method;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.Future;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.ResolvableType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.async.DeferredResult;
import org.springframework.web.context.request.async.WebAsyncTask;
@Component
public final class ResponseTypeIntrospector {
private static final Logger log = LoggerFactory.getLogger(ResponseTypeIntrospector.class);
private static final int MAX_UNWRAP_DEPTH = 8;
private static final Set<String> REACTOR_WRAPPERS = Set.of("reactor.core.publisher.Mono", "reactor.core.publisher.Flux");
public Optional<String> extractDataRefName(Method method) {
if (method == null) return Optional.empty();
ResolvableType type = ResolvableType.forMethodReturnType(method);
type = unwrapToServiceResponse(type);
Class<?> raw = type.resolve();
if (raw == null || !ServiceResponse.class.isAssignableFrom(raw)) return Optional.empty();
if (!type.hasGenerics()) return Optional.empty();
Class<?> dataClass = type.getGeneric(0).resolve();
Optional<String> ref = Optional.ofNullable(dataClass).map(Class::getSimpleName);
if (log.isDebugEnabled()) {
log.debug("Introspected method [{}]: wrapper [{}], data [{}]", method.toGenericString(), raw.getSimpleName(), ref.orElse("<none>"));
}
return ref;
}
private ResolvableType unwrapToServiceResponse(ResolvableType type) {
for (int guard = 0; guard < MAX_UNWRAP_DEPTH; guard++) {
Class<?> raw = type.resolve();
if (raw == null || ServiceResponse.class.isAssignableFrom(raw)) return type;
ResolvableType next = nextLayer(type, raw);
if (next == null) return type;
type = next;
}
return type;
}
private ResolvableType nextLayer(ResolvableType current, Class<?> raw) {
return switch (raw) {
case Class<?> c when ResponseEntity.class.isAssignableFrom(c) -> current.getGeneric(0);
case Class<?> c when CompletionStage.class.isAssignableFrom(c) || Future.class.isAssignableFrom(c) ->
current.getGeneric(0);
case Class<?> c when DeferredResult.class.isAssignableFrom(c) || WebAsyncTask.class.isAssignableFrom(c) ->
current.getGeneric(0);
case Class<?> c when REACTOR_WRAPPERS.contains(c.getName()) -> current.getGeneric(0);
default -> null;
};
}
}
autoreg/AutoWrapperSchemaCustomizer.java
— collects all controller return types and registers composed schemas.
package
<your.base>.common.openapi.autoreg;
import <your.base>.common.openapi.ApiResponseSchemaFactory;
import <your.base>.common.openapi.OpenApiSchemas;
import <your.base>.common.openapi.introspector.ResponseTypeIntrospector;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.Set;
import org.springdoc.core.customizers.OpenApiCustomizer;
import org.springframework.beans.factory.ListableBeanFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;
@Configuration
public class AutoWrapperSchemaCustomizer {
private final Set<String> dataRefs;
private final String classExtraAnnotation;
public AutoWrapperSchemaCustomizer(
ListableBeanFactory beanFactory,
ResponseTypeIntrospector introspector,
@Value("${app.openapi.wrapper.class-extra-annotation:}") String classExtraAnnotation) {
Set<String> refs = new LinkedHashSet<>();
beanFactory.getBeansOfType(RequestMappingHandlerMapping.class).values()
.forEach(rmh -> rmh.getHandlerMethods().values().stream()
.map(HandlerMethod::getMethod)
.forEach(m -> introspector.extractDataRefName(m).ifPresent(refs::add)));
this.dataRefs = Collections.unmodifiableSet(refs);
this.classExtraAnnotation = (classExtraAnnotation == null || classExtraAnnotation.isBlank()) ? null : classExtraAnnotation;
}
@Bean
public OpenApiCustomizer autoResponseWrappers() {
return openApi -> dataRefs.forEach(ref -> {
String name = OpenApiSchemas.SCHEMA_SERVICE_RESPONSE + ref;
openApi.getComponents().addSchemas(name, ApiResponseSchemaFactory.createComposedWrapper(ref, classExtraAnnotation));
});
}
}
OpenApiConfig.java
— optional, for title/version/server URL.
package
<your.base>.common.openapi;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info;
import io.swagger.v3.oas.models.servers.Server;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class OpenApiConfig {
@Value("${app.openapi.version:${project.version:unknown}}")
private String version;
@Value("${app.openapi.base-url:}")
private String baseUrl;
@Bean
public OpenAPI serviceOpenAPI() {
var openapi = new OpenAPI().info(new Info().title("Your Service API").version(version).description("Generic responses via OpenAPI"));
if (baseUrl != null && !baseUrl.isBlank()) {
openapi.addServersItem(new Server().url(baseUrl).description("Local service URL"));
}
return openapi;
}
}
5) Application configuration (application.yml)
server:
port: 8084
servlet:
context-path: /your-service
spring:
application:
name: your-service
profiles:
active: local
app:
openapi:
version: @project.version@
base-url: "http://localhost:${server.port}${server.servlet.context-path:}"
# wrapper:
# class-extra-annotation: "@com.fasterxml.jackson.annotation.JsonIgnoreProperties(ignoreUnknown = true)"
springdoc:
default-consumes-media-type: application/json
default-produces-media-type: application/json
- Set
base-url
if you want Swagger UI to show the server URL. - Uncomment
class-extra-annotation
only if you want to push an extra annotation to generated wrapper classes.
6) Return ServiceResponse<T>
from controllers
Example controller method (update to your domain):
@RestController
@RequestMapping(value = "/v1/customers", produces = MediaType.APPLICATION_JSON_VALUE)
@Validated
class CustomerController {
private final CustomerService customerService;
public CustomerController(CustomerService customerService) {
this.customerService = customerService;
}
@PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
@ResponseStatus(HttpStatus.CREATED)
public ResponseEntity<ServiceResponse<CustomerCreateResponse>> createCustomer(@Valid @RequestBody CustomerCreateRequest request) {
CustomerDto created = customerService.createCustomer(request);
CustomerCreateResponse body = new CustomerCreateResponse(created, Instant.now());
URI location = ServletUriComponentsBuilder.fromCurrentRequest().path("/{id}").buildAndExpand(created.customerId()).toUri();
return ResponseEntity.created(location).body(ServiceResponse.of(HttpStatus.CREATED, "CREATED", body));
}
@GetMapping("/{customerId}")
public ResponseEntity<ServiceResponse<CustomerDto>> getCustomer(@PathVariable @Min(1) Integer customerId) {
CustomerDto dto = customerService.getCustomer(customerId);
return ResponseEntity.ok(ServiceResponse.ok(dto));
}
}
You can wrap any DTO type the same way. The auto‑registration picks up all controller methods that return
ServiceResponse<T>
(includingResponseEntity<ServiceResponse<T>>
,CompletionStage<...>
, etc.).
7) Run & verify
-
Start your service and open:
- Swagger UI →
http://localhost:8084/your-service/swagger-ui/index.html
- OpenAPI JSON →
http://localhost:8084/your-service/v3/api-docs
- OpenAPI YAML →
http://localhost:8084/your-service/v3/api-docs.yaml
- Swagger UI →
-
In the Schemas section, confirm you see:
ServiceResponse
ServiceResponseVoid
ServiceResponse<YourDto>
generated as composed schemas withx-api-wrapper
vendor extensions.
8) What the client generator relies on
ServiceResponse
base envelope schema (added bySwaggerResponseCustomizer
).-
A composed schema per
T
with extensions:x-api-wrapper: true
x-api-wrapper-datatype: <YourDtoName>
- (optional)
x-class-extra-annotation
- These let the client module generate thin wrappers that extend
ServiceClientResponse<T>
.
9) Common pitfalls
- No composed wrappers appear: ensure your controllers actually return
ServiceResponse<T>
and thatAutoWrapperSchemaCustomizer
is loaded (it’s a@Configuration
). - Wrong
data
$ref
: the DTO class name must match the schema name Springdoc emits (usually the simple type name). If you use custom schema names, adaptextractDataRefName
to your naming. - Profiles/paths: if you change
context-path
or port, also updateapp.openapi.base-url
. - Extra annotations: if you don’t need additional annotations on generated client wrappers, keep
class-extra-annotation
unset.
10) Minimal folder map (suggested)
src/main/java/<your/base>/
common/api/response/
ErrorDetail.java
ServiceResponse.java
common/openapi/
OpenApiSchemas.java
SwaggerResponseCustomizer.java
ApiResponseSchemaFactory.java
OpenApiConfig.java
introspector/
ResponseTypeIntrospector.java
autoreg/
AutoWrapperSchemaCustomizer.java
api/controller/
YourControllers...
That’s it — your service now publishes a spec that is generics-aware and ready for client generation.