The Case for Using FunkyORM in Your Next Project: Architecture, Performance, and Complete Field Guide

Every .NET developer eventually faces the data access dilemma.

On one side stands Entity Framework Core: mature, feature-rich, and expressive—but carrying a DbContext lifecycle, a change tracker, and (when used carelessly) accidental Cartesian explosions and layers of architectural ceremony.

On the other side stands Dapper: blisteringly fast and featherweight—but requiring you to write and maintain raw SQL strings by hand, giving up compile-time checking and refactoring confidence on your queries.

Enter FunkyORM (published on NuGet as Funcular.Data.Orm, source on GitHub), an MIT-licensed library maintained by Funcular Labs. FunkyORM is a lambda-powered micro-ORM aiming for the middle ground: a stateless micro-ORM with no change tracker, combined with the type safety and LINQ syntax of modern C#.

This guide is the complete, single-page case for FunkyORM. We’ll explore the architectural pattern that motivates it—“Slim Domain”—look honestly at what has and hasn’t been measured about its performance, and provide technical documentation for using FunkyORM across its most common scenarios with working code examples you can run immediately in LINQPad.

Version note: Examples target Funcular.Data.Orm 3.10.0. Check the project’s Changelog before upgrading or downgrading—3.10 changed query-operator behavior.


Part 1: The Architectural Case for FunkyORM

The Enterprise Architecture Trap

Over the past decade, a lot of enterprise .NET development adopted an increasingly dogmatic interpretation of Clean Architecture and Domain-Driven Design (DDD). In the white paper Slim Domain: A Domain-Structuring Pattern for .NET (Version 1.0 RC1, October 2026), Paul Smith of Funcular Labs describes the core symptom:

“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.”

(Disclosure, as the paper itself makes: Funcular Labs, the author’s organization, maintains FunkyORM.)

When an engineering team builds a feature that reads an employee’s name and manager, they are frequently required to:

  1. Define a database entity with dozens of navigation properties (virtual ICollection<T>).
  2. Map that entity into a “Domain Aggregate” with private setters and value objects.
  3. Map the Domain Aggregate into an “Application DTO” via AutoMapper.
  4. Pass that DTO through MediatR pipeline behaviors.
  5. Map the DTO into a “Response ViewModel”.

All of this happens inside the exact same operating system process, in the exact same memory space.

It’s worth being precise about what Slim Domain is: the paper calls it a domain-structuring pattern—”not an application architecture and not a library.” It doesn’t compete with Clean, Onion, or Vertical Slice architecture; it lives inside whichever one you’ve chosen and changes what happens at the domain and persistence seams. And FunkyORM is one on-ramp to it, not a requirement—the paper says the pattern is “equally practicable with Dapper, LinqToDB, or a deliberately constrained Entity Framework.”

The In-Process DTO Myth

Data Transfer Objects were originally formalized to solve remote-call and boundary problems. Serializing data over HTTP or sending a message across RabbitMQ requires a decoupled schema.

However, copying 40 properties from Class A to Class B inside the same process, between layers with the same owner and release cycle, provides no real decoupling—it creates an ongoing tax on CPU cycles, memory allocation, and developer velocity.

Slim Domain does not ban DTOs. It keeps them wherever a real boundary exists—confidentiality (an allow-list of what leaves), contract stability for external consumers, wire-format fit, crossing into another team’s bounded context, and inbound request DTOs that prevent over-posting. What it removes is the mapper between two layers that share a process, a trust level, and an owner.

The Hard Numbers: What the Paper Actually Measured

Appendix B of the white paper compares two production .NET systems from the same practitioner lineage:

  • System A: a mature, legacy line-of-business platform on designer-era Entity Framework, with a rich domain model and active-record Save() methods.
  • System B: a newer system built on the Slim Domain pattern with a micro-ORM.

Because System A is roughly 17x larger, the paper compares normalized rates per 100,000 lines of business and data-layer code, not raw counts:

Metric System A (raw) System B (raw) Normalized ratio
Schema-mapping attributes ([Column], [Key], [Table], …) 10,843 27 12.6x
[NotMapped] attributes 1,814 12 4.7x
Virtual collection navigation properties 780 1 24.5x
Files participating in persistence ≈560 7 —

The paper is explicit about the limits: the comparison is observational, the baseline is a legacy stack rather than a modern service-gated EF Core or Dapper codebase, and it measures structural surface—not defect rates. Read it as “here’s how much ceremony one legacy architecture obliged developers to carry,” not as a controlled before-and-after migration.

