skills/kashiash/xaf-skills/xaf-ef-models

xaf-ef-models

SKILL.md

XAF: EF Core Business Object Models

Setup

NuGet Packages

<PackageReference Include="DevExpress.Persistent.BaseImpl.EFCore" Version="25.1.*" />
<PackageReference Include="DevExpress.ExpressApp.EFCore" Version="25.1.*" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.*" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="8.*" />
<!-- For change tracking and lazy loading proxies -->
<PackageReference Include="Microsoft.EntityFrameworkCore.Proxies" Version="8.*" />

DbContext (XAF style)

using DevExpress.Persistent.BaseImpl.EF;
using Microsoft.EntityFrameworkCore;

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

    public DbSet<Contact> Contacts { get; set; }
    public DbSet<Department> Departments { get; set; }
    // Required XAF built-in tables:
    public DbSet<ModelDifference> ModelDifferences { get; set; }
    public DbSet<ModelDifferenceAspect> ModelDifferenceAspects { get; set; }

    protected override void OnModelCreating(ModelBuilder modelBuilder) {
        base.OnModelCreating(modelBuilder);
        modelBuilder.HasChangeTrackingStrategy(
            ChangeTrackingStrategy.ChangingAndChangedNotificationsWithOriginalValues);
    }
}

// Required for migrations CLI
public class ApplicationDbContextFactory : IDesignTimeDbContextFactory<ApplicationDbContext> {
    public ApplicationDbContext CreateDbContext(string[] args) {
        var optionsBuilder = new DbContextOptionsBuilder<ApplicationDbContext>();
        optionsBuilder.UseSqlServer("Server=(localdb)\\mssqllocaldb;Database=MyApp;Trusted_Connection=True;");
        optionsBuilder.UseChangeTrackingProxies();
        optionsBuilder.UseLazyLoadingProxies();
        return new ApplicationDbContext(optionsBuilder.Build());
    }
}

XAF Application Setup (Program.cs)

// CRITICAL: Use AddDbContextFactory, NOT AddDbContext
builder.Services.AddDbContextFactory<ApplicationDbContext>((sp, options) => {
    options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection"));
    options.UseChangeTrackingProxies();  // REQUIRED for XAF property notifications
    options.UseLazyLoadingProxies();     // required for virtual navigation properties
});

Base Interfaces / Classes

Type Purpose
BaseObject Use this. Abstract base; Guid PK, all interfaces included
IXafEntityObject OnCreated/OnLoaded/OnSaving lifecycle hooks
IObjectSpaceLink Gives access to IObjectSpace from inside entity
IOptimisticLock Optimistic concurrency (LockToken int column)
IDeferredDeletion Soft-delete via GCRecord column

Always inherit from BaseObject — it implements all interfaces automatically.

// Minimal correct EF Core entity for XAF
using DevExpress.Persistent.BaseImpl.EF;

public class Contact : BaseObject {
    // Guid ID is in BaseObject — do NOT redefine it
    // All properties MUST be virtual for change-tracking + lazy loading proxies

    public virtual string FirstName { get; set; }
    public virtual string LastName { get; set; }
    public virtual string Email { get; set; }
    public virtual Department Department { get; set; }
}

If you cannot inherit BaseObject (existing hierarchy), implement manually:

public class LegacyEntity : IXafEntityObject, IObjectSpaceLink, IOptimisticLock {
    [Key]
    public virtual Guid ID { get; set; } = Guid.NewGuid();

    [ConcurrencyCheck]
    public virtual int LockToken { get; protected set; }

    [NotMapped]
    IObjectSpace IObjectSpaceLink.ObjectSpace { get; set; }

    void IXafEntityObject.OnCreated() { ID = Guid.NewGuid(); }
    void IXafEntityObject.OnLoaded() { }
    void IXafEntityObject.OnSaving() { }
}

Entity Configuration

Data Annotations (simple cases)

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

public class Product : BaseObject {
    [Required]
    [MaxLength(200)]
    public virtual string Name { get; set; }

    [Column("unit_price", TypeName = "decimal(18,2)")]
    public virtual decimal UnitPrice { get; set; }

    [NotMapped]   // Not stored in DB
    public virtual string DisplayLabel => $"{Name} (${UnitPrice})";
}

Fluent API (OnModelCreating)

protected override void OnModelCreating(ModelBuilder modelBuilder) {
    base.OnModelCreating(modelBuilder);
    modelBuilder.HasChangeTrackingStrategy(
        ChangeTrackingStrategy.ChangingAndChangedNotificationsWithOriginalValues);

    modelBuilder.Entity<Product>(entity => {
        entity.Property(e => e.Name).HasMaxLength(200).IsRequired();
        entity.Property(e => e.UnitPrice).HasColumnType("decimal(18,2)");
        entity.HasIndex(e => e.Name).IsUnique();
        entity.HasOne(e => e.Category)
              .WithMany(c => c.Products)
              .HasForeignKey("CategoryId")
              .OnDelete(DeleteBehavior.Restrict);
    });
}

Relations

One-to-Many

public class Department : BaseObject {
    public virtual string Name { get; set; }
    // virtual + ObservableCollection required
    public virtual IList<Contact> Contacts { get; set; } = new ObservableCollection<Contact>();
}

public class Contact : BaseObject {
    public virtual string FullName { get; set; }
    public virtual Department Department { get; set; }
}

Fluent config if needed:

modelBuilder.Entity<Contact>()
    .HasOne(c => c.Department)
    .WithMany(d => d.Contacts)
    .OnDelete(DeleteBehavior.SetNull);

