DEV Community

Cover image for Our BFF Was Working. That Was the Problem.
Gregory Kulp
Gregory Kulp

Posted on

Our BFF Was Working. That Was the Problem.

Backend-for-Frontend (BFF) layers solve a real problem.

As systems grow, frontends often need data from multiple APIs, services, runtimes, and data stores. Without some kind of aggregation layer, the browser ends up coordinating requests, understanding backend topology, and stitching together responses.

That was exactly the problem we were trying to solve, right out the gate.

Our frontend wasn't talking to a single backend application. Different capabilities lived in different domains (We are DDD practitioners). Some experiences were powered by query-oriented read models. Others depended on operational runtimes responsible for workflow execution. Identity, billing, reporting, onboarding, and platform configuration all had their own responsibilities.

The frontend didn't need to understand any of that. So we introduced a Backend-for-Frontend layer. Very simple and straightforward, as it was early.

Initially, it worked exactly as intended.

When Should You Use a BFF?

Before I get into the mistake or turnpoint, it's worth answering:

When should you actually introduce a BFF?

A BFF is useful when the frontend needs a stable, experience-oriented API but the backend is intentionally organized around domain ownership. That was our use case at least.

That usually happens when:

  • the frontend needs data from multiple backend domains
  • backend APIs are optimized for domain ownership rather than screen rendering
  • multiple runtimes or services exist behind the scenes
  • different clients need different API shapes
  • authentication, tenancy, or request context must be normalized
  • the frontend should not understand internal service topology

In those situations, a BFF can be a killer boundary.

But a BFF should not be introduced simply because the frontend needs "somewhere to put logic." We had started to creep into that no-go zone and it was showing.

A useful rule of thumb is:

If the code is adapting data for the frontend, it probably belongs in the BFF.

If the code is deciding what the business means, it probably belongs in a domain.

The BFF Became the Easiest Place to Put Things

Every engineering team eventually discovers a dangerous truth:

The easiest place to put a new rule is often the wrong place. (For me it is WAY too easy to place it exactly where the thought came to mind initially... whoops)

Our BFF already had request context, knew who the user was, knew which account was selected, and already called multiple backend services.

So whenever a new feature needed a decision, the BFF looked like a nice drop zone without much after thought.

Need to determine whether reporting should be visible?
Need to determine whether onboarding is complete?
Need to determine whether billing configuration is ready?
Need to determine whether a capability should be enabled?

Put it in the BFF.

Individually, none of those decisions felt problematic. Collectively, they were turning our BFF slowly into a domain layer itself.

The Warning Sign Wasn't Performance

Most architecture problems announce themselves through performance issues in my experience.

This one didn't.

The warning sign was ownership.

As the application evolved, we found ourselves maintaining logic that combined information from multiple parts of the platform:

const canAccess =
  platformReady &&
  billingReady &&
  userHasAccess;
Enter fullscreen mode Exit fullscreen mode

At first glance, this feels completely reasonable.

The problem is not the conditional itself.

The problem was to answer a question like this, the BFF now needs to understand:

  • readiness state
  • billing state
  • account state
  • user permissions
  • capability rules

The BFF isn't simply coordinating requests anymore. It is interpreting business meaning and forming logic.

And once that starts happening, every new feature pushes more domain knowledge into the same layer. As we are DDD practitioners, that literally defeats the purpose of bounded ownership.

Information Architecture Exposed the Problem

Ironically, the frontend helped us discover the issue.

As the application grew, the navigation became more explicit:

Settings
├── Users
├── Organization
├── Billing
├── Reporting
├── Integrations
└── Platform Setup
Enter fullscreen mode Exit fullscreen mode

At first glance, looks like a navigation tree.

Architecturally, though, every section represents a domain boundary here.

Billing is not a reporting concern. Reporting is not an identity concern. Identity is not an integration concern.

The navigation structure was forcing us to answer an important question:

Who owns the rules behind each capability?

The BFF was coordinating concerns that belonged to multiple domains.

Coordination was fine. Ownership was not and a real concern.

Read Models Made the Problem More Tempting

We rely heavily on query-oriented read models.

Read models are fantastic for frontend experiences because they provide optimized views of data without exposing the complexity of operational systems.

The challenge is that they also make it very easy to centralize decision-making.

When a BFF has access to:

  • query results
  • account state
  • identity information
  • configuration status
  • billing information

all within a single request, it becomes tempting to derive business meaning right there. Over time, the aggregation layer becomes the place where business decisions are made.

That was never a BFFs job.

The Chokepoint Was Real

A BFF is inherently a chokepoint. Every significant frontend interaction flows through it.

The real question isn't whether the BFF becomes a chokepoint. The question is what kind of chokepoint it becomes.

There's a huge difference between:

  • a composition chokepoint

and

  • a policy chokepoint

A composition chokepoint assembles information.

A policy chokepoint owns decisions.

Only one of those scales. What does it start to sound like from this mental model?

The Refactor

The solution was narrowing the BFF responsibilities.

During the refactor, we identified several frontend-facing handlers that had accumulated capability evaluation, readiness determination, and cross-domain orchestration responsibilities. Those responsibilities were progressively moved back into domain-owned services while the BFF retained responsibility for composition and response shaping.

Instead of evaluating business rules directly inside frontend-facing handlers, the BFF began asking domain-owned services for answers.

Before, handlers would gather state from multiple places and infer meaning.

const canAccess =
  platformReady &&
  billingReady &&
  userHasAccess;
Enter fullscreen mode Exit fullscreen mode

After the refactor, the BFF became much less authoritative.

Conceptually, the interaction shifted toward something like:

const accessState = await accessService.getAccessState();

return {
  canAccess: accessState.canAccess,
  reason: accessState.reason
};
Enter fullscreen mode Exit fullscreen mode

The BFF still coordinated the experience. The domains owned the decisions.

That shift reduced duplicated logic, clarified ownership boundaries, and made future changes significantly easier to reason about.

What We Learned

Architectural gravity. Every successful BFF accumulates pressure. It sits in the middle of requests, has access to context, and sees information from multiple domains.

It's easy to put decisions there. Resisting that pressure requires deliberate discipline.

Today, we treat our BFF as an adapter and coordinator.

It can:

  • aggregate
  • compose
  • translate
  • optimize frontend interactions
  • hide backend topology

But it should not:

  • own business policy
  • define capabilities
  • determine lifecycle state
  • duplicate domain logic
  • become the authoritative source of business rules

Those responsibilities belong to the domains themselves.

Because once a BFF starts owning business decisions, it stops being a frontend adapter.

It becomes a second application.

And that's a much harder problem to unwind later.

Quick Glossary

BFF (Backend-for-Frontend): A backend layer designed specifically to support a frontend experience. It typically aggregates data, shapes responses, and hides backend complexity from the UI.

Domain: A business capability that owns its own rules, data, and decisions.

Read Model: A query-optimized view of data designed for retrieval and reporting rather than transactional updates.

Runtime: A backend service or execution environment responsible for operational behavior.

Composition: Combining information from multiple sources into a frontend-friendly response.

Ownership: Responsibility for making and enforcing business decisions.

Questions for You

Have you seen a BFF start accumulating business rules?

How do you decide whether a piece of logic belongs in a domain service versus a frontend-facing layer?

I'd love to hear where you've drawn that boundary in your own systems.

Top comments (0)