Share via

How can I optimize this ASP.NET Core + EF Core + PostgreSQL CRUD API?

Csaba Faragó 0 Reputation points
2026-03-31T21:52:35.2766667+00:00

Hi everyone,

I've been building a REST API using ASP.NET Core with Entity Framework Core and Npgsql (PostgreSQL), and I'd love some feedback on how to optimize it. I'll share the full structure below — it's a fairly standard CRUD setup with two entities: Variable and Group.


Architecture Overview

The project follows a three-layer pattern:

  • VariableDbContext (Persistence) — EF Core DbContext with two DbSets
  • VariableModel (Model) — business logic / data access layer, injected as AddTransient<VariableModel>()
  • api Controller (Controller) — ASP.NET Core ControllerBase with route prefix api/[controller]

Program.cs

builder.Services.AddDbContext<VariableDbContext>(options =>
    options.UseNpgsql(builder.Configuration.GetConnectionString("variabledb")));

builder.Services.AddTransient<VariableModel>();

builder.Services.AddCors(options =>
{
    options.AddPolicy("AllowFrontend", policy =>
        policy.WithOrigins("http://localhost")
              .AllowAnyHeader()
              .AllowAnyMethod());
});

builder.Services.AddControllers();
// ...
app.MapControllers();
app.UseCors("AllowFrontend");
app.UseDefaultFiles();
app.UseStaticFiles();

