BFF Phase 6: Angular-Specific BFF Design

BFF Phase 6: Angular-Specific BFF Design

This note covers the Angular-specific design considerations for a Backend-for-Frontend built on Spring Boot 3.4.x / Spring Cloud Gateway. It synthesises phases 1–5 through the lens of what Angular components actually need from a BFF, and closes the research project with deployment and versioning patterns.

Related research: P1-BFF-Foundations | P2-Spring-Boot-BFF-Stack | P3-BFF-Implementation-Patterns | P4-BFF-Security | P5-BFF-Observability-Testing


ANG-01: Component-Driven API Design

The fundamental BFF design principle for Angular: identify what each Angular component needs, then design the BFF endpoint to deliver exactly that — do not expose microservice structure to the frontend.

The Wrong Approach

Angular ProductListComponent
  → GET /api/products          (product service)
  → GET /api/categories        (category service)
  → GET /api/promotions/active (promotions service)
  [3 sequential or parallel HTTP calls, client-side assembly]

The BFF Approach

Angular ProductListComponent
  → GET /api/products-page     (BFF endpoint)
  [1 call, BFF assembles the composite response]

The BFF endpoint GET /api/products-page internally fans out to product, category, and promotions services using Mono.zip, assembles a ProductListPageResponse, and returns it. The Angular component has a single data contract and a single loading state to manage.

Composite DTO Pattern

Design DTOs around the Angular view, not around microservice boundaries:

// What the Angular dashboard page needs in a single call
public record DashboardPageResponse(
    UserSummaryDto user,
    List<OrderSummaryDto> recentOrders,
    AccountBalanceDto balance,
    List<NotificationDto> unreadNotifications
) {}
 
@GetMapping("/api/dashboard")
public Mono<DashboardPageResponse> getDashboard(
        @AuthenticationPrincipal Mono<OidcUser> principal) {
    return principal.flatMap(user -> {
        String userId = user.getSubject();
        return Mono.zip(
            userServiceClient.getSummary(userId),
            orderServiceClient.getRecent(userId, 5),
            accountServiceClient.getBalance(userId),
            notificationServiceClient.getUnread(userId)
        ).map(tuple -> new DashboardPageResponse(
            tuple.getT1(),
            tuple.getT2(),
            tuple.getT3(),
            tuple.getT4()
        ));
    });
}

Standardised Pagination Envelope

Angular's table and list components need a consistent pagination contract. The BFF translates Spring Data Page<T> from downstream services into a client-friendly envelope:

public record PageResponse<T>(
    List<T> content,
    int page,
    int size,
    long totalElements,
    int totalPages,
    boolean first,
    boolean last
) {}

This PageResponse<T> is the single pagination contract across all BFF endpoints. Angular components depend only on this shape — not on Spring Data internals.


ANG-02: Pagination, Sorting, and Filtering

Angular's HttpClient sends query parameters that the BFF validates, sanitises, and translates to downstream service calls.

BFF Endpoint

@GetMapping("/api/products")
public Mono<PageResponse<ProductView>> getProducts(
        @RequestParam(defaultValue = "0") int page,
        @RequestParam(defaultValue = "20") int size,
        @RequestParam(required = false) String sort,
        @RequestParam(required = false) String search) {
 
    // Guard: cap page size to prevent accidental or malicious large requests
    int safeSize = Math.min(size, 100);
 
    // Guard: validate sort field against allowlist (prevent injection)
    String safeSort = validateSortField(sort);
 
    return productServiceClient.getProducts(page, safeSize, safeSort, search)
        .map(this::toPageResponse);
}
 
private String validateSortField(String sort) {
    if (sort == null) return null;
    Set<String> allowed = Set.of("name,asc", "name,desc", "price,asc", "price,desc", "createdAt,desc");
    return allowed.contains(sort) ? sort : "name,asc";  // default on invalid
}
 
private PageResponse<ProductView> toPageResponse(Page<ProductDto> page) {
    List<ProductView> views = page.getContent().stream()
        .map(this::toView)
        .toList();
    return new PageResponse<>(
        views,
        page.getNumber(),
        page.getSize(),
        page.getTotalElements(),
        page.getTotalPages(),
        page.isFirst(),
        page.isLast()
    );
}

Angular Side

export interface ProductQueryParams {
  page?: number;
  size?: number;
  sort?: string;
  search?: string;
}
 
@Injectable({ providedIn: 'root' })
export class ProductService {
  constructor(private http: HttpClient) {}
 
  getProducts(params: ProductQueryParams): Observable<PageResponse<ProductView>> {
    let httpParams = new HttpParams();
    if (params.page !== undefined) httpParams = httpParams.set('page', params.page);
    if (params.size !== undefined) httpParams = httpParams.set('size', params.size);
    if (params.sort) httpParams = httpParams.set('sort', params.sort);
    if (params.search) httpParams = httpParams.set('search', params.search);
 
    return this.http.get<PageResponse<ProductView>>('/api/products', { params: httpParams });
  }
}