One FunkyORM feature that helps keep that ceremony low is Bidirectional Intersection Mapping: only the intersection of database columns and class properties is mapped. Everything else is ignored automatically on both reads and writes.

Download LINQPad Today

Get started with the most powerful .NET Rapid Progress Tool (RPT) available.

Get LINQPad Premium

Powerful .NET acceleration. Code at the speed of thought.


Part 2: Performance

FunkyORM’s design keeps overhead low:

  • Cached Reflection: Property discovery, column metadata, and schema information are cached (on first use per type), so repeated queries don’t pay the reflection cost again.
  • No change tracker: There’s no snapshot comparison or graph sorting on save—an Update is just an UPDATE.

What’s been measured: the project’s own benchmarks (published in its Usage Guide) report FunkyORM significantly faster than EF Core 7 for single-row writes and on par with EF Core for reads. There is no published FunkyORM-vs-Dapper benchmark. If performance is your deciding factor, the repo includes a PerformanceTests project—run it against your own schema and hardware.

Dimension Entity Framework Core Dapper FunkyORM (Funcular.Data.Orm)
Configuration Heavy (DbContext, fluent API) Minimal (None) Zero (Conventions + POCOs)
Query Language LINQ expressions Raw SQL strings Type-safe LINQ expressions
Change Tracking Automatic (opt out with AsNoTracking) None (Manual) None (Explicit service calls)
Mapping Engine Strict / Explicit Manual SQL-to-column Bidirectional Intersection
Join Handling Navigation properties / projections Manual SQL Joins / Multi-map [RemoteProperty] / [RemoteKey]
Delete Safety Unrestricted ExecuteDelete Unrestricted SQL DELETE Transaction required + guards against obvious “delete all” predicates

Part 3: Quickstart & Setup

1. Installation

FunkyORM includes providers for SQL Server, PostgreSQL, SQLite, and MySQL all in a single NuGet package:

dotnet add package Funcular.Data.Orm

2. Provider Initialization

Instantiate the provider for your target database. No DbContext subclass or fluent builder is required:

// SQLite (bundled, zero configuration)
using Funcular.Data.Orm.Sqlite;
var provider = new SqliteOrmDataProvider("Data Source=app.db");

// Microsoft SQL Server
using Funcular.Data.Orm.SqlServer;
var provider = new SqlServerOrmDataProvider("Server=.;Database=AppDb;Trusted_Connection=True;TrustServerCertificate=True;");

// PostgreSQL
using Funcular.Data.Orm.PostgreSql;
var provider = new PostgreSqlOrmDataProvider("Host=localhost;Database=appdb;Username=postgres;Password=secret;");

// MySQL (GuidFormat=Char36 lets Guid properties round-trip)
using Funcular.Data.Orm.MySql;
var provider = new MySqlOrmDataProvider("Server=localhost;Database=appdb;User ID=root;Password=secret;GuidFormat=Char36");

All four providers share the same base API. Your entity definitions and LINQ query logic are portable across database engines; the generated SQL differs per dialect.

3. Dependency Injection Registration

A provider holds its current transaction as instance state. In ASP.NET Core, register it as Scoped (or Transient) so one request’s BeginTransaction() can never become another request’s transaction:

builder.Services.AddScoped<SqliteOrmDataProvider>(sp =>
    new SqliteOrmDataProvider(builder.Configuration.GetConnectionString("DefaultConnection")));

FunkyORM’s lifetime guidance:

Environment Recommended Lifetime
Console / background service Singleton or Transient
ASP.NET Core (controllers) Scoped or Transient
Blazor Server Scoped (per circuit)

Outside a transaction, each operation gets its own pooled connection, so concurrent reads on one provider are safe. Inside a transaction, operations must be awaited sequentially.


Part 4: Core Technical Documentation & Common Scenarios

Scenario 1: Canonical Entities & Intersection Mapping

In FunkyORM, POCOs map to tables by convention:

  • Class name matches table name, ignoring case and underscores (Person, person, PERSON; PersonAddress ↔ person_address).
  • Property names match column names the same way (FirstName ↔ FirstName, first_name, FIRST_NAME).
  • Primary keys are inferred from [Key], a property named Id, or {ClassName}Id.
// Maps directly to table 'Customer' with zero attributes
public class Customer
{
    public int Id { get; set; } // Auto-detected primary key
    public string FirstName { get; set; }
    public string LastName { get; set; }
    public string Email { get; set; }
    public int? OrganizationId { get; set; }

