Become a member!

DelphiMVCFramework 3.5.0-silicon RC6: Three Hosts, One Controller Stack

DelphiMVCFramework 3.5.0-silicon RC6 is available. This is a release candidate, not the final 3.5.0: the feature set is closed and the test matrix is green, but the point of publishing it is to have it run on machines that are not mine before the stable tag.

The three things that make 3.5 different from 3.4.x are the pluggable server hosts, the Minimal API, and a streaming JSON serializer on the response hot path. There are also three breaking changes, all of them small and all of them documented below with the line that restores the old behavior.

Download it from GitHub.


Three server hosts, one controller stack

Until 3.4.x there was one way to put a DMVCFramework application on a socket: WebBroker, with a WebModule and TIdHTTPWebBrokerBridge underneath. It works, it has worked for years, and for ISAPI and Apache deployments it is still the right answer. For everything else you now have a choice.

3.5 introduces an IMVCServer interface (MVCFramework.Server.Intf) with three implementations, chosen through TMVCServerFactory:

Host Constructor What it is for
Indy Direct TMVCServerFactory.CreateIndyDirect(LEngine) The new default for new projects. A direct TIdHTTPServer, no WebModule, no WebBroker layer.
HTTP.sys TMVCServerFactory.CreateHttpSys(LEngine) Windows kernel-mode HTTP. Needs administrator rights or a netsh http add urlacl.
WebBroker TMVCServerFactory.CreateWebBroker(AConfigAction, AEngineConfig) ISAPI, Apache modules, and applications already built on a WebModule.

Whichever host you pick, your own code is the same. Controllers, actions, entities and middleware behave identically, and switching means editing the .dpr:

// Indy Direct: the default for a new console server
LServer := TMVCServerFactory.CreateIndyDirect(LEngine);

// HTTP.sys: same engine, same controllers
LServer := TMVCServerFactory.CreateHttpSys(LEngine);

// WebBroker: same again, when you deploy into ISAPI or Apache
LServer := TMVCServerFactory.CreateWebBroker(nil, ConfigureEngine);

IMVCServer exposes Listen, Stop, IsRunning and RunAndWait. The last one is the console shortcut: it calls Listen, blocks on the termination signal, then calls Stop. Do not call it from a VCL or FMX form, where the main thread already owns a message loop; there you use Listen and Stop and let the host decide when each runs.

LServer := TMVCServerFactory.CreateIndyDirect(LEngine);
LServer.RunAndWait(8080);

HTTPS is now configured on the server object rather than on the Indy component:

uses
  MVCFramework.Server.HTTPS.TaurusTLS;
...
LServer.HTTPSConfigurator := TaurusTLSIndyConfigurator();
LServer.UseHTTPS := True;
LServer.CertFile := 'certificates\localhost.crt';
LServer.KeyFile := 'certificates\localhost.key';

Each backend handles TLS its own way behind that same API: Indy Direct and WebBroker use TaurusTLS with the certificate properties above, while HTTP.sys takes its certificate from netsh http add sslcert and UseHTTPS only flips the registered prefix to https://.

The sample samples/server_types is the demonstration of the claim: one controller unit in commons, six projects around it (Indy Direct, HTTP.sys, standalone WebBroker, WebBroker through IMVCServer, ISAPI, Apache module). The controller file is shared, not copied.

WebBroker stays supported, indefinitely. It is one option out of three. ISAPI and Apache deployments run through it, existing applications keep compiling untouched, and the TMVCEngine.Create(AWebModule) constructor still works (it is marked deprecated in favor of TMVCEngine.CreateForWebBroker, which is a rename, not a removal).


Minimal API

The second addition is a routing style that works without a controller class. MVCFramework.MinimalAPI lets you register a handler directly on a route group:

procedure ConfigureRoutes(const ARoot: TMVCRouteGroup<TObject>);
var
  lPeople: TMVCRouteGroup<TObject>;
begin
  lPeople := ARoot.Prefix('/people').Use(LogFilter());

  // an interface argument is resolved from the service container
  lPeople.MapGet<IPeopleService>('',
    function (Svc: IPeopleService): IMVCResponse
    begin
      Result := Ok(Svc.GetAll);
    end);

  // a primitive argument is bound to the next route segment
  lPeople.MapGet<Integer>('/($id:int)',
    function (ID: Integer): IMVCResponse
    begin
      Result := Ok(TPerson.Create(ID, 'Daniele', 'Teti', EncodeDate(1979, 11, 4)));
    end);

  // a class argument comes from the body, and is validated before the handler runs
  lPeople.MapPost<TPersonInput>('',
    function (Input: TPersonInput): IMVCResponse
    begin
      Result := Created('', 'Person created');
    end).WithSummary('Create a new person (validated)');
