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

  1. Define per-client DTOs — never expose the raw microservice domain model to clients; define AngularProductView, MobileOrderSummary, etc.
  2. Strip fields at the BFF, not at the service — downstream services should not be aware of client presentation needs.
  3. Normalise formats centrally — date/time, currency, locale-sensitive values all transformed once at the BFF boundary.
  4. 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 UserResponse has 15 fields including passwordHash, internalRole, auditMetadata. Angular view strips all but id, email, displayName, avatarUrl.
  • Flattening — Downstream Order nests shippingAddress.city and shippingAddress.postcode. BFF flattens to city and postcode at the top level for the mobile client.
  • Format normalisation — Downstream returns BigDecimal price and String 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.
  • @JsonView is complex to maintain: It is, for large models. Prefer explicit DTO projection records for clarity. @JsonView suits 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.


ConceptRelationship
BFF-PatternTransformation is a core BFF responsibility
Spring-Cloud-GatewaySCG's ModifyResponseBodyGatewayFilterFactory enables filter-based transformation
Request-AggregationAggregated responses often require transformation before returning
Resilience4jError 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)