    // Bidirectional Intersection:
    // There is no 'FullName' column, so FunkyORM ignores it on reads AND writes.
    public string FullName => $"{FirstName} {LastName}";
}

If your schema diverges from conventions, standard System.ComponentModel.DataAnnotations can be applied (attributes with the same names from EF Core are not recognized):

using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

[Table("tbl_legacy_users")]
public class Customer
{
    [Key]
    [Column("usr_id")]
    public int Id { get; set; }
    
    [Column("usr_email")]
    public string Email { get; set; }
}

Scenario 2: Complete CRUD Operations

FunkyORM provides clean, type-safe persistence methods directly on the provider.

1. Insert (Identity, GUID, or String PK)

// Auto-incrementing Identity PK
var customer = new Customer { FirstName = "Ada", LastName = "Lovelace", Email = "ada@example.com" };
int customerId = provider.Insert<Customer, int>(customer);
// The customer.Id property is also updated in-place automatically!

// GUID Primary Key
var auditLog = new AuditLog { Id = Guid.NewGuid(), Action = "UserLogin" };
Guid logId = provider.Insert<AuditLog, Guid>(auditLog);

// String Primary Key — 'Key' isn't a conventional PK name, so mark it with [Key]
var config = new AppSetting { Key = "Theme", Value = "Nord" };
string settingKey = provider.Insert<AppSetting, string>(config);

public class AppSetting
{
    [System.ComponentModel.DataAnnotations.Key]
    public string Key { get; set; }
    public string Value { get; set; }
}

2. Get by Primary Key

Customer ada = provider.Get<Customer>(customerId);

3. Update

ada.Email = "ada.lovelace@computing.org";
provider.Update(ada);

4. Safe Delete (Transaction Mandate)

To prevent accidental data loss, FunkyORM requires an active transaction for all delete operations (it throws InvalidOperationException otherwise):

// Delete by Primary Key
provider.BeginTransaction();
try
{
    bool deleted = provider.Delete<Customer>(customerId);
    provider.CommitTransaction();
}
catch
{
    provider.RollbackTransaction();
    throw;
}

// Delete by Predicate
provider.BeginTransaction();
try
{
    int rowsDeleted = provider.Delete<Customer>(c => c.Email == "ada@example.com");
    provider.CommitTransaction();
}
catch
{
    provider.RollbackTransaction();
    throw;
}

5. Async

The core operations have async counterparts—GetAsync, GetListAsync, QueryAsync, InsertAsync, UpdateAsync, and DeleteAsync (by predicate):

int newId = await provider.InsertAsync<Customer, int>(customer);
Customer loaded = await provider.GetAsync<Customer>(newId);

Scenario 3: Fluent LINQ Querying

FunkyORM parses C# expression trees directly into parameterized SQL.

1. Filtering & Chaining

var lCustomersWithEmail = provider.Query<Customer>()
    .Where(c => c.LastName.StartsWith("L"))
    .Where(c => c.Email != null)
    .ToList();

2. Ordering & Paging

var pagedResults = provider.Query<Customer>()
    .OrderBy(c => c.LastName)
    .ThenBy(c => c.FirstName)
    .Skip(20)
    .Take(10)
    .ToList();

Generated SQL (SQL Server, illustrative):

SELECT [Id], [FirstName], [LastName], [Email], [OrganizationId]
FROM [Customer]
ORDER BY [LastName], [FirstName]
OFFSET 20 ROWS FETCH NEXT 10 ROWS ONLY

PostgreSQL, MySQL, and SQLite use LIMIT 10 OFFSET 20 instead. PostgreSQL and SQLite quote only reserved words, so most identifiers come out unquoted. To see the exact SQL for your provider, set the provider’s Log property:

provider.Log = sql => Console.WriteLine(sql);

3. Database Aggregates

Always call aggregate operators directly on .Query<T>() so the SQL engine executes the operation instead of buffering rows in memory:

// SELECT COUNT(*) FROM Customer WHERE Email LIKE @p0   (parameterized)
int count = provider.Query<Customer>()
    .Count(c => c.Email.EndsWith("@gmail.com"));

// 64-bit count (v3.10+; COUNT_BIG(*) on SQL Server)
long bigCount = provider.Query<Customer>()
    .LongCount(c => c.Id > 0);