end;

MapGet, MapPost, MapPut, MapDelete and MapPatch cover the single verbs; MapMethods takes an array of them for the cases where one handler answers several:

lPeople.MapMethods<Integer>([httpPUT, httpPATCH], '/($id:int)',
  function (ID: Integer): IMVCResponse
  begin
    Result := Ok('updated ' + ID.ToString);
  end);

Handlers are function(...): IMVCResponse with at most four typed arguments, and the binding is by type, not by name or by a position you have to memorize. An interface argument is resolved from the service container. A primitive (Integer, Int64, string, Boolean, Double, TGUID, TDateTime) is bound to the next unconsumed route segment, in declaration order. A class or a record comes from the body, and a record can declare per-field sources with [MVCFromQueryString], [MVCFromHeader], [MVCFromCookie], [MVCFromContentField] and [MVCFromBody]. A TMVCFormFile argument binds the first uploaded multipart file. Classes descending from TMVCValidatable are validated before the handler is entered, so an invalid payload short-circuits with a 400 and a ProblemDetails body. Route constraints such as ($id:int) reject a non-numeric id with a 404 before your code runs.

Two details fail quietly rather than loudly, so learn them before you write your first route file.

TMVCRouteGroup<T> is a record: Use, Prefix and AsWeb return a new group rather than mutating the one you called them on, so discarding the result is a no-op that compiles cleanly:

// wrong: the returned group is thrown away, LogFilter never runs
ARoot.Prefix('/people').Use(LogFilter());

// right: keep the group and register the routes on it
lPeople := ARoot.Prefix('/people').Use(LogFilter());
lPeople.MapGet<IPeopleService>('', ...);

Classic middleware has to be registered before the first MapXxx. The minimal dispatcher is installed lazily on the first Map call and short-circuits the requests it matches, so anything added with AddMiddleware afterwards will not see them.

Two complete, compilable samples live in samples/wizard_showcase/rest/ (REST) and samples/wizard_showcase/web/ (TemplatePro and HTMX through .AsWeb). Both are heavily commented; they are the fastest way to see all the binding modes in one screen.


Filters

MVCFramework.Filters is the modern surface next to the middleware you already know. There are two kinds.

TMVCEndpointFilter attaches to a route group and runs only when a route in that group matches. It is a closure that receives the context and a Next continuation, so it wraps the handler:

function LogFilter: TMVCEndpointFilter;
begin
  Result := function (const Ctx: TWebContext;
                      const Next: TMVCEndpointFilterNext): IMVCResponse
    begin
      LogI('-> ' + Ctx.Request.PathInfo);
      Result := Next();
      LogI('<- status ' + Result.StatusCode.ToString);
    end;
end;

TMVCHTTPFilter is engine-wide and wraps routing itself, which is what you want for concerns that apply before a route is even chosen:

lEngine
  .UseHTTPFilter(SecurityHeaders)
  .UseHTTPFilter(RateLimit(100, 60))   // 100 requests per minute per IP
  .UseHTTPFilter(Compression(1024))
  .UseHTTPFilter(StaticFiles('/static', 'www'));

18 of the 19 classic middleware helpers have a filter equivalent (MemorySession, CORS, JWT, ActiveRecord, ETag, Analytics, Trace, Redirect, Swagger and the rest); only OIDC is still middleware-only. There is also RangeMedia, which serves files with HTTP Range support (RFC 7233) so HTML5 <audio> and <video> elements can seek, and a Redis-backed RateLimitRedis in the companion unit MVCFramework.Filters.Redis for load-balanced deployments.


Streaming JSON serializer

OKResponse(TObject) and OKResponse(TObjectList<T>) now have a fast path (MVCFramework.Serializer.Streaming). Instead of building a TJDOJsonObject tree, converting it to a UTF-16 Delphi string and re-encoding that to UTF-8, it writes JSON straight to the response stream through System.JSON.Writers.TJsonTextWriter, using an emission plan cached per class. No intermediate tree, no intermediate string.

It requires Delphi 10.3 Rio or newer. On older compilers the new unit is a stub and the legacy serializer is used, unchanged.

