Level: Intermediate 📚 | Time: 30-40 min | Prerequisites: Basics
Integrate Railway-Oriented Programming with Entity Framework Core for type-safe repository patterns. Learn when to use Result<T> vs Maybe<T> in your repositories.
- Installation
- Value Object Configuration
- Repository Return Types
- Result vs Maybe Pattern
- Query Extensions
- Handling Database Exceptions
- Money Property Convention
- Maybe<T> Property Convention
- GUID V7 for Entity IDs
Tip
This guide covers two approaches: using the Trellis.EntityFrameworkCore package (recommended) and a manual approach for teams that prefer not to take the dependency. Sections marked with 📦 use the package; sections marked with 🔧 show the manual equivalent.
dotnet add package Trellis.EntityFrameworkCoreThis package provides:
| Feature | Description |
|---|---|
ApplyTrellisConventions |
Auto-registers EF Core value converters for scalar/symbolic Trellis value objects and auto-maps Money as a structured owned type |
SaveChangesResultAsync |
Wraps SaveChangesAsync — returns Result<int> instead of throwing |
SaveChangesResultUnitAsync |
Same as above but returns Result<Unit> |
DbExceptionClassifier |
Provider-agnostic exception classification (SQL Server, PostgreSQL, SQLite) |
FirstOrDefaultMaybeAsync |
Wraps query results in Maybe<T> |
FirstOrDefaultResultAsync |
Wraps query results in Result<T> with a not-found error |
SingleOrDefaultMaybeAsync |
Single-result variant returning Maybe<T> |
.Where(specification) |
Applies a Specification<T> as a LINQ Where clause |
No extra package needed — just reference Trellis.Results (or whichever Trellis packages you already use) and configure EF Core manually. Each section below shows the manual equivalent.
Override ConfigureConventions in your DbContext and call ApplyTrellisConventions. This registers value converters for all Trellis value objects before EF Core's convention engine runs, so properties are treated as scalars — not navigations.
using Trellis.EntityFrameworkCore;
public class AppDbContext : DbContext
{
public DbSet<Customer> Customers => Set<Customer>();
public DbSet<Order> Orders => Set<Order>();
protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) =>
configurationBuilder.ApplyTrellisConventions(typeof(CustomerId).Assembly);
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Customer>(b =>
{
b.HasKey(c => c.Id);
b.Property(c => c.Name).HasMaxLength(100).IsRequired();
b.Property(c => c.Email).HasMaxLength(254).IsRequired();
});
}
}ApplyTrellisConventions automatically:
- Scans your assemblies for types implementing
IScalarValue<TSelf, TPrimitive>(e.g.,RequiredGuid<T>,RequiredString<T>,RequiredInt<T>,RequiredDecimal<T>, customScalarValueObject<,>subclasses) - Scans for
RequiredEnum<T>types and stores them as strings (usingValue/TryFromName) - Always includes the
Trellis.Primitivesassembly (forEmailAddress,Url,PhoneNumber, etc.)
If you need a custom converter for a specific property, use HasConversion in OnModelCreating — it takes precedence over the convention:
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Customer>(b =>
{
// This overrides the convention-registered converter for Name
b.Property(c => c.Name)
.HasConversion(
name => name.Value.ToUpperInvariant(),
str => CustomerName.Create(str));
});
}| Value Object | Storage Type | Converter |
|---|---|---|
RequiredGuid<T> |
Guid |
v.Value ↔ T.Create(guid) |
RequiredString<T> |
string |
v.Value ↔ T.Create(str) |
RequiredInt<T> |
int |
v.Value ↔ T.Create(num) |
RequiredDecimal<T> |
decimal |
v.Value ↔ T.Create(num) |
RequiredEnum<T> |
string |
v.Value ↔ symbolic value lookup via T.TryFromName(str).Value |
EmailAddress |
string(254) |
v.Value ↔ EmailAddress.Create(str) |
Custom ScalarValueObject<T,P> |
P |
v.Value ↔ T.Create(p) |
Register value converters per-property in OnModelCreating. You must add HasConversion for every Trellis value object property:
public class AppDbContext : DbContext
{
public DbSet<Customer> Customers => Set<Customer>();
public DbSet<Order> Orders => Set<Order>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Customer>(b =>
{
b.HasKey(c => c.Id);
// RequiredGuid<CustomerId> → Guid
b.Property(c => c.Id)
.HasConversion(id => id.Value, guid => CustomerId.Create(guid))
.IsRequired();
// RequiredString<CustomerName> → string
b.Property(c => c.Name)
.HasConversion(name => name.Value, str => CustomerName.Create(str))
.HasMaxLength(100)
.IsRequired();
// EmailAddress → string
b.Property(c => c.Email)
.HasConversion(email => email.Value, str => EmailAddress.Create(str))
.HasMaxLength(254)
.IsRequired();
});
modelBuilder.Entity<Order>(b =>
{
b.HasKey(o => o.Id);
b.Property(o => o.Id)
.HasConversion(id => id.Value, guid => OrderId.Create(guid));
b.Property(o => o.CustomerId)
.HasConversion(id => id.Value, guid => CustomerId.Create(guid));
});
}
}Note
Without ApplyTrellisConventions, every Trellis value object property needs an explicit HasConversion call. Missing one causes EF Core to treat the property as a navigation (class-typed), resulting in runtime errors.
Key Principle: The repository (Anti-Corruption Layer) should not make domain decisions. Use the appropriate return type based on the operation's nature.
| Return Type | Use When | Example |
|---|---|---|
Result<T> |
Operation can fail due to expected infrastructure failures | Concurrency conflict, duplicate key, foreign key violation |
Maybe<T> |
Item may or may not exist (domain's decision) | Looking up by email (might be checking uniqueness) |
bool |
Simple existence check | ExistsByEmailAsync(email) |
Exception |
Unexpected infrastructure failures | Database connection failure, network timeout, disk full |
void/Task |
Fire-and-forget side effects | Publishing domain events |
graph TB
subgraph Controller["Controller Layer"]
REQ[HTTP Request]
end
subgraph Service["Service/Domain Layer"]
VAL{Validate Input}
LOGIC{Business Logic}
DEC{Domain Decision}
end
subgraph Repository["Repository Layer"]
QUERY[Query Methods<br/>return Maybe<T>]
COMMAND[Command Methods<br/>return Result<Unit>]
end
subgraph Database["Database"]
DB[(EF Core<br/>DbContext)]
end
REQ --> VAL
VAL -->|Valid| LOGIC
LOGIC --> DEC
DEC -->|Need Data?| QUERY
QUERY --> DB
DB -.->|null?| MAYBE[Maybe<T>]
MAYBE --> DEC
DEC -->|Save/Update?| COMMAND
COMMAND --> DB
DB -.->|Success| RES_OK[Result.Success]
DB -.->|Duplicate Key| RES_CONFLICT[Error.Conflict]
DB -.->|FK Violation| RES_DOMAIN[Error.Domain]
DB -.->|Concurrency| RES_CONFLICT2[Error.Conflict]
RES_OK --> HTTP_OK[200 OK]
RES_CONFLICT --> HTTP_409[409 Conflict]
RES_DOMAIN --> HTTP_422[422 Unprocessable]
RES_CONFLICT2 --> HTTP_409
style MAYBE fill:#E1F5FF
style RES_OK fill:#90EE90
style RES_CONFLICT fill:#FFB6C6
style RES_DOMAIN fill:#FFD700
style RES_CONFLICT2 fill:#FFB6C6
When the domain needs to interpret "not found":
public interface IUserRepository
{
// 🔍 Returns Maybe - domain decides if absence is good/bad
Task<Maybe<User>> GetByEmailAsync(EmailAddress email, CancellationToken ct);
Task<Maybe<User>> GetByIdAsync(UserId id, CancellationToken ct);
// 🔍 Simple existence check
Task<bool> ExistsByEmailAsync(EmailAddress email, CancellationToken ct);
}
public class UserRepository : IUserRepository
{
private readonly ApplicationDbContext _context;
public async Task<Maybe<User>> GetByEmailAsync(
EmailAddress email,
CancellationToken ct)
{
var user = await _context.Users
.FirstOrDefaultAsync(u => u.Email == email, ct);
return Maybe.From(user); // ✅ Neutral - just presence/absence
}
public async Task<bool> ExistsByEmailAsync(
EmailAddress email,
CancellationToken ct)
{
return await _context.Users
.AnyAsync(u => u.Email == email, ct);
}
}Domain layer interprets the Maybe:
// Example 1: Not found is BAD (user login)
public async Task<Result<User>> LoginAsync(
EmailAddress email,
Password password,
CancellationToken ct)
{
var maybeUser = await _repository.GetByEmailAsync(email, ct);
// Domain decides: no user = error
if (maybeUser.HasNoValue)
return Error.NotFound($"User with email {email} not found");
return maybeUser.Value.VerifyPassword(password);
}
// Example 2: Not found is GOOD (checking availability)
public async Task<Result<User>> RegisterUserAsync(
RegisterUserCommand cmd,
CancellationToken ct)
{
var existingUser = await _repository.GetByEmailAsync(cmd.Email, ct);
// Domain decides: user exists = error
if (existingUser.HasValue)
return Error.Conflict($"Email {cmd.Email} already in use");
// No user = good, can register
return User.Create(cmd.Email, cmd.FirstName, cmd.LastName);
}
// Example 3: Simple boolean check
public async Task<Result<Unit>> CheckEmailAvailabilityAsync(
EmailAddress email,
CancellationToken ct)
{
var exists = await _repository.ExistsByEmailAsync(email, ct);
if (exists)
return Error.Conflict("Email already in use");
return Result.Success();
}When the operation can fail due to infrastructure:
public interface IUserRepository
{
// 🔑 Returns Result - can fail due to DB constraints, concurrency, etc.
Task<Result<Unit>> SaveAsync(User user, CancellationToken ct);
Task<Result<Unit>> DeleteAsync(UserId id, CancellationToken ct);
}
public class UserRepository : IUserRepository
{
private readonly ApplicationDbContext _context;
public async Task<Result<Unit>> SaveAsync(
User user,
CancellationToken ct)
{
_context.Users.Update(user);
return await _context.SaveChangesResultUnitAsync(ct);
}
public async Task<Result<Unit>> DeleteAsync(
UserId id,
CancellationToken ct)
{
var user = await _context.Users.FindAsync(new object[] { id }, ct);
if (user == null)
return Error.NotFound($"User {id} not found");
_context.Users.Remove(user);
return await _context.SaveChangesResultUnitAsync(ct);
}
}using Microsoft.EntityFrameworkCore;
using Trellis;
using Trellis.EntityFrameworkCore;
public interface IUserRepository
{
// Queries - return Maybe (domain interprets)
Task<Maybe<User>> GetByIdAsync(UserId id, CancellationToken ct);
Task<Maybe<User>> GetByEmailAsync(EmailAddress email, CancellationToken ct);
Task<bool> ExistsByEmailAsync(EmailAddress email, CancellationToken ct);
// Commands - return Result (infrastructure can fail)
Task<Result<Unit>> SaveAsync(User user, CancellationToken ct);
Task<Result<Unit>> DeleteAsync(UserId id, CancellationToken ct);
}
public class UserRepository : IUserRepository
{
private readonly ApplicationDbContext _context;
public UserRepository(ApplicationDbContext context) => _context = context;
// Maybe pattern - domain decides if "not found" is good/bad
public async Task<Maybe<User>> GetByIdAsync(UserId id, CancellationToken ct) =>
await _context.Users.FirstOrDefaultMaybeAsync(u => u.Id == id, ct);
public async Task<Maybe<User>> GetByEmailAsync(EmailAddress email, CancellationToken ct) =>
await _context.Users.FirstOrDefaultMaybeAsync(u => u.Email == email, ct);
public async Task<bool> ExistsByEmailAsync(EmailAddress email, CancellationToken ct) =>
await _context.Users.AnyAsync(u => u.Email == email, ct);
// Result pattern - infrastructure can fail
public async Task<Result<Unit>> SaveAsync(User user, CancellationToken ct)
{
_context.Users.Update(user);
return await _context.SaveChangesResultUnitAsync(ct);
}
public async Task<Result<Unit>> DeleteAsync(UserId id, CancellationToken ct)
{
var user = await _context.Users.FindAsync(new object[] { id }, ct);
if (user == null)
return Error.NotFound($"User {id} not found");
_context.Users.Remove(user);
return await _context.SaveChangesResultUnitAsync(ct);
}
}Trellis.EntityFrameworkCore provides query extension methods that wrap EF Core results in Maybe<T> or Result<T>, so you don't need to write your own nullable conversion helpers.
Use FirstOrDefaultMaybeAsync or SingleOrDefaultMaybeAsync when the domain layer should decide whether "not found" is good or bad:
public async Task<Maybe<User>> GetByEmailAsync(
EmailAddress email,
CancellationToken ct) =>
await _context.Users
.FirstOrDefaultMaybeAsync(u => u.Email == email, ct);Use FirstOrDefaultResultAsync when absence is always an error:
public async Task<Result<User>> GetByIdAsync(
UserId id,
CancellationToken ct) =>
await _context.Users
.FirstOrDefaultResultAsync(
u => u.Id == id,
Error.NotFound($"User {id} not found"),
ct);Apply a Specification<T> directly as a LINQ Where clause:
public async Task<List<Order>> GetActiveOrdersAsync(CancellationToken ct) =>
await _context.Orders
.Where(new ActiveOrderSpecification())
.ToListAsync(ct);Use FirstOrDefaultAsync and wrap the result with Maybe.From or check for null:
// Maybe pattern
public async Task<Maybe<User>> GetByEmailAsync(
EmailAddress email,
CancellationToken ct)
{
var user = await _context.Users
.FirstOrDefaultAsync(u => u.Email == email, ct);
return Maybe.From(user);
}
// Result pattern
public async Task<Result<User>> GetByIdAsync(
UserId id,
CancellationToken ct)
{
var user = await _context.Users
.FirstOrDefaultAsync(u => u.Id == id, ct);
return user is not null
? Result.Success(user)
: Result.Failure<User>(Error.NotFound($"User {id} not found"));
}Key Principle: Only convert expected failures to Result<T>. Let unexpected failures (infrastructure exceptions) propagate as exceptions.
The simplest approach — SaveChangesResultAsync handles exception classification for you:
public async Task<Result<Unit>> SaveAsync(User user, CancellationToken ct)
{
_context.Users.Update(user);
return await _context.SaveChangesResultUnitAsync(ct);
// Concurrency conflict → Error.Conflict
// Duplicate key → Error.Conflict
// Foreign key violation → Error.Domain
// Connection failure, timeout → exception propagates
}For custom error messages per exception type, use DbExceptionClassifier directly. It works with SQL Server, PostgreSQL, and SQLite:
public async Task<Result<Unit>> SaveAsync(User user, CancellationToken ct)
{
try
{
_context.Users.Update(user);
await _context.SaveChangesAsync(ct);
return Result.Success();
}
catch (DbUpdateConcurrencyException)
{
return Error.Conflict("User was modified by another process");
}
catch (DbUpdateException ex) when (DbExceptionClassifier.IsDuplicateKey(ex))
{
return Error.Conflict("User with this email already exists");
}
catch (DbUpdateException ex) when (DbExceptionClassifier.IsForeignKeyViolation(ex))
{
return Error.Domain("Cannot save user due to referential integrity");
}
// Unexpected failures (connection, timeout) propagate as exceptions
}Write your own exception classification using DbUpdateException.InnerException message inspection:
public async Task<Result<Unit>> SaveAsync(User user, CancellationToken ct)
{
try
{
_context.Users.Update(user);
await _context.SaveChangesAsync(ct);
return Result.Success();
}
catch (DbUpdateConcurrencyException)
{
return Error.Conflict("User was modified by another process");
}
catch (DbUpdateException ex) when (IsDuplicateKey(ex))
{
return Error.Conflict("User with this email already exists");
}
catch (DbUpdateException ex) when (IsForeignKeyViolation(ex))
{
return Error.Domain("Cannot save user due to referential integrity");
}
// Don't catch generic Exception — let infrastructure failures propagate
}
private static bool IsDuplicateKey(DbUpdateException ex) =>
ex.InnerException?.Message.Contains("duplicate key", StringComparison.OrdinalIgnoreCase) == true
|| ex.InnerException?.Message.Contains("UNIQUE constraint failed", StringComparison.OrdinalIgnoreCase) == true;
private static bool IsForeignKeyViolation(DbUpdateException ex) =>
ex.InnerException?.Message.Contains("FOREIGN KEY constraint", StringComparison.OrdinalIgnoreCase) == true
|| ex.InnerException?.Message.Contains("violates foreign key", StringComparison.OrdinalIgnoreCase) == true;Note
The manual approach uses message-string matching which is fragile across database providers. DbExceptionClassifier in the package handles SQL Server error numbers, PostgreSQL SqlState codes, and SQLite messages correctly.
| Type | Example | Handling |
|---|---|---|
| Expected Failure | Duplicate key, concurrency conflict, foreign key violation | Convert to Result<T> with appropriate error |
| Unexpected Failure | Database connection failure, network timeout | Let exception propagate (don't catch) |
flowchart TB
START[Database Operation] --> CATCH{Exception Type?}
CATCH -->|DbUpdateConcurrencyException| EXPECTED1[Expected Failure]
CATCH -->|DbUpdateException<br/>Duplicate Key| EXPECTED2[Expected Failure]
CATCH -->|DbUpdateException<br/>Foreign Key| EXPECTED3[Expected Failure]
CATCH -->|Connection Error<br/>Timeout<br/>Network Issue| UNEXPECTED[Unexpected Failure]
EXPECTED1 --> CONVERT1[Convert to Result<br/>Error.Conflict]
EXPECTED2 --> CONVERT2[Convert to Result<br/>Error.Conflict]
EXPECTED3 --> CONVERT3[Convert to Result<br/>Error.Domain]
CONVERT1 --> RETURN[Return Result<T><br/>to caller]
CONVERT2 --> RETURN
CONVERT3 --> RETURN
UNEXPECTED --> PROPAGATE[Let Exception<br/>Propagate]
PROPAGATE --> GLOBAL[Global Exception<br/>Handler]
GLOBAL --> RETRY{Retry Policy?}
RETRY -->|Transient| CIRCUIT[Circuit Breaker]
RETRY -->|Non-Transient| LOG[Log & Return 500]
RETURN --> HTTP_4XX[4xx Response<br/>Client Error]
LOG --> HTTP_500[500 Response<br/>Server Error]
style EXPECTED1 fill:#FFE1A8
style EXPECTED2 fill:#FFE1A8
style EXPECTED3 fill:#FFE1A8
style UNEXPECTED fill:#FFB6C6
style RETURN fill:#90EE90
style PROPAGATE fill:#FF6B6B
// ❌ Bad - catches ALL exceptions, even unexpected ones
public async Task<Result<User>> SaveAsync(User user, CancellationToken ct)
{
try
{
_context.Users.Update(user);
await _context.SaveChangesAsync(ct);
return Result.Success(user);
}
catch (Exception ex) // ❌ Too broad - hides infrastructure problems
{
_logger.LogError(ex, "Failed to save user");
return Error.Unexpected("Failed to save user");
}
}
// ✅ Good - only catch expected failures (or use SaveChangesResultUnitAsync)
public async Task<Result<Unit>> SaveAsync(User user, CancellationToken ct)
{
_context.Users.Update(user);
return await _context.SaveChangesResultUnitAsync(ct);
// Database connection failures, etc. will propagate as exceptions
}-
Infrastructure problems need different handling - Connection failures, timeouts, etc. should bubble up to global exception handlers, retry policies, or circuit breakers
-
Hiding infrastructure failures is dangerous - If the database is down, wrapping it in
Result<T>makes it look like a normal business failure -
Let the infrastructure layer fail fast - The calling layer can decide how to handle infrastructure exceptions (retry, circuit breaker, failover)
-
Logging and monitoring - Exception middleware, Application Insights, and monitoring tools can properly track infrastructure failures
// Configure EF Core retry policy for transient failures in Program.cs:
// builder.Services.AddDbContext<ApplicationDbContext>(options =>
// options.UseSqlServer(connectionString,
// sqlOptions => sqlOptions.EnableRetryOnFailure()));
public class UserRepository : IUserRepository
{
private readonly ApplicationDbContext _context;
public UserRepository(ApplicationDbContext context) => _context = context;
public async Task<Maybe<User>> GetByIdAsync(UserId id, CancellationToken ct) =>
await _context.Users
.FirstOrDefaultMaybeAsync(u => u.Id == id, ct);
public async Task<Maybe<User>> GetByEmailAsync(EmailAddress email, CancellationToken ct) =>
await _context.Users
.FirstOrDefaultMaybeAsync(u => u.Email == email, ct);
public async Task<Result<Unit>> SaveAsync(User user, CancellationToken ct)
{
_context.Users.Update(user);
return await _context.SaveChangesResultUnitAsync(ct);
// EF Core handles transient retries via EnableRetryOnFailure
// Expected failures (duplicate key, concurrency) → Result error
// Non-transient failures → exception propagates
}
}When the domain needs to interpret "not found":
flowchart LR
subgraph Repository
REPO_QUERY[Repository Query<br/>GetByEmailAsync]
DB_QUERY[(Database Query<br/>FirstOrDefaultAsync)]
end
subgraph Domain
CHECK{User exists?}
LOGIN[Login Flow<br/>HasNoValue = Error]
REGISTER[Register Flow<br/>HasValue = Error]
end
REPO_QUERY --> DB_QUERY
DB_QUERY -->|User or null| MAYBE[Maybe<User>]
MAYBE --> CHECK
CHECK -->|Login scenario| LOGIN
CHECK -->|Register scenario| REGISTER
LOGIN -->|HasNoValue| ERR1[Error.NotFound<br/>User not found]
LOGIN -->|HasValue| OK1[Result.Success<br/>Verify password]
REGISTER -->|HasValue| ERR2[Error.Conflict<br/>Email taken]
REGISTER -->|HasNoValue| OK2[Result.Success<br/>Can register]
style MAYBE fill:#E1F5FF
style ERR1 fill:#FFB6C6
style ERR2 fill:#FFB6C6
style OK1 fill:#90EE90
style OK2 fill:#90EE90
Implementation:
When the operation can fail due to infrastructure:
flowchart TB
START[SaveAsync User] --> TRY{Try SaveChangesAsync}
TRY -->|Success| SUCCESS[Result.Success]
TRY -->|DbUpdateConcurrencyException| CONFLICT1[Error.Conflict<br/>Modified by another process]
TRY -->|DbUpdateException<br/>Duplicate Key| CONFLICT2[Error.Conflict<br/>Email already exists]
TRY -->|DbUpdateException<br/>Foreign Key| DOMAIN[Error.Domain<br/>Referential integrity]
TRY -->|Other Exception<br/>Connection/Timeout| PROPAGATE[Exception Propagates<br/>Global Handler]
SUCCESS --> HTTP_200[200 OK]
CONFLICT1 --> HTTP_409[409 Conflict]
CONFLICT2 --> HTTP_409_2[409 Conflict]
DOMAIN --> HTTP_422[422 Unprocessable]
PROPAGATE --> HTTP_500[500 Internal Server Error]
style SUCCESS fill:#90EE90
style CONFLICT1 fill:#FFB6C6
style CONFLICT2 fill:#FFB6C6
style DOMAIN fill:#FFD700
style PROPAGATE fill:#FF6B6B
GUID V7 (NewUniqueV7()) provides the same benefits as ULIDs — time-ordered, sequential, timestamp-embedded — while using the standard System.Guid type with better database index performance than random GUIDs:
// Define GUID-based identifiers
public partial class OrderId : RequiredGuid<OrderId> { }
public partial class CustomerId : RequiredGuid<CustomerId> { }
// GUID V7s sort chronologically - great for database indexes!
var orders = await context.Orders
.OrderBy(o => o.Id) // Natural creation-time ordering
.Take(10)
.ToListAsync();| Feature | GUID V7 | GUID V4 |
|---|---|---|
| Database Index Performance | ✅ Sequential (better) | ❌ Random (fragmentation) |
| Natural Ordering | ✅ By creation time | ❌ Random |
| Use Case | Orders, Events, Logs | Legacy systems |
Money properties on entities are automatically mapped as owned types by ApplyTrellisConventions — no OwnsOne configuration needed.
That is the expected structured-value-object path, not a scalar converter special case.
The MoneyConvention (registered by ApplyTrellisConventions) uses two EF Core convention interfaces:
IModelInitializedConvention— callsmodelBuilder.Owned(typeof(Money))to pre-register Money as an owned type before entity discovery runsIModelFinalizingConvention— sets column names, precision, and max length on the owned Money properties
Just declare Money properties on your entities:
public class Order
{
public OrderId Id { get; set; } = null!;
public Money Price { get; set; } = null!;
public Money ShippingCost { get; set; } = null!;
}| Property Name | Amount Column | Currency Column |
|---|---|---|
Price |
Price |
PriceCurrency |
ShippingCost |
ShippingCost |
ShippingCostCurrency |
Amount columns use decimal(18,3) precision. Currency columns use nvarchar(3) (ISO 4217). Scale 3 accommodates all ISO 4217 minor units (0 for JPY, 2 for USD/EUR, 3 for BHD/KWD/OMR/TND).
If you need custom column names or precision, use OwnsOne in OnModelCreating — explicit configuration takes precedence:
modelBuilder.Entity<Order>(b =>
{
b.OwnsOne(o => o.Price, money =>
{
money.Property(m => m.Amount).HasColumnName("UnitPrice").HasPrecision(19, 4);
money.Property(m => m.Currency).HasColumnName("UnitCurrency");
});
});Note
Multiple Money properties on the same entity work automatically — each gets its own pair of columns.
Maybe<T> is a readonly struct. EF Core cannot mark non-nullable struct properties as optional — calling IsRequired(false) or setting IsNullable = true throws InvalidOperationException. Trellis keeps the primary programming model at the CLR property level and hides the EF workaround behind generated code, conventions, and helpers.
Declare optional properties as partial Maybe<T>:
public partial class Customer
{
public CustomerId Id { get; set; } = null!;
public CustomerName Name { get; set; } = null!;
public partial Maybe<PhoneNumber> Phone { get; set; }
public partial Maybe<DateTime> SubmittedAt { get; set; }
}No OnModelCreating configuration needed — MaybeConvention (registered by ApplyTrellisConventions) handles everything automatically.
Use the property-level helpers when querying, indexing, updating, or diagnosing Maybe<T> mappings:
var withoutPhone = await context.Customers.WhereNone(c => c.Phone).ToListAsync(ct);
var withPhone = await context.Customers.WhereHasValue(c => c.Phone).ToListAsync(ct);
var matches = await context.Customers.WhereEquals(c => c.Phone, phone).ToListAsync(ct);
// Comparison operators for Maybe<T> (requires IComparable<T>)
var cutoff = DateTime.UtcNow.AddDays(-7);
var overdue = await context.Orders
.WhereLessThan(o => o.SubmittedAt, cutoff)
.ToListAsync(ct);
// With MaybeQueryInterceptor, natural LINQ also works:
// Register via: optionsBuilder.AddTrellisInterceptors()
var overdueNatural = await context.Orders
.Where(o => o.SubmittedAt.HasValue && o.SubmittedAt.Value < cutoff)
.ToListAsync(ct);
modelBuilder.Entity<Customer>(builder =>
{
builder.HasKey(c => c.Id);
builder.HasTrellisIndex(c => c.Phone);
builder.HasTrellisIndex(c => new { c.Name, c.SubmittedAt });
});
await context.Customers
.Where(c => c.Id == customerId)
.ExecuteUpdateAsync(setters => setters.SetMaybeValue(c => c.Phone, phone), ct);
var mappings = context.GetMaybePropertyMappings();
var debugView = context.ToMaybeMappingDebugString();Warning
Do not use direct property references like .Where(c => c.Phone.HasValue) — EF Core cannot translate them. Use the Trellis query helpers instead.
Under the hood, the source generator emits a private _camelCase storage field and getter/setter for each partial Maybe<T> property:
// Auto-generated
private PhoneNumber? _phone;
public partial Maybe<PhoneNumber> Phone
{
get => _phone is not null ? Maybe.From(_phone) : Maybe<PhoneNumber>.None;
set => _phone = value.HasValue ? value.Value : null;
}The MaybeConvention (IModelFinalizingConvention) then:
- Always ignores the
Maybe<T>CLR property (EF Core can't map structs as nullable) - Discovers the private
_camelCasestorage field - Maps the storage member as optional (
IsRequired(false)) - Sets the column name to the original property name (
Phone, not_phone) - Configures field-only access mode
| Property | Storage Member | Column Name |
|---|---|---|
Phone |
_phone |
Phone |
SubmittedAt |
_submittedAt |
SubmittedAt |
AlternateEmail |
_alternateEmail |
AlternateEmail |
Use the CLR-property helpers when you need features that would otherwise tempt you to reach for raw field names:
modelBuilder.Entity<Customer>(builder =>
{
builder.HasKey(c => c.Id);
builder.HasTrellisIndex(c => c.Phone);
builder.HasTrellisIndex(c => new { c.Name, c.SubmittedAt });
});
await context.Customers
.Where(c => c.Id == customerId)
.ExecuteUpdateAsync(setters => setters.SetMaybeValue(c => c.Phone, phone), ct);
await context.Customers
.Where(c => c.Id == customerId)
.ExecuteUpdateAsync(setters => setters.SetMaybeNone(c => c.Phone), ct);
var mappings = context.GetMaybePropertyMappings();
var debugView = context.ToMaybeMappingDebugString();HasTrellisIndex resolves Maybe<T> selectors to the mapped storage automatically while leaving regular properties unchanged, so mixed composite indexes stay strongly typed.
HasTrellisIndex only accepts direct property access on the lambda parameter. Nested selectors such as o => o.Customer.Phone are rejected with ArgumentException so the helper cannot accidentally resolve a storage member name against the wrong entity type.
For Maybe<T> properties, HasTrellisIndex also validates that the expected generated storage member exists on the CLR type hierarchy or is already mapped in the EF model. If it is missing, the method throws InvalidOperationException with guidance to declare the property as partial or configure the mapping explicitly before calling HasTrellisIndex.
If a Maybe<T> property is not declared partial, the source generator emits diagnostic TRLSGEN100 prompting the developer to add the partial modifier.
See the EF Core Example for a full working example demonstrating:
- Convention-based value object configuration with
ApplyTrellisConventions RequiredGuid<T>for identifiers (OrderId,CustomerId,ProductId)RequiredString<T>for validated strings (ProductName,CustomerName)RequiredEnum<T>for enum-like types stored as stringsEmailAddressfor RFC 5322 email validation- Railway-Oriented Programming for entity creation and validation