// Existence check (SELECT CASE WHEN EXISTS(...) THEN 1 ELSE 0 END)
bool exists = provider.Query<Customer>()
    .Any(c => c.Email == "test@example.com");

Scenario 4: Graph Flattening with [RemoteProperty] and [RemoteKey]

One of FunkyORM’s most powerful capabilities is pulling values from related foreign tables onto flat classes without manual joins or bulky navigation properties.

The Detail Pattern (Inheritance)

Keep your base Customer class clean as the canonical entity for writes. When building views or screens that need foreign columns, extend the class:

public class Organization
{
    public int Id { get; set; }
    public string Name { get; set; }

    // Explicit remote link because the column name deviates from 'CountryId'
    [RemoteLink(typeof(Country))]
    public int HeadquartersCountryId { get; set; }
}

public class Country
{
    public int Id { get; set; }
    public string CountryName { get; set; }
}

[Table("Customer")]
public class CustomerDetail : Customer
{
    // Hop 1: Pull Organization Name directly onto CustomerDetail
    [RemoteProperty(typeof(Organization), nameof(OrganizationId), nameof(Organization.Name))]
    public string OrganizationName { get; set; }

    // Hop 2: Pull Country Name across 2 relational hops
    // Customer -> Organization -> Country
    [RemoteProperty(typeof(Country),
        nameof(OrganizationId),
        nameof(Organization.HeadquartersCountryId),
        nameof(Country.CountryName))]
    public string OrgCountryName { get; set; }
}

Querying Remote Properties

When querying CustomerDetail, FunkyORM automatically constructs the required LEFT JOINs:

var details = provider.Query<CustomerDetail>()
    .Where(c => c.OrganizationName == "Funcular Labs")
    .ToList();

Scenario 5: Replacing Database Views in Code

Instead of creating and maintaining database VIEWs for computed columns, subqueries, or JSON data, FunkyORM provides declarative view-replacing attributes ([SqlExpression], [SubqueryAggregate], [JsonPath], [JsonCollection]). Like remote properties, they belong on Detail classes, not on your canonical entities.

1. [SqlExpression]

public class Order
{
    public int Id { get; set; }
    public decimal SubTotal { get; set; }
    public decimal Tax { get; set; }
}

[Table("Order")]
public class OrderDetail : Order
{
    // Evaluated directly in the SQL SELECT using {PropertyName} tokens,
    // which FunkyORM resolves to the real (quoted, aliased) column names
    [SqlExpression("{SubTotal} + {Tax}")]
    public decimal TotalAmount { get; set; }
}

2. [SubqueryAggregate]

Pull calculated summaries from child tables without writing subqueries:

public class Transaction
{
    public int Id { get; set; }
    public int AccountId { get; set; }
    public decimal Amount { get; set; }
}

public class Account
{
    public int Id { get; set; }
    public string AccountName { get; set; }
}

[Table("Account")]
public class AccountSummary : Account
{
    // Adds a correlated scalar subquery to the SELECT list, roughly:
    // (SELECT SUM(Amount) FROM Transaction WHERE AccountId = Account.Id) AS TotalBalance
    [SubqueryAggregate(typeof(Transaction), nameof(Transaction.AccountId), AggregateFunction.Sum, AggregateColumn = nameof(Transaction.Amount))]
    public decimal TotalBalance { get; set; }
}

Scenario 6: Production Safety Rails

1. Mass Delete Prevention

A frequent terror with micro-ORMs is accidental unbounded deletes. On top of the transaction requirement, FunkyORM inspects delete predicates and rejects the plain “matches every row” shapes before any SQL is sent:

provider.BeginTransaction();
try
{
    // THROWS InvalidOperationException: the predicate reads no column
    provider.Delete<Customer>(c => true);

    // THROWS InvalidOperationException: self-comparison
    provider.Delete<Customer>(c => c.Id == c.Id);
}
finally
{
    provider.RollbackTransaction();
}

It also catches shapes like c => c.Id == id || true. It is a guard rail, not a proof: the docs note that a predicate like x => x.Id == 2 || x.Id != 2 is still accepted. Review delete logic as carefully as you always would.

2. Nullable Expression Rules

FunkyORM automatically unwraps nullable properties in LINQ expressions. Never use .Value or .HasValue in queries:

// CORRECT: The ORM automatically translates this cleanly
var list = provider.Query<Customer>()
    .Where(c => c.OrganizationId == 10)
    .ToList();

