mcpField
D
dotnet-ef-core-configuration

dotnet-ef-core-configuration

github.com/ronnythedev開発7763 回インストール15 週間前に更新
リポジトリ

dotnet-ef-core-configuration について

.NET エンティティ向けに Entity Framework Core の Fluent API 設定を生成するスキルです。リレーション、値オブジェクト、PostgreSQL の規則を扱い、ドメインクラスからマッピング設定を分離します。

主な機能

  1. 01エンティティごとの Fluent API 設定
  2. 02一対多・多対多リレーション
  3. 03値オブジェクトの所有型マッピング
  4. 04PostgreSQL のスネークケース命名
  5. 05インデックスと監査プロパティ
  6. 06論理削除と JSON 列のマッピング

活用シーン

  1. 01EF Core のエンティティマッピング生成
  2. 02エンティティ間リレーションの設定
  3. 03値オブジェクトの所有型マッピング
  4. 04PostgreSQL の列規則の設定

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/ronnythedev/dotnet-clean-architecture-skills --skill dotnet-ef-core-configuration

インストール後、Claude Code で/dotnet-ef-core-configurationを入力して読み込まれたか確認します。

詳細

EF Core 設定ジェネレーター

概要

このスキルでは、Fluent API を使用して Entity Framework Core の設定を生成します。

  • IEntityTypeConfiguration - エンティティごとの設定クラス
  • 属性より Fluent API - ドメインをクリーンに保ちます
  • スネークケースの命名 - PostgreSQL の規約です
  • リレーションシップ - 1 対多、多対多、1 対 1
  • 値オブジェクト - 所有型のマッピング

クイックリファレンス

設定用途
ToTable()テーブル名
HasKey()主キー
Property()列の設定
HasOne/HasMany()リレーションシップ
OwnsOne()値オブジェクト
HasIndex()データベースのインデックス

設定の構造

/Infrastructure/Configurations/
├── {Entity}Configuration.cs
├── {ChildEntity}Configuration.cs
├── {ValueObject}Configuration.cs
└── OutboxMessageConfiguration.cs

テンプレート:基本的なエンティティ設定

// src/{name}.infrastructure/Configurations/{Entity}Configuration.cs
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using {name}.domain.{aggregate};

namespace {name}.infrastructure.configurations;

internal sealed class {Entity}Configuration : IEntityTypeConfiguration<{Entity}>
{
    public void Configure(EntityTypeBuilder<{Entity}> builder)
    {
        // ═══════════════════════════════════════════════════════════════
        // TABLE MAPPING
        // ═══════════════════════════════════════════════════════════════
        builder.ToTable("{entity}");  // snake_case table name

        // ═══════════════════════════════════════════════════════════════
        // PRIMARY KEY
        // ═══════════════════════════════════════════════════════════════
        builder.HasKey(e => e.Id);
        
        builder.Property(e => e.Id)
            .ValueGeneratedNever();  // App generates GUIDs

        // ═══════════════════════════════════════════════════════════════
        // PROPERTIES
        // ═══════════════════════════════════════════════════════════════
        builder.Property(e => e.Name)
            .HasMaxLength(100)
            .IsRequired();

        builder.Property(e => e.Description)
            .HasColumnType("text");  // Unlimited length

        builder.Property(e => e.IsActive)
            .HasDefaultValue(true)
            .IsRequired();

        builder.Property(e => e.CreatedAt)
            .IsRequired()
            .HasDefaultValueSql("CURRENT_TIMESTAMP AT TIME ZONE 'UTC'");

        builder.Property(e => e.UpdatedAt)
            .IsRequired()
            .HasDefaultValueSql("CURRENT_TIMESTAMP AT TIME ZONE 'UTC'");

        // ═══════════════════════════════════════════════════════════════
        // INDEXES
        // ═══════════════════════════════════════════════════════════════
        builder.HasIndex(e => e.Name)
            .IsUnique();

        builder.HasIndex(e => e.OrganizationId);

        builder.HasIndex(e => new { e.OrganizationId, e.Name })
            .IsUnique();
    }
}

テンプレート:リレーションシップを持つエンティティ

// src/{name}.infrastructure/Configurations/{Entity}Configuration.cs
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using {name}.domain.{aggregate};