Many-to-Many (with explicit join entity — recommended in XAF)

public class Employee : BaseObject {
    public virtual string Name { get; set; }
    public virtual IList<EmployeeTask> EmployeeTasks { get; set; }
        = new ObservableCollection<EmployeeTask>();
}

public class ProjectTask : BaseObject {
    public virtual string Title { get; set; }
    public virtual IList<EmployeeTask> EmployeeTasks { get; set; }
        = new ObservableCollection<EmployeeTask>();
}

// Explicit join entity (allows extra fields)
public class EmployeeTask : BaseObject {
    public virtual Employee Employee { get; set; }
    public virtual ProjectTask Task { get; set; }
    public virtual DateTime AssignedDate { get; set; }
}

One-to-One

public class Person : BaseObject {
    public virtual string Name { get; set; }
    public virtual PersonDetail Detail { get; set; }
}

public class PersonDetail : BaseObject {
    public virtual string Biography { get; set; }
    public virtual Person Person { get; set; }
}

Fluent config:

modelBuilder.Entity<Person>()
    .HasOne(p => p.Detail)
    .WithOne(d => d.Person)
    .HasForeignKey<PersonDetail>("PersonId")
    .OnDelete(DeleteBehavior.Cascade);

Lifecycle Hooks

public class Order : BaseObject {
    public virtual string OrderNumber { get; set; }
    public virtual DateTime OrderDate { get; set; }
    public virtual OrderStatus Status { get; set; }

    public override void OnCreated() {
        base.OnCreated();
        OrderDate = DateTime.Now;
        Status = OrderStatus.New;
        OrderNumber = $"ORD-{DateTime.Now:yyyyMMdd}-{Guid.NewGuid().ToString()[..8].ToUpper()}";
    }

    public override void OnSaving() {
        base.OnSaving();
        if (((IObjectSpaceLink)this).ObjectSpace?.IsNewObject(this) == true)
            CreatedDate = DateTime.UtcNow;
        LastModified = DateTime.UtcNow;
    }

    public override void OnLoaded() {
        base.OnLoaded();
    }
}

Migrations in XAF

# Package Manager Console
Add-Migration InitialCreate -Project YourDataProject -StartupProject YourStartupProject
Update-Database -Project YourDataProject -StartupProject YourStartupProject

# CLI
dotnet ef migrations add InitialCreate --project YourDataProject --startup-project YourStartupProject
dotnet ef database update --project YourDataProject --startup-project YourStartupProject

Auto-migration on startup (via DatabaseVersionMismatch)

public class MyModule : ModuleBase {
    public override void Setup(XafApplication application) {
        base.Setup(application);
        application.DatabaseVersionMismatch += (s, e) => {
            e.Updater.Update();
            e.Handled = true;
        };
    }
}

Calculated Properties

public class OrderLine : BaseObject {
    public virtual decimal UnitPrice { get; set; }
    public virtual int Quantity { get; set; }
    public virtual decimal Discount { get; set; }

    // NotMapped: pure C# calculation
    [NotMapped]
    public virtual decimal LineTotal => UnitPrice * Quantity * (1 - Discount);
}

DB-computed column (Fluent API):

modelBuilder.Entity<OrderLine>()
    .Property(e => e.ComputedTotal)
    .HasComputedColumnSql("[UnitPrice] * [Quantity] * (1 - [Discount])", stored: true);

EF Core vs XPO — Key Differences

Aspect XPO EF Core
PK default int (XPObject) or Guid (BaseObject) Guid (BaseObject)
Change tracking SetPropertyValue() method Virtual properties + UseChangeTrackingProxies()
Collections XPCollection<T> via GetCollection<T>() IList<T> with new ObservableCollection<T>()
Session/Context Session constructor arg IObjectSpaceLink.ObjectSpace
Lifecycle hooks AfterConstruction, OnSaving, OnLoaded Same via IXafEntityObject
Associations [Association("name")] both ends Navigation properties
Cascade delete [Aggregated] on collection DeleteBehavior.Cascade Fluent API
Migrations Auto schema update (no migrations) EF Core migrations required
Lazy loading Automatic Requires UseLazyLoadingProxies() + virtual
DevExpress rec. Mature, stable, legacy Recommended for new projects (v21.2+)

Common Pitfalls

Pitfall Solution
Non-virtual properties All properties must be virtual for proxies
AddDbContext instead of AddDbContextFactory XAF requires IDbContextFactory<T>
Missing UseChangeTrackingProxies() Breaks UI, Conditional Appearance, validation
Redefining ID / Oid BaseObject already has Guid ID
AsNoTracking() in queries Bypasses XAF security filters — never use
List<T> instead of ObservableCollection<T> List views won't update automatically
No IDesignTimeDbContextFactory dotnet ef migrations add fails
Missing DbSet<T> for built-in XAF types Add ModelDifference, ModelDifferenceAspect, etc.
Circular cascade delete paths Use DeleteBehavior.Restrict and handle manually
IObjectSpace stored in static field or singleton Memory leak — use IObjectSpaceFactory + using var os = ...
Unbounded GetObjects<T>() without criteria Loads entire table into memory — add CriteriaOperator or Take()

Related Skills

  • xaf-memory-leaks — ObjectSpace disposal patterns, batch processing, CollectionSource lifecycle

Source Links

Weekly Installs
5
GitHub Stars
2
First Seen
6 days ago
Installed on
opencode5
gemini-cli5
claude-code5
github-copilot5
codex5
amp5