Infrastructure Layer — Persistence, External Services, and Dependency Injection
What the Infrastructure layer contains in Clean Architecture, how to implement repository interfaces, wire up DI, and the production mistakes that happen when infrastructure concerns leak into other layers.
What the Infrastructure Layer Is
The Infrastructure layer implements the interfaces defined in the Application layer. It is the only layer allowed to know about databases, HTTP clients, email servers, message queues, and external APIs.
Infrastructure layer contains:
✓ EF Core DbContext and entity configurations
✓ Concrete repository implementations
✓ External API clients (pharmacy system, lab results API)
✓ Email/SMS service implementations
✓ ASP.NET Identity configuration
✓ Redis/cache client wrappers
✓ Logging sinks
✓ DI registration (AddInfrastructure extension)
Infrastructure layer does NOT contain:
✗ Business logic
✗ Validation rules
✗ Domain events (only dispatches them)
✗ HTTP routingInfrastructure.csproj
<!-- src/SystemForge.Infrastructure/SystemForge.Infrastructure.csproj -->
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.*" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.*">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.*" />
<PackageReference Include="StackExchange.Redis" Version="2.*" />
<PackageReference Include="Serilog.AspNetCore" Version="9.*" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Domain\SystemForge.Domain.csproj" />
<ProjectReference Include="..\Application\SystemForge.Application.csproj" />
</ItemGroup>
</Project>The DbContext
// Infrastructure/Persistence/AppDbContext.cs
namespace SystemForge.Infrastructure.Persistence;
public sealed class AppDbContext : DbContext, IUnitOfWork
{
private readonly IDomainEventPublisher _publisher;
public AppDbContext(
DbContextOptions<AppDbContext> options,
IDomainEventPublisher publisher)
: base(options)
{
_publisher = publisher;
}
public DbSet<Patient> Patients => Set<Patient>();
public DbSet<Prescription> Prescriptions => Set<Prescription>();
public DbSet<DrugOrder> DrugOrders => Set<DrugOrder>();
public DbSet<DrugOrderLine> DrugOrderLines => Set<DrugOrderLine>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// Apply all IEntityTypeConfiguration<T> from this assembly automatically
modelBuilder.ApplyConfigurationsFromAssembly(typeof(AssemblyReference).Assembly);
base.OnModelCreating(modelBuilder);
}
public override async Task<int> SaveChangesAsync(CancellationToken ct = default)
{
// Collect domain events before save (entities clear their events in PopDomainEvents)
var domainEvents = ChangeTracker
.Entries<Entity>()
.Select(e => e.Entity)
.SelectMany(entity =>
{
var events = entity.PopDomainEvents();
return events;
})
.ToList();
var rowsAffected = await base.SaveChangesAsync(ct);
// Dispatch after commit so events fire on committed state
if (domainEvents.Count > 0)
await _publisher.PublishAsync(domainEvents, ct);
return rowsAffected;
}
}Concrete Repository Implementation
// Infrastructure/Persistence/Repositories/PatientRepository.cs
public sealed class PatientRepository : IPatientRepository
{
private readonly AppDbContext _context;
public PatientRepository(AppDbContext context) => _context = context;
public async Task<Patient?> GetByIdAsync(PatientId id, CancellationToken ct)
=> await _context.Patients
.Include(p => p.Prescriptions)
.FirstOrDefaultAsync(p => p.Id == id, ct);
public async Task<Patient?> GetByMRNAsync(string mrn, CancellationToken ct)
=> await _context.Patients
.FirstOrDefaultAsync(p => p.MRN == mrn, ct);
public async Task<bool> ExistsByMRNAsync(string mrn, CancellationToken ct)
=> await _context.Patients.AnyAsync(p => p.MRN == mrn, ct);
public async Task AddAsync(Patient patient, CancellationToken ct)
=> await _context.Patients.AddAsync(patient, ct);
public async Task<IReadOnlyList<Patient>> GetActiveAsync(CancellationToken ct)
=> await _context.Patients
.Where(p => p.IsActive)
.AsNoTracking()
.ToListAsync(ct);
}PRO TIP: Use
AsNoTracking()on read-only queries. Every tracked entity consumes memory in the DbContext change tracker. For a query that returns 500 active patients just to display a list, tracking those entities is pure overhead.
External Service Implementation
// Infrastructure/Services/EmailService.cs
public sealed class EmailService : IEmailService
{
private readonly ILogger<EmailService> _logger;
private readonly EmailOptions _options;
private readonly HttpClient _http;
public EmailService(
ILogger<EmailService> logger,
IOptions<EmailOptions> options,
HttpClient http)
{
_logger = logger;
_options = options.Value;
_http = http;
}
public async Task SendAsync(string to, string subject, string body, CancellationToken ct)
{
try
{
var payload = new { To = to, Subject = subject, Body = body };
var response = await _http.PostAsJsonAsync(_options.ApiEndpoint, payload, ct);
response.EnsureSuccessStatusCode();
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
// Email failure should not break the main flow — log and continue
_logger.LogError(ex, "Failed to send email to {Recipient} with subject {Subject}", to, subject);
}
}
}Production issue I've seen: An email service failure was throwing an unhandled exception inside a domain event handler. Because domain events are dispatched after
SaveChanges, the data was already committed — but the exception unwound the request and returned a 500. The patient was created in the database but the caller got an error response. The fix was to catch non-critical infrastructure failures and log them, not re-throw them.
DI Registration
// Infrastructure/DependencyInjection.cs
public static class DependencyInjection
{
public static IServiceCollection AddInfrastructure(
this IServiceCollection services,
IConfiguration configuration)
{
// Persistence
services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(
configuration.GetConnectionString("Database"),
sql => sql.EnableRetryOnFailure(maxRetryCount: 3)));
services.AddScoped<IUnitOfWork>(sp => sp.GetRequiredService<AppDbContext>());
// Repositories
services.AddScoped<IPatientRepository, PatientRepository>();
services.AddScoped<IDrugOrderRepository, DrugOrderRepository>();
// External services
services.AddHttpClient<IEmailService, EmailService>();
services.AddScoped<IPharmacyNotificationService, PharmacyNotificationService>();
services.AddScoped<IAuditLog, DatabaseAuditLog>();
services.AddScoped<IDomainEventPublisher, DomainEventPublisher>();
// Options
services.Configure<EmailOptions>(configuration.GetSection("Email"));
return services;
}
}Red Flag Answers
Red flag: "I inject
AppDbContextdirectly into my command handlers."
The handler now depends on EF Core, which violates the Dependency Rule (Application layer must not know Infrastructure). You also cannot unit test the handler without a real DbContext.
Green answer: "Handlers receive
IPatientRepositoryandIUnitOfWork— interfaces defined in the Application layer. Infrastructure implements them. The handler never importsMicrosoft.EntityFrameworkCore."
Key Takeaway
The Infrastructure layer is the adapter between your application and the outside world. It knows about databases, APIs, and queues — but nothing else knows about it except the DI container in
Program.cs. When you need to swap SQL Server for PostgreSQL, or swap SendGrid for SES, you change one implementation class without touching a single handler or entity.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.