Skip to content
Kumar Chandrachooda
.NET

The Multi-Result-Set Dream EF Core Wouldn't Grant

An entire stored-procedure framework built on an EF6 extension library, shipped inside an EF Core app, in a folder named Future - the anatomy of an abandoned migration and how to recognise one.

By Kumar Chandrachooda 27 May 2025 6 min read
Two result sets, one door that never opened

The most instructive code in the attendance platform never ran once. Sitting beside the three live stored-procedure mechanisms from part six is a folder — named, with painful honesty, Future MultiResult — containing a complete, carefully structured invocation framework that cannot execute inside this application. Not “is never called”: cannot run. It is built on Entity Framework 6 types that do not exist in the EF Core runtime the app actually uses.

This part is a post-mortem of that folder: what its author was trying to buy, why EF Core 3 refused to sell it, and how to recognise an abandoned migration when you inherit one — because you will.

What the folder wanted

The wish was legitimate and specific: multiple result sets from one stored procedure call. Several of the platform's dashboard procedures were written the natural T-SQL way — one round trip that returns a summary block and the detail rows:

CREATE PROCEDURE dbo.GetTeamDashboard @ManagerId int, @From date, @To date
AS
BEGIN
    -- result set 1: the headline numbers
    SELECT COUNT(*) AS PendingApprovals, AVG(...) AS AvgHours
    FROM ...;

    -- result set 2: the rows behind them
    SELECT d.LogDate, d.EmployeeName, d.SuggestedShiftId, d.IsApproved
    FROM ...;
END

One connection, one execution, two shapes. Every database API since classic ADO.NET has supported consuming this — and EF Core 3 simply did not. FromSqlRaw materialises exactly one result set into exactly one entity type; the second SELECT is discarded unread. The workarounds were to split the procedure into two calls (two round trips, and the two sets can now disagree about the world between them), or to drop below EF to raw ADO.NET. The platform mostly split its procs — which is why its repositories are full of paired GetXSummary/GetXRows methods that the database could have answered together.

Old Entity Framework — EF6, the full-framework one — could do it, through the ObjectContext API: execute a command, translate the first result set, then call GetNextResult<T>() to translate the next. A community library, EntityFramework.CodeFirstStoreFunctions, packaged that machinery pleasantly. Someone on the team knew all this. And so the folder came to be.

It is worth being precise about why EF Core 3 refused, because it was a deliberate omission, not an oversight. EF Core was a ground-up rewrite; the entire ObjectContext metadata subsystem that EF6 used to reason about result shapes was left out of the new stack by design, and multiple result sets rode along with it. The team's public position for years was that the feature would return, and it eventually did in a limited form — but in the 3.x window this application targeted, the honest answer was “not supported, use ADO.NET”. A developer coming from EF6, where GetNextResult<T>() was a one-liner, would have experienced this as a regression: a thing the framework used to do, gone. That felt like a bug to route around rather than a boundary to respect, and routing around it by dragging the EF6 library back in is a very human response to a capability that was taken away.

The framework that shipped but never ran

Inside Future MultiResult is a proper little architecture — a base class, a conventions registrar, a typed functions facade. Its bones, reconstructed illustratively:

public class DboFunctions : DbFunctionsBase
{
    [DbFunctionDetails(ResultTypes = new[] { typeof(DashboardSummary), typeof(DashboardRow) })]
    public ObjectResult<DashboardSummary> GetTeamDashboard(int managerId, DateTime from, DateTime to)
    {
        var adapter = (IObjectContextAdapter)Context;         // EF6 only
        return adapter.ObjectContext                          // EF6 only
            .ExecuteFunction<DashboardSummary>("GetTeamDashboard", ...);
    }
}

IObjectContextAdapter. ObjectContext. ObjectResult<T>. None of these types exist in EF Core — not renamed, not relocated; the entire underlying metadata system they belong to was left behind in the rewrite. The folder compiles only because the EF6 package reference brings its own copies of those types along; they just can never be handed an EF Core DbContext. The author built the shape of the solution, reached the seam where an EF Core context must become an ObjectContext, discovered the seam is a wall, and stopped.

