Skip to content

Entity Mapping in Momentum

Entity mapping in Momentum uses LinqToDB to provide type-safe, high-performance database access with PostgreSQL. The mapping system follows conventions while allowing customization for complex scenarios.

Entity Base Classes

DbEntity Base Class

All entities inherit from DbEntity which provides common functionality:

csharp
[Table(Schema = "AppDomain")]
public abstract record DbEntity
{
    [Column(SkipOnUpdate = true)]
    public DateTime CreatedDateUtc { get; set; } = DateTime.UtcNow;

    public DateTime UpdatedDateUtc { get; set; } = DateTime.UtcNow;

    [Column("xmin", SkipOnInsert = true, SkipOnUpdate = true)]
    [OptimisticLockProperty(VersionBehavior.Auto)]
    public int Version { get; init; } = 0;
}

Key Features:

  • Schema: All entities belong to the "AppDomain" schema
  • Audit Fields: Automatic created/updated timestamps
  • Optimistic Locking: Uses PostgreSQL's xmin for concurrency control
  • Records: Immutable by default, promoting safe concurrent access

Basic Entity Mapping

Simple Entity Example

csharp
// Entity definition
namespace AppDomain.Cashiers.Data.Entities;

public record Cashier : DbEntity
{
    [PrimaryKey(order: 0)]
    public Guid TenantId { get; set; }

    [PrimaryKey(order: 1)]
    public Guid CashierId { get; set; }

    public string Name { get; set; } = string.Empty;

    public string? Email { get; set; }

    // Additional properties can be added
    public bool IsActive { get; set; } = true;
    
    [Column(CanBeNull = false)]
    public string Department { get; set; } = "General";
}

Database Table

This maps to a PostgreSQL table:

sql
CREATE TABLE "AppDomain".cashiers (
    tenant_id UUID NOT NULL,
    cashier_id UUID NOT NULL,
    name VARCHAR NOT NULL,
    email VARCHAR NULL,
    is_active BOOLEAN NOT NULL DEFAULT true,
    department VARCHAR NOT NULL DEFAULT 'General',
    created_date_utc TIMESTAMP NOT NULL DEFAULT (NOW() AT TIME ZONE 'UTC'),
    updated_date_utc TIMESTAMP NOT NULL DEFAULT (NOW() AT TIME ZONE 'UTC'),
    
    PRIMARY KEY (tenant_id, cashier_id)
);

Advanced Entity Mapping

Complex Entity with Relationships

csharp
namespace AppDomain.Invoices.Data.Entities;

[Table("invoices")]
public record Invoice : DbEntity
{
    [PrimaryKey]
    public Guid InvoiceId { get; set; }

    [Column(CanBeNull = false)]
    public Guid TenantId { get; set; }

    [Column(CanBeNull = false)]
    public Guid CashierId { get; set; }

    [Column("amount", Precision = 18, Scale = 2)]
    public decimal Amount { get; set; }

    [Column("description", Length = 500)]
    public string Description { get; set; } = string.Empty;

    [Column("status")]
    public InvoiceStatus Status { get; set; } = InvoiceStatus.Draft;

    [Column("due_date")]
    public DateOnly? DueDate { get; set; }

    [Column("paid_date_utc")]
    public DateTime? PaidDateUtc { get; set; }

    // JSON column for metadata
    [Column("metadata", DataType = DataType.Json)]
    public InvoiceMetadata? Metadata { get; set; }

    // Computed column example
    [Column("days_overdue", IsColumn = false)]
    public int DaysOverdue => DueDate.HasValue && DateTime.UtcNow.Date > DueDate.Value.ToDateTime(TimeOnly.MinValue)
        ? (DateTime.UtcNow.Date - DueDate.Value.ToDateTime(TimeOnly.MinValue)).Days
        : 0;
}

// Supporting types
public record InvoiceMetadata
{
    public string? ExternalReference { get; set; }
    public Dictionary<string, string>? CustomFields { get; set; }
    public List<string>? Tags { get; set; }
}

public enum InvoiceStatus
{
    Draft = 0,
    Pending = 1,
    Paid = 2,
    Cancelled = 3,
    Overdue = 4
}

Corresponding SQL Table

sql
CREATE TABLE "AppDomain".invoices (
    invoice_id UUID PRIMARY KEY,
    tenant_id UUID NOT NULL,
    cashier_id UUID NOT NULL,
    amount DECIMAL(18,2) NOT NULL,
    description VARCHAR(500) NOT NULL,
    status INTEGER NOT NULL DEFAULT 0,
    due_date DATE NULL,
    paid_date_utc TIMESTAMP NULL,
    metadata JSONB NULL,
    created_date_utc TIMESTAMP NOT NULL DEFAULT (NOW() AT TIME ZONE 'UTC'),
    updated_date_utc TIMESTAMP NOT NULL DEFAULT (NOW() AT TIME ZONE 'UTC'),
    
    FOREIGN KEY (tenant_id, cashier_id) REFERENCES "AppDomain".cashiers(tenant_id, cashier_id)
);