// WRONG: .Value is translated literally as a column name ("OrganizationId.Value"), producing invalid SQL
var broken = provider.Query<Customer>()
    .Where(c => c.OrganizationId.Value == 10) // DO NOT DO THIS
    .ToList();

When filtering against a collection with .Contains(), cast the collection to match the nullable type:

var orgIds = new List<int> { 1, 2, 3 };

// Cast list to nullable int
var nullableIds = orgIds.Cast<int?>().ToList();
var matches = provider.Query<Customer>()
    .Where(c => nullableIds.Contains(c.OrganizationId))
    .ToList();

Scenario 7: Service-Driven Architecture (“Nouns Never Save Themselves”)

Under the Slim Domain pattern, canonical entities are passive data contracts—just the table’s columns. They do not contain persistence logic (customer.Save() is an anti-pattern), nor do they rely on hidden DbContext tracking state.

That doesn’t mean an anemic domain. The pattern’s second principle is enriched business objects: classes that inherit (or wrap) the entity shape and add behavior and invariants:

[Table("Customer")]
public class CustomerAccount : Customer
{
    public void ChangeEmail(string email)
    {
        if (string.IsNullOrWhiteSpace(email) || !email.Contains("@"))
            throw new ArgumentException("A valid email is required.", nameof(email));
        Email = email;
    }
}

Because FunkyORM maps the column/property intersection, a CustomerAccount can be passed straight to Update with no mapping step. Persistence itself stays in services:

public class CustomerService
{
    private readonly SqliteOrmDataProvider _db; // registered as Scoped

    public CustomerService(SqliteOrmDataProvider db)
    {
        _db = db;
    }

    public CustomerDetail? GetCustomerProfile(int id)
    {
        return _db.Query<CustomerDetail>()
            .Where(c => c.Id == id)
            .FirstOrDefault();
    }

    public int RegisterCustomer(string firstName, string lastName, string email, int? orgId)
    {
        var customer = new Customer
        {
            FirstName = firstName,
            LastName = lastName,
            Email = email,
            OrganizationId = orgId
        };

        return _db.Insert<Customer, int>(customer);
    }

    public bool DeactivateCustomer(int id)
    {
        _db.BeginTransaction();
        try
        {
            bool removed = _db.Delete<Customer>(id);
            _db.CommitTransaction();
            return removed;
        }
        catch
        {
            _db.RollbackTransaction();
            throw;
        }
    }
}

Notice how clean this architecture is:

  • No tracking state carried between requests (with a Scoped provider, not even a transaction).
  • No AutoMapper gymnastics between Customer and CustomerDto.
  • Easy to integration-test against a throwaway SQLite file.

Part 5: When NOT to Use FunkyORM

To maintain architectural credibility, it is critical to know when FunkyORM is not the appropriate tool:

  1. Complex Aggregate Root Change Tracking: If your business logic updates a single aggregate that spans 10 child tables and expects the ORM to calculate the topological insert/update graph automatically, EF Core’s unit-of-work engine is superior.
  2. Dynamic / Deep GraphQL Projections: If your frontend allows arbitrary clients to query unbounded nested relational trees, EF Core’s deep IQueryable composition engine is better suited.
  3. Database Schema Migrations via Code: FunkyORM intentionally does not include a migration generator like dotnet ef migrations add. You should manage database schema changes using standard version-controlled SQL scripts, DbUp, or Flyway.
  4. Bulk Operations: FunkyORM has no bulk insert/update API. If you load millions of rows, reach for SqlBulkCopy, RepoDB, or LinqToDB’s BulkCopy.
  5. Risk Tolerance for a Young Library: The Slim Domain paper itself rates FunkyORM’s maturity and community as “Emerging” next to Dapper’s “Very large.” Some LINQ operators aren’t translated (3.10 throws NotSupportedException for them rather than silently ignoring them), and 3.10.0 has known issues with some aggregate results (Average, Min/Max result types) slated for 3.10.1. Check the supported operators list against your needs.

Part 6: Complete LINQPad Hands-on Recipe

Want to test all of this right now?