namespace {name}.infrastructure.configurations;

internal sealed class {Entity}Configuration : IEntityTypeConfiguration<{Entity}>
{
    public void Configure(EntityTypeBuilder<{Entity}> builder)
    {
        builder.ToTable("{entity}");
        builder.HasKey(e => e.Id);

        // ═══════════════════════════════════════════════════════════════
        // FOREIGN KEY PROPERTIES
        // ═══════════════════════════════════════════════════════════════
        builder.Property(e => e.OrganizationId)
            .IsRequired();

        builder.Property(e => e.ParentId);  // Nullable FK

        // ═══════════════════════════════════════════════════════════════
        // ONE-TO-MANY: Parent has many children
        // ═══════════════════════════════════════════════════════════════
        builder.HasMany(e => e.{ChildEntities})
            .WithOne(c => c.{Entity})
            .HasForeignKey(c => c.{Entity}Id)
            .OnDelete(DeleteBehavior.Cascade);

        // ═══════════════════════════════════════════════════════════════
        // MANY-TO-ONE: Entity belongs to Organization
        // ═══════════════════════════════════════════════════════════════
        builder.HasOne(e => e.Organization)
            .WithMany(o => o.{Entities})
            .HasForeignKey(e => e.OrganizationId)
            .OnDelete(DeleteBehavior.Restrict);  // Prevent cascade delete

        // ═══════════════════════════════════════════════════════════════
        // SELF-REFERENCING: Entity has optional parent
        // ═══════════════════════════════════════════════════════════════
        builder.HasOne(e => e.Parent)
            .WithMany(e => e.Children)
            .HasForeignKey(e => e.ParentId)
            .OnDelete(DeleteBehavior.Restrict)
            .IsRequired(false);
    }
}

テンプレート:子エンティティの設定

// src/{name}.infrastructure/Configurations/{ChildEntity}Configuration.cs
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using {name}.domain.{aggregate};

namespace {name}.infrastructure.configurations;

internal sealed class {ChildEntity}Configuration : IEntityTypeConfiguration<{ChildEntity}>
{
    public void Configure(EntityTypeBuilder<{ChildEntity}> builder)
    {
        builder.ToTable("{child_entity}");
        
        builder.HasKey(c => c.Id);
        
        builder.Property(c => c.Id)
            .ValueGeneratedNever();

        builder.Property(c => c.{Parent}Id)
            .IsRequired();

        builder.Property(c => c.Name)
            .HasMaxLength(100)
            .IsRequired();

        builder.Property(c => c.SortOrder)
            .IsRequired()
            .HasDefaultValue(0);

        builder.Property(c => c.CreatedAt)
            .IsRequired()
            .HasDefaultValueSql("CURRENT_TIMESTAMP AT TIME ZONE 'UTC'");

        // Relationship defined from parent side, but can also define here
        builder.HasOne(c => c.{Parent})
            .WithMany(p => p.{ChildEntities})
            .HasForeignKey(c => c.{Parent}Id)
            .OnDelete(DeleteBehavior.Cascade);

        // Composite unique constraint
        builder.HasIndex(c => new { c.{Parent}Id, c.Name })
            .IsUnique();

        builder.HasIndex(c => new { c.{Parent}Id, c.SortOrder });
    }
}

テンプレート:多対多のリレーションシップ

// src/{name}.infrastructure/Configurations/User{Entity}Configuration.cs
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using {name}.domain.{aggregate};

namespace {name}.infrastructure.configurations;

// Join entity for many-to-many
internal sealed class User{Entity}Configuration : IEntityTypeConfiguration<User{Entity}>
{
    public void Configure(EntityTypeBuilder<User{Entity}> builder)
    {
        builder.ToTable("user_{entity}");

        // Composite primary key
        builder.HasKey(ue => new { ue.UserId, ue.{Entity}Id });

        // Or with separate ID
        // builder.HasKey(ue => ue.Id);
        // builder.HasIndex(ue => new { ue.UserId, ue.{Entity}Id }).IsUnique();

        builder.Property(ue => ue.UserId)
            .IsRequired();

        builder.Property(ue => ue.{Entity}Id)
            .IsRequired();

        builder.Property(ue => ue.IsManager)
            .HasDefaultValue(false);

        builder.Property(ue => ue.CreatedAt)
            .HasDefaultValueSql("CURRENT_TIMESTAMP AT TIME ZONE 'UTC'");

        // Relationships
        builder.HasOne(ue => ue.User)
            .WithMany(u => u.User{Entities})
            .HasForeignKey(ue => ue.UserId)
            .OnDelete(DeleteBehavior.Cascade);

        builder.HasOne(ue => ue.{Entity})
            .WithMany(e => e.User{Entities})
            .HasForeignKey(ue => ue.{Entity}Id)
            .OnDelete(DeleteBehavior.Cascade);
    }
}

