Skip to main content

Command Palette

Search for a command to run...

Designing the BFF Contract: Request Aggregation & Client-Specific Shaping

API design principles, response shaping, versioning strategy, and what belongs in the BFF vs upstream services.

Published
β€’15 min read
Designing the BFF Contract: Request Aggregation & Client-Specific Shaping

In the previous article, we established when a BFF earns its overhead. This article assumes you have made that decision and are now facing the harder question: how do you actually design the thing well?

The BFF contract β€” the API surface your application depends on β€” is the most consequential design decision in this architecture. Get it right and you have a clean, stable interface that lets the frontend move independently of upstream service changes. Get it wrong and you have a new layer that amplifies the coupling problems you were trying to solve, with the added pleasure of owning the infrastructure.

This article covers the four design concerns that determine which outcome you get: what aggregation actually means in practice, how to shape responses for the client rather than the domain, how to version the contract without recreating a backend team's problems, and where the boundary between BFF logic and upstream logic must be drawn and defended.

The examples and architecture decisions throughout are drawn from a production implementation built for a Norwegian enterprise in the education sector. Where the original system cannot be described in full, concepts have been generalised to meet NDA obligations β€” but the engineering trade-offs, the failure modes, and the decisions that shaped the final design are real. We use VueJS and .NET Core as the example frameworks since it's based on the real production project.


Aggregation is not just parallel fetching

The word "aggregation" in BFF descriptions usually conjures an image of parallel HTTP calls fanning out to multiple services and the results being merged before the response is returned. That is part of it. But treating aggregation as a mechanical fan-out pattern misses what makes it valuable β€” and what makes it dangerous.

Consider a dashboard screen in an education platform. The frontend needs: the authenticated user's profile and role, the list of courses they are enrolled in, the next three upcoming sessions, and a count of unread notifications. In a naΓ―ve aggregation implementation, the BFF fires four requests in parallel, waits for all four, and concatenates the results into a response object.

This works. It is also fragile in a way that becomes apparent under load.

A more considered design asks: what are the actual dependency relationships between these data sets? The course list depends on the user's organisation ID, which comes from the user profile. The session list is scoped to the course IDs returned by the course list. The notification count is independent. This is not a fan-out β€” it is a directed graph with two sequential phases:

Phase 1 (parallel):
  β†’ GET /identity/users/{id}         β†’ profile + orgId
  β†’ GET /notifications/unread/count  β†’ notificationCount

Phase 2 (parallel, depends on Phase 1):
  β†’ GET /courses?orgId={orgId}       β†’ courseIds
  
Phase 3 (depends on Phase 2):
  β†’ GET /sessions?courseIds={...}&limit=3 β†’ upcomingSessions

Treating this as a flat parallel fan-out would require either fetching all courses before knowing the org ID (not possible), or making the frontend responsible for the sequencing (which defeats the point). The BFF owns this orchestration β€” it understands the dependency graph and executes it efficiently, shielding the frontend from the fact that it exists.

This has a practical implication for implementation. In .NET Core, Task.WhenAll handles the parallel phases, and the sequential phases chain naturally:

// Phase 1: independent fetches in parallel
var (profile, notificationCount) = await (
    _userService.GetProfileAsync(userId),
    _notificationService.GetUnreadCountAsync(userId)
).WhenBoth();

// Phase 2: depends on profile
var courses = await _courseService.GetByOrgAsync(profile.OrgId);

// Phase 3: depends on courses
var courseIds = courses.Select(c => c.Id).ToArray();
var upcomingSessions = await _sessionService.GetUpcomingAsync(courseIds, limit: 3);

The aggregation logic lives in the BFF. The frontend makes one request and receives one coherent response. The dependency graph is invisible to the client.

When aggregation goes wrong

Two aggregation anti-patterns appear consistently in production BFFs.