Save this script as a .linq file and open it in LINQPad (the XML header at the top adds the NuGet packages and namespaces for you), then press F5. Alternatively, create a new C# Program query, press F4 to add the Funcular.Data.Orm NuGet package and the namespaces listed in 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. Setup isolated SQLite file
    SqliteConnection.ClearAllPools();
    var dbFile = Path.Combine(Path.GetTempPath(), "funky_linqpad_demo.db");
    if (File.Exists(dbFile))
    {
        try { File.Delete(dbFile); } catch { }
    }
    
    var connStr = $"Data Source={dbFile}";

    // 2. Create Schema using SqliteConnection (DDL)
    using (var conn = new SqliteConnection(connStr))
    {
        conn.Open();
        using (var cmd = conn.CreateCommand())
        {
            cmd.CommandText = @"
                DROP TABLE IF EXISTS Employee;
                DROP TABLE IF EXISTS Department;

                CREATE TABLE Department (
                    Id INTEGER PRIMARY KEY AUTOINCREMENT,
                    DeptName TEXT NOT NULL
                );

                CREATE TABLE Employee (
                    Id INTEGER PRIMARY KEY AUTOINCREMENT,
                    FirstName TEXT NOT NULL,
                    LastName TEXT NOT NULL,
                    DepartmentId INTEGER NULL,
                    Salary DECIMAL NOT NULL
                );";
            cmd.ExecuteNonQuery();
        }
    }

    // 3. Initialize FunkyORM SQLite provider
    var provider = new SqliteOrmDataProvider(connStr);

    // 4. Seed Data
    int engId = provider.Insert<Department, int>(new Department { DeptName = "Engineering" });
    int salesId = provider.Insert<Department, int>(new Department { DeptName = "Sales" });

    provider.Insert<Employee, int>(new Employee { FirstName = "Alan", LastName = "Turing", DepartmentId = engId, Salary = 120000 });
    provider.Insert<Employee, int>(new Employee { FirstName = "Margaret", LastName = "Hamilton", DepartmentId = engId, Salary = 135000 });
    provider.Insert<Employee, int>(new Employee { FirstName = "Bob", LastName = "Smith", DepartmentId = salesId, Salary = 85000 });

    // 5. Query flat model with RemoteProperty and Dump output
    var employees = provider.Query<EmployeeDetail>()
        .Where(e => e.Salary >= 100000)
        .OrderByDescending(e => e.Salary)
        .ToList();

    employees.Dump("High Earners with Department Name (RemoteProperty)");

    // 6. Test Mass Delete Protection
    provider.BeginTransaction();
    try
    {
        Console.WriteLine("Attempting illegal mass delete: e => true");
        provider.Delete<Employee>(e => true);
    }
    catch (InvalidOperationException ex)
    {
        ex.Message.Dump("Protected! FunkyORM Mass Delete Guard Triggered");
    }
    finally
    {
        provider.RollbackTransaction();
    }
}

public class Department
{
    public int Id { get; set; }
    public string DeptName { get; set; }
}

public class Employee
{
    public int Id { get; set; }
    public string FirstName { get; set; }
    public string LastName { get; set; }
    public int? DepartmentId { get; set; }
    public decimal Salary { get; set; }

    // Bidirectional Intersection Mapping: no column, so it's ignored automatically
    public string FullName => $"{FirstName} {LastName}";
}

// Map derived class back to the base table
[Table("Employee")]
public class EmployeeDetail : Employee
{
    // Pulls Department.DeptName via foreign key hop
    [RemoteProperty(typeof(Department), nameof(DepartmentId), nameof(Department.DeptName))]
    public string DepartmentName { get; set; }
}

Conclusion

The pendulum in .NET data access has swung far toward architectural abstraction. Layers of in-process DTOs, piles of mapping attributes, and sprawling DbContext classes slow many teams down without delivering commensurate business value.

FunkyORM offers a pragmatic, high-velocity alternative:

  • Zero DbContext ceremony
  • Bidirectional intersection mapping
  • Flattened relational queries via [RemoteProperty]
  • Transaction-gated deletes with mass-delete guards
  • Cross-platform support for SQLite, SQL Server, Postgres, and MySQL

And if you’re not ready to change libraries, the Slim Domain habits—use-case-shaped reads, minimal canonical entities, services-only persistence—work with Dapper or a disciplined EF Core too.

Next time you start a new microservice, API, or rapid prototype, consider leaving the architecture astronautics behind. Give FunkyORM a spin—and use LINQPad to build and iterate at lightspeed.

Sources: FunkyORM on GitHub · Funcular.Data.Orm on NuGet · Slim Domain: A Domain-Structuring Pattern for .NET, Paul Smith, Funcular Labs, v1.0 RC1 (October 2026).


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.

FunkyORM .NET C# Micro-ORM LINQPad Database Performance Architecture