テンプレート:所有型としての値オブジェクト

// src/{name}.infrastructure/Configurations/{Entity}Configuration.cs
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using {name}.domain.{aggregate};

namespace {name}.infrastructure.configurations;

internal sealed class {Entity}Configuration : IEntityTypeConfiguration<{Entity}>
{
    public void Configure(EntityTypeBuilder<{Entity}> builder)
    {
        builder.ToTable("{entity}");
        builder.HasKey(e => e.Id);

        // ═══════════════════════════════════════════════════════════════
        // VALUE OBJECT: Email (stored in same table)
        // ═══════════════════════════════════════════════════════════════
        builder.OwnsOne(e => e.Email, emailBuilder =>
        {
            emailBuilder.Property(email => email.Value)
                .HasColumnName("email")
                .HasMaxLength(255)
                .IsRequired();

            emailBuilder.HasIndex(email => email.Value)
                .IsUnique();
        });

        // ═══════════════════════════════════════════════════════════════
        // VALUE OBJECT: Address (multiple columns)
        // ═══════════════════════════════════════════════════════════════
        builder.OwnsOne(e => e.Address, addressBuilder =>
        {
            addressBuilder.Property(a => a.Street)
                .HasColumnName("address_street")
                .HasMaxLength(200);

            addressBuilder.Property(a => a.City)
                .HasColumnName("address_city")
                .HasMaxLength(100);

            addressBuilder.Property(a => a.State)
                .HasColumnName("address_state")
                .HasMaxLength(50);

            addressBuilder.Property(a => a.ZipCode)
                .HasColumnName("address_zip_code")
                .HasMaxLength(20);

            addressBuilder.Property(a => a.Country)
                .HasColumnName("address_country")
                .HasMaxLength(100);
        });

        // ═══════════════════════════════════════════════════════════════
        // VALUE OBJECT: Money
        // ═══════════════════════════════════════════════════════════════
        builder.OwnsOne(e => e.Price, priceBuilder =>
        {
            priceBuilder.Property(m => m.Amount)
                .HasColumnName("price_amount")
                .HasColumnType("numeric(18,2)")
                .IsRequired();

            priceBuilder.Property(m => m.Currency)
                .HasColumnName("price_currency")
                .HasMaxLength(3)
                .IsRequired();
        });
    }
}

テンプレート:列挙型のマッピング

// src/{name}.infrastructure/Configurations/{Entity}Configuration.cs
internal sealed class {Entity}Configuration : IEntityTypeConfiguration<{Entity}>
{
    public void Configure(EntityTypeBuilder<{Entity}> builder)
    {
        // ═══════════════════════════════════════════════════════════════
        // ENUM AS STRING
        // ═══════════════════════════════════════════════════════════════
        builder.Property(e => e.Status)
            .HasConversion<string>()  // Store as string
            .HasMaxLength(50)
            .IsRequired();

        // ═══════════════════════════════════════════════════════════════
        // ENUM AS INTEGER (default)
        // ═══════════════════════════════════════════════════════════════
        builder.Property(e => e.Priority)
            .HasConversion<int>();  // Store as int

        // ═══════════════════════════════════════════════════════════════
        // CUSTOM CONVERSION
        // ═══════════════════════════════════════════════════════════════
        builder.Property(e => e.Type)
            .HasConversion(
                v => v.ToString().ToLowerInvariant(),
                v => Enum.Parse<EntityType>(v, true))
            .HasMaxLength(50);
    }
}

テンプレート:シャドウプロパティを使用した監査フィールド

