Become a member!

Delphi Minimal API: Simple, Fast REST APIs with DMVCFramework

🌐
This article is also available in other languages:
🇮🇹 Italiano  •  🇪🇸 Español  •  🇩🇪 Deutsch  •  🇧🇷 Português

Simple, fast REST APIs and web apps in Delphi — anonymous-method routes, no controller class, powered by DMVCFramework, the most popular open-source Delphi framework.

A New Way to Write APIs in Delphi with DMVCFramework

DMVCFramework was born to solve “big”, complex problems: long-running server applications, with many endpoints, intricate business rules, and teams working on them for years. That is exactly why the controller-action model has always been the heart of the framework. It is robust, testable, scalable, and it imposes a clear structure as the project grows: it is this solidity that has made DMVCFramework the most widely used open source project for building web solutions in Delphi, and it will stay that way.

But over the last few years I have watched a new approach take its place alongside it in the ecosystem: FastAPI in Python, Minimal API in ASP.NET Core, Hono in TypeScript. They all offer a more direct alternative for certain scenarios: microservices, prototypes, simple APIs.

I wanted to bring the same choice to DMVCFramework. And it works.

The project now has more than 50 contributors: a growing community that makes it possible to work on ambitious features like this one.

And it is not just a matter of code: Minimal API is already integrated into the IDE wizard, with two new templates in the Delphi Object Repository (we look at them in detail later).

The Minimal API RESTful and Minimal API WebApp presets in the Delphi Object Repository

Hello, World

Before anything else: how minimal is it?

AEngine.Root
  .MapGet('/hello',
    function: IMVCResponse
    begin
      Result := Ok('Hello, World!');
    end);

Three lines. No class, no attribute, no separate file.

What does it return? The anonymous method has return type IMVCResponse, the object that describes the entire HTTP response (status, headers, body). Ok(...) is a factory that creates one with status 200 OK, and the body is always a JSON object: pass it a string and that string is wrapped in the message field. The response that reaches the client is therefore:

HTTP/1.1 200 OK
Content-Type: application/json; charset=UTF-8

{"message":"Hello, World!"}

Pass Ok(...) an object or a list instead, and they land in the data field of the same JSON object ({"data": ...}): it is the same mechanism as the controller model, here exposed by a function instead of a method.


What it looks like in a real project

Before looking at the code, a word on filters. There are two types: EndpointFilters, which attach to a specific route group via .Use(...), and HTTPFilters, registered at engine level, which apply to every request regardless of routing, working directly on the HTTP transport (Ctx.Request/Ctx.Response). Both are the conceptual equivalent of DMVCFramework’s classic middleware, and we cover them in detail later. In the example below we use the former: every route in the group passes through them in order before reaching the handler. For now, just know that .Use() means “apply this behaviour to everything that follows”.

Here is a complete REST API (JWT authentication, logging, CRUD endpoints) in a single file, without a single controller class:

procedure RegisterRoutes(AEngine: TMVCEngine);
begin
  // Public health check, no filters
  AEngine.Root
    .MapGet('/health',
      function: IMVCResponse
      begin
        Result := Ok('OK');
      end);

  // /api/v1 group: authentication and logging applied to all routes
  AEngine.Prefix('/api/v1')
    .Use(RequestLoggingFilter())
    .Use(BearerAuthFilter())

    // By convention, every interface (like IPeopleService) is resolved from the DI container
    .MapGet<IPeopleService>('/people',
      function(Svc: IPeopleService): IMVCResponse
      begin
        Result := Ok(Svc.GetAll);
      end)

    .MapGet<string, IPeopleService>('/people/($id)',
      function(ID: string; Svc: IPeopleService): IMVCResponse
      begin
        Result := Ok(Svc.GetByID(ID));
      end)

    .MapPost<TPerson, IPeopleService>('/people',
      function(Person: TPerson; Svc: IPeopleService): IMVCResponse
      begin
        Svc.Create(Person);
        Result := Created('/api/v1/people/' + Person.ID.ToString, Person);
      end);
end;

That is it. No [MVCPath], no [MVCHTTPMethod], no controller class to declare.


Large applications: structure does not disappear, you choose it

The fair question is: “what about when I have 80 routes?”

No minimal routing framework scales well if everything goes into a single RegisterRoutes. The difference is that with DMVC Minimal API the structure is not imposed by the syntax, it is imposed by you, in whatever way makes most sense for your domain.

The natural pattern is to split routes by functional area, each in its own procedure (or unit), passing the group as a parameter:

