2

I am still learning and afraid of captive dependency (a dependency with an incorrectly configured lifetime). Consider the following code snippet and the full code is given at the bottom.

My objectives:

  • organize Minimal Api endpoints with endpoint classes,
  • register the endpoint classes with DI (but I am not sure their lifetime),
  • resolve the endpoint classes with looping and invoke their RegisterEndpoints() (again I am not sure whether they must be in CreateScope() or not)
public abstract class BaseEndpoints
{
    protected BaseEndpoints(string path, ILogger logger){/**/}
    public abstract void RegisterEndpoints(WebApplication app);
}

public sealed class ProductEndpoints : BaseEndpoints
{
    public ProductEndpoints(ILogger<ProductEndpoints> logger, AppDbContext db)
    : base("/api/v1/product", logger){/**/}

    public override void RegisterEndpoints(WebApplication app)
    {
        app.MapGet(path, GetAll);
        app.MapGet(path + "/{id:int}", Get);
    }

    private async ValueTask<IResult> GetAll(){/**/}
    private async ValueTask<IResult> Get(int id){/**/}
}
builder.Services.AddDbContext<AppDbContext>(o => o.UseInMemoryDatabase("AppDbContext"));

// Endpoints should have transient lifetime, right?
builder.Services.AddTransient<BaseEndpoints, ProductEndpoints>();


var app = builder.Build();

using (var scope = app.Services.CreateScope())
{
    var ctx = scope.ServiceProvider.GetService<AppDbContext>();
    ctx?.Database.EnsureCreated();

    // endpoint registration should be here or outside this scope?
    foreach (var endpoint in scope.ServiceProvider.GetServices<BaseEndpoints>())
    {
        endpoint.RegisterEndpoints(app);
    }

    // app.Run() should be here or outside this scope?
    app.Run();
}

Question:

  1. What is the correct lifetime for endpoint classes when registered with DI? In my understanding, they should be transient because they are used just once during application startup.
  2. Where should the endpoints services be resolved? Inside CreateScope() or outside?
  3. Where should I invoke app.Run()? Inside CreateScope() or outside?

Minimal Working Example

using Microsoft.EntityFrameworkCore;

var builder = WebApplication.CreateBuilder(args);

builder.Logging.ClearProviders();
builder.Logging.AddConsole();

builder.Services.AddDbContext<AppDbContext>(o => o.UseInMemoryDatabase("AppDbContext"));

// Endpoints should have transient lifetime, right?
builder.Services.AddTransient<BaseEndpoints, ProductEndpoints>();
// builder.Services.AddTransient<BaseEndpoints, OtherEndpoints>();

builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

var app = builder.Build();

if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}

app.UseHttpsRedirection();

using (var scope = app.Services.CreateScope())
{
    var ctx = scope.ServiceProvider.GetService<AppDbContext>();
    ctx?.Database.EnsureCreated();

    // endpoint registration should be here or outside this scope?
    foreach (var endpoint in scope.ServiceProvider.GetServices<BaseEndpoints>())
    {
        endpoint.RegisterEndpoints(app);
    }

    // app.Run() should be here or outside this scope?
    app.Run();
}




public class AppDbContext : DbContext
{
    public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }

    public DbSet<Product> Products => Set<Product>();

    protected override void OnModelCreating(ModelBuilder mb)
    {
        base.OnModelCreating(mb);

        mb.Entity<Product>().HasData(
            new Product { Id = 1, Name = "Allen Keys" },
            new Product { Id = 2, Name = "Bolts" },
            new Product { Id = 3, Name = "Cutters" }
        );
    }
}


public class Product
{
    public int Id { get; set; }
    public string Name { get; set; } = default!;
}



public abstract class BaseEndpoints
{
    protected readonly string path;
    protected readonly ILogger logger;

    protected BaseEndpoints(string path, ILogger logger)
    {
        this.path = path;
        this.logger = logger;
    }

    public abstract void RegisterEndpoints(WebApplication app);
}




public sealed class ProductEndpoints : BaseEndpoints
{
    private readonly AppDbContext db;

    public ProductEndpoints(ILogger<ProductEndpoints> logger, AppDbContext db)
    : base("/api/v1/product", logger)
    {
        this.db = db;
    }

    public override void RegisterEndpoints(WebApplication app)
    {
        app.MapGet(path, GetAll);
        app.MapGet(path + "/{id:int}", Get);
        // Other app.Map*() invocations go here.
    }

    private async ValueTask<IResult> GetAll()
    {
        return await Task.FromResult(TypedResults.Ok(db.Products));
    }

    private async ValueTask<IResult> Get(int id)
    {
        if (await db.Products.FindAsync(id) is Product p)
        {
            return TypedResults.Ok(p);
        }
        else
        {
            return TypedResults.NotFound();
        }
    }
}
  • 1
    Check out [this answer](https://stackoverflow.com/questions/75932346/minimal-api-generic-crud-with-dynamic-endpoint-mapping#75932445) – Guru Stron Apr 21 '23 at 00:13
  • _"I am still learning and afraid of captive dependency (a dependency with an incorrectly configured lifetime)."_ - there's nothing to be afraid of: provided you do this: https://stackoverflow.com/questions/49149065/how-do-i-validate-the-di-container-in-asp-net-core - i.e. `services.AddControllers().AddControllersAsServices();` and `UseDefaultServiceProvider( o => o.ValidateOnBuild = true; )`. – Dai Apr 21 '23 at 00:25

1 Answers1

1

I would argue that you should not register them in DI at all, which will save you from making this mistake:

public sealed class ProductEndpoints : BaseEndpoints
{
    private readonly AppDbContext db; // <--- problem
 
    // ....
    public override void RegisterEndpoints(WebApplication app)
    {
       
        app.MapGet(path + "/{id:int}", Get);
    }

    private async ValueTask<IResult> Get(int id)
    {
        if (await db.Products.FindAsync(id) is Product p) // <--- problem
        // ...
    }
}

This will lead to the same instance of the database context being used for the whole app lifetime in ProductEndpoints handlers which will produce a lot of problems with concurrency, stale data, performance ...

Minimal APIs allows to bind parameters from the DI, so try removing corresponding parameters from the ProductEndpoints constructor (and corresponding fields) and specify them on methods:

private async ValueTask<IResult> Get(int id, AppDbContext db)
{
    if (await db.Products.FindAsync(id) is Product p)
    {
        return TypedResults.Ok(p);
    }
    else
    {
        return TypedResults.NotFound();
    }
}

As for the BaseEndpoints inheritors instantiation - personally I would go with reflection to find and instantiate them or just created a collection of instances manually and just looped throw it.

Also check out:

P.S.

return await Task.FromResult(TypedResults.Ok(db.Products)); -> return TypedResults.Ok(await db.Products.ToListAsync());

Guru Stron
  • 102,774
  • 10
  • 95
  • 132
  • 1
    As an alternative to injecting your `DbContext` into each handler method, you could inject an `IDbContextFactory` into the ctor and then have a short-lived `DbContext` created from the factory in each method in a `using` block as-required. – Dai Apr 21 '23 at 00:35
  • 1
    Using constructor injection I got the same context as you said. `Console.WriteLine(db.ContextId);` – The Real Masochist Apr 21 '23 at 06:41