What they did not do is delete. The folder stayed. The EntityFramework.CodeFirstStoreFunctions package reference stayed — an EF6 library in an EF Core csproj, baffling every subsequent reader and every dependency audit. The IDE reports zero usages of every type in the folder. It shipped to production in every release for years, dead weight with perfect attendance. It is the largest single headstone in what part fourteen will tour as the dependency graveyard.

Reading an abandoned migration

I have come to treat artefacts like this as a genre, and the genre has field marks. In ascending order of certainty:

  1. A package from the wrong lineage. EF6 beside EF Core, System.Web types beside ASP.NET Core, Microsoft.Azure.KeyVault beside Azure.Security.KeyVault (this estate manages that one too — see part thirteen). Parallel-universe dependencies mean someone was mid-crossing.
  2. Zero inbound references. Not few — zero. Live code, however bad, accumulates callers. Dead code is pristine.
  3. Aspirational naming. Future, New, V2, Temp. A folder named Future MultiResult is a confession with a timestamp: the future was planned, then postponed, then forgotten. The name outlived the intention by years.
  4. The stopping point is a hard wall, not a soft edge. Abandonment from boredom leaves TODOs mid-file. Abandonment at an impossibility stops precisely at the impossible line — here, the cast to IObjectContextAdapter. You can read the moment of discovery in the code's structure.

The kindest thing you can do for the next reader, when you hit such a wall yourself, is leave a tombstone instead of a body: delete the code, and put the knowledge in a short note or ADR — “EF Core 3 cannot translate multiple result sets; we split procedures instead; revisit if the runtime gains support.” Git remembers the code forever; the repository's job is to remember the decision.

What I would have done for the actual need

The unglamorous truth is that the need never required a framework. Raw ADO.NET consumes multiple result sets in a dozen lines, and it composes fine with EF Core — same connection, same types:

using var command = context.Database.GetDbConnection().CreateCommand();
command.CommandText = "dbo.GetTeamDashboard";
command.CommandType = CommandType.StoredProcedure;
command.Parameters.Add(new SqlParameter("@ManagerId", managerId));

await context.Database.OpenConnectionAsync();
using var reader = await command.ExecuteReaderAsync();

var summary = await ReadSingleAsync<DashboardSummary>(reader);
await reader.NextResultAsync();                       // the whole feature, one call
var rows = await ReadAllAsync<DashboardRow>(reader);

NextResultAsync is the entire multi-result-set feature; everything else is mapping, and Dapper's QueryMultiple will do the mapping for you if reflection-by-hand offends. Two procedures in this app justified it. A twelve-line helper beats a framework you have to abandon.

The general lesson outlasts the specific library. When a framework declines to do something, it has said one of two things: not yet, or not here. The mistake this folder made was hearing “not yet” and trying to import the previous framework's yes wholesale, rather than stepping one layer down to the primitive both frameworks are built on. EF Core and EF6 both sit on ADO.NET; the multi-result-set capability never left — it was always available at the DbDataReader level, one NextResult() call away, in a form neither framework can remove because neither framework owns it. When the ORM says no, the answer is usually one layer down, not one framework back. Reaching backward pulls an entire alternate runtime into your dependency graph to recover a feature that a dozen lines of the shared substrate would have given you cleanly.

The dream was reasonable; the framework was the mistake. Wanting EF to do everything is how you end up building a second EF inside your first one — and this folder is what it looks like when the second one dies in the shell.

There is a pleasing irony in where the effort should have gone: the same estate contains a hand-rolled dynamic SQL engine of real ambition, powering the platform's flagship feature. Users drag columns onto a canvas, and the application writes their SQL for them — parameterising the values and, alas, string-gluing the identifiers. That story is next.