Key BFF responsibilities:

  1. Cap page size — protect downstream from size=10000 requests
  2. Validate sort fields — allowlist prevents sort-based injection attacks
  3. Translate Angular's sort=name,asc param format to whatever the downstream service expects (may differ)
  4. Normalise the response — always return PageResponse<T> regardless of what the downstream service returns

ANG-03: Real-Time Data — SSE vs WebSocket

See Server-Sent-Events for the full comparison and code reference. Summary:

Use SSE when: notifications, live feed updates, progress events, status polling replacement — any server-initiated, unidirectional data push.

Use WebSocket when: chat, collaborative editing, gaming, or any bidirectional communication where the client also sends messages in real time.

For the typical Angular SPA served by a BFF, SSE is the correct choice in 80%+ of real-time use cases. The BFF exposes a Flux<ServerSentEvent<T>> endpoint; Angular consumes it with EventSource + withCredentials: true.

Spring Boot SSE Endpoint

@GetMapping(
    value = "/api/notifications/stream",
    produces = MediaType.TEXT_EVENT_STREAM_VALUE
)
public Flux<ServerSentEvent<NotificationDto>> streamNotifications(
        @AuthenticationPrincipal Mono<OidcUser> user) {
    return user.flatMapMany(u ->
        notificationService.getNotificationsForUser(u.getSubject())
            .map(notification -> ServerSentEvent.<NotificationDto>builder()
                .id(notification.id())
                .event("notification")
                .data(notification)
                .build())
    );
}

The @AuthenticationPrincipal Mono<OidcUser> injection means authentication is handled by the BFF's existing OAuth2 session — the SSE endpoint inherits the same security context as all other BFF endpoints.

Angular EventSource Consumer

const eventSource = new EventSource('/api/notifications/stream', {
  withCredentials: true  // essential: sends the session cookie
});
 
eventSource.addEventListener('notification', (event: MessageEvent) => {
  const notification = JSON.parse(event.data) as NotificationDto;
  this.notificationSubject.next(notification);
});

ANG-04: BFF as Single CORS Origin

