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.
// 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.
| 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. | |
Scoped |
One repo per HTTP request, shared between every service injected within that request. | ✅ |
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.
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.
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.
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=..."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: