DEV Community

HosamEldeen Reda
HosamEldeen Reda

Posted on

"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.

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.

// 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();
Enter fullscreen mode Exit fullscreen mode

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.

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; }
}
Enter fullscreen mode Exit fullscreen mode

EFFluentify emits:

// <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();
    }
}
Enter fullscreen mode Exit fullscreen mode

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.

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.

Install

Requires the .NET 9 SDK. It's published on NuGet as EFFluentify.Tool:

dotnet tool install --global EFFluentify.Tool
Enter fullscreen mode Exit fullscreen mode

Usage

Point it at your models:

effluentify --input ./Models --out ./Configurations
Enter fullscreen mode Exit fullscreen mode

Useful options:

Option Description
--input <path> Required. A .cs file or a directory (scanned recursively). Repeat it for multiple inputs.
--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 (default: EFFluentify.Configurations).

Convert, clean up the originals, and use a custom namespace:

effluentify --input ./Models --out ./Configurations --removeAnnotationsFromMyOriginal -n MyApp.Data.Configurations
Enter fullscreen mode Exit fullscreen mode

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).

"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.

Try it / feedback

If you're doing the annotations → Fluent API migration, I'd love your feedback — what should it support next?

Top comments (0)