// src/{name}.infrastructure/Configurations/{Entity}Configuration.cs
internal sealed class {Entity}Configuration : IEntityTypeConfiguration<{Entity}>
{
    public void Configure(EntityTypeBuilder<{Entity}> builder)
    {
        builder.ToTable("{entity}");

        // ═══════════════════════════════════════════════════════════════
        // SHADOW PROPERTIES (not on domain entity)
        // ═══════════════════════════════════════════════════════════════
        builder.Property<DateTime>("CreatedAt")
            .IsRequired()
            .HasDefaultValueSql("CURRENT_TIMESTAMP AT TIME ZONE 'UTC'");

        builder.Property<DateTime>("UpdatedAt")
            .IsRequired()
            .HasDefaultValueSql("CURRENT_TIMESTAMP AT TIME ZONE 'UTC'");

        builder.Property<string>("CreatedBy")
            .HasMaxLength(100);

        builder.Property<string>("UpdatedBy")
            .HasMaxLength(100);

        builder.Property<uint>("Version")
            .IsRowVersion();  // Concurrency token
    }
}

テンプレート:論理削除の設定

// src/{name}.infrastructure/Configurations/{Entity}Configuration.cs
internal sealed class {Entity}Configuration : IEntityTypeConfiguration<{Entity}>
{
    public void Configure(EntityTypeBuilder<{Entity}> builder)
    {
        builder.ToTable("{entity}");

        // Soft delete property
        builder.Property(e => e.IsDeleted)
            .HasDefaultValue(false)
            .IsRequired();

        builder.Property(e => e.DeletedAt);

        // ═══════════════════════════════════════════════════════════════
        // GLOBAL QUERY FILTER (excludes soft-deleted)
        // ═══════════════════════════════════════════════════════════════
        builder.HasQueryFilter(e => !e.IsDeleted);

        // Index for soft delete queries
        builder.HasIndex(e => e.IsDeleted);
    }
}

テンプレート:JSON 列(PostgreSQL)

// src/{name}.infrastructure/Configurations/{Entity}Configuration.cs
internal sealed class {Entity}Configuration : IEntityTypeConfiguration<{Entity}>
{
    public void Configure(EntityTypeBuilder<{Entity}> builder)
    {
        builder.ToTable("{entity}");

        // ═══════════════════════════════════════════════════════════════
        // JSON COLUMN (PostgreSQL JSONB)
        // ═══════════════════════════════════════════════════════════════
        builder.Property(e => e.Metadata)
            .HasColumnType("jsonb")
            .HasConversion(
                v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null),
                v => JsonSerializer.Deserialize<Dictionary<string, object>>(v, (JsonSerializerOptions?)null)!);

        // Or for owned types in JSON
        builder.OwnsOne(e => e.Settings, settingsBuilder =>
        {
            settingsBuilder.ToJson();  // EF Core 7+
            settingsBuilder.Property(s => s.NotificationsEnabled);
            settingsBuilder.Property(s => s.Theme);
        });
    }
}

テンプレート:アウトボックスメッセージの設定

// src/{name}.infrastructure/Configurations/OutboxMessageConfiguration.cs
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using {name}.infrastructure.outbox;

namespace {name}.infrastructure.configurations;

internal sealed class OutboxMessageConfiguration : IEntityTypeConfiguration<OutboxMessage>
{
    public void Configure(EntityTypeBuilder<OutboxMessage> builder)
    {
        builder.ToTable("outbox_message");

        builder.HasKey(o => o.Id);

        builder.Property(o => o.Id)
            .ValueGeneratedNever();

        builder.Property(o => o.OccurredOnUtc)
            .IsRequired();

        builder.Property(o => o.Type)
            .HasMaxLength(500)
            .IsRequired();

        builder.Property(o => o.Content)
            .HasColumnType("jsonb")
            .IsRequired();

        builder.Property(o => o.ProcessedOnUtc);

        builder.Property(o => o.Error)
            .HasColumnType("text");

        // Index for processing unprocessed messages
        builder.HasIndex(o => o.ProcessedOnUtc)
            .HasFilter("processed_on_utc IS NULL");
    }
}

PostgreSQL 固有の設定

