DEV Community

KevinTen
KevinTen

Posted on

Building Capa-BFF: The Three Design Principles Behind This Zero-Cost BFF Solution

Building Capa-BFF: The Three Design Principles Behind This Zero-Cost BFF Solution

Honestly, I didn't expect to be writing this post. When I first started working with Capa-BFF three months ago, I thought it was just another hackathon project that would die after the competition ended. But here we are — people are actually using it, asking questions about how it works, and genuinely interested in the design decisions behind it.

So here's the thing: after using it in production for a few months and reading through the source code multiple times, I've come to appreciate the simplicity of its design. It's not the most complex BFF solution out there, but that's exactly the point. Sometimes the best solutions are the ones that get out of your way and let you get stuff done.

Let me walk you through the three core design principles that make Capa-BFF work, and why I think they're actually pretty brilliant.

What Problem Are We Even Solving Here?

Before we jump into the code, let me remind you why BFFs exist in the first place. If you're working on a project with multiple clients (web, mobile, different devices), you've probably run into this issue:

  • The backend writes APIs for one client, and then another client needs different data, so you have to change the API again
  • Frontend developers waste time waiting for backend changes just to get the data shaped differently
  • Solutions like GraphQL are powerful but require a whole separate layer of development that you might not need

I learned this the hard way on a previous project. We spent two weeks setting up GraphQL, writing schemas, integrating everything — and then the project scope changed, and half of that work was useless. That's when I started thinking: do we really need all that complexity just to let frontend get the data it needs?

Enter Capa-BFF. The promise is "zero-cost BFF" — you drop it into your existing SpringBoot app, write a JSON config, and you're done. No extra deployment, no new services to maintain. Sounds too good to be true, right? Let's look under the hood.

Principle 1: Dynamic Invocation with the Triple Pattern

The first core principle is dynamic invocation using the (appId, methodName, data) triple.

Here's the thing: any service call, no matter what framework you're using (Dubbo, SpringCloud, Istio, whatever), eventually boils down to three things:

  1. Which application do you want to call? (appId)
  2. Which method on that application? (methodName)
  3. What data do you send it? (data)

That's it. Capa-BFF takes this observation and runs with it. Instead of trying to create some fancy new abstraction, they just embrace this reality. Any service call can be represented as a triple.

Let me show you what that looks like in practice with a real example. Suppose you're building a content platform where you need to get KOL details and then get their application information. Here's how you'd write that in Capa-BFF's DSL:

