Integrating EF Core with Minimal APIs

When I first tried using EF Core with Minimal APIs, I honestly thought it would be just a smaller version of a regular Web API controller setup. I was wrong. It’s simple, yes, but it forces you to think differently especially if you’ve been doing MVC for years.

In one of my projects, we wanted to build a lightweight internal service that didn’t need controllers, filters, or the usual MVC overhead. It just had to read and write data from a few tables using EF Core. That’s when I decided to use Minimal APIs.

At first, I struggled a bit with how to structure the code, handle dependency injection, and make it testable. But after a few iterations (and a couple of bugs that taught me the hard way), I ended up really liking this pattern. Let’s go through it step by step, in a way that you can actually use in a real project.

Understanding What is Minimal APIs Really Are

Minimal APIs are just a simpler way to build HTTP endpoints without needing controllers or attributes. You define routes directly in your Program.cs (or another file if you organize it), and you can inject dependencies like DbContext straight into the endpoint handler.

It’s great for microservices or internal APIs where you want to keep things fast, simple and clean.

Example of a super-basic Minimal API:

C#
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

app.MapGet("/", () => "Hello, world!");

app.Run();

That’s it. No controller, no Startup.cs. Now, let’s bring EF Core into this picture.

Setting Up EF Core in a Minimal API Project

In .NET 8, everything starts in Program.cs. Let’s start simple with a Product model.

Step 1: Define your entity

C#
public class Product
{
    public int Id { get; set; }
    public string? Name { get; set; }
    public decimal Price { get; set; }
    public int Stock { get; set; }
}

Step 2: Create your DbContext

C#
using Microsoft.EntityFrameworkCore;

public class AppDbContext : DbContext
{
    public AppDbContext(DbContextOptions<AppDbContext> options)
        : base(options)
    {
    }

    public DbSet<Product> Products => Set<Product>();
}

Step 3: Configure EF Core in the builder

Inside Program.cs

C#
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseSqlite("Data Source=app.db"));

var app = builder.Build();

app.MapGet("/", () => "EF Core with Minimal API is working!");

app.Run();

SQLite is great for testing or small internal tools. Of course, in production, you’ll replace it with SQL Server or PostgreSQL.

Creating the CRUD Endpoints

Here’s where Minimal APIs shine. You can inject your AppDbContext directly in each endpoint, and EF Core will handle it using scoped lifetime by default.

C#
app.MapGet("/products", async (AppDbContext db) =>
    await db.Products.ToListAsync());

app.MapGet("/products/{id}", async (int id, AppDbContext db) =>
    await db.Products.FindAsync(id)
        is Product product
            ? Results.Ok(product)
            : Results.NotFound());

app.MapPost("/products", async (AppDbContext db, Product product) =>
{
    db.Products.Add(product);
    await db.SaveChangesAsync();
    return Results.Created($"/products/{product.Id}", product);
});

app.MapPut("/products/{id}", async (int id, Product input, AppDbContext db) =>
{
    var product = await db.Products.FindAsync(id);
    if (product is null) return Results.NotFound();

    product.Name = input.Name;
    product.Price = input.Price;
    product.Stock = input.Stock;

    await db.SaveChangesAsync();
    return Results.NoContent();
});

app.MapDelete("/products/{id}", async (int id, AppDbContext db) =>
{
    var product = await db.Products.FindAsync(id);
    if (product is null) return Results.NotFound();

    db.Products.Remove(product);
    await db.SaveChangesAsync();
    return Results.Ok();
});

You can already see how simple and readable this is.

I remember the first time I built this, it felt weird not having controllers, but after a few endpoints, I realized how clean it looks. No attribute routing, no IActionResult. Just straight functions that return Results.

Filtering and Query Logic

In a real project, I rarely return all rows. Clients usually want filters like products under a certain price or low in stock. Let’s add a simple filter endpoint:

C#
app.MapGet("/products/filter", async (decimal? maxPrice, int? stockLessThan, AppDbContext db) =>
{
    var query = db.Products.AsQueryable();

    if (maxPrice.HasValue)
        query = query.Where(p => p.Price <= maxPrice.Value);

    if (stockLessThan.HasValue)
        query = query.Where(p => p.Stock < stockLessThan.Value);

    var results = await query.ToListAsync();
    return Results.Ok(results);
});

This is one of those real-life cases that always shows up. In one of my past projects, I had a dashboard that needed to display “soon-to-run-out” items. This same pattern worked perfectly.

