Skip to main content

BFF Layers

Let's explore how to layer (the L of MonoLISA) backends for frontends (BFFs).

Battle-tested layers for BFF applications:

  • API
  • Integration
  • Services

API layer

The API Layer provides the backend endpoints that the frontend communicates with.

It exposes HTTP endpoints the Data Access layer (of the frontend) calls.

Example

Here's an example of the API layer:

// libs/server/api/PizzaMenu/Program.cs
using Microsoft.AspNetCore.Mvc;
using MyCompany.Services.PizzaMenu;
using MyCompany.Services.PizzaMenu.Models;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.AddScoped<IPizzaService, PizzaService>();

var app = builder.Build();

if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}

app.UseHttpsRedirection();

app.MapGet("/api/pizzas", async (IPizzaService pizzaService) =>
{
var pizzas = await pizzaService.GetPizzasAsync();
return Results.Ok(pizzas);
})
.WithName("GetPizzas")
.WithOpenApi();

app.MapGet("/api/pizzas/{id}", async (string id, IPizzaService pizzaService) =>
{
var pizza = await pizzaService.GetPizzaByIdAsync(id);
return pizza is not null ? Results.Ok(pizza) : Results.NotFound();
})
.WithName("GetPizzaById")
.WithOpenApi();

app.MapPatch("/api/pizzas/{id}", async (
string id,
[FromBody] UpdatePizzaRequest request,
IPizzaService pizzaService) =>
{
var pizza = await pizzaService.UpdatePizzaAsync(id, request);
return pizza is not null ? Results.Ok(pizza) : Results.NotFound();
})
.WithName("UpdatePizza")
.WithOpenApi();

app.Run();

Notice how the API layer:

  • Exposes REST endpoints that match the Data Access layer's expectations.
  • Delegates behaviour to the Services layer.

Constraints

The API layer:

  • May only have endpoints and controllers.
  • May only depend on the Services layer.

Integration layer

The integration layer is used to integrate with downstream services. It contains services or functions that call downstream APIs.

Example

Here's an example of the Integration layer calling a downstream API (called 'DownstreamPizzas'):

// libs/server/integration/DownstreamPizzas/IPizzaIntegrationService.cs
namespace MyCompany.Integration.DownstreamPizzas;

using MyCompany.Integration.DownstreamPizzas.Models;

public interface IPizzaIntegrationService
{
Task<IEnumerable<DownstreamPizza>> GetPizzasAsync();
Task<DownstreamPizza?> GetPizzaByIdAsync(string id);
Task<DownstreamPizza?> UpdatePizzaAsync(string id, DownstreamUpdatePizzaRequest request);
}

// libs/server/integration/DownstreamPizzas/PizzaIntegrationService.cs
namespace MyCompany.Integration.DownstreamPizzas;

using System.Text;
using System.Text.Json;
using MyCompany.Integration.DownstreamPizzas.Models;

public class PizzaIntegrationService : IPizzaIntegrationService
{
private readonly HttpClient _httpClient;
private readonly ILogger<PizzaIntegrationService> _logger;

public PizzaIntegrationService(
HttpClient httpClient,
ILogger<PizzaIntegrationService> logger)
{
_httpClient = httpClient;
_logger = logger;
}

public async Task<IEnumerable<DownstreamPizza>> GetPizzasAsync()
{
try
{
var response = await _httpClient.GetAsync("api/pizzas");
response.EnsureSuccessStatusCode();

var pizzas = await response.Content.ReadFromJsonAsync<IEnumerable<DownstreamPizza>>();
return pizzas ?? Enumerable.Empty<DownstreamPizza>();
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "Failed to fetch pizzas from DownstreamPizzas");
throw;
}
}

public async Task<DownstreamPizza?> GetPizzaByIdAsync(string id)
{
try
{
var response = await _httpClient.GetAsync($"api/pizzas/{id}");

if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
return null;
}

response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<DownstreamPizza>();
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "Failed to fetch pizza {PizzaId} from DownstreamPizzas", id);
throw;
}
}

public async Task<DownstreamPizza?> UpdatePizzaAsync(
string id,
DownstreamUpdatePizzaRequest request)
{
try
{
var json = JsonSerializer.Serialize(request);
var content = new StringContent(json, Encoding.UTF8, "application/json");

var response = await _httpClient.PatchAsync(
$"api/pizzas/{id}",
content);

if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
return null;
}

response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<DownstreamPizza>();
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "Failed to update pizza {PizzaId} in DownstreamPizzas", id);
throw;
}
}
}

// libs/server/integration/DownstreamPizzas/Models/DownstreamPizza.cs
namespace MyCompany.Integration.DownstreamPizzas.Models;

public record DownstreamPizza(
string Id,
string Name,
decimal Price,
string ImageUrl,
string[] Ingredients,
string Description,
bool IsVegetarian
);