{
  "20725.gscontentcenterservice": [
    "getkoldetail": {
      "request": {
        "kolNo": "1"
      },
      "response": {
        "kolOrderDetail.id": "kol.id",
        "kolOrderDetail.applyId": "kol.applyId"
      }
    },
    "getkolapplydetail": {
      "request": {
        "userId": "${kol.applyId}"
      },
      "response": {
        "user.userId": "user.id"
      }
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Wait, that's just JSON. There's no special schema language to learn, no complicated CLI to generate types. It's just JSON. That's the beauty of it.

The Capa-BFF parser takes this JSON and converts each method call into a triple. That's it. No magic, no complicated reflection tricks (well, okay, some reflection, but it's hidden from you).

I remember when I first saw this, I thought "that's too simple — how can this work?" But then I realized: simplicity is the point. We've been conditioned to think that solving hard problems requires complex solutions, but sometimes the opposite is true.

Principle 2: Alias Space Mapping for Data Shaping

The second principle is alias space mapping. This is where things get interesting.

When you call multiple services, you get multiple responses back, each in their own "namespace." But the frontend doesn't want multiple separate responses — it wants one coherent object with all the data it needs, shaped exactly how it needs it.

Also, when one service depends on data from another service, you need a way to reference that data. How do you do that without making a mess?

Capa-BFF's answer is alias mapping. Every response field gets mapped into a single shared alias space. When you need to reference data from a previous call, you just use the alias with ${alias.field} syntax.

Let me extend the previous example to show you what this looks like:

{
  "20725.gscontentcenterservice": [
    "getkoldetail": {
      "request": {
        "kolNo": "1"
      },
      "response": {
        "kolOrderDetail.id": "kol.id",
        "kolOrderDetail.applyId": "kol.applyId"
      }
    },
    "getkolapplydetail": {
      "request": {
        "userId": "${kol.applyId}"
      },
      "response": {
        "user.userId": "user.id"
      }
    }
  ],
  "24901.livebackendservice": [
    "getLiveInfo": {
      "request": {
        "liveId": "${live.id}",
        "userId": "${user.id}"
      },
      "response": {
        "article.info.id": "article.id"
      }
    }
  ],
  "11933.contentdeliveryservice": [
    "getarticleinfo": {
      "request": {
        "artilceid": "${article.id}"
      },
      "response": {
        "*"
      }
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

See how that works? When getkolapplydetail needs the applyId from the first call, it just references ${kol.applyId}. The kol alias was created in the first response mapping.

This approach solves two problems at once:

  1. Data aggregation: All the results get combined into one response object with your aliases
  2. Dependency handling: Dependencies are explicit and easy to follow

I've messed around with other BFF solutions where dependencies are implicit or handled through some complex context mechanism. I'll take this explicit approach every single time. It's easier to read, easier to debug, and when something breaks (which it always does eventually), you can actually figure out why.

Principle 3: ID-Based Function Enhancement

The third principle is ID-based function enhancement. Honestly, when I first read this, I thought it was just theoretical fluff. But after using it, I get why it's useful.

The idea is simple: anything can be an ID. You can add functions to enhance it. What counts as an ID?

  • A whole service method: appId#methodName
  • A specific field path in a response
  • An alias in the alias space

That's it. Because everything is identified by an ID, you can inject functions that modify the behavior or the data without changing the core architecture.

Want to add caching to a specific service call? Write a function and attach it to the service method ID. Want to transform a date field from ISO format to something your frontend expects? Write a function and attach it to the field ID.

Here's what that looks like in practice (simplified):

{
  "myapp.userservice": [
    "getUser": {
      "request": {
        "id": 123
      },
      "response": {
        "result.user.birthdate": "user.birthdate" @formatDate("YYYY-MM-DD")
      }
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

The @formatDate is a function enhancement attached to the user.birthdate field ID. Capa-BFF runs the function after getting the response and formats the date before returning it to the frontend.

I love this because it's open-ended but not overwhelming. The core stays simple, and you can add functions when you actually need them. You don't pay for what you don't use.

The Hidden Gem: Dependency Resolution with DAG

Okay, that's the three principles. But there's something I haven't mentioned yet that I think is really cool — how Capa-BFF handles dependencies automatically.

When you have multiple service calls where some depend on others, Capa-BFF builds a Directed Acyclic Graph (DAG) of your dependencies, checks for cycles, and then generates the optimal execution order using topological sorting. Services with no dependencies run in parallel. Services that depend on other services run after their dependencies are ready.

Let me show you what the dependency graph looks like for our example:

getkoldetail → getkolapplydetail → getLiveInfo → getarticleinfo
Enter fullscreen mode Exit fullscreen mode

Each step depends on the previous one, so they run sequentially. But if you had multiple independent services, they'd run in parallel automatically. No extra code from you — it just works.

And if you accidentally create a circular dependency? It detects it and throws a clear error immediately, instead of getting stuck in an infinite loop or failing in some weird, hard-to-debug way.

Here's a snippet of how they implement the cycle detection (from reading the source code):

public class GraphUtil {
    public void queryHasIllegalInvocation(List<DependOnFieldInfo> fieldInfoList) 
            throws IllegalInvocationRequestException {
        // Build adjacency matrix for the graph
        boolean[][] adjacencyMatrix = buildAdjacencyMatrix(fieldInfoList);

        // Check for cycles using DFS
        for (int i = 0; i < adjacencyMatrix.length; i++) {
            if (hasCycle(i, adjacencyMatrix, new boolean[adjacencyMatrix.length], 
                        new boolean[adjacencyMatrix.length])) {
                throw new IllegalInvocationRequestException(
                    "Cyclic dependency detected at node " + i);
            }
        }
    }

    private boolean hasCycle(int node, boolean[][] adjacency, 
                           boolean[] visited, boolean[] recursionStack) {
        if (recursionStack[node]) return true;
        if (visited[node]) return false;

        visited[node] = true;
        recursionStack[node] = true;

        for (int i = 0; i < adjacency.length; i++) {
            if (adjacency[node][i] && hasCycle(i, adjacency, visited, recursionStack)) {
                return true;
            }
        }

        recursionStack[node] = false;
        return false;
    }
}
Enter fullscreen mode Exit fullscreen mode

Nothing fancy here — just classic computer science applied correctly. I appreciate that. They didn't reinvent the wheel; they just used the right tool for the job.

Pros & Cons: Let's Be Honest

I know I've been positive so far, but this wouldn't be a real post if I didn't tell you the bad stuff too. I promised I'd keep it real, so here we go.

The Good (Pros)

Zero extra deployment: You just drop it into your existing SpringBoot application. No extra Kubernetes pods, no extra CI/CD steps, nothing. For small teams that don't want to operate another service, this is huge.

No learning curve if you know JSON: If you can write JSON, you can use Capa-BFF. I got my first configuration working in under 10 minutes. Compare that to GraphQL where you need to learn the schema language, set up a separate server, figure out all the tooling — that's hours or days.

Automatic dependency handling: I mentioned this earlier, but it bears repeating. You just write your calls with their dependencies, and Capa-BFF figures out the optimal order. Parallel execution where possible, sequential when necessary. It's transparent and just works.

It solves the actual problem: The problem it's solving is "we have a SpringBoot app, we need to let frontend aggregate data without changing backend code every time." It solves that problem perfectly and doesn't try to solve everything else. I respect that — so many projects overpromise and underdeliver by trying to be everything to everyone.

It's lightweight: The entire library is small enough that you can read the whole codebase in an afternoon. If something goes wrong, you can actually figure it out yourself instead of digging through layers of abstraction.

The Not-So-Good (Cons)

Currently Java/SpringBoot only: If you're not in the Java ecosystem, this isn't for you right now. The project mentions they might add other languages later, but it's not there yet.

Documentation is a bit sparse: It's a hackathon project turned open source, so the docs are what they are. You'll need to read the examples and maybe the source code if you want to do anything advanced. I'm not saying it's bad — just don't expect the level of documentation you get from projects backed by big companies.

No built-in authentication/authorization: You have to handle that yourself. The project doesn't magically propagate user credentials to downstream services. That's actually fine for many use cases — it's easier to handle it in your existing security filter — but it's something you need to be aware of.

Dynamic invocation means no compile-time checking: Since everything is JSON, you won't get compile-time errors if you misspell a service name or method. You'll find out at runtime. This is the tradeoff for dynamic invocation — there's no free lunch here. For small projects, I think it's worth it, but it's something to consider.

Not designed for extremely high throughput: It's fast enough for most use cases, but if you're handling thousands of requests per second, the dynamic reflection-based approach probably won't beat compiled code. That said, I've been running it in production with a few hundred RPS and haven't had any issues.

Who Should Actually Use This?

After using it for three months, here's my take:

Use Capa-BFF if:

  • You're working on a SpringBoot project
  • You need a simple BFF layer to let frontend aggregate data
  • You don't want to deploy and maintain another service
  • Your team is small and you value simplicity over completeness
  • You don't need all the bells and whistles of GraphQL

Don't use Capa-BFF if:

  • You're not using Java/SpringBoot (yet)
  • You need strong typing and compile-time checks
  • You already have GraphQL and your team is happy with it
  • You're building something at massive scale where every microsecond counts

Honestly, I think it fits perfectly for many teams I've worked with. Most teams aren't Google-scale. Most teams just want to ship features without dealing with extra infrastructure. That's where Capa-BFF shines.

My Personal Takeaway

Working with Capa-BFF reminded me of something I'd forgotten: good design is about removing complexity, not adding it.

We're in an era where every new project seems to require adding another layer, another service, another tool to your stack. Sometimes it feels like we're collecting tools more than we're solving problems.

Capa-BFF takes the opposite approach. It asks: what's the minimal thing we need to add to solve this specific problem? The answer is just a library you drop into your existing app. That's it.

I learned the hard way that complexity accumulates. Every extra service you add is extra operational load forever. Every extra abstraction you add is something new developers have to learn. Sometimes, the best architecture is the one you don't have to see.

Would I use it on my next project? Yeah, I probably would. For the use case it targets, it's better than the alternatives I've tried.

What's Your Take?

I'm always curious to hear about other people's experiences with BFF solutions. Have you tried Capa-BFF? Are you still dealing with the pain of constant backend API changes for different clients? Have you found a different solution that works better for your team?

I'd love to hear your thoughts in the comments below. Do you prefer the zero-cost approach like Capa-BFF, or do you think the complexity of GraphQL is worth it for the type safety? Let me know!


Capa-BFF is open source and available on GitHub. Go check it out if you're interested — and if you like it, give them a star!

Top comments (0)