Projections (Selecting Only What You Need)

Sometimes you don’t need all fields. You might only need a summary to reduce payload size.

C#
app.MapGet("/products/summary", async (AppDbContext db) =>
    await db.Products
        .Select(p => new
        {
            p.Id,
            p.Name,
            Value = p.Price * p.Stock
        })
        .ToListAsync());

In one of my real systems, we had to send thousands of rows to a reporting API. Doing projections like this saved a lot of bandwidth and memory. The less EF has to track, the faster it performs.

Joins and Relationships

Let’s say we now have a Category entity.

C#
public class Category
{
    public int Id { get; set; }
    public string? Name { get; set; }
    public List<Product> Products { get; set; } = new();
}

Add this to your AppDbContext:

C#
public DbSet<Category> Categories => Set<Category>();

Now let’s map an endpoint that shows all products with their categories:

C#
app.MapGet("/products-with-category", async (AppDbContext db) =>
{
    var results = await db.Products
        .Include(p => p.Category)
        .Select(p => new
        {
            p.Id,
            p.Name,
            Category = p.Category.Name
        })
        .ToListAsync();

    return Results.Ok(results);
});

This pattern appears a lot in reporting APIs or dashboards. I remember in one of my Blazor + EF Core projects, this saved me from manually joining tables using LINQ EF did it neatly.

Handling Transactions and Business Logic

Minimal APIs don’t mean minimal logic. In one internal system, we had to decrease stock when an order was placed, and rollback if any part failed.

C#
app.MapPost("/orders", async (AppDbContext db, OrderRequest request) =>
{
    using var transaction = await db.Database.BeginTransactionAsync();

    try
    {
        foreach (var item in request.Items)
        {
            var product = await db.Products.FindAsync(item.ProductId);
            if (product is null) return Results.NotFound($"Product {item.ProductId} not found.");

            if (product.Stock < item.Quantity)
                return Results.BadRequest($"Not enough stock for {product.Name}.");

            product.Stock -= item.Quantity;
        }

        await db.SaveChangesAsync();
        await transaction.CommitAsync();

        return Results.Ok("Order processed successfully.");
    }
    catch
    {
        await transaction.RollbackAsync();
        return Results.Problem("Something went wrong processing the order.");
    }
});

Real talk: I’ve broken more data by forgetting transactions than I’d like to admit. Using BeginTransactionAsync in these scenarios gives you full control over rollback behavior, which is crucial when multiple database operations depend on each other.

Structuring a Real Application

When your project grows, you don’t want all routes in Program.cs. You can split them by feature using extension methods.

For example:

C#
public static class ProductEndpoints
{
    public static void MapProductEndpoints(this WebApplication app)
    {
        app.MapGet("/products", async (AppDbContext db) => await db.Products.ToListAsync());
        // ... other product endpoints here
    }
}

Then in your Program.cs:

C#
var app = builder.Build();
app.MapProductEndpoints();
app.Run();

In one of my internal microservices, this made the codebase way easier to maintain. Each module (Products, Orders, Users) had its own file with all related routes.

Bonus: Using EF Core Migrations

Don’t forget migrations still work the same way.

Bash
dotnet ef migrations add InitialCreate
dotnet ef database update

If you’re using SQLite, you’ll see the app.db file created instantly. If you’re on SQL Server or PostgreSQL, it’ll build your schema automatically.

Lessons I’ve Learned Along the Way

  • Keep it simple. Don’t over-engineer small APIs with layers you don’t need.
  • Use DTOs wisely. For public APIs, avoid exposing EF entities directly.
  • Take advantage of dependency injection. It works the same as in controllers.
  • Transactions matter. Especially when multiple tables are affected.
  • Use projections for performance. Only select what you really need.

I’ve personally used this setup in at least three internal services, and it works perfectly. The beauty of Minimal APIs is in how fast you can go from idea to working endpoint without all the boilerplate.

Wrapping Up

Integrating EF Core with Minimal APIs is one of those things that feels “too simple” at first, but the simplicity is the point. You don’t lose power or flexibility you just remove unnecessary ceremony.

If you’re building a small service, internal API, or just prototyping, this combo is powerful and clean.

I hope this helped you see a more practical side of using Minimal APIs with EF Core. Try it in your next project and see how it feels.


Sources

Assi Arai
Assi Arai
Articles: 46