In production, the Angular SPA and the BFF share the same HTTP origin. This is the cleanest security posture:

  • Angular static files served from CDN at app.example.com
  • All /api/* requests routed to BFF at the same app.example.com hostname
  • The browser never makes a cross-origin request — CORS is irrelevant

For development, Angular's proxy configuration forwards API calls to the locally running BFF:

// proxy.conf.json (Angular)
{
  "/api": {
    "target": "http://localhost:8080",
    "secure": false,
    "changeOrigin": true
  }
}
// angular.json
{
  "serve": {
    "options": {
      "proxyConfig": "proxy.conf.json"
    }
  }
}

The BFF's development CORS configuration (see P4-BFF-Security for full detail):

@Bean
public CorsConfigurationSource corsConfigurationSource() {
    CorsConfiguration config = new CorsConfiguration();
    config.setAllowedOrigins(List.of("http://localhost:4200"));
    config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
    config.setAllowedHeaders(List.of("*"));
    config.setAllowCredentials(true);  // required for session cookies
    UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
    source.registerCorsConfiguration("/api/**", config);
    return source;
}

allowCredentials(true) is required because Angular sends the session cookie. This also means allowedOrigins cannot use the wildcard * — an explicit origin is required.

In production (same origin), this CORS configuration is not needed. Keep a profile guard so it only activates in development.


ANG-05: BFF API Versioning Strategy

See BFF-Versioning for the full comparison table and Spring Cloud Gateway YAML. Summary:

URL path versioning (/api/v1/, /api/v2/) is the pragmatic choice for a BFF serving Angular:

  1. Angular services embed the version constant once (environment.apiBase = '/api/v2')
  2. Spring-Cloud-Gateway routes distinguish versions via Path predicates
  3. The BFF maintains N and N-1 during deployment windows
  4. Old v1 routes can use a ResponseTransformer filter to adapt v2 response shapes for backward compatibility

The BFF version tracks the Angular application version — when the Angular app ships a breaking data contract change, the BFF gets a new version prefix and the old prefix is maintained until all users have updated.


ANG-06: Deployment Patterns

See BFF-Deployment-Patterns for full topology diagrams and trade-offs. Summary:

Topology 1 (Standalone BFF) — baseline. BFF behind a load balancer, Redis for session, downstream services on private network.

Topology 2 (BFF at Edge / CDN) — production recommendation. CDN serves Angular static assets; /api/* routes to BFF on the same origin. Eliminates CORS entirely.

Topology 3 (API Gateway + BFF layered) — enterprise. Infrastructure API Gateway handles TLS, rate limiting, DDoS; BFF handles Angular-specific aggregation and session.

Key principle: downstream services are never accessible directly from the internet. The BFF is the single ingress point for the Angular application.


ANG-07: When to Split BFFs

Split rule: one BFF per frontend team / frontend application.

Split when:

  • Mobile app needs different data shapes (e.g., deeply nested for REST vs flat for GraphQL-style mobile clients)
  • Partner API uses different authentication (API key vs OAuth2 PKCE)
  • Client types have different SLAs (internal dashboard vs customer-facing, where downtime tolerance differs)
  • Release cadences diverge significantly (mobile app releases quarterly; web app releases weekly)

Do not split when:

  • It is simply different pages/features of the same Angular app — use route prefixes in one BFF
  • The team cannot sustain multiple deployments — operational overhead is real

Multi-BFF monorepo layout:

bff-web/       ← Angular web app BFF (OAuth2 + session cookies)
bff-mobile/    ← React Native BFF (JWT, stateless)
bff-partner/   ← Partner API BFF (API key auth, dedicated rate limits)
shared-lib/    ← Common: PageResponse<T>, circuit breaker config, tracing setup

Synthesis: The BFF Architecture Decision Framework

After six phases of research, the BFF pattern resolves to a clear decision framework:

QuestionIf YesIf No
Does the frontend need data aggregated from 3+ services per page?Strong BFF caseConsider simpler proxy or direct client calls
Is there a team dedicated to the frontend?BFF enables team autonomyShared backend may reduce overhead
Is the frontend a SPA (Angular / React)?BFF-for-SPA security requiredEvaluate other auth patterns
Does the app need real-time features (live updates)?SSE via BFF is naturalStandard REST BFF sufficient
Are there multiple distinct client types (web + mobile + partner)?One BFF per client typeSingle shared BFF

The 5 Most Important Insights

  1. BFF is an organisational pattern first, a technical pattern second — team autonomy and the ability for a frontend team to evolve its API contract without coordinating with every downstream team is the core value proposition. The technical implementation follows from that.

  2. BFF and API Gateway are not competitors — they solve different problems at different layers. The API Gateway is infrastructure (TLS, DDoS, rate limiting, API key auth). The BFF is an application-level aggregation and adaptation layer. Both can and often should coexist (Topology 3).

  3. For SPAs: httpOnly session cookie in BFF is non-negotiable — storing OAuth2 tokens in localStorage or sessionStorage is a critical XSS vulnerability. The BFF session pattern (httpOnly cookie → BFF holds token → BFF relays token to downstream) is the only correct security model for an Angular SPA. See OAuth2-BFF-Pattern and BFF-For-SPA.

  4. Reactive (WebFlux) is not optional for BFF — a BFF that fans out to 3–5 services per request must be non-blocking. A blocking servlet stack holds a thread per in-flight downstream call; under load, fan-out amplifies thread exhaustion. Project-Reactor Mono.zip + WebFlux is architecturally required for aggregation at scale.

  5. Consumer-driven contracts close the stub-drift loop — WireMock stubs that are not verified against the real service will drift and mask integration bugs. Consumer-Driven-Contract-Testing with Spring Cloud Contract: the BFF team writes the contracts, downstream teams run the verifier. When a downstream service breaks the contract, the build fails before the BFF is deployed.

The 3 Most Common Mistakes

  1. Turning BFF into a Smart Gateway — avoid putting business logic that belongs in domain services into the BFF. The BFF aggregates and transforms; it does not enforce business rules, calculate prices, or manage inventory. When the BFF starts containing domain logic, it becomes a distributed monolith.

  2. Disabling CSRF on a browser-facing BFF — a BFF using session cookies for authentication is subject to Cross-Site Request Forgery. CSRF protection must stay enabled. The Spring Security CsrfWebFilter is disabled by many developers because it complicates initial setup. Re-enabling it is non-negotiable. See P4-BFF-Security.

  3. Choosing the servlet stack (Spring MVC) for a BFF — Spring MVC can technically build a BFF, but each downstream call blocks a thread from the servlet pool. With 3 downstream calls per request, the effective throughput is cut to one-third. Spring WebFlux with Mono.zip handles all three calls on a single event loop thread. The Reactive-Programming stack is architecturally required.


Open Questions for Future Research

  • gRPC streaming from BFF — can the BFF expose Flux-based SSE to Angular while internally consuming gRPC server-streaming from downstream services? What is the mapping between gRPC flow control and reactive back-pressure?
  • GraphQL Federation as BFF alternative — a federated GraphQL gateway (Apollo Federation, Netflix DGS) provides some of the same aggregation benefits as a BFF. When is GraphQL Federation preferable? When does it add complexity without benefit?
  • BFF in Kubernetes with service mesh — with Istio or Linkerd providing mTLS, circuit breaking, and observability at the mesh level, what does the BFF still own? Which responsibilities overlap? Which remain BFF-specific?
  • BFF cold start in GraalVM native — Spring Boot 3 supports GraalVM native images. What is the trade-off for BFF (instant startup vs longer build, missing reflection-heavy libraries)?