The "God endpoint." A single endpoint that returns everything the application might ever need, used on multiple screens because it is convenient. The God endpoint inflates payload sizes, makes partial failure handling impossible, and couples unrelated features together. If a notification service outage should not take down the course list, they must not share an endpoint. Design endpoints around screen-level data contracts, not around service boundaries.

Cascading failure without isolation. If Phase 2 in the example above fails because the course service is down, a poorly designed BFF either returns a 500 (crashing the whole screen) or silently swallows the error (showing stale or empty data with no indication of the problem). The correct design is explicit partial failure handling: return what succeeded, mark what failed, and let the frontend decide how to render a degraded state.

public record DashboardResponse(
    UserProfile Profile,
    IReadOnlyList<Course> Courses,
    IReadOnlyList<Session> UpcomingSessions,
    int NotificationCount,
    IReadOnlyList<string> PartialFailures  // e.g. ["courses", "sessions"]
);

This is not error handling for its own sake. It is a deliberate contract: the BFF guarantees it will always return a structurally valid response, and the Vue component decides what to render when parts of it are empty.


Response shaping: the frontend owns the shape

The single most impactful thing a BFF does is decouple the response shape from the domain model. This is also where engineers most frequently make the wrong call.

The wrong call is to return the upstream service's response more or less as-is, perhaps with light field selection. This is understandable β€” it is the path of least resistance, it keeps the BFF thin, and it avoids the question of what the "right" shape is. But it is not response shaping. It is proxying, and a proxy does not justify the overhead of a dedicated service.

Response shaping means designing the response around the component tree that will consume it. The question is not "what did the upstream service return?" but "what does the Vue component need, and in what shape does it need it?"

Flatten, don't nest

Domain models are frequently deeply nested because they reflect real-world entity relationships. Frontend components rarely need that nesting β€” they need flat data they can bind to template properties without traversal logic in the component.

An upstream Course entity might look like this:

{
  "id": "c-1",
  "metadata": {
    "title": "Mathematics β€” Year 9",
    "code": "MATH-9",
    "curriculum": {
      "framework": "NOR-K20",
      "subject": "Mathematics",
      "level": { "grade": 9, "label": "Year 9" }
    }
  },
  "enrollment": {
    "capacity": 30,
    "enrolled": 24,
    "waitlist": 2
  },
  "status": { "code": "ACTIVE", "since": "2024-08-15T00:00:00Z" }
}

A course card component in Vue needs: a title, a code, the enrollment fraction, and a status label. The BFF shapes this into:

{
  "id": "c-1",
  "title": "Mathematics β€” Year 9",
  "code": "MATH-9",
  "enrollmentLabel": "24 / 30",
  "enrollmentPercent": 80,
  "status": "Active",
  "activeFrom": "2024-08-15"
}

The component receives exactly what it renders. No traversal logic, no null-guard chains, no formatting in the template. The formatting decisions β€” how to display the enrollment fraction, how to present the date β€” are made once, in the BFF, and are consistent across every component that uses this data.

Computed fields belong in the BFF

The enrollment label and enrollment percentage in the example above are computed fields β€” they do not exist in the upstream response and must be derived. They belong in the BFF, not in the component.

The underlying principle: any derivation that is deterministic, presentation-oriented, and would be repeated across multiple components is a BFF concern. This includes percentage calculations, label generation, date formatting, status code translation, and currency formatting with locale awareness.

What does not belong in the BFF: business logic that should live upstream, validation that changes application state, or calculations that depend on runtime user input. The BFF is a rendering layer for data that is already computed β€” it is not a domain service.

Naming conventions are a contract decision

The upstream service uses enrollmentCapacity and enrolledCount. The BFF exposes enrollmentLabel and enrollmentPercent. The Vue component uses enrollmentLabel and enrollmentPercent.

This means your BFF's property names are part of its contract with the frontend. Changing enrollmentLabel to enrollmentText is a breaking change, even if the value is identical. Name properties for their rendering purpose, not their origin, and treat them with the same stability you would expect from any API you consume.

