Skip to main content

BFF Platform Recipe (Modular)

This recipe shows how to use PearDrop as a Backend for Frontend (BFF) platform with separate modules for each concern.

The goal is to avoid a single large BFF module and instead split the platform into focused modules that can evolve independently.

When to Use This Pattern

Use this recipe when your frontend needs:

  • UI-specific API orchestration across multiple downstream services
  • Separate policies per capability (for example, checkout vs profile)
  • Independent deployment and ownership boundaries per domain area
  • Clear CQRS boundaries and testable module-level behavior

A practical starting point is one module per frontend capability:

  • Bff.Orders - order timeline, status summary, order actions
  • Bff.Catalog - listing pages, faceted filters, product detail composition
  • Bff.Account - profile, preferences, security settings
  • Bff.Notifications - notification center, unread counts, delivery preferences
  • Bff.Files - attachment metadata, upload orchestration, download policies

Keep modules centered around frontend use cases, not downstream service names.

Step 1: Create Modules

From your solution root:

peardrop new module Bff.Orders
peardrop new module Bff.Catalog
peardrop new module Bff.Account
peardrop new module Bff.Notifications
peardrop new module Bff.Files

Each module will follow the standard PearDrop structure (source, tests, client contracts, and Radzen UI components).

Step 2: Add One Aggregate Per Capability Slice

Add aggregates that represent each module's core BFF behavior:

peardrop add aggregate OrderView --module Bff.Orders --properties "OrderId:Guid,CustomerId:Guid,Status:string"
peardrop add aggregate CatalogView --module Bff.Catalog --properties "ItemId:Guid,Name:string,Category:string"
peardrop add aggregate AccountView --module Bff.Account --properties "UserId:Guid,DisplayName:string,Email:string"

In BFF modules, aggregates often represent orchestration state and policy, not full source-of-truth domains.

Step 3: Add Commands for UI Actions

Commands model user intent from the frontend:

peardrop add command RefreshOrderSummary --module Bff.Orders --aggregate OrderView --properties "OrderId:Guid"
peardrop add command UpdateProfilePreferences --module Bff.Account --aggregate AccountView --properties "UserId:Guid,Theme:string,Language:string"

Use command handlers to coordinate data retrieval, policy checks, and write updates.

Do not place cross-module query handlers in these modules.

Step 4: Add Queries for View Models

Use a single query pattern: composition queries live in the app/BFF composition layer.

Implement dashboard/screen queries in the app/BFF composition project and read through module read model interfaces.

Modules own commands and projections; the app/BFF layer owns cross-module query composition.

internal sealed class GetDashboardQueryHandler : IQueryProcessor<GetDashboardQuery, GetDashboardQueryResult>
{
private readonly IOrdersReadModels ordersReadModels;
private readonly INotificationsReadModels notificationsReadModels;

public GetDashboardQueryHandler(
IOrdersReadModels ordersReadModels,
INotificationsReadModels notificationsReadModels)
{
this.ordersReadModels = ordersReadModels;
this.notificationsReadModels = notificationsReadModels;
}

public async Task<QueryResult<GetDashboardQueryResult>> Handle(
GetDashboardQuery request,
CancellationToken cancellationToken = default)
{
var openOrders = await this.ordersReadModels.OrderSummaries
.Where(x => x.UserId == request.UserId && !x.IsCompleted)
.CountAsync(cancellationToken);

var unreadNotifications = await this.notificationsReadModels.Notifications
.Where(x => x.UserId == request.UserId && !x.IsRead)
.CountAsync(cancellationToken);

return QueryResult<GetDashboardQueryResult>.Succeeded(
new GetDashboardQueryResult(openOrders, unreadNotifications));
}
}

Prefer query DTOs optimized for screens over reusing backend domain objects. Never read module DbContext types directly from the composition layer.

Step 5: Add Integration Events Between BFF Modules

Use integration events for eventual consistency across modules:

peardrop add integration-event OrderSummaryRefreshed --aggregate OrderView --properties OrderId:Guid,RefreshedAt:DateTime
peardrop add integration-event PreferencesUpdated --aggregate AccountView --properties UserId:Guid,UpdatedAt:DateTime

Use domain events only when coordinating aggregates inside the same bounded context/transaction.

Step 6: Register Modules in App Composition

In your app host and client registration, add each BFF module explicitly so services and CQRS contracts are wired.

Follow the existing module registration pattern used across your project and keep registration grouped by capability.

Example Target Architecture

MyApp.App (BFF host)
|- Bff.Orders
|- Bff.Catalog
|- Bff.Account
|- Bff.Notifications
|- Bff.Files

Each module exposes focused commands and projections and can be tested independently.

Guardrails

  • Keep each module frontend-capability focused.
  • Do not mirror downstream microservices one-to-one unless that matches UI behavior.
  • Keep read models projection-based (IProjection) and query-optimized.
  • Keep module boundaries strict: no module-to-module dependency for query composition.
  • Put cross-module query composition in app/BFF layer using module IReadModel interfaces.
  • Use one transaction per command handler (SaveEntitiesAsync once).
  • Add module-level tests for policies and response shaping.

Next Steps

  1. Start with 2 modules (Bff.Account, Bff.Orders) and validate boundaries.
  2. Add cross-module integration events only where eventual consistency is acceptable.
  3. Add dashboard queries that return screen-ready DTOs and avoid client-side over-fetching.