スネークケースの命名規約
// In DependencyInjection.cs
services.AddDbContext<ApplicationDbContext>(options =>
{
    options.UseNpgsql(connectionString)
           .UseSnakeCaseNamingConvention();  // Requires EFCore.NamingConventions
});
列の型リファレンス
C# の型PostgreSQL の型設定
stringtext.HasColumnType("text")
string(長さ制限あり)varchar(n).HasMaxLength(n)
decimalnumeric(p,s).HasColumnType("numeric(18,2)")
DateTimetimestamp.HasColumnType("timestamp")
DateTimeOffsettimestamptz.HasColumnType("timestamptz")
Guiduuid(自動)
boolboolean(自動)
byte[]bytea(自動)
Dictionaryjsonb.HasColumnType("jsonb")

ApplicationDbContext の設定

// src/{name}.infrastructure/ApplicationDbContext.cs
using Microsoft.EntityFrameworkCore;
using {name}.domain.abstractions;

namespace {name}.infrastructure;

public sealed class ApplicationDbContext : DbContext, IUnitOfWork
{
    public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
        : base(options)
    {
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        // Apply all configurations from assembly
        modelBuilder.ApplyConfigurationsFromAssembly(
            typeof(ApplicationDbContext).Assembly);

        base.OnModelCreating(modelBuilder);
    }

    public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
    {
        // Add domain events to outbox
        AddDomainEventsAsOutboxMessages();
        
        // Update audit fields
        UpdateAuditFields();

        return await base.SaveChangesAsync(cancellationToken);
    }

    private void UpdateAuditFields()
    {
        var entries = ChangeTracker.Entries()
            .Where(e => e.State is EntityState.Added or EntityState.Modified);

        foreach (var entry in entries)
        {
            if (entry.State == EntityState.Added)
            {
                entry.Property("CreatedAt").CurrentValue = DateTime.UtcNow;
            }

            entry.Property("UpdatedAt").CurrentValue = DateTime.UtcNow;
        }
    }
}

重要なルール

  1. 属性ではなく Fluent API を使用する - ドメインをクリーンに保ちます
  2. エンティティごとに設定する - IEntityTypeConfiguration<T> ごとに 1 ファイルを用意します
  3. PostgreSQL ではスネークケースを使用する - 命名規約のパッケージを使用します
  4. GUID には ValueGeneratedNever を使用する - ID はアプリケーションで生成します
  5. 列の型を明示する - 規約に頼らないでください
  6. 片側からリレーションシップを設定する - 重複を避けます
  7. 削除時の動作は慎重に選択する - カスケードと制限を使い分けます
  8. 外部キーにインデックスを設定する - EF Core は外部キーに自動でインデックスを作成しません
  9. 論理削除にはクエリフィルターを使用する - 一貫したフィルタリングを行います
  10. 値オブジェクトは所有型として扱う - 値オブジェクトには OwnsOne を使用します

避けるべきアンチパターン

// ❌ WRONG: Data annotations on domain
public class User
{
    [Key]
    public Guid Id { get; set; }
    
    [Required]
    [MaxLength(100)]
    public string Name { get; set; }  // Pollutes domain!
}

// ✅ CORRECT: Fluent API in configuration
builder.Property(u => u.Name).HasMaxLength(100).IsRequired();

// ❌ WRONG: Not specifying string length
builder.Property(e => e.Name);  // Defaults to MAX!

// ✅ CORRECT: Always specify max length
builder.Property(e => e.Name).HasMaxLength(100);

// ❌ WRONG: Auto-increment for GUIDs
builder.Property(e => e.Id).ValueGeneratedOnAdd();

// ✅ CORRECT: App generates GUIDs
builder.Property(e => e.Id).ValueGeneratedNever();

// ❌ WRONG: No cascade strategy
builder.HasMany(e => e.Children).WithOne();

// ✅ CORRECT: Explicit delete behavior
builder.HasMany(e => e.Children)
    .WithOne(c => c.Parent)
    .OnDelete(DeleteBehavior.Cascade);

関連スキル

  • dotnet-domain-entity-generator - 設定対象のエンティティを生成します
  • dotnet-repository-pattern - リポジトリで設定を使用します
  • dotnet-clean-architecture - インフラストラクチャレイヤーへの配置

コメント