Column Mapping Attributes

Common Column Attributes

csharp
public record Product : DbEntity
{
    [PrimaryKey]
    public Guid ProductId { get; set; }

    // Custom column name
    [Column("product_name")]
    public string Name { get; set; } = string.Empty;

    // Length constraint
    [Column("sku", Length = 50)]
    public string SKU { get; set; } = string.Empty;

    // Precision and scale for decimals
    [Column("price", Precision = 10, Scale = 2)]
    public decimal Price { get; set; }

    // Nullable column
    [Column("discount_rate", CanBeNull = true)]
    public decimal? DiscountRate { get; set; }

    // Skip on operations
    [Column("calculated_field", SkipOnInsert = true, SkipOnUpdate = true)]
    public string CalculatedField { get; set; } = string.Empty;

    // Data type specification
    [Column("tags", DataType = DataType.Json)]
    public List<string>? Tags { get; set; }

    // Not mapped to database
    [NotColumn]
    public string DisplayName => $"{Name} ({SKU})";
}

Custom Data Types

csharp
public record Customer : DbEntity
{
    [PrimaryKey]
    public Guid CustomerId { get; set; }

    // Custom converter for complex types
    [Column("address", DataType = DataType.Json)]
    public Address Address { get; set; } = new();

    // Enum mapping
    [Column("customer_type")]
    public CustomerType Type { get; set; }

    // Date-only mapping
    [Column("birth_date", DataType = DataType.Date)]
    public DateOnly? BirthDate { get; set; }

    // Time-only mapping
    [Column("preferred_contact_time", DataType = DataType.Time)]
    public TimeOnly? PreferredContactTime { get; set; }
}

public record Address
{
    public string Street { get; set; } = string.Empty;
    public string City { get; set; } = string.Empty;
    public string PostalCode { get; set; } = string.Empty;
    public string Country { get; set; } = string.Empty;
}

public enum CustomerType
{
    Individual = 1,
    Business = 2,
    Enterprise = 3
}

Composite Keys and Indexes

Multi-Column Primary Keys

csharp
public record CashierCurrency : DbEntity
{
    [PrimaryKey(order: 0)]
    public Guid TenantId { get; set; }

    [PrimaryKey(order: 1)]
    public Guid CashierId { get; set; }

    [PrimaryKey(order: 2)]
    public string CurrencyCode { get; set; } = string.Empty;

    public decimal ExchangeRate { get; set; } = 1.0m;
    public bool IsDefault { get; set; } = false;
}

Index Definitions

csharp
// Using fluent mapping for complex scenarios
public class InvoiceMapping : EntityMappingBuilder<Invoice>
{
    public override void Configure()
    {
        // Composite index
        HasIndex(nameof(Invoice.TenantId), nameof(Invoice.Status), nameof(Invoice.DueDate))
            .HasName("IX_invoices_tenant_status_due_date");

        // Unique index
        HasIndex(nameof(Invoice.TenantId), "external_reference")
            .IsUnique()
            .HasName("IX_invoices_external_reference");

        // Partial index (PostgreSQL specific)
        HasIndex(nameof(Invoice.Status))
            .HasFilter("status IN (1, 4)") // Only for Pending and Overdue
            .HasName("IX_invoices_active_status");
    }
}

Foreign Key Relationships

While LinqToDB doesn't have built-in navigation properties like Entity Framework, you can define relationships:

csharp
public record Invoice : DbEntity
{
    // ... other properties

    [Column("cashier_id")]
    public Guid CashierId { get; set; }

    // Navigation property (not mapped)
    [NotColumn]
    public Cashier? Cashier { get; set; }
}

// Extension method for loading relationships
public static class InvoiceExtensions
{
    public static async Task<Invoice> LoadCashierAsync(this Invoice invoice, AppDomainDb db, CancellationToken cancellationToken = default)
    {
        var cashier = await db.Cashiers
            .FirstOrDefaultAsync(c => c.CashierId == invoice.CashierId, cancellationToken);

        return invoice with { Cashier = cashier };
    }
}

// Usage
var invoice = await db.Invoices
    .FirstAsync(i => i.InvoiceId == invoiceId);

invoice = await invoice.LoadCashierAsync(db);

Join Queries

