Dependency Injection
Model-bound queries use method-level dependency injection, where dependencies are resolved and injected as parameters to your static query methods. This approach provides flexibility and testability while keeping the query logic clean and focused.
How Method-Level Dependency Injection Works
Section titled “How Method-Level Dependency Injection Works”Unlike controller-based queries that use constructor injection, model-bound queries inject dependencies directly as method parameters. The Arc framework automatically resolves these dependencies from the service collection based on their parameter types.
[ReadModel]public record DebitAccount(AccountId Id, AccountName Name, CustomerId Owner, decimal Balance){ public static IEnumerable<DebitAccount> GetAllAccounts( IMongoCollection<DebitAccount> collection) // ← Dependency injected as parameter { return collection.Find(_ => true).ToList(); }}Common Dependency Types
Section titled “Common Dependency Types”Database Collections
Section titled “Database Collections”MongoDB collections are the most common dependencies:
[ReadModel]public record DebitAccount(AccountId Id, AccountName Name, CustomerId Owner, decimal Balance){ public static async Task<IEnumerable<DebitAccount>> GetActiveAccountsAsync( IMongoCollection<DebitAccount> collection) { var result = await collection.FindAsync(a => a.Balance > 0); return result.ToList(); }}Entity Framework DbContext
Section titled “Entity Framework DbContext”For Entity Framework Core scenarios:
[ReadModel]public record DebitAccount(AccountId Id, AccountName Name, CustomerId Owner, decimal Balance){ public static async Task<IEnumerable<DebitAccount>> GetAccountsFromEFAsync( ApplicationDbContext dbContext) { return await dbContext.DebitAccounts .Where(a => a.Balance >= 0) .ToListAsync(); }}Business Services
Section titled “Business Services”Inject domain services for complex business logic:
[ReadModel]public record DebitAccount(AccountId Id, AccountName Name, CustomerId Owner, decimal Balance){ public static async Task<AccountRiskAssessment> GetAccountRiskAssessment( AccountId accountId, IMongoCollection<DebitAccount> collection, IRiskCalculationService riskService, ITransactionHistoryService transactionService) { var account = await collection.Find(a => a.Id == accountId).FirstOrDefaultAsync(); if (account is null) throw new AccountNotFoundException(accountId);
var transactions = await transactionService.GetRecentTransactionsAsync(accountId); var riskScore = await riskService.CalculateRiskAsync(account, transactions);
return new AccountRiskAssessment(accountId, riskScore); }}Logging
Section titled “Logging”Structured logging with dependency injection:
[ReadModel]public record DebitAccount(AccountId Id, AccountName Name, CustomerId Owner, decimal Balance){ public static async Task<IEnumerable<DebitAccount>> SearchAccountsWithLogging( string searchTerm, IMongoCollection<DebitAccount> collection, ILogger<DebitAccount> logger) { logger.LogInformation("Searching accounts with term: {SearchTerm}", searchTerm);
var filter = Builders<DebitAccount>.Filter.Regex( a => a.Name, new BsonRegularExpression(searchTerm, "i"));
var result = await collection.FindAsync(filter); var accounts = result.ToList();
logger.LogInformation("Found {AccountCount} accounts matching '{SearchTerm}'", accounts.Count, searchTerm);
return accounts; }}Configuration
Section titled “Configuration”Inject configuration objects using IOptions<T> or IConfiguration:
public class AccountQueryOptions{ public int MaxSearchResults { get; set; } = 100; public TimeSpan CacheExpiry { get; set; } = TimeSpan.FromMinutes(5); public bool EnableAuditLogging { get; set; } = true;}
[ReadModel]public record DebitAccount(AccountId Id, AccountName Name, CustomerId Owner, decimal Balance){ public static async Task<IEnumerable<DebitAccount>> GetPagedAccountsWithOptions( int page, int pageSize, IMongoCollection<DebitAccount> collection, IOptions<AccountQueryOptions> options, ILogger<DebitAccount> logger) { var opts = options.Value; var actualPageSize = Math.Min(pageSize, opts.MaxSearchResults);
if (opts.EnableAuditLogging) { logger.LogInformation("Retrieving page {Page} with size {PageSize}", page, actualPageSize); }
var result = await collection.FindAsync(_ => true); return result.Skip(page * actualPageSize).Limit(actualPageSize).ToList(); }}Caching Services
Section titled “Caching Services”Integrate caching for performance optimization:
[ReadModel]public record DebitAccount(AccountId Id, AccountName Name, CustomerId Owner, decimal Balance){ public static async Task<IEnumerable<DebitAccount>> GetCachedAccountsByOwner( CustomerId ownerId, IMongoCollection<DebitAccount> collection, IMemoryCache cache, ILogger<DebitAccount> logger) { var cacheKey = $"accounts-by-owner-{ownerId}";
if (cache.TryGetValue(cacheKey, out IEnumerable<DebitAccount>? cachedAccounts)) { logger.LogInformation("Returning cached accounts for owner {OwnerId}", ownerId); return cachedAccounts ?? Enumerable.Empty<DebitAccount>(); }
logger.LogInformation("Loading accounts for owner {OwnerId} from database", ownerId); var accounts = await collection.Find(a => a.Owner == ownerId).ToListAsync();
cache.Set(cacheKey, accounts, TimeSpan.FromMinutes(5)); return accounts; }}Parameter Order Flexibility
Section titled “Parameter Order Flexibility”Dependencies can be placed in any position among your method parameters. The framework resolves them by type, not by position:
[ReadModel]public record DebitAccount(AccountId Id, AccountName Name, CustomerId Owner, decimal Balance){ // Dependencies first, then query parameters public static async Task<IEnumerable<DebitAccount>> GetAccountsByStatusPattern1( IMongoCollection<DebitAccount> collection, ILogger<DebitAccount> logger, AccountStatus status, bool includeInactive) { logger.LogInformation("Getting accounts by status: {Status}", status); // Implementation... return await collection.Find(_ => true).ToListAsync(); }
// Query parameters first, then dependencies public static async Task<IEnumerable<DebitAccount>> GetAccountsByStatusPattern2( AccountStatus status, bool includeInactive, IMongoCollection<DebitAccount> collection, ILogger<DebitAccount> logger) { logger.LogInformation("Getting accounts by status: {Status}", status); // Implementation... return await collection.Find(_ => true).ToListAsync(); }
// Mixed order public static async Task<IEnumerable<DebitAccount>> GetAccountsByStatusPattern3( AccountStatus status, IMongoCollection<DebitAccount> collection, bool includeInactive, ILogger<DebitAccount> logger) { logger.LogInformation("Getting accounts by status: {Status}", status); // Implementation... return await collection.Find(_ => true).ToListAsync(); }}Multiple Dependencies of Same Type
Section titled “Multiple Dependencies of Same Type”When you need multiple dependencies of the same type, use named dependencies or specific implementations:
[ReadModel]public record DebitAccount(AccountId Id, AccountName Name, CustomerId Owner, decimal Balance){ public static async Task<CrossAccountSummary> GetCrossAccountSummary( IMongoCollection<DebitAccount> debitCollection, IMongoCollection<CreditAccount> creditCollection, ILogger<DebitAccount> logger) { var debitAccounts = await debitCollection.Find(_ => true).ToListAsync(); var creditAccounts = await creditCollection.Find(_ => true).ToListAsync();
logger.LogInformation("Processing {DebitCount} debit and {CreditCount} credit accounts", debitAccounts.Count, creditAccounts.Count);
return new CrossAccountSummary( debitAccounts.Sum(a => a.Balance), creditAccounts.Sum(a => a.Balance)); }}Generic Dependencies
Section titled “Generic Dependencies”Use generic dependencies for flexible, reusable patterns:
[ReadModel]public record DebitAccount(AccountId Id, AccountName Name, CustomerId Owner, decimal Balance){ public static async Task<IEnumerable<DebitAccount>> GetAccountsWithGenericRepository( IRepository<DebitAccount> repository, ILogger<DebitAccount> logger) { logger.LogInformation("Loading accounts using generic repository"); return await repository.GetAllAsync(); }
public static async Task<DebitAccount?> GetAccountByIdWithGenericRepository( AccountId id, IRepository<DebitAccount> repository, IValidator<AccountId> validator) { var validationResult = await validator.ValidateAsync(id); if (!validationResult.IsValid) { throw new ValidationException(validationResult.Errors); }
return await repository.GetByIdAsync(id); }}Scoped Dependencies
Section titled “Scoped Dependencies”Dependencies are resolved with their registered lifetime (Singleton, Scoped, Transient):
[ReadModel]public record DebitAccount(AccountId Id, AccountName Name, CustomerId Owner, decimal Balance){ public static async Task<IEnumerable<DebitAccount>> GetAccountsWithScopedServices( IMongoCollection<DebitAccount> collection, // Scoped ICurrentUserService currentUserService, // Scoped ISystemClock systemClock, // Singleton IAuditService auditService) // Scoped { var currentUser = await currentUserService.GetCurrentUserAsync(); var currentTime = systemClock.UtcNow;
await auditService.LogQueryAsync("GetAccountsWithScopedServices", currentUser.Id, currentTime);
// Filter based on user permissions var filter = BuildUserFilter(currentUser); return await collection.Find(filter).ToListAsync(); }
private static FilterDefinition<DebitAccount> BuildUserFilter(User user) { if (user.IsAdmin) return Builders<DebitAccount>.Filter.Empty;
return Builders<DebitAccount>.Filter.Eq(a => a.Owner, user.CustomerId); }}Service Registration
Section titled “Service Registration”Ensure your dependencies are properly registered in the service collection:
// In Program.cs or Startup.csbuilder.Services.AddScoped<IRiskCalculationService, RiskCalculationService>();builder.Services.AddScoped<ITransactionHistoryService, TransactionHistoryService>();builder.Services.AddSingleton<ISystemClock, SystemClock>();builder.Services.AddScoped<ICurrentUserService, CurrentUserService>();builder.Services.AddScoped<IAuditService, AuditService>();builder.Services.Configure<AccountQueryOptions>( builder.Configuration.GetSection("AccountQueries"));
// MongoDB collections are typically registered as:builder.Services.AddScoped<IMongoCollection<DebitAccount>>(provider =>{ var database = provider.GetRequiredService<IMongoDatabase>(); return database.GetCollection<DebitAccount>("debit-accounts");});Dependency Injection Best Practices
Section titled “Dependency Injection Best Practices”- Order parameters logically - Group related parameters together, but remember that dependency resolution is by type
- Use specific interface types - Prefer
ILogger<T>overILogger,IOptions<TOptions>overIConfiguration - Avoid service locator pattern - Don’t inject
IServiceProviderand resolve services manually - Keep methods focused - If you need many dependencies, consider if the method is doing too much
- Use appropriate lifetimes - Understand Singleton, Scoped, and Transient lifetimes for your dependencies
- Test with mocked dependencies - The method-level injection makes unit testing straightforward
Testing with Dependency Injection
Section titled “Testing with Dependency Injection”Method-level dependency injection makes unit testing simple:
[Fact]public async Task GetAccountsByOwner_Should_Return_Filtered_Accounts(){ // Arrange var mockCollection = Substitute.For<IMongoCollection<DebitAccount>>(); var mockLogger = Substitute.For<ILogger<DebitAccount>>(); var ownerId = new CustomerId(Guid.NewGuid()); var expectedAccounts = new List<DebitAccount> { new(new AccountId(Guid.NewGuid()), new AccountName("Test Account"), ownerId, 1000m) };
var mockCursor = Substitute.For<IAsyncCursor<DebitAccount>>(); mockCursor.ToList().Returns(expectedAccounts);
mockCollection.FindAsync(Arg.Any<FilterDefinition<DebitAccount>>()) .Returns(mockCursor);
// Act var result = await DebitAccount.GetAccountsByOwnerWithLogging( ownerId, mockCollection, mockLogger);
// Assert result.Should().BeEquivalentTo(expectedAccounts); mockLogger.Received(1).LogInformation( Arg.Is<string>(s => s.Contains("Getting accounts for owner")), ownerId);}Error Handling with Dependencies
Section titled “Error Handling with Dependencies”Handle dependency-related errors gracefully:
[ReadModel]public record DebitAccount(AccountId Id, AccountName Name, CustomerId Owner, decimal Balance){ public static async Task<IEnumerable<DebitAccount>> GetAccountsWithErrorHandling( IMongoCollection<DebitAccount> collection, ILogger<DebitAccount> logger, IHealthCheckService healthCheck) { try { // Check if database is healthy before querying var healthResult = await healthCheck.CheckHealthAsync(); if (healthResult.Status != HealthStatus.Healthy) { logger.LogWarning("Database health check failed: {Status}", healthResult.Status); return Enumerable.Empty<DebitAccount>(); }
var result = await collection.FindAsync(_ => true); return result.ToList(); } catch (MongoException ex) { logger.LogError(ex, "MongoDB error while retrieving accounts"); throw new DataAccessException("Unable to retrieve accounts", ex); } catch (Exception ex) { logger.LogError(ex, "Unexpected error while retrieving accounts"); throw; } }}Method-level dependency injection in model-bound queries provides a clean, testable, and flexible approach to accessing services and repositories while keeping your query logic focused and maintainable.