// PeopleRoutesU.pas
procedure RegisterPeopleRoutes(AGroup: TMVCRouteGroup<TObject>);
begin
  AGroup
    .MapGet<IPeopleService>('/people',        ...)
    .MapGet<string, IPeopleService>('/people/($id)', ...)
    .MapPost<TPerson, IPeopleService>('/people',    ...)
    .MapPut<string, TPerson, IPeopleService>('/people/($id)', ...)
    .MapDelete<string, IPeopleService>('/people/($id)', ...);
end;

// OrdersRoutesU.pas
procedure RegisterOrdersRoutes(AGroup: TMVCRouteGroup<TObject>);
begin
  AGroup
    .MapGet<IOrderService>('/orders',          ...)
    .MapPost<TOrder, IOrderService>('/orders', ...);
end;

// RoutesU.pas: single assembly point
procedure RegisterRoutes(AEngine: TMVCEngine);
var
  lAPI: TMVCRouteGroup<TObject>;
begin
  lAPI := AEngine.Prefix('/api/v1')
    .Use(RequestLoggingFilter())
    .Use(BearerAuthFilter());

  RegisterPeopleRoutes(lAPI);
  RegisterOrdersRoutes(lAPI);
end;

The result is an explicit tree structure: shared filters live in one place, each functional area lives in its own file, and the assembly point is a list of calls readable in ten seconds.

With the classic controller model, structure exists but is enforced by the syntax: every resource requires a dedicated class. With Minimal API it is optional: you can start with everything in one file and refactor when it makes sense, without rewriting anything.


How argument binding works

Each anonymous method parameter is resolved by type, automatically:

Parameter type Source
TWebContext Current request HTTP context
Interface in the DI container Injected service
Class JSON body (POST/PUT/PATCH) or query string (GET/DELETE)
Record with [MVCFromBody] / [MVCFromQueryString] / [MVCFromContentField] attributes Mixed binding, field by field
string, Integer, TGUID, TDateTime, … Route param if present, otherwise query string

There is no need to declare where each parameter comes from: the framework infers it from the type and the context.


Filters: two levels, one pipeline

One of the things I am most satisfied with in this implementation is the two-level filter model.

EndpointFilter: per group, fires only when a route matches:

AEngine.Prefix('/admin')
  .Use(
    function(Ctx: TWebContext; Next: TMVCEndpointFilterNext): IMVCResponse
    begin
      if not IsAdmin(Ctx) then
        Result := Status(403, 'Forbidden')
      else
        Result := Next();  // continues to the next filter or the handler
    end);

HTTPFilter: registered at engine level, it wraps the entire handling of every request, before routing even happens, working directly on the HTTP transport (Ctx.Request/Ctx.Response). Ideal for rate limiting, static files, security headers, compression:

AEngine.UseHTTPFilter(
  procedure(Ctx: TWebContext; Next: TMVCHTTPFilterNext)
  begin
    Ctx.Response.SetCustomHeader('X-Frame-Options', 'DENY');
    Next();  // lets the pipeline continue
  end);

Both types compose in registration order. The order is predictable, explicit, and readable in one place.


Not just APIs: web apps too

Minimal API is not JSON-only. With .AsWeb a group becomes a collection of server-rendered routes that return HTML via TemplatePro. RenderView and ViewData work as expected, and the session is just a filter:

AEngine.Root.AsWeb
  .Use(MemorySession(10))
  .MapGet('/',
    function: IMVCResponse
    begin
      ViewData['title'] := 'Home';
      Result := RenderView('pages/home');
    end);

Routes of type rkWeb are automatically excluded from the OpenAPI spec: the framework knows how to tell API endpoints apart from HTML pages.


IDE Wizard support

The wizard was not going to be left out. You will find two new presets in the Delphi Object Repository:

  • Minimal API RESTful: pure JSON API, per-group filters, DI container
  • Minimal API Web App: .AsWeb routes, sessions, login/logout, HTMX ready

A working project, zero configuration, ready in 30 seconds.


Status

The feature is available on the master branch starting from version 3.5.0-silicon-rc3. All 872 integration tests pass on both Win32 and Win64, against three distinct server backends (WebBroker, Indy Direct, HTTP.sys): 5,232 total executions across all platform and server combinations.

Coming next:

  • Classic API vs Minimal API benchmark (spoiler: the results surprised me)
  • OpenAPI 3.x integration
  • A dedicated guide with real-world patterns, advanced filters, and DI container integration

Daniele

Comments

comments powered by Disqus