In practice, this argues for establishing naming conventions before writing the first endpoint and enforcing them through code review. Consistency in the contract reduces cognitive load on both sides of it.


Versioning: the problem you are creating

A BFF contract is an API. APIs have versions, and versions accumulate. This is the most underspecified aspect of BFF design in most articles, because it is uncomfortable to discuss β€” you are creating a versioning problem in order to solve a coupling problem, and the question is whether the trade is favourable.

There are three approaches, each with a clear use case.

URL versioning for major breaking changes

GET /api/v1/dashboard
GET /api/v2/dashboard

URL versioning is the most visible, most explicit, and most operationally expensive approach. It is appropriate when a response shape change is so significant that the old and new shapes cannot coexist under one contract β€” for example, when a screen is redesigned and the data model changes completely.

The operational cost is that both versions must be maintained simultaneously during the migration period, and the migration period in practice extends far longer than anticipated. Budget for it.

Header versioning for incremental evolution

GET /api/dashboard
Accept: application/vnd.bff.dashboard+json; version=2

Header versioning keeps URLs stable and moves the version negotiation into headers. It is cleaner for incremental evolution β€” adding fields, changing response structure within a stable conceptual model β€” and it does not require duplicating route definitions. The cost is that it is less discoverable and requires slightly more discipline in the client to set the header correctly.

Additive-only evolution: the best versioning strategy

The most effective versioning strategy is one you do not need. If the BFF contract evolves additively β€” new fields are added, existing fields are never removed, semantics never change β€” versioning becomes a maintenance concern rather than a migration project.

This is achievable in practice with two rules:

Never remove a field. If a field is no longer needed by the frontend, mark it as deprecated in internal documentation and stop populating it (return null), but leave it in the response schema. Removal is a v2 concern.

Never change the semantics of an existing field. If status currently returns "Active" and you need it to return a structured object, that is a new field β€” statusDetail β€” not a change to status. The original field continues as-is.

Additive-only evolution is not infinitely sustainable β€” response shapes accumulate cruft over time β€” but it defers versioning costs to the natural cadence of major releases rather than introducing them with every sprint.


The boundary: what belongs in the BFF

This is the question that determines whether your BFF stays healthy or becomes the new monolith. The answer is a firm principle rather than a checklist: the BFF transforms and aggregates; it does not originate.

What this means in concrete terms:

BFF owns

  • Response aggregation: combining data from multiple upstream services into a single response shaped for the Vue component

  • Field selection and projection: choosing which upstream fields to include and which to omit

  • Presentation-layer computation: formatting, label generation, percentage derivation, date localisation

  • Authentication enforcement: validating the session, exchanging tokens, enforcing access before any upstream call is made

  • Caching of presentation-layer data: caching aggregated, shaped responses where staleness is acceptable

  • Error translation: converting upstream error codes into client-meaningful error shapes

Upstream services own

  • Business rules: what constitutes a valid enrollment, whether a course is at capacity, eligibility logic

  • Domain validation: ensuring data integrity constraints are enforced at the source of truth

  • State mutation: creating, updating, and deleting domain entities

  • Cross-entity consistency: ensuring that a session cannot reference a deleted course

The grey area: where teams disagree

The genuinely contested cases are usually one of two types:

Computed fields that require domain knowledge. Is isEnrollmentOpen β€” a boolean derived from enrollment capacity and a business rule about the waitlist threshold β€” a presentation concern or a domain concern? The answer: if the rule could change (and business rules do change), it belongs upstream. The BFF should receive a pre-computed enrollmentStatus from the course service, not derive it locally. A BFF that embeds business rules is a BFF that becomes inconsistent with the backend when those rules change.

Input validation on write endpoints. The BFF handles write operations too β€” course enrollments, session registrations. Validation that is purely structural (is this field present, is this ID a valid UUID format) is reasonable in the BFF as a fast-fail before the upstream call. Validation that is semantic (is this user eligible to enroll in this course) must happen upstream. Drawing the line here prevents a situation where the BFF and the upstream service have conflicting validation logic.