// libs/server/integration/DownstreamPizzas/Models/DownstreamUpdatePizzaRequest.cs
namespace MyCompany.Integration.DownstreamPizzas.Models;

public record DownstreamUpdatePizzaRequest(
string? Name,
decimal? Price,
string? ImageUrl,
string[]? Ingredients,
string? Description,
bool? IsVegetarian
);

Notice how the Integration layer:

  • Handles all HTTP communication with the downstream DownstreamPizzas.
  • Isolates downstream-service details from the rest of the BFF.

Databases

We don’t recommend that BFFs interface with databases. They should integrate with downstream services that talk to databases.

Constraints

  • May not depend on other layers.
  • Each library in the Integration layer may only call one downstream service.

Services layer

The Services layer orchestrates data flow between the API and Integration layers. It contains services or functions the API layer calls to fulfill requests.

Business rules

The Services layer contains business rules specific to the BFF. For example, if the BFF needs to combine data from multiple downstream services, that logic belongs here.

Example

Here's an example of the Services layer:

// libs/server/services/PizzaMenu/IPizzaService.cs
namespace MyCompany.Services.PizzaMenu;

using MyCompany.Services.PizzaMenu.Models;

public interface IPizzaService
{
Task<IEnumerable<Pizza>> GetPizzasAsync();
Task<Pizza?> GetPizzaByIdAsync(string id);
Task<Pizza?> UpdatePizzaAsync(string id, UpdatePizzaRequest request);
}

// libs/server/services/PizzaMenu/PizzaService.cs
namespace MyCompany.Services.PizzaMenu;

using MyCompany.Services.PizzaMenu.Models;
using MyCompany.Integration.DownstreamPizzas;
using MyCompany.Integration.DownstreamPizzas.Models;

public class PizzaService : IPizzaService
{
private readonly IPizzaIntegrationService _integrationService;

public PizzaService(IPizzaIntegrationService integrationService)
{
_integrationService = integrationService;
}

public async Task<IEnumerable<Pizza>> GetPizzasAsync()
{
// Fetch from downstream service
var downstreamPizzas = await _integrationService.GetPizzasAsync();

// Map from Integration layer models to Services layer models
var pizzas = downstreamPizzas.Select(dp => new Pizza(
dp.Id,
dp.Name,
dp.Price,
dp.ImageUrl,
dp.Ingredients,
dp.Description,
dp.IsVegetarian
));

// Apply any BFF-specific business logic here
// For example: filtering, sorting, combining data from multiple sources

return pizzas;
}

public async Task<Pizza?> GetPizzaByIdAsync(string id)
{
var downstreamPizza = await _integrationService.GetPizzaByIdAsync(id);

if (downstreamPizza is null)
{
return null;
}

// Map from Integration layer model to Services layer model
var pizza = new Pizza(
downstreamPizza.Id,
downstreamPizza.Name,
downstreamPizza.Price,
downstreamPizza.ImageUrl,
downstreamPizza.Ingredients,
downstreamPizza.Description,
downstreamPizza.IsVegetarian
);

// Apply any BFF-specific transformations or enrichment

return pizza;
}

public async Task<Pizza?> UpdatePizzaAsync(string id, UpdatePizzaRequest request)
{
// Apply any BFF-specific validation or business rules

// Map from Services layer model to Integration layer model
var downstreamRequest = new DownstreamUpdatePizzaRequest(
request.Name,
request.Price,
request.ImageUrl,
request.Ingredients,
request.Description,
request.IsVegetarian
);

var downstreamPizza = await _integrationService.UpdatePizzaAsync(id, downstreamRequest);

if (downstreamPizza is null)
{
return null;
}

// Map from Integration layer model to Services layer model
return new Pizza(
downstreamPizza.Id,
downstreamPizza.Name,
downstreamPizza.Price,
downstreamPizza.ImageUrl,
downstreamPizza.Ingredients,
downstreamPizza.Description,
downstreamPizza.IsVegetarian
);
}
}

// libs/server/services/PizzaMenu/Models/Pizza.cs
namespace MyCompany.Services.PizzaMenu.Models;

public record Pizza(
string Id,
string Name,
decimal Price,
string ImageUrl,
string[] Ingredients,
string Description,
bool IsVegetarian
);

// libs/server/services/PizzaMenu/Models/UpdatePizzaRequest.cs
namespace MyCompany.Services.PizzaMenu.Models;

public record UpdatePizzaRequest(
string? Name,
decimal? Price,
string? ImageUrl,
string[]? Ingredients,
string? Description,
bool? IsVegetarian
);

Notice how the Services layer:

  • Delegates integration with downstream services to the Integration layer.
  • Contains business logic specific to the BFF.
  • Can combine or transform data from multiple sources.

Constraints

The Services layer:

  • May only depend on the Integration layer.
  • May not make HTTP calls directly (delegates to Integration layer).