CSharp Entity Framework PostgreSQL
`=Introduction= So this page is to nudge my little gray cells need time I think of the Entity Framework (EF) and PostgreSQL
Packages
As of Jan 2026 I used this
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.1" />
<PackageReference Include="Npgsql" Version="10.0.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" />
Areas for Implementation
So we need to think about the following parts of the code when implementing the EF with PostgreSQL
- Context
- Connection Configuration
- Table Configuration
- Repository
Context
For the context, this is where you specify what entities you are going to be handling. The ApplyConfigurationsFromAssembly performs the registering of the generated table configurations
namespace Infrastructure.Persistence.EF.Context;
using Domain.Entities;
using Microsoft.EntityFrameworkCore;
public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options)
{
public DbSet<EntityActor> Actors => Set<EntityActor>();
public DbSet<EntityFilm> Films => Set<EntityFilm>();
protected override void OnModelCreating(ModelBuilder modelBuilder) =>
modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly);
}
Connection Configuration
We need to configure our connection. We do this at startup.
services.AddDbContext<AppDbContext>(options =>
{
options.UseNpgsql(
dbConfig.ConnectionString,
// Discussed below npgsqlOptions => npgsqlOptions.MapEnum<DbRating>("mpaa_rating")
);
});
Table Configuration
This is provide info on your table to allow EF to work. Not shown here but I think you can say relationship and other things too. This is a simplish example to demonstrate. This will map the columns to the Entity. I guess HasKey is obvious along with HasDefaultValueSql
public class ActorConfiguration : IEntityTypeConfiguration<EntityActor>
{
public void Configure(EntityTypeBuilder<EntityActor> builder)
{
builder.ToTable("actor");
builder.HasKey(a => a.ActorId);
builder.Property(a => a.ActorId)
.HasColumnName("actor_id");
builder.Property(a => a.FirstName)
.HasColumnName("first_name");
builder.Property(a => a.LastName)
.HasColumnName("last_name");
builder.Property(f => f.LastUpdate)
.HasColumnName("last_update")
.HasColumnType("timestamp")
.HasDefaultValueSql("now()")
.IsRequired();
}
}
Repository
This is where you actually access the database. Not of it is complicated once set up above is completed.
// Get
return await _db.Actors
.AsNoTracking()
.FirstOrDefaultAsync(a => a.ActorId == actorId, ct);
// Add
_db.Actors.Add(actor);
await _db.SaveChangesAsync(token);
// Delete
_db.Actors.Remove(actor);
await _db.SaveChangesAsync(token);
// Update
var existing = await _db.Actors.FirstOrDefaultAsync(a => a.ActorId == actor.ActorId, token);
if (existing is null)
return null;
_db.Entry(existing).CurrentValues.SetValues(actor);
await _db.SaveChangesAsync(token);
PostgreSQL Custom Types Walk Through
My Custom Type
So in DVD Rental there is a custom type of the table Film. Shown below
dvdrental-> \dT+ mpaa_rating
List of data types
Schema | Name | Internal name | Size | Elements | Owner | Access privileges | Description
--------+-------------+---------------+------+----------+----------+-------------------+-------------
public | mpaa_rating | mpaa_rating | 4 | G +| postgres | |
| | | | PG +| | |
| | | | PG-13 +| | |
| | | | R +| | |
| | | | NC-17 | | |
Wanted to walk through how to do this in EF+PostgreSQL. You can see that the possibilities suggest an enum but custom types do support composites
CREATE TYPE full_name AS (
first_name text,
last_name text,
name_length int
);
I asked how this would be done via the robot so I could see how I would approach something a tad simpler. So onward with Rating.
What's wrong with Enum
Glad we are not on Typescript. What's wrong with Enum is you cannot have a hyphen in one. So my Domain Enum for this is like this with no hyphen. So we need to be able to read the original e.g. PG-13 and translate to PG13
namespace Domain.ValueObjects;
public enum Rating
{
G,
PG,
PG13,
R,
NC17
}
Register the type
So as mentions above we need to register the type so EF knows it is a type. hence
services.AddDbContext<AppDbContext>(options =>
{
options.UseNpgsql(
dbConfig.ConnectionString,
npgsqlOptions => npgsqlOptions.MapEnum<DbRating>("mpaa_rating")
);
});
Create a Infra Enum
We can map the DB enums to the Rating values. Note the using of NpgsqlTypes, we would not want this in Domain.
namespace Infrastructure.Persistence.EF.ValueObjects;
using NpgsqlTypes;
public enum DbRating
{
[PgName("G")] G,
[PgName("PG")] PG,
[PgName("PG-13")] PG13,
[PgName("R")] R,
[PgName("NC-17")] NC17
}
Configure Column in Film
Now we can configure the column. With this we can specify the translation from DbRating to Rating.
public sealed class FilmConfiguration : IEntityTypeConfiguration<EntityFilm>
{
public void Configure(EntityTypeBuilder<EntityFilm> builder)
{
builder.ToTable("film");
// Primary key
builder.HasKey(f => f.FilmId);
builder.Property(f => f.FilmId)
.HasColumnName("film_id")
.UseIdentityAlwaysColumn()
.IsRequired();
...
builder.Property(f => f.Rating)
.HasColumnName("rating")
.HasColumnType("mpaa_rating")
.HasConversion(
v => (DbRating)v,
v => (Rating)v
);
...
}
}
Alternative Approach
Whilst writing this up the Robot was kind enough to suggest and better approach using EnumMember. I like the conversion demo but remove Npgsql stuff out sounds better. So we could have an infra DbRating with.
using System.Runtime.Serialization;
public enum DbRating {
[EnumMember(Value = "G")] G,
[EnumMember(Value = "PG")] PG,
[EnumMember(Value = "PG-13")] PG13,
[EnumMember(Value = "R")] R,
[EnumMember(Value = "NC-17")] NC17
}
You must tell Npgsql to use the EnumMember attributes:
services.AddDbContext<AppDbContext>(options =>
{
options.UseNpgsql(
dbConfig.ConnectionString,
npgsqlOptions => npgsqlOptions.MapEnum<DbRating>("mpaa_rating")
);
});
And for the column configuration no change
builder.Property(f => f.Rating)
.HasColumnName("rating")
.HasColumnType("mpaa_rating")
.HasConversion(
v => (DbRating)v, // Domain → Infra → DB
v => (Rating)v // DB → Infra → Domain
);
Other Topics I Did Not Touch
Owned Types (EF Core)
Intro
Owned types are EF Core’s way of representing value objects inside an entity. They have no identity, are not mapped to their own tables, and always exist as part of a parent entity. Key Points - Owned types belong entirely to one entity - They are not DbSets and have no primary key - EF stores their properties inside the parent table (flattened) - They map naturally to Domain value objects - They can be mapped to PostgreSQL composite types via Npgsql
Configuration
Here is how to configure one.
modelBuilder.Entity<Person>()
.OwnsOne(p => p.FullName);
PostgreSQL Composite Types Owned types and PostgreSQL composite types are separate concepts: - Owned Type → EF modelling concept - Composite Type → PostgreSQL structured type - Npgsql maps one to the other Example mapping:
options.UseNpgsql(conn, o =>
{
o.MapComposite<FullName>("full_name");
});
NpgsqlDataSource
General
Looking at this when converting from Native to EF with just DbContext, we get
- Parses the connection string
- Creates connections ad‑hoc
- Doesn’t know about Npgsql plugins
- Can’t use multiplexing
- Can’t share a unified connection pipeline with Dapper or raw Npgsql
- Has no access to custom type handlers or global JSON settings
Switching
Old DbContext
We having used the DbContext we could not configure programmatically the MaxPoolSize,MinPoolSize, ConnectionIdleLifetime so this is a great reason to switch. Currently we could have
services.AddDbContext<AppDbContext>(options =>
{
options.UseNpgsql(
dbConfig.ConnectionString,
npgsqlOptions =>
{
npgsqlOptions.MapEnum<DbRating>("mpaa_rating");
npgsqlOptions.CommandTimeout(dbConfig.CommandTimeoutSeconds);
});
});
With this added to the connection string.
... MinPoolSize=5;MaxPoolSize=10;ConnectionIdleLifetime=5;
Not that must of a drawback but enough.
New Approach
So here is a revised approach and should be noted I forgot to put TLS in for the old approach. This got quite length so when for the an extension. First the IServiceCollection part which looks very obvious on what it is doing.
var builder = new NpgsqlDataSourceBuilder(dbConfig.ConnectionString)
.ConfigurePooling(dbConfig)
.ConfigureTls(dbConfig)
.ConfigureTimeouts(dbConfig)
.ConfigureEnums();
var dataSource = builder.Build();
services.AddDbContext<AppDbContext>(options =>
{
options.UseNpgsql(dataSource);
});
Now the extension
namespace Infrastructure.Extensions;
using Infrastructure.Persistence.Common.Configuration;
using Infrastructure.Persistence.EF.ValueObjects;
using Npgsql;
public static class NpgsqlBuilderExtensions
{
public static NpgsqlDataSourceBuilder ConfigurePooling(
this NpgsqlDataSourceBuilder builder,
DatabaseConfig config)
{
builder.ConnectionStringBuilder.MinPoolSize = config.MinPoolSize;
builder.ConnectionStringBuilder.MaxPoolSize = config.MaxPoolSize;
// Npgsql 10 rule: IdleLifetime >= PruningInterval (default 10)
builder.ConnectionStringBuilder.ConnectionIdleLifetime =
Math.Max(config.ConnectionIdleLifetime, 10);
return builder;
}
public static NpgsqlDataSourceBuilder ConfigureTls(
this NpgsqlDataSourceBuilder builder,
DatabaseConfig config)
{
builder.ConnectionStringBuilder.SslMode =
string.IsNullOrEmpty(config.RootCertificatePath)
? SslMode.Require
: SslMode.VerifyFull;
if (!string.IsNullOrEmpty(config.RootCertificatePath))
{
builder.ConnectionStringBuilder.RootCertificate = config.RootCertificatePath;
}
return builder;
}
public static NpgsqlDataSourceBuilder ConfigureTimeouts(
this NpgsqlDataSourceBuilder builder,
DatabaseConfig config)
{
builder.ConnectionStringBuilder.CommandTimeout = config.CommandTimeoutSeconds;
return builder;
}
public static NpgsqlDataSourceBuilder ConfigureEnums(
this NpgsqlDataSourceBuilder builder)
{
builder.MapEnum<DbRating>("mpaa_rating");
return builder;
}
}
Tracking vs NoTracking
So this seems to be some kind of memory management for Entities inside of Entity Framework. The suggestion is, for instance, without tracking on a the Id would not be returned to the Entity.
_db.Actors.Add(actor);
await _db.SaveChangesAsync(token);
Rule of thumb, GET is no tracking, POST, PUT, PATCH, DEL tracking
Logging & diagnostics
Pretty easy to set up, just add stuff to the AddDbContext
services.AddDbContext<AppDbContext>(options =>
{
options.UseNpgsql(dataSource);
options.EnableSensitiveDataLogging();
options.EnableDetailedErrors(false); // Probably best on my wiki
options.LogTo(message => logger.LogInformation("{Message}", message), LogLevel.Information);
});
This gave me this on the console.
info: InfrastructureStartup[0]
warn: 01/10/2026 16:37:55.426 CoreEventId.SensitiveDataLoggingEnabledWarning[10400] (Microsoft.EntityFrameworkCore.Infrastructure)
Sensitive data logging is enabled. Log entries and exception messages may include sensitive application data; this mode should only be enabled during development.
info: InfrastructureStartup[0]
info: 01/10/2026 16:37:55.882 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
Executed DbCommand (34ms) [Parameters=[@actorId='15'], CommandType='Text', CommandTimeout='30']
SELECT a.actor_id, a.first_name, a.last_name, a.last_update
FROM actor AS a
WHERE a.actor_id = @actorId
LIMIT 1
info: Infrastructure.Persistence.EF.Repositories.RepositoryActorEf[0]
DB operation ActorGetById succeeded in 723.9534ms
But we can do better given my telemetry. Plug into OpenTel and we get the connection from Npgsql and the SQL from EF instrumentation
// Configure basic tracing without external exporters first
tracerProviderBuilder
.AddSamplerWhen(telemetryConfig.Environment == "Development")
// Add ASP.NET Core instrumentation
.AddAspNetCoreInstrumentation()
// Add EF Core instrumentation
.AddEntityFrameworkCoreInstrumentation(options =>
{
options.EnrichWithIDbCommand = (activity, command) =>
{
var stateDisplayName = $"{command.CommandType} main";
activity.DisplayName = stateDisplayName;
activity.SetTag("db.name", stateDisplayName);
};
})
// Add Npgsql instrumentation
.AddNpgsql()
// .AddHttpClientInstrumentation()
.AddSource(telemetryConfig.ServiceName)
.AddRedisInstrumentation()
.AddConsoleExporterWhen(telemetryConfig.EnableConsoleExporter == true && telemetryConfig.Environment == "Development")
.AddOtlpExporterWhen(honeycombConfig, telemetryConfig.Enabled);
return tracerProviderBuilder;
LINQ Translation Notes
EF Core translates LINQ expression trees into SQL. Only expressions that can be
converted into SQL will run on the database server. Everything else will throw
An example of an expression is
var actors = await _db.Actors
.Where(a => a.LastName == "Smith") // runs on SQL
.Select(a => new
{
a.ActorId,
FullName = $"{a.FirstName} {a.LastName}" // runs in .NET after SQL
})
.ToListAsync(ct);
a translation exception.
What EF Core Can Translate
- Basic comparisons (==, !=, >, <, >=, <=)
- Boolean logic (&&, ||, !)
- String operations (StartsWith, Contains, EndsWith, ToLower, ToUpper)
- Math operations (+, -, *, /)
- Date/time operations (AddDays, Date, Year, Month, etc.)
- Navigation properties and joins
- Aggregates (Any, All, Count, Sum, Min, Max)
- Most IQueryable operators (Where, Select, OrderBy, GroupBy*)
What EF Core Cannot Translate
- Arbitrary .NET methods
- Local functions or lambdas not expressible as expression trees
- Regex or complex string parsing
- Reflection
- Domain logic inside predicates
- Value objects without EF mapping or converters
- Large client-side collections inside Contains()
Translation Boundary
EF only translates expression trees. Any code executed before the expression tree is built runs on the client. Any code inside the expression tree must be translatable to SQL.
Rule of Thumb
If PostgreSQL can do it, EF can probably translate it. If PostgreSQL cannot do it, EF definitely cannot translate it.
Naming Conventions
C# Domain Model
Use PascalCase for:
- Entity classes
- Value objects
- Properties
- Navigation properties
Example: public string FirstName { get; set; }
EF Core Model
Enable snake_case mapping: modelBuilder.UseSnakeCaseNamingConvention();
This ensures:
- ActorId → actor_id
- FirstName → first_name
- LastUpdate → last_update
PostgreSQL Schema
Use snake_case for:
- Tables
- Columns
- Foreign keys
- Indexes
- Constraints
Example: CREATE TABLE actor (
actor_id int PRIMARY KEY, first_name text NOT NULL
);
Why This Matters
Consistent naming ensures:
- Correct LINQ translation
- Predictable SQL generation
- Fewer mapping bugs
- Cleaner diagnostics
- Easier debugging
Robots Repository Pattern Caveats
The Repository Pattern is a well‑known abstraction for data access, but when used with EF Core it introduces several practical caveats. EF Core already provides many of the capabilities that repositories traditionally offer, and layering another abstraction on top can hide important behaviour, reduce composability, and create subtle bugs.
1. Loss of Query Composability
EF Core queries are composable. A repository method that returns a fully materialized list removes the ability to: - Apply dynamic filters - Apply dynamic sorting - Apply pagination - Combine queries - Reuse query fragments Returning IEnumerable<T> instead of IQueryable<T> forces all downstream filtering to run in memory.
2. Hidden LINQ Translation Errors
EF Core throws translation exceptions when a LINQ expression cannot be converted to SQL. If a repository materializes early (e.g., ToListAsync() inside the repository), translation errors are hidden until runtime or never surface at all.
3. Accidental Client‑Side Evaluation
If a repository returns IEnumerable<T>, any additional filtering runs client‑side. This can cause: - Full table scans - Poor performance - Missing index usage - Silent correctness issues EF Core cannot optimize queries it never sees.
4. Duplication of EF Core’s Unit of Work
EF Core’s DbContext is already a Unit of Work. Repositories that wrap SaveChanges or manage transactions duplicate this responsibility and can interfere with: - Change tracking - Concurrency tokens - Batching - Retry policies - Transaction scopes
5. Loss of Change Tracking Semantics
Repositories often return entities with AsNoTracking() or reattach detached entities. This breaks: - Partial updates - Owned types - Navigation fix‑up - Concurrency resolution Tracking is contextual; repositories hide that context.
6. Hidden Transactions and Concurrency Behaviour
Repositories that wrap operations in try/catch or call SaveChanges internally obscure: - Transaction boundaries - Retry logic - Concurrency exceptions - Execution strategies This makes debugging harder and behaviour less predictable.
7. Anemic Repository Interfaces
Most repositories devolve into: - Add - Update - Delete - GetById - GetAll These methods are not expressive, not domain‑driven, and not intention‑revealing. They often force consumers to re‑implement filtering and business logic outside the repository.
8. Reduced Observability
EF Core emits rich diagnostics: - SQL spans - Npgsql spans - Connection pool events - Translation warnings Repositories that hide the DbContext or materialize early reduce visibility into what EF is doing.
9. Harder, Not Easier, Testing
The original justification for repositories was testability. In practice: - EF Core is rarely swapped out - Repository mocks do not behave like EF - Tests become inaccurate - Integration tests become more important Modern testing uses EF Core InMemory or Testcontainers, not repository mocks.
10. Two Sources of Truth
EF Core already knows: - Table names - Column names - Keys - Relationships - Constraints Repositories often duplicate this knowledge in method names, parameters, or custom SQL, creating drift.