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
Recommended BFF Module Boundaries
A practical starting point is one module per frontend capability:
Bff.Orders- order timeline, status summary, order actionsBff.Catalog- listing pages, faceted filters, product detail compositionBff.Account- profile, preferences, security settingsBff.Notifications- notification center, unread counts, delivery preferencesBff.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
IReadModelinterfaces. - Use one transaction per command handler (
SaveEntitiesAsynconce). - Add module-level tests for policies and response shaping.
Next Steps
- Start with 2 modules (
Bff.Account,Bff.Orders) and validate boundaries. - Add cross-module integration events only where eventual consistency is acceptable.
- Add dashboard queries that return screen-ready DTOs and avoid client-side over-fetching.