csharp
// Method in your repository or query handler
public static async Task<List<InvoiceWithCashier>> GetInvoicesWithCashiers(
    AppDomainDb db, 
    Guid tenantId, 
    CancellationToken cancellationToken)
{
    var query = from i in db.Invoices
                join c in db.Cashiers on new { i.TenantId, i.CashierId } equals new { c.TenantId, c.CashierId }
                where i.TenantId == tenantId
                select new InvoiceWithCashier
                {
                    InvoiceId = i.InvoiceId,
                    Amount = i.Amount,
                    Description = i.Description,
                    Status = i.Status,
                    CashierName = c.Name,
                    CashierEmail = c.Email
                };

    return await query.ToListAsync(cancellationToken);
}

public record InvoiceWithCashier
{
    public Guid InvoiceId { get; set; }
    public decimal Amount { get; set; }
    public string Description { get; set; } = string.Empty;
    public InvoiceStatus Status { get; set; }
    public string CashierName { get; set; } = string.Empty;
    public string? CashierEmail { get; set; }
}

Database Context Configuration

DbContext Setup

csharp
public class AppDomainDb : DataConnection
{
    public AppDomainDb(DataOptions<AppDomainDb> options) : base(options.Options)
    {
        // Configure mapping options
        MappingSchema.SetDefaultFromEnumType<InvoiceStatus>(typeof(int));
        MappingSchema.SetDefaultFromEnumType<CustomerType>(typeof(int));
        
        // Custom converters
        MappingSchema.SetConverter<Address, string>(address => JsonSerializer.Serialize(address));
        MappingSchema.SetConverter<string, Address>(json => JsonSerializer.Deserialize<Address>(json) ?? new Address());
    }

    public ITable<Cashier> Cashiers => this.GetTable<Cashier>();
    public ITable<Invoice> Invoices => this.GetTable<Invoice>();
    public ITable<CashierCurrency> CashierCurrencies => this.GetTable<CashierCurrency>();
    public ITable<Customer> Customers => this.GetTable<Customer>();
}

Configuration in Program.cs

csharp
var builder = WebApplication.CreateBuilder(args);

// Configure database
builder.Services.AddLinqToDbContext<AppDomainDb>((provider, options) =>
{
    var connectionString = builder.Configuration.GetConnectionString("AppDomain")!;
    
    options
        .UsePostgreSQL(connectionString)
        .UseDefaultLogging(provider) // Integrate with ILogger
        .UseMappingSchema(CreateMappingSchema()); // Custom mapping schema
});

static MappingSchema CreateMappingSchema()
{
    var mappingSchema = new MappingSchema();
    
    // Global configurations
    mappingSchema.SetDataType<Guid>(DataType.Guid);
    mappingSchema.SetDataType<DateTime>(DataType.DateTime2);
    mappingSchema.SetDataType<DateOnly>(DataType.Date);
    mappingSchema.SetDataType<TimeOnly>(DataType.Time);
    
    // Enum mappings
    mappingSchema.SetDefaultFromEnumType<InvoiceStatus>(typeof(int));
    mappingSchema.SetDefaultFromEnumType<CustomerType>(typeof(int));
    
    // JSON converters
    SetupJsonConverters(mappingSchema);
    
    return mappingSchema;
}

static void SetupJsonConverters(MappingSchema mappingSchema)
{
    // Generic JSON converter for complex types
    mappingSchema.SetConverter<InvoiceMetadata, string>(
        metadata => JsonSerializer.Serialize(metadata, JsonSerializerOptions.Default));
        
    mappingSchema.SetConverter<string, InvoiceMetadata>(
        json => string.IsNullOrEmpty(json) 
            ? null 
            : JsonSerializer.Deserialize<InvoiceMetadata>(json, JsonSerializerOptions.Default));
}

Model Mapping Extensions

Entity to Model Conversion

csharp
// Extension methods for entity-to-model mapping
public static class CashierMappingExtensions
{
    public static Contracts.Models.Cashier ToModel(this Data.Entities.Cashier entity)
    {
        return new Contracts.Models.Cashier
        {
            Id = entity.CashierId,
            TenantId = entity.TenantId,
            Name = entity.Name,
            Email = entity.Email,
            IsActive = entity.IsActive,
            Department = entity.Department,
            CreatedDate = entity.CreatedDateUtc,
            UpdatedDate = entity.UpdatedDateUtc,
            Version = entity.Version
        };
    }

    public static Data.Entities.Cashier ToEntity(this Contracts.Models.Cashier model)
    {
        return new Data.Entities.Cashier
        {
            CashierId = model.Id,
            TenantId = model.TenantId,
            Name = model.Name,
            Email = model.Email,
            IsActive = model.IsActive,
            Department = model.Department,
            CreatedDateUtc = model.CreatedDate,
            UpdatedDateUtc = model.UpdatedDate,
            Version = model.Version
        };
    }
}