The streaming path has full feature parity with the legacy serializer, and parity here means byte-identical output, verified across 50 scenarios by a dedicated harness (performancetest/parity/ParityCheck.exe): every primitive type, every NullableXxx record, nested objects with cycle detection at plan-build time, TObjectList<T> and TList<T> with per-item polymorphic resolution, TArray<T>, streams as base64, TDataSet properties (delegated to the legacy dataset serializer, so name casing, ignored fields, nested datasets and blob handling all behave exactly as before), and the MVCNameAs, MVCNameCase and MVCDoNotSerialize attributes.

Two shapes stay with the legacy serializer by design: classes marked [MVCSerialize(stFields)], and properties whose type has a custom IMVCTypeSerializer registered. The output is byte-identical there too.

If something unsupported does turn up in the middle of an emission, on a polymorphic list item resolved at runtime for instance, the streaming writer rewinds the output stream to the mark it took before the first write, discards its thread-local state and returns False, so the caller re-serializes the whole response through the legacy path. No partial bytes ever reach the wire.

Related but separate: a forward-only dataset can now be streamed to the client record by record, with flat server memory, instead of being materialized in full:

[MVCPath('/customers')]
[MVCHTTPMethod([httpGET])]
function GetCustomers: TMVCStreamedResponse;
begin
  Result := StreamDataSet(qry);
end;

Chunked streaming needs a backend that can hand out the socket, so this one works on Indy Direct and HTTP.sys; on WebBroker it fails cleanly with a 501 before any byte is sent.


ActiveRecord

The headline change is composite primary keys. For years TMVCActiveRecord accepted exactly one foPrimaryKey column and refused the second at startup, leaving junction tables and every naturally multi-column key ((order_id, line_no), (tenant, code)) to a surrogate id column that nobody ever queried. You now mark every key column the same way you already marked one:

[MVCTable('user_roles')]
TUserRole = class(TMVCActiveRecord)
private
  [MVCTableField('user_id', [foPrimaryKey])]
  fUserID: Integer;
  [MVCTableField('role_id', [foPrimaryKey])]
  fRoleID: Integer;
  // ...
end;

The by-key methods grew plural counterparts, LoadByPKs, GetByPKs, GetPKs, SetPKs, plus HasCompositePK when you need to ask:

lRole := TMVCActiveRecord.GetByPKs<TUserRole>([1, 42]);

Load and Refresh are fail-loud: a key that matches no row raises, so what you hold after the call is always a real row. In TMVCActiveRecordController a composite key travels as a JSON array in the URL segment, GET /user_roles/[1,42], while single-key entities keep the familiar /customers/1. Single-key entities also generate byte-for-byte identical SQL to 3.4.x: the composite path only activates when a second foPrimaryKey is declared.

Alongside the class methods there is IMVCRepository<T> (MVCFramework.Repository) over the same entities. Being an interface, it can be registered in the container and injected into controllers and services with [MVCInject], which is the difference that matters when you want to substitute it in a test.

The shared ActiveRecord suite now runs against SQLite, Firebird, PostgreSQL, MySQL/MariaDB, InterBase and Oracle.

There is a longer walkthrough of composite keys, including the questions this section skips, in Composite Primary Keys in Delphi MVC Framework ActiveRecord.


Breaking changes

Three, and each one is a small edit or nothing at all. If you are upgrading from 3.4.x, this is the section to read carefully.

1. TGUID serializes without braces

Before:

{ "id": "{550E8400-E29B-41D4-A716-446655440000}" }

After:

{ "id": "550e8400-e29b-41d4-a716-446655440000" }

The new default is RFC 4122, which is what JavaScript, Java, Python, .NET and database clients expect. It bites only Delphi callers that parse responses with a regex assuming the braces. Restore the old format globally at startup:

uses MVCFramework.Serializer.Commons;
...
MVCGuidSerializationTypeDefault := gstBraces;

or per field with [MVCGuidSerialization(gstBraces)].

2. A zero TDate / TDateTime / TTime no longer serializes as null

Before, a zero TDateTime emitted null, because the framework used zero as a “not set” sentinel from a time when NullableDateTime did not exist. After, zero is what it actually is, a valid instant:

{ "when": "1899-12-30T00:00:00.000+00:00" }

There is no flag to restore the old behavior here, and that is deliberate: the sentinel was lossy and broke round-trips. If a field really can be absent, declare it NullableTDateTime, which serializes HasValue = False as null and lets zero keep meaning zero.

