Experiencing Entity Framework Nightmares? Try FunkyORM.

We’ve lost count of the number of codebases — client work, inherited platforms, the occasional “can you just take a look at this?” — where Entity Framework was the thing that made a simple reporting screen feel like a hostage situation. EF isn’t the villain in that story; it will cheerfully let you write C# that looks finished… while the SQL that actually hits MSSQL is something you would never have signed if you’d seen it first.
That gap — between the LINQ you wrote and the query plan you got — is where the nightmares live. At Funcular Labs we’ve walked into the same ones over and over; N+1 loops dressed up as “clean” repositories, reverse joins that explode a one-row lookup into a cartesian mess, an EDMX (or a Core model snapshot) change whose impact on the emitted SQL nobody in the room can predict, and the classic Include() / ThenInclude() chain that returns every column on every table in the join set… when all you wanted was a customer name and last order date.
We built FunkyORM to close that gap. Framework wars bore us; predictable SQL does not. The other goal was to shorten the path from “zero” to “I know what this query will do,” which EF makes you walk the long way around.
The nightmares we keep seeing
You already know the greatest hits. Still worth naming them out loud, because each one is a different way the framework hides cost.
N+1, wearing a business-logic costume. A list screen loads customers. Then, inside the loop that binds the grid, something asks for that customer’s open orders. In C# it reads like a reasonable object model; in SQL Profiler it reads like a denial-of-service test against your own database. Thirty customers, thirty extra round-trips — or three hundred, if the page size “accidentally” went up last sprint.

Lazy loading makes this worse, not better; you don’t even see the second query in the method that started the trouble. You see it in production, on a Tuesday, when the Midwest region finally has enough rows to matter.
Include() as a blunt instrument. The “fix” for N+1 is usually a graph load. That’s the other ditch.

That’s six lines of C# that feel tidy. EF has to compose a shape that can materialize the whole graph, so the SQL picks up columns — lots of them — from every table you touched. The short version already looks like a cargo manifest:

And that is the polite rendering. Once the EDMX (or the Core model) has billing and shipping addresses, row versions, and a product description hanging off the line, the same Include() chain emits the thing we actually keep finding in Profiler — every column, at every level, aliased as id1 / name1 / created_utc3 so the materializer can stuff the graph back together:

You asked for Midwest customers and enough related data to render a list. You received billing streets, shipping streets, product descriptions, costs, row versions, and three copies of created_utc — the whole join set, row-multiplied by every order line. That’s fine for a detail page you’ll hit once. It’s a problem for a grid you’ll hit all day. Notice also what you cannot do from the C#: glance at the Include() list and know which columns come back. The column list is a property of the model, not of the query.
Reverse joins and fan-out you didn’t budget for. This is the EDMX special. You wire Order.Customer and, because the designer likes symmetry, you also get Customer.Orders. Then someone writes a perfectly reasonable “load this invoice” query and walks back through the parent onto the parent’s collection:

Navigation properties don’t know your intention; they know the model. Start at the child, Include the parent, Include the parent’s other children… and a one-row lookup becomes a join back into order for every sibling, plus every note on those siblings. The emitted SQL says it out loud — Extent1 is the invoice you wanted, Extent3 is every other order for that customer, and the customer columns get selected again as id1 / created_utc1 while the materializer rebuilds a graph you never asked to hold:

One invoice. One customer. And then the rest of that customer’s life, because the relationship was reciprocal and the Include() path was allowed to turn around. We have seen this pattern so many times in inherited EDMXs that we treat Include(o => o.Customer.Orders) as a smell of its own.
Model changes you can’t mentally compile. This one is older than Core and it never really left. In the EDMX days you dragged a relationship, hit Save, and hoped the next query still resembled the last one. In EF Core you edit a fluent mapping or a snapshot, add a shadow property, flip a delete behavior — and the emitted SQL shifts in ways that are technically correct and operationally surprising. We’ve sat with teams who could not tell us, before running the app, whether a given change would add a join, change a cardinality, or start selecting a column that used to be ignored. If you can’t predict the SQL, you don’t own the query; you’re renting it.
That’s the through-line. Generated SQL we can live with. What keeps us up is the version of the story where nobody in the room can say what this C# will cost until it has already cost us.
What we wanted instead
When we started FunkyORM, the brief was deliberately small:
You should be able to look at a class and know which tables it touches.
You should be able to look at a query and know which columns come back.
Related data should be opt-in: a property you declared, on purpose, with a name you can search for.
The provider should log the SQL in a form a DBA would recognize; parameters and all.
And you should get there without a DbContext, without a migrations assembly, and without a week of “just getting the model right.”
That’s the product. The rest of this post is how you actually use it — and how the C# and the SQL stay in the same conversation.
If you want the origin story and the original MSSQL-only release notes, they’re in Introducing Funcular ORM. The bits below are the parts that kill the nightmares: remote properties, the light/heavy entity split, and view-style report types.
Two classes for two jobs
FunkyORM’s habit — the one that pays the rent — is to keep a canonical entity skinny, and hang the expensive shape on a second type.
The canonical class maps the table. No joins. No navigation collections. You can Get it, Query it, insert it, update it, and the SQL stays boring (boring is a compliment).
The detail class inherits that shape and adds [RemoteProperty] (and friends). Those attributes are a join declaration you can read without opening a profiler.

