iTranslated by AI
[C#] Introducing Linqraft: Bringing a Prisma-like Developer Experience to EF Core
The other day, I released a C# library called Linqraft! In this article, I would like to introduce it.

Motivation
C# is a wonderful language. I think it is a great language that can handle almost any use case, from GUI app development to web building, thanks to its strong type safety and extensive standard library.
Despite being such a great language, there is something I feel frustrated with on a daily basis.
It is that "defining classes is a pain!" and "writing Select queries is a pain!"
C# is a statically typed language, so basically all classes you want to use must be defined. That itself is somewhat inevitable, but having to define derived classes every time is extremely tedious.
In particular, when performing database access using an ORM (Object-Relational Mapping), the shape of the data you want must be defined as a DTO (Data Transfer Object) every time, leading to writing similar class definitions over and over again.
Let's compare it with Prisma, a TypeScript ORM. In Prisma, you can write it like this:
// The type for 'user' is automatically generated from the schema file
const users = await prisma.user.findMany({
// Specify the content of the data you want with 'select'
select: {
id: true,
name: true,
posts: {
// Data from related tables can also be specified with 'select' in the same way
select: {
title: true,
},
},
},
});
// The type of 'users' automatically becomes as follows (it does it automatically!)
// It is also easy to reuse this type
type Users = {
id: number;
name: string;
posts: {
title: string;
}[];
}[];
Trying to do the same thing with EFCore in C# looks like this:
// Assume the User type is defined in a separate file
var users = dbContext.Users
// Specifying the desired data with Select is the same
.Select(u => new UserWithPostDto
{
Id = u.Id,
Name = u.Name,
// Child classes are also specified with Select in the same way
Posts = u.Posts.Select(p => new PostDto { Title = p.Title }).ToList()
})
.ToList();
// You have to define the DTO classes yourself!
public class UserWithPostDto
{
public int Id { get; set; }
public string Name { get; set; }
public List<PostDto> Posts { get; set; }
}
// Same for the child class
public class PostDto
{
public string Title { get; set; }
}
// Since there is a User class, it feels like it could be automatically generated from there, but...
In this regard, Prisma feels clearly easier and more convenient. Even though the Users type itself is defined as a class[1], I feel like, "Why do I have to go through the trouble of defining derived DTO classes myself?"
I can tolerate it at the scale shown above, but it gets even more painful in more complex cases.
var result = dbContext.Orders
.Select(o => new OrderDto
{
Id = o.Id,
Customer = new CustomerDto
{
CustomerId = o.Customer.Id,
CustomerName = o.Customer.Name,
// Tedious point
CustomerAddress = o.Customer.Address != null
? o.Customer.Address.Location
: null,
// Since I don't want to check every time, I might group them into another DTO
AdditionalInfo = o.Customer.AdditionalInfo != null
? new CustomerAdditionalInfoDto
{
InfoDetail = o.Customer.AdditionalInfo.InfoDetail,
CreatedAt = o.Customer.AdditionalInfo.CreatedAt
}
: null
},
Items = o.Items.Select(i => new OrderItemDto
{
ProductId = i.ProductId,
Quantity = i.Quantity,
// Same for arrays. Hard to read...
ProductComments = i.CommentInfo != null
? i.CommentInfo.Comments.Select(c => new ProductCommentDto
{
CommentText = c.CommentText,
CreatedBy = c.CreatedBy
}).ToList()
: new List<ProductCommentDto>()
}).ToList()
})
.ToList();
// I won't write them all out here, but all the DTO classes used above need to be defined.
To begin with, there are 5 DTO classes in the example above, which is already incredibly tedious, but an even more troublesome factor is "null checks."
First, the ?. (null-conditional operator) cannot be used inside EFCore's Select expression. Specifically, it is a specification that it cannot be used inside Expression<...>.
Therefore, you have to write code like the one above, using the ternary operator to perform null checks and accessing members only if they are not null.
If it's just a child class, o.A != null ? o.A.B : null is simple enough, but as it goes deeper into grandchild and great-grandchild classes, the null-check code keeps increasing, making it very hard to read.
// Incredibly hard to read
Property = o.A != null && o.A.B != null && o.A.B.C != null
? o.A.B.C.D
: null
The same applies when picking up values from an array within a child class (which might be null); you have to write tedious code.
// Give me a break
Items = o.Child != null
? o.Child.Items.Select(i => new ItemDto{ /* ... */ }).ToList()
: new List<ItemDto>()
What do you think? Personally, I hate this very much.
What I want
Looking again at the Prisma example above, it roughly possesses the following features (albeit leveraging TypeScript's language features):
- Once a query is written, the corresponding type is generated.
- You can write things like
?.directly within the query without worrying about null checks.
Taking on the Implementation
Using Anonymous Types
Are you familiar with anonymous types in C#? It's a feature where the compiler automatically generates a corresponding class when you write new { ... }, as shown below.
// No type name is written after new
var anon = new
{
Id = 1,
Name = "Alice",
IsActive = true
};
While some of you may not have used them much, they are extremely useful for defining throwaway classes within a Select query.
var users = dbContext.Users
.Select(u => new
{
Id = u.Id,
Name = u.Name,
Posts = u.Posts.Select(p => new { Title = p.Title }).ToList()
})
.ToList();
// You can access and use them normally
var user = users[0];
Console.WriteLine(user.Name);
foreach(var post in user.Posts)
{
Console.WriteLine(post.Title);
}
However, as the name "anonymous" suggests, no actual type name exists, so they cannot be used as method arguments or return values. This constraint is quite painful, so opportunities to use them are surprisingly limited.
Automatically Generating Corresponding Classes
This leads to a natural progression: what if we created a Source Generator that automatically generates corresponding classes based on the content defined in the anonymous type? This is exactly what Linqraft achieves.
Specifically, using a specific method name (SelectExpr) as a hook point, it automatically generates class definitions based on the anonymous type passed as its argument.
Since it would be inconvenient if you couldn't specify the name of the generated class, I've made it possible to specify the class name as a generic type argument.
var users = dbContext.Users
// In this case, it automatically generates a class called UserDto
.SelectExpr<User,UserDto>(u => new
{
Id = u.Id,
Name = u.Name,
Posts = u.Posts.Select(p => new { Title = p.Title }).ToList()
})
.ToList();
// ---
// A class like this is automatically generated
public class UserDto
{
public int Id { get; set; }
public string Name { get; set; }
public List<PostDto_Hash1234> Posts { get; set; }
}
// Child classes are also automatically generated in the same way
// Hash values are automatically added to avoid name collisions
public class PostDto_Hash1234
{
public string Title { get; set; }
}
It simply looks at the elements of the passed anonymous type and generates the corresponding class definitions using the Roslyn API (though it's quite a lot of work!). It's straightforward.
At this point, we've achieved automatic class generation, but we need to swap the behavior of the called SelectExpr to make it work like a normal Select.
This is where interceptors come in.
Replacing Logic with Interceptors
Did you know that C# has a feature called "interceptors"?
It's such a niche area that few people likely know about it, but it's a feature that allows you to hook into specific method calls and swap them with arbitrary processing.
It was released as a preview in .NET 8 and became stable in .NET 9.
Even with that explanation, it might be hard to visualize, so let's consider the following code:
// A pattern where a very time-consuming process is called with constant values
var result1 = "42".ComputeSomething(); // case 1
var result2 = "420".ComputeSomething(); // case 2
var result3 = "4200".ComputeSomething(); // case 3
Since these are called with constant values, it seems possible to calculate the results at compile time. In such cases, by implementing an interceptor in advance in combination with a Source Generator, you can swap the calls as follows:
// Imagine this class being automatically generated by a Source Generator.
// The visibility level can be 'file'.
file static class PreExecutedInterceptor
{
// Obtain the hash value of the caller using the Roslyn API and add the InterceptsLocationAttribute
[global::System.Runtime.CompilerServices.InterceptsLocationAttribute(1, "(hash of case1)")]
// The function name can be random. The arguments and return value must match the original function.
public static int ComputeSomething_Case1(this string value)
{
// Pre-calculate the result for case 1 and return it
return 84;
}
// Same for case 2 and 3
[global::System.Runtime.CompilerServices.InterceptsLocationAttribute(1, "(hash of case2)")]
public static int ComputeSomething_Case2(this string value) => 168;
[global::System.Runtime.CompilerServices.InterceptsLocationAttribute(1, "(hash of case3)")]
public static int ComputeSomething_Case3(this string value) => 336;
}
While defining these as regular extension methods would result in duplicate definitions, using interceptors allows you to swap in different logic for each individual call site.
Linqraft uses this mechanism to intercept SelectExpr calls and swap them for a standard Select.
// Imagine a call like this
var orders = dbContext.Orders
.SelectExpr<Order,OrderDto>(o => new
{
Id = o.Id,
CustomerName = o.Customer?.Name,
CustomerAddress = o.Customer?.Address?.Location,
})
.ToList();
// Example of generated code
file static partial class GeneratedExpression
{
[global::System.Runtime.CompilerServices.InterceptsLocationAttribute(1, "hash of SelectExpr call")]
// Since we need to keep the original anonymous type conversion query, we also take the selector as an argument (though it's not actually used)
public static IQueryable<TResult> SelectExpr_0ED9215A_7FE9B5FF<TIn, TResult>(
this IQueryable<TIn> query,
Func<TIn, object> selector)
{
// By specification, it can only be received as <TIn>, but since we know the original type, we cast it.
var matchedQuery = query as object as IQueryable<global::Order>;
// Convert the written pseudo-query into a normal Select
// Map it to the automatically generated DTO class created earlier
var converted = matchedQuery.Select(s => new global::OrderDto
{
Id = s.Id,
// Mechanically replace the null-conditional operator with a standard ternary operator check
CustomerName = s.Customer != null ? s.Customer.Name : null,
CustomerAddress = s.Customer != null && s.Customer.Address != null
? s.Customer.Address.Location
: null,
});
// By specification, it can only be returned as <TResult>, so cast it again.
return converted as object as IQueryable<TResult>;
}
}
This allows users to write queries easily, with the same feel as a regular Select!
Moving Towards Zero Dependency
With the implementation so far, basically all calls to SelectExpr are intercepted by the individually generated code. As a result, there is absolutely nothing for the original SelectExpr body to do, and it exists solely for the purpose of editor autocompletion.
If that's the case, if the dummy method itself is also emitted by the Source Generator, then a reference to Linqraft itself should no longer even be necessary! So, I decided to emit it that way.
public static void ExportAll(IncrementalGeneratorPostInitializationContext context)
{
context.AddSource("SelectExprExtensions.g.cs", SelectExprExtensions);
}
const string SelectExprExtensions = $$""""
{{CommonHeader}}
using System;
using System.Collections.Generic;
using System.Linq;
/// <summary>
/// Dummy expression methods for Linqraft to compile correctly.
/// </summary>
internal static class SelectExprExtensions
{
/// <summary>
/// Create select expression method, usable nullable operators, and generate instance DTOs.
/// </summary>
public static IQueryable<TResult> SelectExpr<TIn, TResult>(this IQueryable<TIn> query, Func<TIn, TResult> selector)
where TIn : class => throw InvalidException;
// Other derived forms are also included in here
}
"""";
After that, if you enable DevelopmentDependency, you can make it a package that is not included in the actual build output at all!
<PropertyGroup>
<DevelopmentDependency>true</DevelopmentDependency>
</PropertyGroup>
In fact, when you install Linqraft via NuGet or similar, the entry should look like this. This indicates it is a development-only package.
<PackageReference Include="Linqraft" Version="0.4.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
</PackageReference>
Analyzer for Replacing Existing Code
Now, after hearing all this, some of you might be thinking you want to try it out immediately!
For those people, Linqraft also provides a Roslyn Analyzer that automatically replaces existing Select queries with SelectExpr.
It is extremely easy to use; you can perform the replacement in one go via Quick Actions by right-clicking on the Select query.

Summary
In conclusion, by using Linqraft, you can simply write a query as shown below and:
- Corresponding DTO classes are automatically generated,
- You can write queries without worrying about null checks,
- Additionally, since it has zero dependencies, it remains identical to handwritten code,
- Migration is also reasonably straightforward.
// Zero dependency!
var orders = dbContext.Orders
.SelectExpr<Order, OrderDto>(o => new
{
Id = o.Id,
// You can use ?. !
CustomerName = o.Customer?.Name,
CustomerAddress = o.Customer?.Address?.Location,
})
.ToList();
// OrderDto class and its contents are automatically generated!
I might be biased, but I think I've created a quite useful library.
Please give it a try! If you like it, please give it a star.
Side Note
I also put some effort into the introduction webpage. Specifically, you can now try it out directly on the page!
I've also implemented a feature that feeds token information analyzed by Roslyn into the Monaco Editor for syntax highlighting.

Please check it out as well.
-
Think of it as defining what Prisma calls a schema using classes in C#. This part isn't so bad. ↩︎
Discussion