Entity Framework Core Integration
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.
Table of Contents
- Repository Return Types
- Result vs Maybe Pattern
- Extension Methods for Nullable Conversion
- Handling Database Exceptions
Repository Return Types
Key Principle: The repository (Anti-Corruption Layer) should not make domain decisions. Use the appropriate return type based on the operation's nature.
When to Use Each Type
| 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 |
Repository Pattern Architecture
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
Result vs Maybe Pattern
✅ Use Maybe for Queries
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();
}
? Use Result for Commands
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;
private readonly ILogger<UserRepository> _logger;
public async Task<Result<Unit>> SaveAsync(
User user,
CancellationToken ct)
{
try
{
_context.Users.Update(user);
await _context.SaveChangesAsync(ct);
return Result.Success();
}
catch (DbUpdateConcurrencyException)
{
// Infrastructure failure
return Error.Conflict("User was modified by another process");
}
catch (DbUpdateException ex) when (IsDuplicateKeyException(ex))
{
// Database constraint violation
return Error.Conflict("User with this email already exists");
}
catch (Exception ex)
{
_logger.LogError(ex, "Error saving user {UserId}", user.Id);
return Error.Unexpected("Failed to save user");
}
}
public async Task<Result<Unit>> DeleteAsync(
UserId id,
CancellationToken ct)
{
try
{
var user = await _context.Users.FindAsync(new object[] { id }, ct);
if (user == null)
return Error.NotFound($"User {id} not found");
_context.Users.Remove(user);
await _context.SaveChangesAsync(ct);
return Result.Success();
}
catch (DbUpdateException ex) when (IsForeignKeyViolation(ex))
{
// Database constraint violation
return Error.Domain("Cannot delete user with active orders");
}
catch (Exception ex)
{
_logger.LogError(ex, "Error deleting user {UserId}", id);
return Error.Unexpected("Failed to delete user");
}
}
private static bool IsDuplicateKeyException(DbUpdateException ex)
=> ex.InnerException?.Message.Contains("duplicate key") ?? false;
private static bool IsForeignKeyViolation(DbUpdateException ex)
=> ex.InnerException?.Message.Contains("FOREIGN KEY constraint") ?? false;
}
Complete Repository Example
using Microsoft.EntityFrameworkCore;
using FunctionalDdd;
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);
// Pagination - return Result (query execution can fail)
Task<Result<PagedResult<User>>> GetPagedAsync(
int page,
int pageSize,
CancellationToken ct);
}
public class UserRepository : IUserRepository
{
private readonly ApplicationDbContext _context;
private readonly ILogger<UserRepository> _logger;
public UserRepository(
ApplicationDbContext context,
ILogger<UserRepository> logger)
{
_context = context;
_logger = logger;
}
// Maybe pattern - domain decides if "not found" is good/bad
public async Task<Maybe<User>> GetByIdAsync(UserId id, CancellationToken ct)
{
var user = await _context.Users
.FirstOrDefaultAsync(u => u.Id == id, ct);
return Maybe.From(user);
}
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);
}
public async Task<bool> ExistsByEmailAsync(
EmailAddress email,
CancellationToken ct)
{
return await _context.Users
.AnyAsync(u => u.Email == email, ct);
}
// Result pattern - infrastructure can fail
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 (IsDuplicateKeyException(ex))
{
return Error.Conflict("User with this email already exists");
}
catch (Exception ex)
{
_logger.LogError(ex, "Error saving user {UserId}", user.Id);
return Error.Unexpected("Failed to save user");
}
}
public async Task<Result<Unit>> DeleteAsync(UserId id, CancellationToken ct)
{
try
{
var user = await _context.Users.FindAsync(new object[] { id }, ct);
if (user == null)
return Error.NotFound($"User {id} not found");
_context.Users.Remove(user);
await _context.SaveChangesAsync(ct);
return Result.Success();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error deleting user {UserId}", id);
return Error.Unexpected("Failed to delete user");
}
}
public async Task<Result<PagedResult<User>>> GetPagedAsync(
int page,
int pageSize,
CancellationToken ct)
{
try
{
if (page < 0)
return Error.Validation("Page number must be non-negative", "page");
if (pageSize <= 0 || pageSize > 100)
return Error.Validation("Page size must be between 1 and 100", "pageSize");
var skip = page * pageSize;
var totalCount = await _context.Users.CountAsync(ct);
var users = await _context.Users
.AsNoTracking()
.OrderBy(u => u.CreatedAt)
.Skip(skip)
.Take(pageSize)
.ToListAsync(ct);
var result = new PagedResult<User>(
Items: users,
From: skip,
To: skip + users.Count - 1,
TotalCount: totalCount
);
return Result.Success(result);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching paged users");
return Error.Unexpected("Failed to retrieve users");
}
}
private static bool IsDuplicateKeyException(DbUpdateException ex)
=> ex.InnerException?.Message.Contains("duplicate key") ?? false;
}
public record PagedResult<T>(
IEnumerable<T> Items,
long From,
long To,
long TotalCount);
Extension Methods for Nullable Conversion
Create reusable extension methods for common nullable-to-Result conversions:
public static class RepositoryExtensions
{
/// <summary>
/// Converts a task returning a nullable reference type to a Result.
/// </summary>
public static async Task<Result<T>> ToResultAsync<T>(
this Task<T?> task,
Error notFoundError) where T : class
{
var entity = await task;
return entity != null
? Result.Success(entity)
: Result.Failure<T>(notFoundError);
}
/// <summary>
/// Converts a task returning a nullable value type to a Result.
/// </summary>
public static async Task<Result<T>> ToResultAsync<T>(
this Task<T?> task,
Error notFoundError) where T : struct
{
var entity = await task;
return entity.HasValue
? Result.Success(entity.Value)
: Result.Failure<T>(notFoundError);
}
}
Usage
public async Task<Result<User>> GetByIdAsync(UserId id, CancellationToken ct)
{
return await _context.Users
.FirstOrDefaultAsync(u => u.Id == id, ct)
.ToResultAsync(Error.NotFound($"User {id} not found"));
}
public async Task<Result<Order>> GetOrderByNumberAsync(string orderNumber, CancellationToken ct)
{
return await _context.Orders
.Include(o => o.Items)
.Include(o => o.Customer)
.FirstOrDefaultAsync(o => o.OrderNumber == orderNumber, ct)
.ToResultAsync(Error.NotFound($"Order {orderNumber} not found"));
}
Handling Database Exceptions
Key Principle: Only convert expected failures to Result<T>. Let unexpected failures (infrastructure exceptions) propagate as exceptions.
Expected vs Unexpected Failures
| 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) |
Exception Handling Strategy
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
✅ Convert Expected Failures to Result
public async Task<Result<Unit>> SaveAsync(User user, CancellationToken ct)
{
try
{
_context.Users.Update(user);
await _context.SaveChangesAsync(ct);
return Result.Success();
}
// Expected failure: concurrent modification
catch (DbUpdateConcurrencyException)
{
return Error.Conflict("User was modified by another process");
}
// Expected failure: unique constraint violation
catch (DbUpdateException ex) when (IsDuplicateKeyException(ex))
{
return Error.Conflict("User with this email already exists");
}
// Expected failure: foreign key violation
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
}
public async Task<Result<Unit>> DeleteAsync(UserId id, CancellationToken ct)
{
try
{
var user = await _context.Users.FindAsync(new object[] { id }, ct);
if (user == null)
return Error.NotFound($"User {id} not found");
_context.Users.Remove(user);
await _context.SaveChangesAsync(ct);
return Result.Success();
}
// Expected failure: foreign key violation (user has orders)
catch (DbUpdateException ex) when (IsForeignKeyViolation(ex))
{
return Error.Domain("Cannot delete user with active orders");
}
// ?? Let unexpected failures (connection issues, etc.) propagate
}
❌ Don't Catch Unexpected Failures
// ? 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 catches expected failures
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 (IsDuplicateKeyException(ex))
{
return Error.Conflict("User with this email already exists");
}
// Database connection failures, etc. will propagate as exceptions
}
Why Let Unexpected Failures Propagate?
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 failureLet 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
Exception Helper Methods
public static class DbExceptionHelpers
{
public static bool IsDuplicateKeyException(DbUpdateException ex)
{
// SQL Server
if (ex.InnerException?.Message.Contains("duplicate key") ?? false)
return true;
// PostgreSQL
if (ex.InnerException?.Message.Contains("duplicate key value violates unique constraint") ?? false)
return true;
// SQLite
if (ex.InnerException?.Message.Contains("UNIQUE constraint failed") ?? false)
return true;
return false;
}
public static bool IsForeignKeyViolation(DbUpdateException ex)
{
// SQL Server
if (ex.InnerException?.Message.Contains("FOREIGN KEY constraint") ?? false)
return true;
// PostgreSQL
if (ex.InnerException?.Message.Contains("violates foreign key constraint") ?? false)
return true;
// SQLite
if (ex.InnerException?.Message.Contains("FOREIGN KEY constraint") ?? false)
return true;
return false;
}
}
Global Exception Handling
Let unexpected infrastructure failures be handled by ASP.NET Core's global exception handler:
// Program.cs
var app = builder.Build();
// Global exception handler for unexpected failures
app.UseExceptionHandler(errorApp =>
{
errorApp.Run(async context =>
{
var exceptionHandlerFeature = context.Features.Get<IExceptionHandlerFeature>();
var exception = exceptionHandlerFeature?.Error;
// Log the infrastructure failure
var logger = context.RequestServices.GetRequiredService<ILogger<Program>>();
logger.LogError(exception, "Unhandled exception occurred");
// Return Problem Details for infrastructure failures
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
context.Response.ContentType = "application/problem+json";
var problemDetails = new ProblemDetails
{
Status = StatusCodes.Status500InternalServerError,
Title = "An error occurred",
Detail = "An unexpected error occurred. Please try again later.",
Instance = context.Request.Path
};
await context.Response.WriteAsJsonAsync(problemDetails);
});
});
Complete Example with Retry Policy
For transient failures (connection issues, timeouts), use a retry policy instead of catching exceptions:
// Using Polly for retry logic
public class UserRepository : IUserRepository
{
private readonly ApplicationDbContext _context;
private readonly IAsyncPolicy _retryPolicy;
public UserRepository(ApplicationDbContext context)
{
_context = context;
// Retry transient failures (connection issues, timeouts)
_retryPolicy = Policy
.Handle<DbUpdateException>(ex => IsTransientFailure(ex))
.Or<TimeoutException>()
.WaitAndRetryAsync(3, retryAttempt =>
TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)));
}
public async Task<Result<Unit>> SaveAsync(User user, CancellationToken ct)
{
return await _retryPolicy.ExecuteAsync(async () =>
{
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 (IsDuplicateKeyException(ex))
{
return Error.Conflict("User with this email already exists");
}
// Transient failures will be retried by Polly
// Non-transient failures will propagate as exceptions
});
}
private static bool IsTransientFailure(DbUpdateException ex)
{
// SQL Server transient error codes
var sqlErrorCodes = new[] { -1, -2, 1205, 49918, 49919, 49920, 4060, 40197, 40501, 40613, 49918, 49919, 49920 };
// Check if it's a transient SQL error
return false; // Implement based on your database provider
}
private static bool IsDuplicateKeyException(DbUpdateException ex)
=> ex.InnerException?.Message.Contains("duplicate key") ?? false;
}
✅ Use Maybe for Queries
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:
✅ Use Result for Commands
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