⚠️ HTTPS redirection is currently commented out (//app.UseHttpsRedirection();) for local dev.


Persistence (DbContext)

public class VariableDbContext : DbContext
{
    public DbSet<Variable> variables { get; set; }
    public DbSet<Group> groups { get; set; }

    public VariableDbContext(DbContextOptions<VariableDbContext> options) : base(options) { }
}

public class Variable
{
    [Key]
    [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
    public int variableId { get; set; }
    public string title { get; set; }
    public string director { get; set; }
    public int releaseYear { get; set; }
    public int groupId { get; set; }
}

Model Layer — the part I'm most unsure about

Here are the operations I'm performing. I suspect several of these have performance or correctness issues:

GetAllVariables

Resolves the group name with a nested LINQ query per row:

return _dbContext.variables.Select(x => new GetAllVariablesDto
{
    VariableId = x.variableId,
    Title = x.title,
    Director = x.director,
    ReleaseYear = x.releaseYear,
    GroupId = x.groupId,
    GroupName = _dbContext.groups
        .Where(y => y.groupId == x.groupId)
        .Select(y => y.name).First()
});

GetVariableById

Filters with .Where() and checks for null, but I'm not sure the null check is even reachable on a LINQ queryable:

var data = _dbContext.variables.Where(x => x.variableId == id);
if (data == null) throw new KeyNotFoundException();

CreateNewVariable

Validates group existence by pulling all names into memory first:

if (!_dbContext.groups.Select(x => x.name).ToList().Contains(data.GroupName))
    throw new KeyNotFoundException();

UpdateVariable

Wraps property assignments in a using var trx block, but SaveChanges() and Commit() are called outside the using block. Also has a duplicated null check — data.groupId == null appears twice in the condition.

DeleteVariable

Checks id < 0 first, then calls .First() (which would throw on its own if not found), then checks if (todelete == null) — which I believe is unreachable after .First().

Async methods

All write methods are declared async Task but don't use await anywhere internally.


Controller Endpoints

Method Route Description
GET api/api/allvariables Get all variables
GET api/api/variable?id= Get variable by ID
POST api/api/variable Create variable
PUT api/api/variable Update variable
DELETE api/api/variable?id= Delete variable
GET api/api/groups Get all groups
POST api/api/addgroup Create group

Each endpoint catches typed exceptions (KeyNotFoundException → 404, ArgumentOutOfRangeException → 400, DataException → 400), though GetAllVariables catches a generic Exception and returns BadRequest(e.Message), and GetAllGroups returns BadRequest(e.InnerException) which might silently swallow the message.


My Specific Questions

  1. Is the nested _dbContext.groups.Where(...).Select(...).First() inside GetAllVariables's projection causing an N+1 query problem, and should I use a Join or .Include() with a navigation property instead?
  2. Should AddTransient<VariableModel>() be changed to AddScoped to better match the DbContext lifetime?
  3. Is there a cleaner way to handle the manual BeginTransaction() / SaveChanges() / Commit() pattern when I'm only doing a single write operation? Does EF Core's SaveChanges() already wrap operations in an implicit transaction?
  4. How should I properly handle the async Task methods that currently have no await — should I mark them as synchronous, or add await and switch to SaveChangesAsync()?
  5. Are there any other anti-patterns or improvements you'd recommend?

Thanks in advance!

Developer technologies | C#
Developer technologies | C#

An object-oriented and type-safe programming language that has its roots in the C family of languages and includes support for component-oriented programming.

0 comments No comments

1 answer

Sort by: Most helpful
  1. Jack Dang (WICLOUD CORPORATION) 16,115 Reputation points Microsoft External Staff Moderator
    2026-04-01T04:09:58.59+00:00

    Hi @Csaba Faragó ,

    Thanks for sharing the details.

    I would suggest a few improvements below:

    1. GetAllVariables and the group lookup

    The current projection:

    _dbContext.groups
        .Where(y => y.groupId == x.groupId)
        .Select(y => y.name)
        .First()
    

    is not ideal, but I would not describe it as a guaranteed N+1 problem. In EF Core, this kind of expression is still translated into a single SQL query. The issue is that it is harder to read and maintain than modeling the relationship explicitly.

    A cleaner approach is to add a navigation property and project through it:

    public class Variable
    {
        [Key]
        [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
        public int variableId { get; set; }
    
        public string title { get; set; }
        public string director { get; set; }
        public int releaseYear { get; set; }
    
        public int groupId { get; set; }
        public Group Group { get; set; }
    }
    

    Then query like this:

    return await _dbContext.variables
        .AsNoTracking()
        .Select(x => new GetAllVariablesDto
        {
            VariableId = x.variableId,
            Title = x.title,
            Director = x.director,
            ReleaseYear = x.releaseYear,
            GroupId = x.groupId,
            GroupName = x.Group.name
        })
        .ToListAsync();
    

    If you are projecting into a DTO, Include(...) is usually not needed. EF Core can translate the navigation access into the appropriate SQL.

    1. AddTransient vs AddScoped

    Since VariableModel depends on VariableDbContext, AddScoped<VariableModel>() is the better fit for a typical web API:

    builder.Services.AddScoped<VariableModel>();
    

    Using AddTransient is not necessarily broken, but scoped lifetime is usually the better semantic match for a request-oriented service that works with a scoped DbContext.

    1. Manual transactions

    For a single write operation followed by a single SaveChanges() call, you generally do not need to start a transaction manually. EF Core already wraps SaveChanges() in a transaction when needed.

    So if your method is doing one unit of work, this is enough:

    await _dbContext.SaveChangesAsync();
    

    Manual transactions are more appropriate when:

    • You need multiple SaveChanges() calls to succeed or fail together
    • You are combining EF Core work with raw SQL or other database operations

    Also, if SaveChanges() and Commit() are outside the using block, that is a correctness issue and should be fixed regardless.

    1. Async methods with no await

    If a method is marked async, it should normally contain real asynchronous database calls such as ToListAsync(), FirstOrDefaultAsync(), AnyAsync(), or SaveChangesAsync().

    So you have two valid choices:

    • Make the methods truly async
    • Remove async and make them synchronous

    For ASP.NET Core APIs, async database access is usually the better choice for scalability and consistency.

    1. Where(...) plus null checks

    This pattern is incorrect:

    var data = _dbContext.variables.Where(x => x.variableId == id);
    if (data == null)
        throw new KeyNotFoundException();
    

    Where(...) returns a query, not an entity, so that null check is not meaningful.

    Use something like:

    var data = await _dbContext.variables
        .FirstOrDefaultAsync(x => x.variableId == id);
    
    if (data == null)
        throw new KeyNotFoundException();
    

    If this is a primary key lookup, FindAsync(id) is also a good option.

    1. Avoid loading data into memory for existence checks

    This pattern is inefficient:

    _dbContext.groups.Select(x => x.name).ToList().Contains(data.GroupName)
    

    because it loads all matching values into memory before checking.

    Prefer letting the database do the existence check:

    var exists = await _dbContext.groups
        .AnyAsync(x => x.name == data.GroupName);
    

    That said, I would also review whether GroupName is really the correct lookup key. If the relationship is based on groupId, using the foreign key directly is usually better. If group names are meant to be unique identifiers, that should be enforced in the database.

    1. Update and delete cleanup

    The issues you identified there are valid:

    • data.groupId == null is suspicious if groupId is a non-nullable int
    • .First() followed by a null check is inconsistent, because .First() already throws if nothing is found
    • duplicate validation conditions should be removed

    FirstOrDefaultAsync() plus an explicit not-found check is clearer than relying on exceptions from .First().

    1. Controller error handling

    The general direction is good, but I would avoid returning raw exception details directly from controllers for unexpected failures.

    A better pattern is:

    • 400 for invalid input or validation issues
    • 404 for missing resources
    • 500 for unexpected errors

    For larger APIs, a global exception handler with ProblemDetails keeps controllers much cleaner and gives you more consistent responses.

    1. Additional improvements

    A few other improvements worth considering:

    • Use AsNoTracking() for read-only queries
    • Use conventional REST routes such as GET api/variables and GET api/variables/{id}
    • Re-enable HTTPS redirection outside local development
    • Verify that appropriate indexes and foreign key constraints exist in PostgreSQL
    • If you have a real relationship between Variable and Group, configure it explicitly in EF Core

    Hope this helps! If my answer was helpful, I would greatly appreciate it if you could follow the instructions here so others with the same problem can benefit as well.


Your answer

Answers can be marked as 'Accepted' by the question author and 'Recommended' by moderators, which helps users know the answer solved the author's problem.