Using Artisan in ASP.NET Core - lobodava/artisan-orm GitHub Wiki

A repository derived from RepositoryBase owns one SqlConnection and a mutable Transaction field, so it is not safe to share across concurrent operations. The right lifetime is "one repository per unit of work" — typically one HTTP request, one background work item, or one console run.

In ASP.NET Core, the DI container handles this for you: register repositories as Scoped and let the framework dispose them when each request ends.

TL;DR

// Program.cs
var builder = WebApplication.CreateBuilder(args);
var connStr = builder.Configuration.GetConnectionString("DatabaseConnection")!;

builder.Services.AddScoped(_ => new UserRepository(connStr));
builder.Services.AddScoped(_ => new RecordRepository(connStr));
// Controller
public class UsersController : ControllerBase
{
    private readonly UserRepository _repo;

    public UsersController(UserRepository repo) => _repo = repo;

    [HttpGet("{id:int}")]
    public async Task<User?> Get(int id, CancellationToken ct) =>
        await _repo.ReadToAsync<User>("dbo.GetUserById", ct,
            new SqlParameter("@Id", id));
}

That is the whole pattern. The DI container disposes _repo at the end of the HTTP request automatically.

Why Scoped and not Singleton / Transient?

Lifetime Effect Verdict
Singleton All requests share one SqlConnection and one mutable Transaction field. ❌ Race conditions, broken transactions
Transient Each injection allocates a fresh repo — even when the same request injects it twice. ⚠️ Works, but loses transaction sharing across services in the same request
Scoped One repo per HTTP request, shared between every service injected within that request.

Sharing transactions across services

Because Scoped repos are shared across services within one request, a transaction started by one service is visible to the others as long as they use the same repository:

[HttpPost]
public async Task<IActionResult> Transfer(TransferDto dto, CancellationToken ct)
{
    // Both _accountService and _ledgerService inject the SAME AccountRepository
    // (Scoped), so commands they issue land in the same transaction.
    await _accountRepo.RunInTransactionAsync(async (tran, ct2) =>
    {
        await _accountService.WithdrawAsync(dto.From, dto.Amount, ct2);
        await _accountService.DepositAsync (dto.To,   dto.Amount, ct2);
        await _ledgerService .RecordAsync   (dto, ct2);
    }, ct);

    return Ok();
}

If the registration was Transient, each of those services would receive a different repository instance with its own connection, and the transaction started in one would not cover the others.

Background services / hosted workers

IHostedService and BackgroundService are singletons themselves, so they cannot inject a Scoped repository directly. Use IServiceScopeFactory to create a fresh scope per work item:

public class ImportWorker : BackgroundService
{
    private readonly IServiceScopeFactory _scopeFactory;

    public ImportWorker(IServiceScopeFactory scopeFactory) =>
        _scopeFactory = scopeFactory;

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            // One scope per iteration -> one repository per iteration -> disposed at scope end.
            using var scope = _scopeFactory.CreateScope();
            var repo = scope.ServiceProvider.GetRequiredService<RecordRepository>();

            await repo.ImportNextBatchAsync(stoppingToken);

            await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
        }
    }
}

If your worker is simple and doesn't need DI for anything else, using var repo = new RecordRepository(connStr); inside the loop is a fine alternative.

Connection pool — make sure it's on

ADO.NET pools physical SQL Server connections per process by connection-string identity. With pooling enabled (the default), using var repo = new MyRepo(connStr) borrows from the pool — opening it is microseconds. With Pooling=False, every Open() does a full TDS handshake, which is hundreds of milliseconds and will absolutely show up in your ASP.NET Core p99 latency.

# Production: Pooling is on by default; do not turn it off.
Data Source=...;Initial Catalog=...;Integrated Security=True;TrustServerCertificate=True;

# Tests sometimes use Pooling=False to surface connection-leak bugs that
# would otherwise be masked by the pool. That's fine for tests, never for
# production.

Using a connection string from IConfiguration

Layered configuration works the same way as for any other service:

// appsettings.json + appsettings.Development.json + environment variables + user-secrets
var connStr = builder.Configuration.GetConnectionString("DatabaseConnection")!;

For developer-machine overrides, prefer .NET user-secrets over machine-name-suffixed config keys:

dotnet user-secrets set "ConnectionStrings:DatabaseConnection" "Data Source=..."

Multiple repository assemblies — bulk registration

If you have many repository classes, you can roll your own bulk-registration extension to avoid one AddScoped line per type:

public static IServiceCollection AddRepositories(
    this IServiceCollection services,
    string connectionString,
    Assembly assembly)
{
    foreach (var t in assembly.GetTypes()
                              .Where(t => !t.IsAbstract &&
                                          typeof(RepositoryBase).IsAssignableFrom(t)))
    {
        services.AddScoped(t, sp => ActivatorUtilities.CreateInstance(sp, t, connectionString));
    }
    return services;
}

ActivatorUtilities.CreateInstance lets the constructor request additional DI dependencies (an ILogger<T>, etc.) alongside the connection string.


See also:

⚠️ **GitHub.com Fallback** ⚠️