Repelling Architecture Astronauts: Why FunkyORM Terrifies Enterprise Purists (And How to Test It in LINQPad)
Want to repel architecture astronauts from your .NET codebase? Show them this ORM.
It makes [NotMapped] mostly unnecessary, gives you no excuse for layers of in-process DTOs, and pushes your team to just get shit done.
If you’ve spent any time working in enterprise C# over the past decade, you know the feeling of opening a pull request that touches one single database column, only to find yourself editing twelve different files across six projects.
Let’s break down why enterprise purists get nervous around FunkyORM (Funcular.Data.Orm), the pattern that motivates it—the “Slim Domain” white paper by Paul Smith of Funcular Labs (the organization that also maintains FunkyORM, which the paper discloses)—and how you can spin it up in LINQPad in under sixty seconds.
The Enterprise .NET Epidemic: A Screen of 6 Fields, An Aggregate of 60
Paul Smith describes the core disease of a lot of .NET data access with brutal precision:
“A screen that displays six fields materializes an aggregate of sixty; a service that updates one column tracks the state of forty; a report that needs three joined values pulls three object graphs and discards them after reading.”
How did we get here?
Somewhere along the line, a lot of enterprise teams decided that “Clean Architecture” meant wrapping every relational database table in concentric layers of ceremony.
To display an employee’s name and department on a web page, you have:
- An Entity Framework Core entity with 40 navigation properties.
- A Domain Entity with private setters and factory methods.
- An Application DTO mapped via AutoMapper.
- A CQRS Query Handler with a MediatR wrapper.
- A Presentation ViewModel mapped again from the DTO.
And the crowning glory? The mapping-attribute tax.
Appendix B of the Slim Domain white paper measured two production systems from the same lineage: a legacy, designer-era Entity Framework platform with active-record Save() methods, and a newer system built on the Slim Domain pattern with a micro-ORM. The legacy system’s business and data layers carried:
- 10,843 schema-mapping attributes (
[Column],[Key],[Table], …) - 1,814
[NotMapped]attributes
The two systems differ about 17x in size, so the paper compares rates per 100,000 lines rather than raw counts. Normalized, the legacy system carried 12.6x more schema-mapping attributes and 4.7x more [NotMapped] attributes per line than the Slim Domain system. That’s an observational comparison against a legacy stack, not a controlled migration (the paper is careful to say so), but it puts a number on a cost that’s usually only argued about. (The companion field guide has the full table and caveats.)
Every one of those [NotMapped] attributes marks a settable property with no backing column—a transient UI flag, a value populated by a service—that the ORM would otherwise try to read and write. (EF Core already skips get-only computed properties like FullName => ...; it’s the settable ones that need the attribute.)
Download LINQPad Today
Get started with the most powerful .NET Rapid Progress Tool (RPT) available.
Get LINQPad PremiumPowerful .NET acceleration. Code at the speed of thought.
The In-Process DTO Myth
Let’s address the elephant in the room: in-process DTO gymnastics.
Data Transfer Objects (DTOs) were originally invented for a clear, valuable reason: boundaries. When passing data over HTTP, gRPC, or an asynchronous message queue, you need a stable contract that decouples your internal database schema from the external caller.
But a lot of architecture dogma took this concept and applied it inside the exact same operating system process:
Database Record
──> EF Core Entity
──> Domain Model (AutoMapper)
──> Service Layer DTO (AutoMapper)
──> API Response DTO (AutoMapper)
You are allocating memory on the managed heap, burning CPU cycles in AutoMapper profiles, and maintaining dozens of nearly identical C# classes—all to transport data within 8 inches of motherboard silicon.
DTOs belong at real boundaries: the network, but also trust (an allow-list of what leaves), contract stability for consumers you don’t control, another team’s bounded context, and inbound request DTOs that stop over-posting. Slim Domain keeps every one of those. But inside a single process, between layers with the same owner and release cycle, copying 40 properties from one class into another identical class is pure waste.
Enter FunkyORM: A Micro-ORM That Makes the “Slim Domain” Easy
To be clear about the relationship: Slim Domain is a pattern, not a library—the paper says it works with Dapper, LinqToDB, or a constrained EF Core too. It isn’t an application architecture either; it fits inside Clean, Onion, or Vertical Slice designs. FunkyORM is simply the on-ramp that makes its habits the path of least resistance.
FunkyORM is an open-source (MIT), lambda-powered micro-ORM: no change tracker, no DbContext, and type-safe LINQ queries instead of SQL strings.
Here are the key superpowers that make it radically different:
1. Bidirectional Intersection Mapping (Goodbye [NotMapped])
In FunkyORM, mapping operates on the intersection of your C# class and your database table:
- If your class has a property that doesn’t exist in the database table (a calculated
FullName, or a settable UI flag), FunkyORM ignores it. No[NotMapped]attribute needed. - If the database table has columns that aren’t on your C# class (like audit timestamps or internal IDs), FunkyORM ignores them too.
// No attributes required!
// Maps to table 'Person' or 'person' automatically
public class Person
{
public int Id { get; set; } // Auto-detected as Primary Key
public string FirstName { get; set; }
public string LastName { get; set; }
public int? EmployerId { get; set; }
// No matching column, so it's ignored on reads AND writes.
public string FullName => $"{FirstName} {LastName}";
// Settable and still no attribute needed.
public bool IsSelected { get; set; }
}
2. [RemoteProperty] & [RemoteKey]: Flattening Relational Joins
What happens when your screen needs columns from related tables?
In Entity Framework, you typically choose between:
- An
.Include().ThenInclude().ThenInclude()chain that pulls full entity graphs into memory. - A
Select(x => new PersonView { ... })projection—efficient, but a query-by-query shape you write and maintain at every call site.
FunkyORM introduces Remote Properties: you declare the shape once, on a class, and pull foreign table values directly onto it:
[Table("Person")]
public class PersonDetail : Person
{
// Fetches the organization name from the related table via EmployerId
[RemoteProperty(typeof(Organization), nameof(EmployerId), nameof(Organization.Name))]
public string EmployerName { get; set; }
// Traverses 3 joins: Person -> Organization -> Address -> Country
[RemoteKey(typeof(Country), new[] {
nameof(EmployerId),
nameof(Organization.HeadquartersAddressId),
nameof(Address.CountryId),
nameof(Country.Id)
})]
public int? EmployerCountryId { get; set; }
}
When you query provider.Query<PersonDetail>(), FunkyORM automatically emits the necessary LEFT JOINs in SQL. You get the exact flat model you need without materializing bloated entity graphs.
3. No Navigation Properties = No N+1 Query Traps
Look at the Person class above. Notice what’s missing?
There is no public virtual ICollection<Order> Orders { get; set; }.
FunkyORM has no navigation properties and no lazy loading at all, so the lazy-loading N+1 trap (opt-in in EF Core, but common in codebases that turned it on) and Include-driven Cartesian explosions simply have nowhere to happen. You can still write a loop that queries per row—no ORM can stop that—but nothing will do it behind your back.
If you need orders, you query orders explicitly:
var orders = provider.Query<Order>()
.Where(o => o.CustomerId == customerId)
.ToList();
4. “Nouns Never Save Themselves”
The Slim Domain pattern rejects the Active Record pattern (customer.Save()), and FunkyORM has no ambient DbContext change tracker to hide behind.
Canonical entities are simple, passive data carriers—just the table’s columns. They do not know how to persist themselves, and they don’t carry tracking state across method calls. (Domain behavior still has a home: business objects that inherit or wrap the entity and add methods and invariants. Slim persistence contract, not an anemic domain.)
Persistence is always an explicit action gated by services:
provider.Insert(person);
provider.Update(person);
// Deletes require an explicit transaction
provider.BeginTransaction();
try
{
provider.Delete<Person>(person.Id);
provider.CommitTransaction();
}
catch
{
provider.RollbackTransaction();
throw;
}
5. Built-in Mass Delete Guards
This is not cowboy code. FunkyORM includes built-in safety rails that protect production databases from the most common catastrophic typos:
// Inside a transaction, both of these throw before any SQL is sent!
provider.Delete<Person>(p => true);
provider.Delete<Person>(p => p.Id == p.Id);
The guards catch the plain “matches every row” shapes (including things like p => p.Id == id || true). They aren’t a proof—the docs admit x => x.Id == 2 || x.Id != 2 slips through—so they’re a seatbelt, not a substitute for reviewing your delete logic.
Testing FunkyORM in LINQPad: The Zero-Setup Scratchpad
The absolute fastest way to experience FunkyORM is inside LINQPad. Because Funcular.Data.Orm bundles the SQLite provider in the one package, you don’t even need a running SQL Server instance to test this workflow.
Save the script below as a .linq file and open it in LINQPad—the XML header adds the NuGet packages and namespaces for you—then hit F5. Or create a new C# Program query, press F4 to add Funcular.Data.Orm and the namespaces from the header, and paste everything below the </Query> line:
<Query Kind="Program">
<NuGetReference>Funcular.Data.Orm</NuGetReference>
<NuGetReference>Microsoft.Data.Sqlite</NuGetReference>
<Namespace>Funcular.Data.Orm.Sqlite</Namespace>
<Namespace>Funcular.Data.Orm.Attributes</Namespace>
<Namespace>Microsoft.Data.Sqlite</Namespace>
<Namespace>System.ComponentModel.DataAnnotations.Schema</Namespace>
</Query>
void Main()
{
// 1. Connection string & database path
SqliteConnection.ClearAllPools();
var dbPath = Path.Combine(Path.GetTempPath(), "funky_demo.db");
var connStr = $"Data Source={dbPath}";
// 2. Setup schema using SqliteConnection (DDL)
using (var conn = new SqliteConnection(connStr))
{
conn.Open();
using (var cmd = conn.CreateCommand())
{
cmd.CommandText = @"
DROP TABLE IF EXISTS Person;
DROP TABLE IF EXISTS Organization;
CREATE TABLE Organization (
Id INTEGER PRIMARY KEY AUTOINCREMENT,
Name TEXT NOT NULL
);
CREATE TABLE Person (
Id INTEGER PRIMARY KEY AUTOINCREMENT,
FirstName TEXT NOT NULL,
LastName TEXT NOT NULL,
EmployerId INTEGER NULL
);";
cmd.ExecuteNonQuery();
}
}
// 3. Initialize FunkyORM SQLite provider
var provider = new SqliteOrmDataProvider(connStr);
// 4. Seed test data
var org = new Organization { Name = "Acme Corp" };
int orgId = provider.Insert<Organization, int>(org);
var person = new Person
{
FirstName = "Grace",
LastName = "Hopper",
EmployerId = orgId
};
provider.Insert<Person, int>(person);
// 5. Query using derived detail class with [RemoteProperty]
var results = provider.Query<PersonDetail>()
.Where(p => p.LastName.StartsWith("Hop"))
.ToList();
// 6. Inspect in LINQPad
results.Dump("FunkyORM Flat Query with RemoteProperty");
}
public class Organization
{
public int Id { get; set; }
public string Name { get; set; }
}
public class Person
{
public int Id { get; set; }
public string FirstName { get; set; }
public string LastName { get; set; }
public int? EmployerId { get; set; }
// Bidirectional Intersection: no column, so it's ignored automatically
public string FullName => $"{FirstName} {LastName}";
}
// Map derived class back to the base table
[Table("Person")]
public class PersonDetail : Person
{
[RemoteProperty(typeof(Organization), nameof(EmployerId), nameof(Organization.Name))]
public string EmployerName { get; set; }
}
Notice what just happened:
- No
DbContextconfiguration. - No migration files.
- No
[NotMapped]onFullName. PersonDetailautomatically pulledEmployerNamefromOrganizationvia aLEFT JOINgenerated by FunkyORM..Dump()rendered the exact flat shape instantly.
When Should You NOT Use FunkyORM?
Pragmatism means knowing when not to use a tool. FunkyORM is deliberately opinionated, and there are valid scenarios where Entity Framework Core (or another library) remains the better choice:
- Arbitrary Graph Traversal (GraphQL / OData): If you expose client-driven graph endpoints where frontend callers can request arbitrary nested entity trees, EF Core’s LINQ provider is far better suited.
- Complex DDD Aggregate State Tracking: If your business logic modifies 15 entities across an aggregate boundary and relies on a Unit of Work to commit them in a single change-set, EF Core’s change tracker is designed for that pattern.
- Existing Tooling & Migrations: If your CI/CD pipeline is built around
dotnet ef migrations, adopting FunkyORM means managing schema changes through SQL scripts or Flyway/DbUp. - Bulk loads and library maturity: FunkyORM has no bulk-operations API, and it’s a young library—the Slim Domain paper itself rates its community “Emerging.” Pin your version and read the Changelog before upgrading.
The Verdict
If your team is suffering from architectural fatigue—where building simple CRUD APIs feels like wading through wet cement—FunkyORM is an eye-opening antidote.
By pairing a Slim Domain with convention-based intersection mapping and remote properties, you eliminate layers of boilerplate and let your C# code do what it does best: deliver value quickly.
Grab Funcular.Data.Orm from NuGet, browse the source on GitHub, pop open LINQPad, and test it for yourself. When you’re ready for the full API tour—CRUD, querying, view-replacing attributes, and DI lifetimes—read the complete field guide.
Want to practice these concepts hands-on? Try LINQPad—it’s the fastest way to experiment with code, test database queries, and build your confidence for interviews. For more in-depth learning, check out our LINQPad Fundamentals course.