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** ⚠️