"EFFluentify: Convert EF Core Data Annotations to Fluent API in one command"

EFFluentify: Convert EF Core Data Annotations to Fluent API in one command

One of my dreams when I started in software development was to build something like #include <iostream> — a tool that other developers like me could actually reach for in their own projects. Now I have one. It’s called EFFluentify, and this post is about the problem it solves and how it works.

当我刚开始从事软件开发时,我的梦想之一就是构建像 #include <iostream> 那样的工具——一个像我一样的开发者在自己的项目中真正能够用得上的工具。现在我终于做到了。它叫 EFFluentify,这篇文章将介绍它解决的问题以及它的工作原理。

The problem: one project, three mapping styles

If you’ve worked on a real EF Core codebase for any length of time, you’ve seen this: mapping configuration ends up scattered. Some entities are configured with Data Annotations right on the class. Others are configured with the Fluent API, buried somewhere in OnModelCreating. And a few unlucky ones have both — so now nobody’s sure which one actually wins.

问题所在:一个项目,三种映射风格

如果你在真实的 EF Core 代码库中工作过一段时间,你一定见过这种情况:映射配置最终变得四分五裂。有些实体直接在类上使用数据注解(Data Annotations)进行配置;另一些则使用 Fluent API 配置,埋在 OnModelCreating 的某个角落里;还有一些倒霉的实体两者兼有——导致没人确定到底哪种配置生效。

// Order.cs — Data Annotations
[Table("Orders")]
public class Order {
    [Required, MaxLength(32)]
    public string Code { get; set; }
}

// AppDbContext.cs — Fluent API
builder.Entity<Product>()
    .Property(p => p.Name)
    .HasMaxLength(100);

// User.cs + AppDbContext.cs — both 🙃
[Table("Users")]
public class User { /* ... */ }

builder.Entity<User>()
    .Property(u => u.Email).IsRequired();

Data Annotations are convenient, but they scatter persistence concerns across your domain classes and can’t express everything the Fluent API can. The usual recommendation is to move configuration into dedicated IEntityTypeConfiguration<T> classes — but doing that by hand across a whole project is tedious and easy to get subtly wrong. So I built a tool to do it in one pass.

数据注解虽然方便,但它们将持久化关注点分散到了领域类中,且无法表达 Fluent API 所能实现的所有功能。通常的建议是将配置移至专门的 IEntityTypeConfiguration<T> 类中——但在整个项目中手动执行此操作既繁琐又容易出错。所以我构建了一个工具,可以一次性完成这项工作。

Before / after

Given an annotated entity:

转换前后

假设有一个带有注解的实体:

[Table("Users", Schema = "dbo")]
[Index(nameof(Email), IsUnique = true, Name = "IX_User_Email")]
public class User {
    [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
    public int Id { get; set; }
    [Required, MaxLength(50)]
    public string Name { get; set; }
    public string? Email { get; set; }
    [NotMapped]
    public string TemporaryToken { get; set; }
    [ConcurrencyCheck]
    public string RowGuid { get; set; }
    public int? ManagerId { get; set; }
    [ForeignKey(nameof(ManagerId))]
    public User Manager { get; set; }
    public ICollection<User> Subordinates { get; set; }
}

EFFluentify emits:

EFFluentify 生成的代码:

// <auto-generated />
namespace EFFluentify.Configurations;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;

internal sealed class UserConfiguration : IEntityTypeConfiguration<User> {
    public void Configure(EntityTypeBuilder<User> builder) {
        builder.ToTable("Users", "dbo");
        builder.HasIndex(e => e.Email).IsUnique().HasDatabaseName("IX_User_Email");
        builder.Ignore(e => e.TemporaryToken);
        builder.HasOne(x => x.Manager).WithMany(x => x.Subordinates).HasForeignKey(x => x.ManagerId);
        builder.Property(x => x.Id).ValueGeneratedOnAdd();
        builder.Property(x => x.Name).IsRequired().HasMaxLength(50);
        builder.Property(x => x.Email).IsRequired(false);
        builder.Property(x => x.RowGuid).IsConcurrencyToken();
    }
}

And if you ask it to, it strips the now-redundant annotations from User.cs too — leaving a clean POCO and a .bak backup next to the original.

如果你有要求,它还会从 User.cs 中移除那些多余的注解,留下一个干净的 POCO 类,并在原文件旁边生成一个 .bak 备份。

What makes it more than find-and-replace

It uses Roslyn. EFFluentify parses your actual C#, so it understands your real types, nullability, and relationships — not regex over text. Relationships are resolved across all your entities. A [ForeignKey] on one side is matched to its inverse navigation on the other, so you get a proper HasOne(...).WithMany(...).HasForeignKey(...) instead of a half-mapped relationship. The generated code is verified by compiling it. The test suite compiles the emitter’s output and locks behavior down with expected-output fixtures — so what comes out actually builds.

为什么它不仅仅是“查找并替换”

它使用了 Roslyn。EFFluentify 会解析你实际的 C# 代码,因此它理解你真实的类型、可空性(nullability)和关系,而不是简单地对文本进行正则匹配。关系会在所有实体中进行解析。一侧的 [ForeignKey] 会与另一侧的反向导航属性匹配,因此你会得到一个完整的 HasOne(...).WithMany(...).HasForeignKey(...),而不是半吊子的映射关系。生成的代码会通过编译进行验证。测试套件会编译生成器的输出,并通过预期的输出夹具(fixtures)锁定行为,确保生成的代码确实可以编译通过。

Install

Requires the .NET 9 SDK. It’s published on NuGet as EFFluentify.Tool: dotnet tool install --global EFFluentify.Tool

安装

需要 .NET 9 SDK。它已发布在 NuGet 上,名为 EFFluentify.Tooldotnet tool install --global EFFluentify.Tool

Usage

Point it at your models: effluentify --input ./Models --out ./Configurations

使用方法

指向你的模型目录: effluentify --input ./Models --out ./Configurations

Useful options:

