.NET Minimal API’s vs Traditional API’s using K6


This is my first post so I wanted to do something simple. Minimal API’s are new to .NET 6 and there’s a lot of talk about them. If you’re calling a database, are Minimal API’s faster? I say let’s load test it and see if it’s any better than the traditional model. For this, I will be using MS-SQL Express and Entity Framework to communicate with it. The source code for this can be found on GitHub here.

K6 is a nice load testing platform that is often used for these kinds of tests. I will use it for this situation.

Let’s first look at the data model:

There are three tables: Student, Course, and Enrollment. Enrollments contain a student id and a course id so there’s foreign keys involved. Let’s first look at a student:

public class Student
    {
        [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
        public int ID { get; set; }
        public string LastName { get; set; }
        public string FirstMidName { get; set; }
        public DateTime EnrollmentDate { get; set; }

        [JsonIgnore]
        public virtual ICollection<Enrollment> Enrollments { get; set; }
    }

The student contains an ID which is auto-generated. Also, it contains LastName, FirstMidName, which are simple strings, and an Enrollment Date which is a Datetime object. The Enrollments collection is due to enrollment containg a Student ID foreign key.

Now, let’s look at a course:

public class Course
    {
        [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
        public int CourseID { get; set; }
        public string Title { get; set; }
        public int Credits { get; set; }
        [JsonIgnore]
        public virtual ICollection<Enrollment> Enrollments { get; set; }
    }

A course contains an auto-generated ID, a string Title and number of credits the course is worth (which an integer). The Enrollments collection is due to the foreign key that an Enrollment has using the Course Id.

Here’s the Enrollment object:

 public enum Grade
    {
        A, B, C, D, F
    }

    public class Enrollment
    {
        [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
        public int EnrollmentID { get; set; }
        public int CourseID { get; set; }
        public int StudentID { get; set; }
        public Grade? Grade { get; set; }

        public virtual Course Course { get; set; }
        public virtual Student Student { get; set; }
    }

So, an Enrollment contains an auto-generated Id, a Course Id (foreign key), a Student Id (foreign key), and an optional Grade field.

Here’s my solution configuration:

The Database project contains Entity Framework models (explained earlier), the data context, and a school service which contains the database functionality that I’m going to use. It also uses Entity Framework Migrations to create the database.

The school service looks like this:

public interface ISchoolService
    {
        Task<int> GetEnrollmentCount();
        Task<List<Enrollment>> GetEnrollments(int from, int count);
    }

    public class SchoolService : ISchoolService
    {
        private readonly SchoolContext db;

        public SchoolService(SchoolContext db)
        {
            this.db = db;
        }

        public async Task<int> GetEnrollmentCount()
        {
            return await db.Enrollments.CountAsync();
        }

        public async Task<List<Enrollment>> GetEnrollments(int from, int count)
        {
            var result = await db.Enrollments.Include(x => x.Course)
                .AsNoTracking()
                .Include(x => x.Student)
                .OrderBy(x => x.EnrollmentID)
                .Skip(from)
                .Take(count)
                .ToListAsync();
            return result;
        }

    }

It’s a simple wrapper around the school database context. There are 2 functions, one returns the total enrollment count and the other returns paginated enrollments. Also, note that inside of the Database project there is a Notes.txt file and that contains the function used to create the initial database.

The Database Seed Script project is used to create some random(ish) data in SQL.

Then, there’s the Minimal and Traditional API projects.

Let’s take a look at the Minimal API first:

using Database;
using Microsoft.EntityFrameworkCore;

var builder = WebApplication.CreateBuilder(args);
var dbString = builder.Configuration.GetConnectionString("SchoolContext");
builder.Services.AddDbContext<SchoolContext>(options =>
            options.UseSqlServer(dbString));
builder.Services.AddScoped<ISchoolService, SchoolService>();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

var app = builder.Build();

// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}
app.UseHttpsRedirection();

app.MapGet("/api/school/quick", () => Results.Ok("Hello world"));
app.MapGet("/api/school/enrollments", async (ISchoolService schoolService) => Results.Ok(await schoolService.GetEnrollmentCount()));
app.MapGet("/api/school/enrollments/{start}/{count}", async (int start, int count, ISchoolService schoolService) 
    => Results.Ok(await schoolService.GetEnrollments(start, count)));


app.Run("https://localhost:7011");

First, it sets up the database, then set’s up the school service for dependency injection. After that, it sets up swagger.

There are three endpoints. The first one just returns a string. The second one uses the school service (which is injected) to retrieve the total enrollment count. And the third one returns enrollments between the start and end indices. Note, the returned enrollments include students and courses. Short and concise.

For the traditional API, there are two files to look at. Program.cs and the school controller:

using Database;
using Microsoft.EntityFrameworkCore;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllers();
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

var dbString = builder.Configuration.GetConnectionString("SchoolContext");
builder.Services.AddDbContext<SchoolContext>(options =>
            options.UseSqlServer(dbString));
builder.Services.AddScoped<ISchoolService, SchoolService>();

var app = builder.Build();


// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}

app.UseHttpsRedirection();

//app.UseAuthorization();

app.MapControllers();

app.Run();

The program file sets up the data context and adds the school service for dependency injection (very similar to the minimal API). Then it sets up swagger and maps the controllers.

Next is the school controller:

[Route("api/[controller]")]
    [ApiController]
    public class SchoolController : ControllerBase
    {
        private readonly ISchoolService schoolService;

        public SchoolController(ISchoolService schoolService)
        {
            this.schoolService = schoolService;
        }

        [HttpGet, Route("quick")]
        public IActionResult Quick()
        {
            return Ok("hello world");
        }

        [HttpGet, Route("enrollments")]
        public async Task<IActionResult> GetEnrollmentCount()
        {
            return Ok(await schoolService.GetEnrollmentCount());
        }

        [HttpGet, Route("enrollments/{start}/{count}")]
        public async Task<IActionResult> GetEnrollments(int start, int count)
        {
            var result = await schoolService.GetEnrollments(start, count);
            return Ok(result);
        }
    }

As you can see, the school service is injected and the controller has the same 3 endpoints as the minimal API. The routes and results are both the same as the Minimal API.

K6 is the load testing frame that I’m going to use. It uses JavaScript and sets up virtual users to hit a backend service of your choosing. If you don’t have K6 installed yet, you can get instructions here. Let’s look at the file layout for my load testing project:

The start.js file contains the setup and execution details. The settings file contains the site URL and the api-functions.js file contains the http calls. First to look at is start.js:

import { sleep } from 'k6';
import { getEnrollments, getEnrollmentCount, quick } from './api-functions.js';
import { Trend } from 'k6/metrics';

export let quickResults = new Trend('quick');
export let enrollmentCount = new Trend('getEnrollmentCount');
export let enrollmentResults = new Trend('getEnrollments');

//How many users and for how long should run this script
export let options = {
    vus: 10,           // How many VUs (virtual users)
    duration: '60s',  // How long does the test run
};


export default function () {

    console.log(`Virtual User ${__VU}:`);
    quick();
    sleep(1);
    let range = getEnrollmentCount();
    sleep(1);
    getEnrollments(range.start, range.end);
}

It’s pretty simple. First, it imports the necessary functions and objects. Then, it sets up a few trends. Trends allow the user to have metrics for each call that’s made. In this case, I’m using a metric for each endpoint that’s called. Next, it sets up the options. This contains how many virtual users to have (e.g. 10 virtual users will simulate 10 people hitting the endpoints at the same time) and the total duration for the run. Finally, it exports the main function which logs the virtual user # and calls the functions to run. Let’s look at the api-functions.js file:

import http from 'k6/http';
import { settings } from './settings.js'
import { enrollmentCount, enrollmentResults, quickResults } from './start.js';

export function quick() {
    let res = http.get(`${settings.baseUrl}/api/school/quick`);
    quickResults.add(res.timings.duration);
}

export function getEnrollmentCount() {
    let res = http.get(`${settings.baseUrl}/api/school/enrollments`);
    enrollmentCount.add(res.timings.duration);
    let count = parseInt(res.body);
    let start = Math.max(Math.floor(Math.random() * count) - 100, 0);
    let end = 100;
    //console.log(`start: ${start}.  count: ${end}`);

    return { start, end };
}

export function getEnrollments(start, count) {
    let res = http.get(`${settings.baseUrl}/api/school/enrollments/${start}/${count}`);
    enrollmentResults.add(res.timings.duration);
}

Frist, it imports the http object from K6. This is used to make any http calls and is a wrapper around actual ajax calls which K6 uses to time the calls. Next, there are three functions that call the three backend endpoints. Notice this file imports the trends from start.js and sets the duration after every call to the API.

Ok, next is to perform the tests. To run K6 from the command line, make sure you’re in the same folder as the start.js file. Then, type ‘k6 run start.js’ and that will start the tests. Don’t forget to start the API first. For this example, I’m using 10 virtual users over a 1 minute duration. Note, I’m using SQL Express, running in release mode, and running on my local computer, so there’s no network lag. Also, I’m running multiple times to handle the ‘warm up’ issue.

First, I will run the tests against the Minimal API.

While running, the process memory capped out at 231MB.

There are quite a few stats here. One thing to note is that under http_reqs that it’s only performing 14.62 requests per second which is pretty dismal. I haven’t looked at basic queries in a while and it’s surprising how slow SQL is. Use caching whenever you can! I digress.

The stats I’d like to look at deeper are getEnrommmentCount, getEnrollments, and quick. These are the trends that were setup in the program and only include the total time for the http requests. We’re off to a good start. Let’s run K6 against the Traditional API.

While running, the process memory for the traditional API was 246MB

Ok, so let’s compare:

EndpointAverage/Max Time in ms Minimal APIAverage Time/Max in ms Traditional API
getEnrollmentCount10.77/34.0013.06/38.48
getEnrollments16.32/57.0018.74/41.80
quick2.66/23.844.44/122.61

Ok, so what does this tell us? Consistently, the Minimal API performs very slightly better than the Traditional API. The first 2 are obviously hindered by entity framework (enough so that we can consider it a wash). The third one (quick), is a little bit surprising. Just returning a 200 response with a string is almost 2 MS faster (which is about 40%) on the Minimal API. Also, the memory footprint for the Minimal API is about 15MB less.

Take it for what it’s worth but the Minimal API is slightly faster – Even when using entity framework. I’d like to revisit this in the future, and use a caching layer to see what kind of gains that we can get. At any rate, thanks for stopping by!

, ,