A practical heuristic: if a product manager could change the rule in a sprint, it belongs upstream. If it is structural and invariant (a user ID is always a UUID), it is acceptable in the BFF.


Designing for the Vue component tree

The most useful frame for BFF endpoint design is not "what data does this screen need?" but "what does the Vue component tree look like, and what does each component expect from its props?".

A screen is composed of components. Each component has a data contract β€” its props interface. The BFF response should map cleanly onto that props interface, ideally such that a single destructure in the composable produces the data each component needs without further transformation.

In practice this means walking the component tree before designing the endpoint:

DashboardView
β”œβ”€β”€ UserProfileCard        β†’ { displayName, role, avatarUrl }
β”œβ”€β”€ CourseListPanel
β”‚   └── CourseCard (Γ—n)   β†’ { id, title, code, enrollmentLabel, status }
β”œβ”€β”€ UpcomingSessionsList
β”‚   └── SessionItem (Γ—n)  β†’ { id, title, startsAt, courseTitle, locationLabel }
└── NotificationBadge      β†’ { count }

The BFF endpoint for this screen returns an object that mirrors this structure:

{
  "user": {
    "displayName": "Ingrid Solberg",
    "role": "Teacher",
    "avatarUrl": "/avatars/i-solberg.jpg"
  },
  "courses": [
    { "id": "c-1", "title": "Mathematics β€” Year 9", "code": "MATH-9",
      "enrollmentLabel": "24 / 30", "status": "Active" }
  ],
  "upcomingSessions": [
    { "id": "s-1", "title": "Integration review", "startsAt": "2025-04-08T09:00:00",
      "courseTitle": "Mathematics β€” Year 9", "locationLabel": "Room 204" }
  ],
  "notifications": { "count": 3 }
}

The Vue composable for this screen receives this response and distributes it to components without further transformation:

const { data } = useDashboard()

// Each ref maps directly to a component's props
const user = computed(() => data.value?.user)
const courses = computed(() => data.value?.courses ?? [])
const upcomingSessions = computed(() => data.value?.upcomingSessions ?? [])
const notificationCount = computed(() => data.value?.notifications.count ?? 0)

No adapter logic. No field mapping. The BFF contract and the component props interface are aligned by design.


A note on OpenAPI and type safety

A BFF contract without a schema is a verbal agreement. It will drift. The response shape the BFF returns today will not match what the Vue components expect in three months, and you will discover the mismatch at runtime.

The mitigation is simple and should be non-negotiable: define the BFF contract with OpenAPI, generate TypeScript types from it, and import those types into the Vue application. Changes to the BFF response shape become compile-time errors in the frontend before they reach the browser.

In .NET Core, Swashbuckle generates an OpenAPI spec from controller or Minimal API route definitions automatically. In Vue 3, openapi-typescript or @hey-api/openapi-ts generates typed interfaces from that spec. The generation step belongs in the build pipeline, not as a manual step.

This is not optional complexity. A BFF that lacks type-safe contracts between its two surfaces β€” the upstream services and the Vue client β€” is a BFF that will break silently in production.


What comes next

This article has covered the design principles that govern a well-structured BFF contract. The next article steps back before the implementation begins to address the comparative question that should be answered before writing any code: how does BFF compare to API Gateway and GraphQL as architectural options, where does each pattern win, and where do they coexist?


☰ Series navigation

The Frontend's Contract: Building Backends for Frontends

Part 1 of 3

A practitioner's guide to the BFF pattern β€” from architectural rationale to production-grade implementation. Covers when BFF earns its complexity, how to design a clean client-specific API layer, and what it takes to run it reliably on Azure. Stack: Vue 3 Β· .NET Core 8+ Β· Azure.

Up next

What Is BFF β€” and When Is It Actually Worth It?

The problem it solves, the cost it introduces, and the honest answer on when not to use it.