3. TMVCListener is an Indy Direct server now, and is deprecated

TMVCListener and TMVCListenerProperties (MVCFramework.Server) used to require a TWebModuleClass and run on TIdHTTPWebBrokerBridge. They now host a TMVCEngine directly on TMVCIndyServer, with no WebBroker layer, so the configuration API changed: SetWebModuleClass and SetSSLOptions are gone, replaced by SetConfigAction (engine config keys, applied while the engine is created) and SetEngineConfig (controllers and middleware, applied after).

Before:

TMVCListener.Create(TMVCListenerProperties.New
  .SetName('App').SetPort(8080)
  .SetWebModuleClass(TMyWebModule));

After:

TMVCListener.Create(TMVCListenerProperties.New
  .SetName('App').SetPort(8080)
  .SetEngineConfig(
    procedure(AEngine: TMVCEngine)
    begin
      AEngine.AddController(TMyController);
      AEngine.AddMiddleware(UseMemorySessionMiddleware(0));
    end));

The migration is mechanical: the body of the old WebModuleCreate, the AddController and AddMiddleware calls, moves into the SetEngineConfig procedure, and any TMVCConfig assignments move into SetConfigAction.

TMVCListener is also deprecated, and will be removed in 4.0. After the conversion it is a thin wrapper over IMVCServer that exposes strictly less: Indy only, no HTTPS, only MaxConnections. Build servers through TMVCServerFactory instead, which is the same lifecycle with the other two backends and built-in TLS available. Existing code keeps compiling with a deprecation warning in the meantime.


Performance

All the numbers below are the median of 3 runs of 30 seconds at c=100, on a loopback HTTP.sys bench: i9-13980HX, Windows 11, Release Win64. That context belongs with the numbers: a throughput figure without the machine, the concurrency and the transport behind it says nothing.

Scenario Before After Delta
health 2354 3380 +44%
json/small 2099 2858 +36%
json/large 735 889 +21%
heavy chain 1874 3131 +67%
upload 1 MB 95 892 +839%
pods/small (*) new 3132 +18.6% over legacy
pods/large (*) new 438 +74.6% over legacy

(*) new benchmark scenarios introduced in 3.5.x to exercise the streaming serializer.

The gains split into two kinds. The route table (computed once at AddController time and indexed by method, then by path, replacing the per-request RTTI scan) and the render fast path for OKResponse(TJsonBaseObject) are cross-cutting optimizations: they help every backend, in the 20% to 70% range on this workload.

The 1 MB upload row is a different animal. 95 requests per second meant the kernel-mode server was broken rather than slow, and the reason was that the HTTP.sys listener read the body and ran the whole pipeline on the listener thread, serially. RC6 dispatches both to the default task pool, and when Content-Length is known the body is written straight into a pre-sized TBytes instead of a TMemoryStream followed by a SetLength and a Move. Read the 892 rps as a bug fix rather than an optimization: it is what HTTP.sys was always able to do.

The full matrix is honest about the other direction too: heavy on Indy Direct measured -9%, which on a bench machine with run-to-run variance around 20% reads as neutral, not as a regression. Deltas under roughly 15% on this rig are noise. The cross-backend comparison, and the WebBroker runs (not comparable at c=100 on this machine, where the server does not stay up for the whole run), are in performancetest/results/BASELINE_AFTER.md.


Trying it out

Two ways in.

Download the zip from the release page, add sources to your library path, and that is the whole setup for an existing project.

Or install the IDE wizard and let it scaffold one. The presets show up in the IDE’s New Items dialog, under Delphi > DelphiMVCFramework:

The 8 DelphiMVCFramework project presets in the RAD Studio New Items dialog

It ships 8 project presets: RESTful API, Minimal API RESTful, Web Application, Minimal API WebApp, JSON-RPC Service, Real-Time Application (WebSocket), Full-Stack Application, and Custom Project with every option exposed. Each preset fills the same wizard form with different defaults, so you can accept them or change anything before generating. The default host in every preset is Indy Direct.

If something breaks, or if an upgrade from 3.4.x needs a step that is not in the breaking changes section above, please open an issue on GitHub before the stable tag. That is what a release candidate is for. A bug found now is a fix in 3.5.0; the same bug found later is a fix in 3.5.1 and an afternoon of somebody’s life.


Resources

PATREON Community

Enjoy!

– Daniele Teti

Comments

comments powered by Disqus