public static class InvoiceMappingExtensions
{
    public static Contracts.Models.Invoice ToModel(this Data.Entities.Invoice entity)
    {
        return new Contracts.Models.Invoice
        {
            Id = entity.InvoiceId,
            TenantId = entity.TenantId,
            CashierId = entity.CashierId,
            Amount = entity.Amount,
            Description = entity.Description,
            Status = (Contracts.Models.InvoiceStatus)(int)entity.Status,
            DueDate = entity.DueDate,
            PaidDate = entity.PaidDateUtc,
            Metadata = entity.Metadata?.ToModel(),
            CreatedDate = entity.CreatedDateUtc,
            UpdatedDate = entity.UpdatedDateUtc,
            Version = entity.Version
        };
    }
}

Snake Case Convention

Automatic Snake Case Naming

csharp
public class SnakeCaseNamingConvention : IColumnAliasProvider
{
    public string GetColumnAlias(string columnName)
    {
        return ToSnakeCase(columnName);
    }

    private static string ToSnakeCase(string input)
    {
        if (string.IsNullOrEmpty(input))
            return input;

        var result = new StringBuilder();
        
        for (int i = 0; i < input.Length; i++)
        {
            char c = input[i];
            
            if (char.IsUpper(c))
            {
                if (i > 0)
                    result.Append('_');
                
                result.Append(char.ToLower(c));
            }
            else
            {
                result.Append(c);
            }
        }
        
        return result.ToString();
    }
}

// Apply globally
builder.Services.Configure<LinqToDbSettings>(settings =>
{
    settings.DefaultSettings = new DataOptions()
        .UseNamingConvention<SnakeCaseNamingConvention>();
});

Best Practices

Entity Design

  1. Use Records: Prefer records for immutability and value semantics
  2. Inherit from DbEntity: Use the base class for consistent audit fields
  3. Explicit Mapping: Be explicit about column mappings for clarity
  4. Validate Data Types: Choose appropriate data types for your use cases

Performance

  1. Index Strategy: Add indexes for frequently queried columns
  2. Avoid N+1: Use joins instead of multiple queries
  3. Projection: Use Select() to limit returned columns
  4. Compiled Queries: Use compiled queries for frequently executed queries

Naming Conventions

  1. Consistent Naming: Use consistent naming for tables and columns
  2. Snake Case: Follow PostgreSQL conventions with snake_case
  3. Meaningful Names: Use descriptive names for entities and properties
  4. Prefix/Suffix: Use consistent prefixes or suffixes for related entities

Schema Management

  1. Schema Organization: Group related entities in schemas
  2. Version Control: Keep entity definitions in version control
  3. Migration Strategy: Plan for schema changes and migrations
  4. Documentation: Document entity relationships and business rules

Testing Entity Mapping

Unit Tests for Mapping

csharp
[Test]
public void ToModel_ValidEntity_MapsAllProperties()
{
    // Arrange
    var entity = new Data.Entities.Cashier
    {
        TenantId = Guid.NewGuid(),
        CashierId = Guid.NewGuid(),
        Name = "John Doe",
        Email = "john@example.com",
        IsActive = true,
        Department = "Sales",
        CreatedDateUtc = DateTime.UtcNow.AddDays(-1),
        UpdatedDateUtc = DateTime.UtcNow,
        Version = 1
    };

    // Act
    var model = entity.ToModel();

    // Assert
    model.Id.Should().Be(entity.CashierId);
    model.TenantId.Should().Be(entity.TenantId);
    model.Name.Should().Be(entity.Name);
    model.Email.Should().Be(entity.Email);
    model.IsActive.Should().Be(entity.IsActive);
    model.Department.Should().Be(entity.Department);
    model.CreatedDate.Should().Be(entity.CreatedDateUtc);
    model.UpdatedDate.Should().Be(entity.UpdatedDateUtc);
    model.Version.Should().Be(entity.Version);
}

Integration Tests with Database

csharp
[Test]
public async Task Database_InsertAndRetrieve_MaintainsDataIntegrity()
{
    // Arrange
    using var testContext = new IntegrationTestContext();
    var db = testContext.CreateDatabase<AppDomainDb>();

    var entity = new Data.Entities.Cashier
    {
        TenantId = Guid.NewGuid(),
        CashierId = Guid.NewGuid(),
        Name = "Jane Doe",
        Email = "jane@example.com",
        IsActive = true,
        Department = "Marketing"
    };

    // Act
    var insertedId = await db.InsertWithIdentityAsync(entity);
    var retrieved = await db.Cashiers
        .FirstOrDefaultAsync(c => c.CashierId == entity.CashierId);

    // Assert
    retrieved.Should().NotBeNull();
    retrieved!.Name.Should().Be(entity.Name);
    retrieved.Email.Should().Be(entity.Email);
    retrieved.IsActive.Should().Be(entity.IsActive);
    retrieved.Department.Should().Be(entity.Department);
    retrieved.Version.Should().BeGreaterThan(0); // xmin should be set
}

Next Steps