A few things to notice, because they are the whole point:
Customer does not know Employee exists. Querying it cannot accidentally join employee.
CustomerDetail names the path in nameof — AccountManagerId to Employee.LastName — so a rename breaks at compile time, not at 2 a.m.
There is no Customer.Orders collection hanging off the type “for convenience.” That kind of convenience is how N+1 gets into production.
That’s the light/heavy split. Use the light type when you mean the table. Use the heavy type when you mean the view.
A provider, a lambda, a query you can read
Setup is one object and a connection string. Log the SQL if you like; we do, especially in the first week on a new schema.

Same filter — Midwest — two different shapes. The light query emits a single-table select. No apology tour through order or product:

The detail query adds exactly the join you declared, and exactly the remote columns you named — last name and email, period. The rest of employee stays put. No collection. No cartesian product with order lines.

Hold those two SQL screenshots next to the EF Include() output from earlier. Benchmarks can wait for the next post; the thing we care about here is whether you can look at the C# and describe the SQL before it runs. On the Funky side, you can. On the EF graph-load side, you are guessing at column lists and row multiplication.
Remote properties compose with the usual lambda operators — equality, null checks, Contains, comparisons — and they parameterize. That @p__linq__0 in the listing is the real parameter name FunkyORM emits today (see ParameterGenerator in the SQL Server provider). Values stay out of the command text.
When the “entity” is really a report
A lot of the pain we see isn’t CRUD at all. It’s the page that wants a customer, the account manager’s name, a count of orders, and the most recent order date. In EF that turns into a projection DTO, or a database view, or an Include you will regret. In older shops it turns into an EDMX entity mapped to a view that three teams are afraid to touch.
FunkyORM lets that shape live in C#, as an extended detail type — a view you can compile. Remote columns, a subquery aggregate, a SQL expression when you genuinely need a scalar the LINQ surface doesn’t cover:

And the SQL is still a thing you can read — a narrow select, one join, two scalar subqueries that match the attributes you wrote:

Is that the fastest possible plan on a 40-million-row order table? On a quiet day, maybe; on a busy one you’d index order.customer_id and perhaps rewrite the count as a persisted summary if this page is the heartbeat of the business. Either way, you can see the plan’s ingredients from the class — instead of meeting them for the first time in a 200-line EF command text after the deploy.
That’s also why we tell people not to hang [RemoteProperty] on the canonical type. The moment you do, every “just get me the customer” call pays for the join. Keep the skinny type skinny; give the report its own name. Your future self — the one writing the next list screen — will not have to wonder which shape they just loaded.
What this has to do with the learning curve
We owe you a follow-up post; this one is the groundwork.
The next piece will try to quantify two intervals for both frameworks: the time from zero to productive, and the time from productive to mastery. “Productive” meaning you can ship a real query without hovering over the docs. “Mastery” meaning you can predict the SQL — columns, joins, parameters, cardinality — before you run it.
EF’s zero-to-productive story is genuinely good. Scaffold a context, dotnet ef a migration, write a LINQ predicate, get rows. Plenty of developers are productive in a week. The tax shows up in the second interval. Mastery in EF is a large surface: change tracking and when to turn it off, AsNoTracking vs identity resolution, split queries vs one big join, lazy vs explicit vs eager, global filters, compiled queries, raw SQL fallbacks, the way a “small” model change rewrites a query you didn’t touch. You can be productive and still be unable to forecast what Friday’s mapping edit does to Monday’s report.
FunkyORM’s bet is that the first interval stays short — provider, POCO, lambda — and the second interval collapses, because the concepts you have to master are fewer and they sit closer to SQL. Remote properties. Light vs heavy types. An opt-in expression when the database already knows how to say it. If you can read a LEFT JOIN, you are most of the way to mastery; the remaining folklore fits on an index card.
We’re not trying to wave a stopwatch here (though Funky does pretty well on stopwatches); we’re trying to name the finish line we were racing toward. Predictive SQL is the mastery test we intend to use. The nightmares above are what it looks like when a team never takes that test and ships anyway.
Try it on the query that already hurts
If you’ve got a screen that makes you flinch when someone says “just Include the collection,” that’s the one to port first. Keep the canonical entity for writes and simple reads; hang a detail or scorecard type next to it; turn on Log; read the SQL. You can install the package from NuGet (Funcular.Data.Orm) and steal patterns from the GitHub repo — Usage.md is the long-form map.
We’ll be back with numbers on that learning curve. Until then: if the C# and the SQL don’t tell the same story, the framework is improvising… and improvisation is a lousy strategy for a database.
Need a hand mapping a gnarly EF graph onto a couple of detail types? That’s the day job at Funcular Labs. Bring the query plan; we’ll bring the skinny classes.
