Response Transformation
Response Transformation
A BFF responsibility: modifying the shape, fields, or encoding of a downstream service response before sending it to the client — stripping internal fields, flattening nested structures, normalising formats.
Core Idea
Downstream microservices expose domain-centric APIs: they return every field they own, use internal IDs, nest objects deeply, and often use formats convenient for the server (e.g., Instant for timestamps). A BFF must translate these into client-centric responses: fewer fields, flat structures, and formats the client framework expects (e.g., ISO-8601 strings for Angular's DatePipe).
Response transformation is distinct from routing. Spring-Cloud-Gateway (SCG) handles routing; transformation logic belongs either in a custom GatewayFilterFactory (for SCG-managed routes) or directly in aggregation controller code.
Key Principles
- Define per-client DTOs — never expose the raw microservice domain model to clients; define
AngularProductView,MobileOrderSummary, etc. - Strip fields at the BFF, not at the service — downstream services should not be aware of client presentation needs.
- Normalise formats centrally — date/time, currency, locale-sensitive values all transformed once at the BFF boundary.
- Flattening reduces client complexity — deeply nested JSON increases client-side mapping code; a flat DTO is easier to bind in Angular templates.
How It Works
Approach 1: DTO Projection in Controller Code
The most straightforward approach: the aggregation controller maps the downstream response to a client-specific view record.
// Downstream model (20 fields, internal IDs, Instant timestamps)
public record Product(
String id, String internalSku, String name, String description,
BigDecimal price, String currencyCode, Instant createdAt,
Instant updatedAt, String createdBy, String updatedBy,
// ... 10 more fields
) {}
// Angular view model (5 fields, ISO-8601 string dates)
public record AngularProductView(
String id,
String name,
String description,
BigDecimal price,
String lastUpdated // ISO-8601 string, not Instant
) {
public static AngularProductView from(Product p) {
return new AngularProductView(
p.id(),
p.name(),
p.description(),
p.price(),
p.updatedAt().toString() // Instant.toString() is ISO-8601
);
}
}Approach 2: Jackson @JsonView for Per-Client Field Visibility
Useful when a single service class serves multiple client types with different visibility requirements.
public class Views {
public interface Angular {}
public interface Mobile extends Angular {}
public interface Internal extends Mobile {}
}
public class ProductResponse {
@JsonView(Views.Angular.class)
public String id;
@JsonView(Views.Angular.class)
public String name;
@JsonView(Views.Internal.class) // only visible to internal clients
public String internalSku;
@JsonView(Views.Internal.class)
public String createdBy;
}
// Controller — Angular client gets only Angular-view fields
@GetMapping("/products/{id}")
@JsonView(Views.Angular.class)
public Mono<ProductResponse> getProduct(@PathVariable String id) {
return productService.findById(id);
}Approach 3: ModifyResponseBodyGatewayFilterFactory in SCG
For transformations on SCG-proxied routes (where no controller code is involved), use the built-in ModifyResponseBodyGatewayFilterFactory.
@Component
public class FieldStrippingFilterFactory
extends AbstractGatewayFilterFactory<FieldStrippingFilterFactory.Config> {
private final ObjectMapper objectMapper;
public FieldStrippingFilterFactory(ObjectMapper objectMapper) {
super(Config.class);
this.objectMapper = objectMapper;
}
@Override
public GatewayFilter apply(Config config) {
return new ModifyResponseBodyGatewayFilterFactory.ModifyResponseBodyGatewayFilter(
new ModifyResponseBodyGatewayFilterFactory.Config()
.setInClass(String.class)
.setOutClass(String.class)
.setRewriteFunction(String.class, String.class,
(exchange, body) -> {
try {
JsonNode root = objectMapper.readTree(body);
ObjectNode node = (ObjectNode) root;
// Strip internal fields
config.getFieldsToRemove().forEach(node::remove);
return Mono.just(objectMapper.writeValueAsString(node));
} catch (Exception e) {
return Mono.just(body); // pass through on parse failure
}
})
);
}
public static class Config {
private List<String> fieldsToRemove = List.of();
public List<String> getFieldsToRemove() { return fieldsToRemove; }
public void setFieldsToRemove(List<String> f) { this.fieldsToRemove = f; }
}
}Date/Time Format Normalisation
Backend services typically store and return Instant. Angular's DatePipe expects ISO-8601 strings. Configure Jackson globally in the BFF:
@Configuration
public class JacksonConfig {
@Bean
public Jackson2ObjectMapperBuilderCustomizer jacksonCustomizer() {
return builder -> builder
.featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
.featuresToEnable(SerializationFeature.WRITE_DATES_WITH_ZONE_ID)
.modules(new JavaTimeModule());
}
}With this config, Instant serialises as "2026-03-06T14:30:00Z" — valid ISO-8601 that Angular can parse natively.
Examples
- Field stripping — Downstream
UserResponsehas 15 fields includingpasswordHash,internalRole,auditMetadata. Angular view strips all butid,email,displayName,avatarUrl. - Flattening — Downstream
OrdernestsshippingAddress.cityandshippingAddress.postcode. BFF flattens tocityandpostcodeat the top level for the mobile client. - Format normalisation — Downstream returns
BigDecimal priceandString currencyCode. BFF combines into"£29.99"string for display in the Angular template.
See complete GatewayFilterFactory implementation in P3-BFF-Implementation-Patterns — Example 2.
Common Misconceptions
Transformation should happen in the microservice: Services should expose complete domain models; BFFs are responsible for client adaptation. Coupling presentation format to service logic violates separation of concerns.: It is, for large models. Prefer explicit DTO projection records for clarity.@JsonViewis complex to maintain@JsonViewsuits retrofit scenarios where full refactoring is impractical.Response modification in SCG is expensive: It buffers the full response body in memory for modification. For large payloads (files, exports), prefer streaming-through or off-loading transformation to a dedicated controller.
Why It Matters
Angular templates are tightly bound to JSON field names. Every unnecessary field in the response increases payload size, parsing time, and potential security exposure (e.g., leaking internal IDs). Response transformation at the BFF enforces interface segregation: each client gets exactly the data it needs, in the format it expects, with no internal details leaked.
Related Concepts
| Concept | Relationship |
|---|---|
| BFF-Pattern | Transformation is a core BFF responsibility |
| Spring-Cloud-Gateway | SCG's ModifyResponseBodyGatewayFilterFactory enables filter-based transformation |
| Request-Aggregation | Aggregated responses often require transformation before returning |
| Resilience4j | Error responses from circuit breakers also require transformation/normalisation |
Sources
- Spring Cloud Gateway reference: ModifyResponseBodyGatewayFilterFactory
- Jackson ObjectMapper + JavaTimeModule docs
- P3-BFF-Implementation-Patterns (Phase 3 research, IMPL-02, IMPL-04)