  • --input <path>: Required. A .cs file or a directory (scanned recursively).
  • --out <dir>: Output directory. Omit it to preview in the console instead of writing to disk.
  • --manyFiles: One configuration file per entity (default: everything in a single file).
  • --removeAnnotationsFromMyOriginal: Strip the converted annotations from your originals, writing a .bak backup next to each file.
  • --namespace / -n: Root namespace for generated files.

常用选项:

  • --input <path>:必需。一个 .cs 文件或目录(递归扫描)。
  • --out <dir>:输出目录。省略此项可在控制台预览,而不写入磁盘。
  • --manyFiles:每个实体一个配置文件(默认:所有内容合并在一个文件中)。
  • --removeAnnotationsFromMyOriginal:从原始文件中移除已转换的注解,并在每个文件旁边写入 .bak 备份。
  • --namespace / -n:生成文件的根命名空间。

Convert, clean up the originals, and use a custom namespace: effluentify --input ./Models --out ./Configurations --removeAnnotationsFromMyOriginal -n MyApp.Data.Configurations

转换、清理原始文件并使用自定义命名空间: effluentify --input ./Models --out ./Configurations --removeAnnotationsFromMyOriginal -n MyApp.Data.Configurations

Supported annotations

Entity-level: [Table], [Index], [Key], [Keyless], [NotMapped], [Comment], [ForeignKey] Property-level: [Required], [MaxLength] / [StringLength], [Precision], [Column], [DefaultValue], [ConcurrencyCheck], [Timestamp], [Unicode], [DatabaseGenerated], and nullable reference/value types → IsRequired(false).

支持的注解

实体级:[Table], [Index], [Key], [Keyless], [NotMapped], [Comment], [ForeignKey] 属性级:[Required], [MaxLength] / [StringLength], [Precision], [Column], [DefaultValue], [ConcurrencyCheck], [Timestamp], [Unicode], [DatabaseGenerated],以及可空引用/值类型 → IsRequired(false)

“But AI could do this for me”

Yes — you’re right, you could. But then… what about the water, global warming, and the innocent trees in California? 😄 Honestly, I just had fun building it by hand. It’s a small, focused tool with a clean architecture (Domain / Application / Infrastructure / CLI) and real tests, and writing it that way was the whole point.

“但是 AI 也能帮我做这个”

是的,你说得对,确实可以。但是……那水资源、全球变暖以及加州那些无辜的树木怎么办呢?😄 说实话,我只是享受亲手构建它的过程。这是一个小巧、专注的工具,拥有清晰的架构(领域/应用/基础设施/CLI)和真实的测试,而以这种方式编写它正是我的初衷。

Try it / feedback

GitHub: https://github.com/hosamr/EfFluentify NuGet: https://www.nuget.org/packages/EFFluentify.Tool If you’re doing the annotations → Fluent API migration, I’d love your feedback — what should it support next?

试用 / 反馈

GitHub: https://github.com/hosamr/EfFluentify NuGet: https://www.nuget.org/packages/EFFluentify.Tool 如果你正在进行从注解到 Fluent API 的迁移,我很乐意听取你的反馈——接下来它应该支持什么功能?