When you are building a frontend for React, Angular, Blazor, or plain JavaScript, you often do not have a real backend ready on day one. The UI team wants to start wiring up screens, but the actual API is still being designed. A common workaround is to write a throwaway set of endpoints that return fake data just so the frontend has something to talk to. This gets repetitive fast, and most developers end up copy pasting the same boilerplate for every new resource.
Khalid Abuhakmeh, a .NET developer advocate, ran into this exact problem while preparing demos. Instead of hand rolling fake endpoints every time, he built a small extension method for ASP.NET Core Minimal APIs that generates a full set of CRUD endpoints for any C# model, backed by the Bogus fake data library. This article walks through how that approach works, what it looks like in practice, and where it fits into a real project.
Why a Fake Backend Still Needs Real HTTP Semantics
It is tempting to think a mock API just needs to return some JSON, but that is not quite right. A frontend developer testing pagination, error handling, or optimistic updates needs the fake backend to behave like a real one. That means proper status codes, a predictable request and response shape, and support for the standard verbs: GET for reading, POST for creating, PUT for updating, and DELETE for removing.
The author frames this around the idea of a resource, a logical entity such as a Person or a Quote that the client reads and writes through the API. Building endpoints around resources and HTTP semantics, rather than ad hoc actions, keeps the mock API close enough to production behaviour that the frontend code you write against it will not need major rework later.
Registering a Bogus Endpoint in Program.cs
The goal was a single line of registration per resource. Here is what that looks like for a Person model in a typical Program.cs file.
using BogusEndpoints;
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapGet("/", () => "Hello World!");
app.MapAutoBogusEndpoint<Person>("/people", rules =>
{
rules.RuleFor(p => p.Id, f => f.IndexGlobal + 1);
rules.RuleFor(p => p.FullName, f => f.Name.FullName());
});
app.Run();
public record Person(int Id, string FullName)
{
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public Dog Dog { get; set; }
}
public record Dog(string Name);
MapAutoBogusEndpoint is a custom extension method that takes a route prefix and an optional configuration action. Inside that action, you use Bogus’s RuleFor syntax to control how specific properties are generated. Any property you do not configure explicitly, like the Dog navigation property here, still gets populated with random but reasonably realistic data based on its type.
Calling this method for /people spins up a full set of endpoints: a list endpoint with paging, a get by id endpoint, and create, update, and delete endpoints, all backed by an in memory collection of 1000 generated Person records. Hitting the list endpoint with a page size of one gives you a response like this.
GET http://localhost:5208/people?pageSize=1
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
{
"results": [
{
"id": 1,
"fullName": "Ofelia Vandervort",
"createdAt": "2023-02-02T01:32:03.1170216-05:00",
"dog": { "name": "Toys & Health" }
}
],
"page": 1,
"pageSize": 1,
"totalItemCount": 1000
}
Notice the response includes paging metadata alongside the results array, which is exactly what a frontend grid or infinite scroll component expects. Update and delete work the same way, using the record’s Id property to locate the matching item in the in memory list.
How MapAutoBogusEndpoint Works Under the Hood
The real work happens in a static extension class. It creates an AutoFaker for the resource type, applies any rules the caller configured, generates a working set of 1000 records up front, and then wires up the five standard endpoints against that in memory list. The full implementation looks like this.
using AutoBogus;
using Microsoft.AspNetCore.Mvc;
using X.PagedList;
namespace BogusEndpoints;
public static class BogusEndpointsExtensions
{
public static RouteGroupBuilder MapAutoBogusEndpoint<TResource>(
this WebApplication app,
PathString prefix,
Action<AutoFaker<TResource>>? builder = null
) where TResource : class
{
var group = app.MapGroup(prefix)
.WithGroupName($"{typeof(TResource).FullName}_Bogus");
var faker = new AutoFaker<TResource>();
builder?.Invoke(faker);
var db = faker.Generate(1000);
group.MapGet("", (int? pageSize, int? page) =>
{
var result = db.ToPagedList(
page.GetValueOrDefault(1),
pageSize.GetValueOrDefault(10));
return new
{
results = result,
page = result.PageNumber,
pageSize = result.PageSize,
totalItemCount = result.TotalItemCount
};
}).WithName($"{typeof(TResource).FullName}_Bogus+List");
group.MapGet("{id}", (string id) =>
{
var result = db.FirstOrDefault(t => FindById(t, id));
return result != null ? Results.Ok(result) : Results.NotFound();
}).WithName($"{typeof(TResource).FullName}_Bogus+Show");
group.MapPost("", (TResource item) =>
{
try
{
dynamic generated = faker.Generate(1)[0];
SetId(item, generated.Id);
db.Add(item);
return Results.CreatedAtRoute(
$"{typeof(TResource).FullName}_Bogus+Show",
new { id = generated.Id },
item
);
}
catch
{
return Results.Ok(item);
}
}).WithName($"{typeof(TResource).FullName}_Bogus+Create");
group.MapPut("{id}", (string id, [FromBody] TResource item) =>
{
var index = db.FindIndex(t => FindById(t, id));
if (index < 0) return Results.NotFound();
SetId(id, item);
db[index] = item;
return Results.Ok(item);
}).WithName($"{typeof(TResource).FullName}_Bogus+Update");
group.MapDelete("{id}", (string id) =>
{
db.RemoveAll(t => FindById(t, id));
return Results.Accepted();
}).WithName($"{typeof(TResource).FullName}_Bogus+Delete");
return group;
}
private static bool FindById<TResource>(TResource target, object? id)
{
if (id is null) return false;
var identifier = typeof(TResource).GetProperties()
.FirstOrDefault(p => p.Name == "Id");
if (identifier == null) return false;
var converted = Convert.ChangeType(id, identifier.PropertyType);
if (converted == null) return false;
return converted.Equals(identifier.GetValue(target));
}
private static void SetId<TResource>(TResource target, object? id)
{
if (id is null) return;
var identifier = typeof(TResource).GetProperties()
.FirstOrDefault(p => p.Name == "Id");
if (identifier == null) return;
var converted = Convert.ChangeType(id, identifier.PropertyType);
if (converted == null) return;
identifier.SetValue(target, converted);
}
}
A few things stand out here. The FindById and SetId helpers use reflection to locate a property literally named Id, which means this only works cleanly for models that follow that naming convention. The MapGroup call also returns a RouteGroupBuilder, and the method returns that same group instead of void, which is a small but deliberate design choice: it lets the caller attach endpoint filters, like validation, to the whole group after registration.
One thing worth flagging for production awareness, even though this is a prototyping tool: the POST handler swallows exceptions with a bare catch block and falls back to returning the item as is. That is fine for a throwaway mock, but it is the kind of pattern you would never want in real API code, since it hides genuine failures instead of surfacing them.
Adding Validation with FluentValidation
A mock API that accepts anything is only half useful, since real APIs almost always validate input. Because MapAutoBogusEndpoint returns the RouteGroupBuilder, adding an endpoint filter for validation takes one extra line.
using BogusEndpoints;
using FluentValidation;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddValidatorsFromAssemblyContaining<Program>();
var app = builder.Build();
app.MapGet("/", () => "Hello World!");
app.MapAutoBogusEndpoint<Person>("/people", rules =>
{
rules.RuleFor(p => p.Id, f => f.IndexGlobal + 1);
rules.RuleFor(p => p.FullName, f => f.Name.FullName());
})
.AddEndpointFilter<PersonValidationFilter>();
app.Run();
public record Person(int Id, string FullName)
{
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public Dog Dog { get; set; }
}
public record Dog(string Name);
The filter itself is a plain IEndpointFilter implementation. It resolves the validator from the DI container, checks the incoming argument against it, and short circuits with a validation problem response if the model fails.
using FluentValidation;
public class PersonValidationFilter : IEndpointFilter
{
public async ValueTask<object?> InvokeAsync(
EndpointFilterInvocationContext context,
EndpointFilterDelegate next)
{
var validator = context.HttpContext.RequestServices
.GetRequiredService<PersonValidator>();
foreach (var arg in context.Arguments)
{
if (arg is not Person person) continue;
var result = await validator.ValidateAsync(person);
if (result.IsValid) continue;
var errors = result.Errors
.GroupBy(e => e.PropertyName)
.ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray());
return Results.ValidationProblem(errors);
}
return await next(context);
}
}
public class PersonValidator : AbstractValidator<Person>
{
public PersonValidator()
{
RuleFor(m => m.FullName).NotEmpty();
}
}
With this filter in place, posting a Person without a FullName returns a proper 400 response with a problem details body, matching what ASP.NET Core would produce for a real validated endpoint.
POST http://localhost:5208/people
HTTP/1.1 400 Bad Request
Content-Type: application/problem+json
{
"type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
"title": "One or more validation errors occurred.",
"status": 400,
"errors": {
"FullName": [ "'Full Name' must not be empty." ]
}
}
This is a good pattern to copy even outside this specific mock API use case: keep your endpoint registration lean, and push validation, logging, or authorization concerns into separate endpoint filters that you attach where needed. It keeps the Program.cs readable as the number of resources grows.
Where This Pattern Falls Short
This is explicitly a prototyping tool, not something to carry into production, and the author is upfront about that. The current implementation relies on runtime reflection and generation, which works fine for a handful of resources in a demo project but adds overhead and fragility as the model count grows. The reflection based FindById and SetId helpers assume every resource has a property literally called Id, so anything using a different naming convention, like PersonId or a composite key, will silently fail to match.
There is also no persistence beyond the process lifetime. Restart the app and your 1000 generated records regenerate from scratch with new random values, which is usually what you want for a demo but worth knowing if you are relying on stable test data across a session.
The author points to an alternative worth knowing about: using C# source generators to register Minimal API endpoints instead of relying purely on runtime reflection, an approach detailed by fellow .NET community member Joao Antunes. Source generators catch more mistakes at compile time and avoid the reflection overhead entirely, at the cost of more upfront complexity in the generator code itself. If you find yourself building this kind of mock API tooling for repeated use across projects, that is the direction worth exploring next.
When to Reach for This Approach
Use this pattern when a frontend team needs a working backend to build against before the real API is ready, or when you are putting together a quick demo and do not want to hand write fake data generators for every model. It saves real time in exactly that narrow window between UI design and backend delivery.
Do not use it as a substitute for proper API contract testing, and do not let it linger in a codebase past the prototyping phase. Once the real backend is ready, swap it out. Leaving a bogus data endpoint wired into a shipping application, even behind a feature flag, is an easy way to leak fake records into an environment where someone expects real ones.
Leave a Reply