Clean Architecture for Senior .NET Developers: CQRS, EF Core and SQL Server
Let’s treat this as a serious mentoring session.
Clean Architecture is one of those topics that sounds simple when people draw circles on a whiteboard, but becomes confusing when you open a real .NET solution and ask:
Where does my entity go? Where does my DTO go? Where does EF Core go? Where does validation go? Where does the business rule go? Where should the SQL query live? Do I need repositories? Do I need CQRS? Is MediatR required? Am I overengineering this?
This article is designed to remove that confusion.
Clean Architecture is not about creating many projects because it looks professional. It is not about making every CRUD endpoint pass through 15 classes. It is not about copying someone’s GitHub template.
Clean Architecture is about protecting the business rules from technical details.
That is the heart of it.
The database can change. The UI can change. The API framework can change. The cloud provider can change. The logging library can change. The business rules should not collapse because of those changes.
In simple words:
Your core business logic should not depend on EF Core, SQL Server, HTTP, Angular, React, Azure, files, queues or external APIs. Those things should depend on your application, not the other way around.
That is why Clean Architecture exists.
1. The main idea: dependency direction
Most developers start with this kind of thinking:
Controller → Service → Repository → EF Core → SQL Server
That is not automatically wrong. For small applications, it may be enough.
But over time, business logic often leaks everywhere.
The controller starts validating business rules. The repository starts deciding workflow. The EF entity becomes polluted with API concerns. The frontend starts depending on database-shaped responses. Stored procedures return random shapes. Services become huge god classes. Tests become painful. Changing one feature breaks unrelated areas.
Clean Architecture says: slow down. Let’s create clear boundaries.
The usual layers are:
Domain
Application
Infrastructure
Presentation/API
Sometimes you also see:
Shared Kernel
Contracts
Web
Persistence
But the names matter less than the responsibility.
The rule is:
Outer layers can depend on inner layers.
Inner layers should not depend on outer layers.
So:
API depends on Application
Application depends on Domain
Infrastructure depends on Application and Domain
Domain depends on nothing important
The Domain layer should not know that EF Core exists. The Application layer should not know that the app is hosted in ASP.NET Core. The API layer should not contain deep business logic. The Infrastructure layer should implement technical details.
Think of it like a business office.
The Domain is the company’s policy book: the actual business rules. The Application layer is the operations manager: it coordinates use cases. Infrastructure is the equipment: database, email, file storage, message bus. The API is reception: it receives requests and sends responses.
Reception should not rewrite company policy. Equipment should not decide business rules. The operations manager should coordinate, but the policy book should remain the source of truth.
2. The Domain Layer: the heart of the system
The Domain layer contains the business model.
This is where your core business concepts live:
Entities
Value Objects
Enums
Domain Events
Domain Exceptions
Business Rules
Aggregates
Let’s use a real example: Loan Management Supermarket.
The system allows brokers to submit loan applications. A loan application can be drafted, submitted, reviewed, approved or rejected.
A poor model might look like this:
public class LoanApplication
{
public int Id { get; set; }
public string Status { get; set; } = "";
public decimal RequestedAmount { get; set; }
}
This is technically valid C#, but it is weak domain modelling.
Why?
Because anyone can do this:
application.Status = "Banana";
application.RequestedAmount = -5000;
The class does not protect itself.
A stronger domain entity would look like this:
public sealed class LoanApplication
{
private readonly List<LoanApplicationStatusHistory> _statusHistory = new();
private LoanApplication()
{
// Required by EF Core
}
public LoanApplication(
int customerId,
int loanProductId,
decimal requestedAmount,
decimal annualIncome)
{
if (customerId <= 0)
throw new DomainException("Customer is required.");
if (loanProductId <= 0)
throw new DomainException("Loan product is required.");
if (requestedAmount <= 0)
throw new DomainException("Requested amount must be greater than zero.");
if (annualIncome <= 0)
throw new DomainException("Annual income must be greater than zero.");
CustomerId = customerId;
LoanProductId = loanProductId;
RequestedAmount = requestedAmount;
AnnualIncome = annualIncome;
Status = LoanApplicationStatus.Draft;
CreatedAtUtc = DateTime.UtcNow;
AddStatusHistory(null, LoanApplicationStatus.Draft);
}
public int Id { get; private set; }
public int CustomerId { get; private set; }
public int LoanProductId { get; private set; }
public decimal RequestedAmount { get; private set; }
public decimal AnnualIncome { get; private set; }
public LoanApplicationStatus Status { get; private set; }
public DateTime CreatedAtUtc { get; private set; }
public DateTime? SubmittedAtUtc { get; private set; }
public IReadOnlyCollection<LoanApplicationStatusHistory> StatusHistory =>
_statusHistory.AsReadOnly();
public void Submit()
{
if (Status != LoanApplicationStatus.Draft)
throw new DomainException("Only draft applications can be submitted.");
Status = LoanApplicationStatus.Submitted;
SubmittedAtUtc = DateTime.UtcNow;
AddStatusHistory(LoanApplicationStatus.Draft, LoanApplicationStatus.Submitted);
}
public void Approve()
{
if (Status != LoanApplicationStatus.UnderReview)
throw new DomainException("Only applications under review can be approved.");
Status = LoanApplicationStatus.Approved;
AddStatusHistory(LoanApplicationStatus.UnderReview, LoanApplicationStatus.Approved);
}
public void Reject(string reason)
{
if (string.IsNullOrWhiteSpace(reason))
throw new DomainException("A rejection reason is required.");
if (Status == LoanApplicationStatus.Approved)
throw new DomainException("Approved applications cannot be rejected.");
var oldStatus = Status;
Status = LoanApplicationStatus.Rejected;
AddStatusHistory(oldStatus, LoanApplicationStatus.Rejected, reason);
}
private void AddStatusHistory(
LoanApplicationStatus? oldStatus,
LoanApplicationStatus newStatus,
string? reason = null)
{
_statusHistory.Add(new LoanApplicationStatusHistory(
Id,
oldStatus,
newStatus,
reason,
DateTime.UtcNow));
}
}
This is not just a data container. This is a business object.
It protects rules:
Requested amount must be positive.
Only draft applications can be submitted.
Only applications under review can be approved.
Approved applications cannot be rejected.
Rejection requires a reason.
Now the rules are not scattered across controllers, services and UI code. They live close to the business concept.
That is Domain layer thinking.
Domain entities should not depend on EF Core
This is important.
The domain should not contain:
[Table("LoanApplications")]
[Column("RequestedAmount")]
public DbSet<Something> Something { get; set; }
Those are persistence details.
Now, in real-world EF Core, sometimes people use EF attributes in domain classes for convenience. Is it always evil? No. But if you want clean separation, prefer Fluent API configuration in Infrastructure.
The domain model should be persistence-ignorant as much as practical.
Value objects
A value object represents a concept identified by its value, not an ID.
Examples:
Money
EmailAddress
PostCode
DateRange
LoanAmount
PercentageRate
Instead of passing raw strings everywhere:
public string Email { get; set; }
You can model:
public sealed record EmailAddress
{
public string Value { get; }
public EmailAddress(string value)
{
if (string.IsNullOrWhiteSpace(value))
throw new DomainException("Email is required.");
if (!value.Contains("@"))
throw new DomainException("Email is invalid.");
Value = value.Trim().ToLowerInvariant();
}
public override string ToString() => Value;
}
Now invalid email cannot quietly move around the system.
The Contains("@") check is intentionally illustrative, not production-grade email validation. In a real system, agree the business definition of an acceptable address, use a well-tested validation approach, enforce the database length and verify ownership through confirmation when required.
Use value objects when a primitive has business meaning and rules.
The sample entity calls DateTime.UtcNow directly to keep the example readable. In production, using a time abstraction such as TimeProvider and passing timestamps into domain methods makes time-dependent behaviour deterministic in tests.
Do not overdo it for every single string. Clean Architecture is not an excuse to turn one property into ten classes unless the domain deserves it.
Domain events
Domain events represent something meaningful that happened in the business.
LoanApplicationSubmitted
LoanApplicationApproved
LoanApplicationRejected
Example:
public sealed record LoanApplicationSubmittedDomainEvent(
int LoanApplicationId,
int CustomerId,
DateTime SubmittedAtUtc);
When the application is submitted, the entity can record an event:
public void Submit()
{
if (Status != LoanApplicationStatus.Draft)
throw new DomainException("Only draft applications can be submitted.");
Status = LoanApplicationStatus.Submitted;
SubmittedAtUtc = DateTime.UtcNow;
AddDomainEvent(new LoanApplicationSubmittedDomainEvent(
Id,
CustomerId,
SubmittedAtUtc.Value));
}
Then another part of the application can react: send email, create audit entry, publish message.
Senior rule:
Domain events describe business facts. They should not directly send emails or call external services.
The event says what happened. The application/infrastructure decides how to react.
3. The Application Layer: use cases and orchestration
The Application layer contains the system’s use cases.
This is where CQRS fits beautifully.
The Application layer answers:
What can the system do?
What commands can users perform?
What queries can users ask?
What validation is required?
What repositories or services are needed?
What transaction should be completed?
It contains:
Commands
Queries
Handlers
DTOs
Validators
Interfaces
Application services
Pipeline behaviours
Mapping logic
The Application layer depends on Domain, but not Infrastructure.
A pragmatic EF Core design may let Application reference EF Core abstractions and LINQ extension methods through an IApplicationDbContext, as the query example later does. That preserves the project dependency direction but couples application query code to EF Core. Some teams accept this for clear, efficient reads; others place query implementations behind ports in Infrastructure. Make that trade-off deliberately rather than claiming the code is persistence-agnostic when it is not.
It can define interfaces like:
public interface ILoanApplicationRepository
{
Task<LoanApplication?> GetByIdAsync(
int id,
CancellationToken cancellationToken);
Task AddAsync(
LoanApplication application,
CancellationToken cancellationToken);
Task SaveChangesAsync(CancellationToken cancellationToken);
}
But it does not implement the repository. Infrastructure implements it using EF Core.
This is the dependency inversion principle.
The Application layer says:
“I need a way to save loan applications.”
Infrastructure says:
“I can do that using SQL Server and EF Core.”
CQRS: Commands and Queries
CQRS means Command Query Responsibility Segregation.
In simple terms:
Commands change state.
Queries read state.
A command:
Submit loan application.
Approve loan application.
Reject loan application.
Upload document.
A query:
Get loan application by ID.
Search loan applications.
Get dashboard summary.
Get applications waiting for review.
Why separate them?
Because write logic and read logic often have different needs.
Write side cares about business rules, validation, consistency and transactions. Read side cares about shape, filtering, sorting, pagination and performance.
Trying to use the same model for both creates confusion.
Command example
public sealed record SubmitLoanApplicationCommand(
int CustomerId,
int LoanProductId,
decimal RequestedAmount,
decimal AnnualIncome)
: IRequest<SubmitLoanApplicationResult>;
Result:
public sealed record SubmitLoanApplicationResult(
int LoanApplicationId,
LoanApplicationStatus Status);
Validator:
public sealed class SubmitLoanApplicationCommandValidator
: AbstractValidator<SubmitLoanApplicationCommand>
{
public SubmitLoanApplicationCommandValidator()
{
RuleFor(x => x.CustomerId)
.GreaterThan(0);
RuleFor(x => x.LoanProductId)
.GreaterThan(0);
RuleFor(x => x.RequestedAmount)
.GreaterThan(0);
RuleFor(x => x.AnnualIncome)
.GreaterThan(0);
}
}
Handler:
public sealed class SubmitLoanApplicationCommandHandler
: IRequestHandler<SubmitLoanApplicationCommand, SubmitLoanApplicationResult>
{
private readonly ILoanApplicationRepository _repository;
private readonly ICustomerReadService _customerReadService;
private readonly ILoanProductReadService _loanProductReadService;
public SubmitLoanApplicationCommandHandler(
ILoanApplicationRepository repository,
ICustomerReadService customerReadService,
ILoanProductReadService loanProductReadService)
{
_repository = repository;
_customerReadService = customerReadService;
_loanProductReadService = loanProductReadService;
}
public async Task<SubmitLoanApplicationResult> Handle(
SubmitLoanApplicationCommand request,
CancellationToken cancellationToken)
{
var customerExists = await _customerReadService.ExistsAsync(
request.CustomerId,
cancellationToken);
if (!customerExists)
throw new NotFoundException("Customer not found.");
var productExists = await _loanProductReadService.ExistsAsync(
request.LoanProductId,
cancellationToken);
if (!productExists)
throw new NotFoundException("Loan product not found.");
var application = new LoanApplication(
request.CustomerId,
request.LoanProductId,
request.RequestedAmount,
request.AnnualIncome);
application.Submit();
await _repository.AddAsync(application, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
return new SubmitLoanApplicationResult(
application.Id,
application.Status);
}
}
Now look at the roles:
The command represents user intent. The validator checks input shape/basic rules. The handler orchestrates the use case. The domain entity protects business invariants. The repository saves data.
This is clean.
Where should validation live?
This confuses many developers.
There are different kinds of validation:
Request validation
Application validation
Domain validation
Database validation
Example:
RequestedAmount must be greater than zero.
This can exist in validator and domain. Is that duplication? Slightly, but useful.
The validator catches bad input early and returns a nice API error. The domain protects itself in case bad data comes from somewhere else. The database may also have a check constraint.
For important business rules, layered protection is not a bad thing.
But be careful:
A validator can check:
CustomerId > 0
Email format
Required fields
Maximum length
A domain entity should check:
Can this application move from Draft to Submitted?
Can an approved application be rejected?
Is this loan amount allowed by business policy?
A database constraint should protect:
Not null
Foreign key existence
Unique reference numbers
Positive amounts
Valid status values
Senior answer:
Use validators for input quality, domain methods for business rules, and database constraints for data integrity.
Query example
Queries should return read DTOs. They do not need to load full domain aggregates unless necessary.
public sealed record SearchLoanApplicationsQuery(
LoanApplicationStatus? Status,
DateTime? SubmittedFromUtc,
DateTime? SubmittedToUtc,
int PageNumber,
int PageSize)
: IRequest<PagedResult<LoanApplicationListItemDto>>;
DTO:
public sealed record LoanApplicationListItemDto(
int Id,
string ReferenceNumber,
string CustomerName,
decimal RequestedAmount,
LoanApplicationStatus Status,
DateTime? SubmittedAtUtc);
Handler:
public sealed class SearchLoanApplicationsQueryHandler
: IRequestHandler<SearchLoanApplicationsQuery, PagedResult<LoanApplicationListItemDto>>
{
private readonly IApplicationDbContext _dbContext;
public SearchLoanApplicationsQueryHandler(IApplicationDbContext dbContext)
{
_dbContext = dbContext;
}
public async Task<PagedResult<LoanApplicationListItemDto>> Handle(
SearchLoanApplicationsQuery request,
CancellationToken cancellationToken)
{
var query = _dbContext.LoanApplications
.AsNoTracking()
.AsQueryable();
if (request.Status is not null)
{
query = query.Where(x => x.Status == request.Status);
}
if (request.SubmittedFromUtc is not null)
{
query = query.Where(x => x.SubmittedAtUtc >= request.SubmittedFromUtc);
}
if (request.SubmittedToUtc is not null)
{
query = query.Where(x => x.SubmittedAtUtc < request.SubmittedToUtc);
}
var totalCount = await query.CountAsync(cancellationToken);
var items = await query
.OrderByDescending(x => x.SubmittedAtUtc)
.ThenByDescending(x => x.Id)
.Skip((request.PageNumber - 1) * request.PageSize)
.Take(request.PageSize)
.Select(x => new LoanApplicationListItemDto(
x.Id,
x.ReferenceNumber,
x.Customer.FullName,
x.RequestedAmount,
x.Status,
x.SubmittedAtUtc))
.ToListAsync(cancellationToken);
return new PagedResult<LoanApplicationListItemDto>(
items,
totalCount,
request.PageNumber,
request.PageSize);
}
}
This is a query. It reads data. It projects directly to a DTO. It uses AsNoTracking. It filters before materialisation. It paginates.
This is what you want in read-side code.
Do we need a repository here?
Not always.
This is where people get religious.
For write-side domain operations, repository can be useful because you are loading aggregates and saving changes.
For read-side queries, using EF Core directly through an application-level abstraction like IApplicationDbContext is often cleaner and faster.
Senior rule:
Do not force repositories onto every query if they make read models harder. For CQRS, repositories are often more useful on the command side than the query side.
4. The Infrastructure Layer: technical implementation
Infrastructure contains technical details.
This is where you put:
EF Core DbContext
Entity configurations
Repository implementations
SQL stored procedure callers
Email services
File storage
Blob storage
Message bus
External API clients
Date/time providers
Identity providers
The Application layer defines interfaces. Infrastructure implements them.
Example:
public sealed class LoanApplicationRepository : ILoanApplicationRepository
{
private readonly ApplicationDbContext _dbContext;
public LoanApplicationRepository(ApplicationDbContext dbContext)
{
_dbContext = dbContext;
}
public Task<LoanApplication?> GetByIdAsync(
int id,
CancellationToken cancellationToken)
{
return _dbContext.LoanApplications
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
}
public async Task AddAsync(
LoanApplication application,
CancellationToken cancellationToken)
{
await _dbContext.LoanApplications.AddAsync(application, cancellationToken);
}
public Task SaveChangesAsync(CancellationToken cancellationToken)
{
return _dbContext.SaveChangesAsync(cancellationToken);
}
}
EF Core DbContext:
public sealed class ApplicationDbContext : DbContext, IApplicationDbContext
{
public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
: base(options)
{
}
public DbSet<LoanApplication> LoanApplications => Set<LoanApplication>();
public DbSet<Customer> Customers => Set<Customer>();
public DbSet<LoanProduct> LoanProducts => Set<LoanProduct>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ApplyConfigurationsFromAssembly(
typeof(ApplicationDbContext).Assembly);
}
}
Entity configuration:
public sealed class LoanApplicationConfiguration
: IEntityTypeConfiguration<LoanApplication>
{
public void Configure(EntityTypeBuilder<LoanApplication> builder)
{
builder.ToTable("LoanApplications");
builder.HasKey(x => x.Id);
builder.Property(x => x.RequestedAmount)
.HasColumnType("decimal(18,2)")
.IsRequired();
builder.Property(x => x.AnnualIncome)
.HasColumnType("decimal(18,2)")
.IsRequired();
builder.Property(x => x.Status)
.HasConversion<string>()
.HasMaxLength(50)
.IsRequired();
builder.Property(x => x.CreatedAtUtc)
.IsRequired();
builder.Property(x => x.SubmittedAtUtc);
builder.HasMany(x => x.StatusHistory)
.WithOne()
.HasForeignKey(x => x.LoanApplicationId);
builder.Metadata
.FindNavigation(nameof(LoanApplication.StatusHistory))!
.SetPropertyAccessMode(PropertyAccessMode.Field);
}
}
Notice what happened.
The Domain entity does not contain EF attributes. Infrastructure maps it.
That is clean separation.
SQL in Clean Architecture
Where does SQL live?
It depends.
If you use EF Core migrations, table definitions are in Infrastructure.
If you use stored procedures, scripts can live in Infrastructure or a database project.
If a query is read-side application logic, the query handler may use EF LINQ or call a SQL/stored procedure abstraction.
Example application interface:
public interface ILoanApplicationReadRepository
{
Task<PagedResult<LoanApplicationListItemDto>> SearchAsync(
SearchLoanApplicationsQuery query,
CancellationToken cancellationToken);
}
Infrastructure implementation using raw SQL:
public sealed class LoanApplicationReadRepository
: ILoanApplicationReadRepository
{
private readonly ApplicationDbContext _dbContext;
public LoanApplicationReadRepository(ApplicationDbContext dbContext)
{
_dbContext = dbContext;
}
public async Task<PagedResult<LoanApplicationListItemDto>> SearchAsync(
SearchLoanApplicationsQuery query,
CancellationToken cancellationToken)
{
var items = await _dbContext.Database
.SqlQuery<LoanApplicationListItemDto>($@"
EXEC dbo.SearchLoanApplications
@Status = {query.Status},
@SubmittedFromUtc = {query.SubmittedFromUtc},
@SubmittedToUtc = {query.SubmittedToUtc},
@PageNumber = {query.PageNumber},
@PageSize = {query.PageSize}")
.ToListAsync(cancellationToken);
// Read the unpaged total from an output parameter, second result set,
// or a separate count query. items.Count is only the current page size.
var totalCount = await GetMatchingApplicationCountAsync(
query,
cancellationToken);
return new PagedResult<LoanApplicationListItemDto>(
items,
totalCount,
query.PageNumber,
query.PageSize);
}
}
Senior point:
Clean Architecture does not ban SQL. Clean Architecture does not ban EF Core. Clean Architecture says: keep technical details behind clear boundaries.
If SQL is the best tool for a complex report, use SQL. But do not let SQL-shaped chaos leak into your whole application.
5. The Presentation/API Layer: HTTP boundary
The API layer contains:
Controllers or Minimal APIs
Request models
Response models
Authentication configuration
Authorization policies
Middleware
Filters
Swagger/OpenAPI
Endpoint routing
The API layer should be thin.
It receives HTTP requests, maps them to commands/queries, sends them to Application layer, returns HTTP responses.
Example controller:
[ApiController]
[Route("api/loan-applications")]
public sealed class LoanApplicationsController : ControllerBase
{
private readonly ISender _sender;
public LoanApplicationsController(ISender sender)
{
_sender = sender;
}
[HttpPost]
public async Task<ActionResult<SubmitLoanApplicationResult>> Submit(
SubmitLoanApplicationRequest request,
CancellationToken cancellationToken)
{
var command = new SubmitLoanApplicationCommand(
request.CustomerId,
request.LoanProductId,
request.RequestedAmount,
request.AnnualIncome);
var result = await _sender.Send(command, cancellationToken);
return CreatedAtAction(
nameof(GetById),
new { id = result.LoanApplicationId },
result);
}
[HttpGet("{id:int}")]
public async Task<ActionResult<LoanApplicationDetailsDto>> GetById(
int id,
CancellationToken cancellationToken)
{
var result = await _sender.Send(
new GetLoanApplicationByIdQuery(id),
cancellationToken);
return Ok(result);
}
[HttpGet]
public async Task<ActionResult<PagedResult<LoanApplicationListItemDto>>> Search(
[FromQuery] SearchLoanApplicationsRequest request,
CancellationToken cancellationToken)
{
var query = new SearchLoanApplicationsQuery(
request.Status,
request.SubmittedFromUtc,
request.SubmittedToUtc,
request.PageNumber,
request.PageSize);
var result = await _sender.Send(query, cancellationToken);
return Ok(result);
}
}
This controller is not doing business logic. Good.
It is also not returning EF entities directly. Good.
It uses request/response DTOs. Good.
It sends commands/queries. Good.
Request models vs Commands
Some developers ask: can my controller accept the command directly?
public async Task<IActionResult> Submit(SubmitLoanApplicationCommand command)
You can, but I usually prefer separate request models for public API contracts.
Why?
Because HTTP contract and application command may evolve differently.
API request may include strings, optional fields, route values.
Command may include already-normalised values and internal context like UserId.
Example:
public sealed record SubmitLoanApplicationRequest(
int CustomerId,
int LoanProductId,
decimal RequestedAmount,
decimal AnnualIncome);
Then map to command.
This keeps API boundary clean.
6. Dependency Injection wiring
Your API project usually wires everything together.
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddApplication();
builder.Services.AddInfrastructure(builder.Configuration);
var app = builder.Build();
app.UseExceptionHandler();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Run();
Application registration:
public static class DependencyInjection
{
public static IServiceCollection AddApplication(
this IServiceCollection services)
{
services.AddMediatR(configuration =>
{
configuration.RegisterServicesFromAssembly(
typeof(DependencyInjection).Assembly);
});
services.AddValidatorsFromAssembly(
typeof(DependencyInjection).Assembly);
services.AddTransient(
typeof(IPipelineBehavior<,>),
typeof(ValidationPipelineBehavior<,>));
return services;
}
}
Infrastructure registration:
public static class DependencyInjection
{
public static IServiceCollection AddInfrastructure(
this IServiceCollection services,
IConfiguration configuration)
{
services.AddDbContext<ApplicationDbContext>(options =>
{
options.UseSqlServer(
configuration.GetConnectionString("DefaultConnection"));
});
services.AddScoped<IApplicationDbContext>(
provider => provider.GetRequiredService<ApplicationDbContext>());
services.AddScoped<ILoanApplicationRepository, LoanApplicationRepository>();
services.AddScoped<IEmailSender, SmtpEmailSender>();
return services;
}
}
The API composes the application. The inner layers do not know about this wiring.
7. MediatR pipeline behaviours
One reason CQRS with MediatR is useful is pipeline behaviours.
Instead of putting validation/logging around every handler manually, you can create pipeline behaviours.
Validation behaviour:
public sealed class ValidationPipelineBehavior<TRequest, TResponse>
: IPipelineBehavior<TRequest, TResponse>
where TRequest : notnull
{
private readonly IEnumerable<IValidator<TRequest>> _validators;
public ValidationPipelineBehavior(
IEnumerable<IValidator<TRequest>> validators)
{
_validators = validators;
}
public async Task<TResponse> Handle(
TRequest request,
RequestHandlerDelegate<TResponse> next,
CancellationToken cancellationToken)
{
if (!_validators.Any())
return await next();
var context = new ValidationContext<TRequest>(request);
var validationResults = await Task.WhenAll(
_validators.Select(validator =>
validator.ValidateAsync(context, cancellationToken)));
var failures = validationResults
.SelectMany(result => result.Errors)
.Where(error => error is not null)
.ToList();
if (failures.Count != 0)
throw new ValidationException(failures);
return await next();
}
}
You can also add:
Logging behaviour
Performance timing behaviour
Transaction behaviour
Authorization behaviour
Unhandled exception behaviour
Senior warning:
Do not hide too much magic in pipelines. They are powerful, but the team must understand them.
8. Transactions and Unit of Work
Where should transaction boundaries live?
Usually around a command handler.
A command changes state. It should either complete fully or fail fully.
If using EF Core, DbContext already acts like a Unit of Work. SaveChangesAsync commits changes.
For simple commands, this is enough:
await _repository.AddAsync(application, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
For more complex commands involving multiple repositories, you may use a transaction pipeline behaviour.
But be careful with external services.
Bad:
BEGIN TRANSACTION
Save application
Send email
Call external credit API
Commit transaction
Do not hold database transactions while calling external services.
Better pattern:
Save application
Save domain/outbox event
Commit transaction
Background worker publishes email/message
This leads to the outbox pattern.
Clean Architecture works very well with this because domain events can be captured, saved, and processed by infrastructure/background services.
9. EF Core best practices in Clean Architecture
EF Core belongs in Infrastructure.
But EF Core is powerful enough that you should not hide it badly.
Common mistake: generic repository over EF Core that destroys query power.
Bad:
Task<List<T>> GetAllAsync();
Then application does:
var applications = await repository.GetAllAsync();
var pending = applications.Where(x => x.Status == Submitted).ToList();
This loads everything into memory.
Better:
For command side, repository methods should express domain needs:
Task<LoanApplication?> GetByIdAsync(int id, CancellationToken cancellationToken);
Task AddAsync(LoanApplication application, CancellationToken cancellationToken);
For read side, use query handlers with projection:
var items = await _dbContext.LoanApplications
.AsNoTracking()
.Where(x => x.Status == LoanApplicationStatus.Submitted)
.OrderByDescending(x => x.SubmittedAtUtc)
.Select(x => new LoanApplicationListItemDto(
x.Id,
x.ReferenceNumber,
x.Customer.FullName,
x.RequestedAmount,
x.Status,
x.SubmittedAtUtc))
.ToListAsync(cancellationToken);
Best practices:
Use AsNoTracking for read-only queries.
Project to DTOs for list screens.
Avoid Include unless you need full entity graphs.
Avoid ToListAsync too early.
Keep filtering in IQueryable.
Use pagination.
Inspect generated SQL for important queries.
Use migrations carefully.
Use concurrency tokens where needed.
10. When Clean Architecture is worth it
Clean Architecture is useful when:
The business domain has meaningful rules.
The application will grow.
Multiple developers work on it.
Testing business logic matters.
Infrastructure may change.
You need clear boundaries.
You have complex workflows.
You care about long-term maintainability.
Examples:
Loan management platform
Legal case management system
Healthcare ordering platform
Property development lifecycle platform
Finance/investment platform
Insurance quote system
Enterprise workflow engine
Clean Architecture may be overkill when:
The app is a tiny CRUD admin screen.
The system is short-lived.
There is no meaningful business logic.
One developer is building a simple internal tool.
The ceremony slows delivery more than it protects quality.
Senior answer:
Use Clean Architecture when the cost of change is important enough to justify the structure.
Not every app needs full Clean Architecture. But every app benefits from clear boundaries.
11. Common mistakes and code smells
Mistake 1: anemic domain plus fake clean architecture
People create Domain project but put only empty classes there.
public class LoanApplication
{
public int Id { get; set; }
public string Status { get; set; }
}
All business rules live in services. This is not strong domain modelling.
Mistake 2: controllers doing too much
If controller has business rules, EF queries, email sending and workflow decisions, it is not clean.
Mistake 3: overusing generic repository
Generic repositories often hide EF badly and cause inefficient code.
Mistake 4: DTOs leaking everywhere
API DTOs, application DTOs and domain entities should not be mixed casually.
Mistake 5: using CQRS for everything blindly
Not every operation needs a command, handler, validator, repository, mapper and pipeline.
For very simple apps, this can be overengineering.
Mistake 6: Infrastructure referenced by Application
If Application depends on Infrastructure, dependency direction is broken.
Mistake 7: business rules in EF configuration
EF configuration should map persistence. It should not decide business workflow.
Mistake 8: read queries loading full aggregates
For list screens, project to DTOs. Do not load entire object graphs unnecessarily.
12. Production mentoring case: submit a loan application
Let us move from layers on a diagram to one use case.
An authenticated applicant submits a draft loan application. The system must verify the draft belongs to them, ensure required declarations are current, prevent duplicate submission, enforce valid state transition, persist the submission and publish an integration event for downstream decisioning. A lost HTTP response must not create a second submission.
Junior: Which project should contain SubmitLoanApplicationHandler?>
Senior: Application is a reasonable home, but placement is not the first question. Define the business invariant, transaction boundary and dependencies. The folder follows responsibility.Write the use-case contract:
public sealed record SubmitLoanApplicationCommand(
Guid RequestId,
Guid ApplicationId,
long ExpectedVersion,
IReadOnlyList<AcceptedDeclaration> AcceptedDeclarations)
: ICommand<SubmitLoanApplicationResult>;
public sealed record AcceptedDeclaration(Guid Id, int Version);
public abstract record SubmitLoanApplicationResult
{
public sealed record Submitted(
Guid ApplicationId,
DateTimeOffset SubmittedAt,
long Version) : SubmitLoanApplicationResult;
public sealed record AlreadySubmitted(
Guid ApplicationId,
DateTimeOffset SubmittedAt,
long Version) : SubmitLoanApplicationResult;
public sealed record VersionConflict(long CurrentVersion)
: SubmitLoanApplicationResult;
public sealed record DeclarationMismatch(
IReadOnlyList<Guid> ChangedDeclarationIds)
: SubmitLoanApplicationResult;
}
The command expresses intent, not HTTP. It contains no HttpContext, status code, EF entity or controller request type. The result names expected business/application outcomes. Authentication identity can enter through an application abstraction such as ICurrentActor, or as a trusted parameter assembled by the API boundary; do not accept an applicant ID from the body and treat it as authority.
Define the aggregate boundary
The LoanApplication aggregate owns its lifecycle:
public sealed class LoanApplication
{
private readonly List<IDomainEvent> _domainEvents = [];
private LoanApplication() { }
public Guid Id { get; private set; }
public Guid ApplicantId { get; private set; }
public LoanApplicationStatus Status { get; private set; }
public long Version { get; private set; }
public DateTimeOffset? SubmittedAt { get; private set; }
public IReadOnlyCollection<IDomainEvent> DomainEvents => _domainEvents;
public SubmissionOutcome Submit(
IReadOnlyCollection<AcceptedDeclaration> accepted,
IReadOnlyCollection<RequiredDeclaration> required,
DateTimeOffset now)
{
if (Status == LoanApplicationStatus.Submitted)
return SubmissionOutcome.AlreadySubmitted(SubmittedAt!.Value, Version);
if (Status != LoanApplicationStatus.Draft)
throw new InvalidApplicationTransitionException(Status, "Submitted");
var changed = required
.Where(r => !accepted.Any(a => a.Id == r.Id && a.Version == r.Version))
.Select(r => r.Id)
.ToArray();
if (changed.Length > 0)
return SubmissionOutcome.DeclarationsChanged(changed);
Status = LoanApplicationStatus.Submitted;
SubmittedAt = now;
Version++;
_domainEvents.Add(new LoanApplicationSubmitted(
Id, ApplicantId, SubmittedAt.Value, Version));
return SubmissionOutcome.Success(SubmittedAt.Value, Version);
}
}
The domain protects transition and declaration acceptance. It does not query SQL Server for current declarations, inspect claims or publish to a broker. The application service supplies facts and coordinates effects.
Do not expose a public Status setter and hope every caller remembers the rules. Also avoid injecting repositories or HTTP clients into an entity. Domain objects should be testable with ordinary values.
Junior: Isn't returning outcomes and raising events duplication?>
Senior: They serve different consumers. The immediate outcome tells the use case what happened. A domain event records a fact for in-process follow-up. Keep each only when it has a clear purpose.
13. Build the handler as an explicit transaction script around the domain
public sealed class SubmitLoanApplicationHandler(
ILoanApplicationRepository applications,
IRequiredDeclarationReader declarations,
ICurrentActor actor,
IAuthorizationService authorization,
IIdempotencyStore idempotency,
IUnitOfWork unitOfWork,
TimeProvider timeProvider)
: ICommandHandler<SubmitLoanApplicationCommand, SubmitLoanApplicationResult>
{
public async Task<SubmitLoanApplicationResult> Handle(
SubmitLoanApplicationCommand command,
CancellationToken cancellationToken)
{
var existing = await idempotency.FindAsync(
command.RequestId, cancellationToken);
if (existing is not null)
return existing.ToResult();
var application = await applications.GetForUpdateAsync(
command.ApplicationId, cancellationToken);
if (application is null)
throw new LoanApplicationNotFoundException(command.ApplicationId);
await authorization.EnsureCanSubmitAsync(
actor.Principal, application, cancellationToken);
if (application.Version != command.ExpectedVersion)
return new SubmitLoanApplicationResult.VersionConflict(
application.Version);
var required = await declarations.GetCurrentAsync(cancellationToken);
var outcome = application.Submit(
command.AcceptedDeclarations,
required,
timeProvider.GetUtcNow());
var result = Map(outcome, application);
idempotency.Record(command.RequestId, command, result);
await unitOfWork.SaveChangesAsync(cancellationToken);
return result;
}
}
This is a teaching shape. The idempotency lookup and insert need database uniqueness and a transaction; otherwise two concurrent requests can both pass the lookup. Store a request fingerprint so reusing a key with different content is rejected. Decide whether expected conflict/declaration outcomes consume the idempotency key.
The handler has several dependencies, but each represents a real boundary. If it continues growing, group cohesive policies or reconsider the use case; do not hide dependencies behind a service locator.
Application orchestration may be procedural. Clean Architecture does not require every line to live inside an aggregate. Loading, authorisation, deduplication, clocks and persistence are use-case coordination.
14. Validation belongs at multiple depths
Different checks answer different questions.
Transport validation
Can JSON be parsed? Are required fields present? Are IDs syntactically valid? Is the collection within request-size limits? The API/framework handles shape and returns a stable validation response.
Application validation
Does the command contain a nonempty request ID? Are declaration IDs unique? Does the use case accept this actor/context? A validator/pipeline can reject before loading the aggregate.
public sealed class SubmitLoanApplicationValidator
: AbstractValidator<SubmitLoanApplicationCommand>
{
public SubmitLoanApplicationValidator()
{
RuleFor(x => x.RequestId).NotEmpty();
RuleFor(x => x.ApplicationId).NotEmpty();
RuleFor(x => x.ExpectedVersion).GreaterThanOrEqualTo(0);
RuleFor(x => x.AcceptedDeclarations)
.Must(items => items.Select(x => x.Id).Distinct().Count() == items.Count)
.WithMessage("Declaration IDs must be unique.");
}
}
Domain validation
Can a draft transition to Submitted? Were current required declarations accepted? These invariants belong with the domain model because every caller must respect them.
Database constraints
Can two idempotency rows share a request ID? Can required relational fields be null? Can a foreign key reference a missing aggregate? Storage constraints protect data against races and alternate writers.
Do not insist on one “validation layer.” Repeat an invariant where a different trust/failure boundary requires it, while keeping the business definition authoritative.
15. Persistence mapping without contaminating the domain
Infrastructure maps domain state:
internal sealed class LoanApplicationConfiguration
: IEntityTypeConfiguration<LoanApplication>
{
public void Configure(EntityTypeBuilder<LoanApplication> builder)
{
builder.ToTable("LoanApplications", "Lending");
builder.HasKey(x => x.Id);
builder.Property(x => x.ApplicantId).IsRequired();
builder.Property(x => x.Status)
.HasConversion<string>()
.HasMaxLength(32)
.IsRequired();
builder.Property(x => x.Version)
.IsConcurrencyToken();
builder.Ignore(x => x.DomainEvents);
}
}
If ApplicantId is a value object, configure a conversion/complex/owned mapping appropriate to the EF Core version and model. The domain type does not need EF attributes. A private parameterless constructor can support materialisation without making invalid construction public.
SQL Server's rowversion can serve as a database-managed concurrency token; an application-managed numeric version can also be useful for domain/event sequencing. They are not interchangeable without design. Choose and map intentionally.
The repository speaks aggregate language:
public interface ILoanApplicationRepository
{
Task<LoanApplication?> GetForUpdateAsync(
Guid applicationId,
CancellationToken cancellationToken);
void Add(LoanApplication application);
}
It does not offer GetAll, arbitrary include strings or generic update. EF tracking observes changes to a loaded aggregate. Calling Update on an entire disconnected graph can mark unintended columns and weaken concurrency.
16. Queries may use the database efficiently
Clean Architecture does not require loading an aggregate to show a list. A query handler can depend on a narrow read abstraction and project:
public sealed record GetApplicantApplications(
int Page,
int PageSize) : IQuery<PagedResult<ApplicationListItem>>;
public sealed class GetApplicantApplicationsHandler(
IReadDbContext db,
ICurrentActor actor)
{
public async Task<PagedResult<ApplicationListItem>> Handle(
GetApplicantApplications query,
CancellationToken cancellationToken)
{
var source = db.LoanApplications
.AsNoTracking()
.Where(x => x.ApplicantId == actor.ApplicantId)
.OrderByDescending(x => x.CreatedAt)
.ThenBy(x => x.Id);
return await source.Select(x => new ApplicationListItem(
x.Id,
x.ProductName,
x.RequestedAmount,
x.Status,
x.CreatedAt))
.ToPagedResultAsync(query.Page, query.PageSize, cancellationToken);
}
}
The exact abstraction can expose queryable entity projections, named query methods, Dapper/SQL access or a read service. Each has trade-offs. An IReadDbContext referencing EF abstractions in Application accepts some framework coupling for query productivity; strict ports can keep EF entirely outside but may create more interfaces. Make the decision explicit rather than calling one universally clean.
Tenant/applicant filtering must be authoritative. A query object's ApplicantId from the client is not enough. Apply identity-derived scope and test cross-tenant access.
For hot or complex reads, SQL/Dapper in Infrastructure can return an Application-owned DTO. Parameterise values, test against SQL Server and monitor the plan. Dependency direction concerns source-code dependencies and policy ownership; it does not ban SQL.
17. Domain events, integration events and the outbox
A domain event is an in-process statement that something occurred in the domain. An integration event is a versioned external contract. Do not publish the domain object itself to the broker.
public sealed record LoanApplicationSubmittedIntegrationEventV1(
Guid MessageId,
DateTimeOffset OccurredAt,
Guid ApplicationId,
long ApplicationVersion);
An event translator in Application/Infrastructure maps the domain fact to a minimal external contract. Avoid including applicant details unless consumers genuinely require and are authorised for them.
Publishing after SaveChanges can lose the event if the process stops. Publishing before commit can announce state that rolls back. Store an outbox record in the same SQL Server transaction as the aggregate.
public override async Task<int> SaveChangesAsync(
CancellationToken cancellationToken = default)
{
var domainEvents = ChangeTracker.Entries<IAggregateRoot>()
.SelectMany(x => x.Entity.DequeueDomainEvents())
.ToArray();
foreach (var domainEvent in domainEvents)
{
OutboxMessages.Add(outboxMapper.Map(domainEvent));
}
return await base.SaveChangesAsync(cancellationToken);
}
Be careful: dequeuing before a failed save can lose in-memory events on retry. Design event collection/clearing around successful persistence, or recreate the unit of work. Serialisation can fail too; test the complete transaction path.
The publisher may send twice if it crashes after broker publish but before marking dispatched. Consumers need idempotency/inbox handling. Clean project references do not solve distributed delivery semantics.
Junior: Should domain event handlers send email inside the transaction?>
Senior: No. Record reliable intent, commit local state, then perform external work asynchronously. A slow email provider should not hold SQL locks or roll back a valid submission.
18. HTTP mapping belongs at the edge
app.MapPost("/api/loan-applications/{applicationId:guid}/submission",
async Task<IResult> (
Guid applicationId,
SubmitLoanApplicationRequest request,
ISender sender,
CancellationToken cancellationToken) =>
{
var command = new SubmitLoanApplicationCommand(
request.RequestId,
applicationId,
request.ExpectedVersion,
request.AcceptedDeclarations);
var result = await sender.Send(command, cancellationToken);
return result switch
{
SubmitLoanApplicationResult.Submitted x =>
Results.Created($"/api/loan-applications/{x.ApplicationId}", x),
SubmitLoanApplicationResult.AlreadySubmitted x => Results.Ok(x),
SubmitLoanApplicationResult.VersionConflict x =>
Results.Conflict(new { code = "application_version_conflict", x.CurrentVersion }),
SubmitLoanApplicationResult.DeclarationMismatch x =>
Results.Conflict(new { code = "declarations_changed", x.ChangedDeclarationIds }),
_ => Results.Problem()
};
});
The endpoint maps transport to command and result to HTTP. It does not decide whether the transition is valid. Use endpoint filters/model binding/exception handling according to the application's standards.
Authentication middleware establishes identity. Resource authorisation should happen with the loaded application or a query guaranteed to check ownership. Decide whether forbidden and not found are intentionally indistinguishable to prevent enumeration.
Return safe problem details for unexpected failures with correlation/trace ID. Do not expose exception messages or domain class names as public contract.
19. Pipeline behaviours: useful cross-cutting mechanisms
MediatR is optional. A command dispatcher and decorators can implement the same pattern. If using pipeline behaviours, keep their order and responsibility explicit.
Potential behaviours:
- tracing/correlation;
- authorisation when it can occur before resource loading;
- command validation;
- idempotency for suitable commands;
- transaction/unit of work;
- handler;
- metrics/logging result.
public sealed class ValidationBehavior<TRequest, TResponse>(
IEnumerable<IValidator<TRequest>> validators)
: IPipelineBehavior<TRequest, TResponse>
where TRequest : notnull
{
public async Task<TResponse> Handle(
TRequest request,
RequestHandlerDelegate<TResponse> next,
CancellationToken cancellationToken)
{
var context = new ValidationContext<TRequest>(request);
var results = await Task.WhenAll(validators.Select(v =>
v.ValidateAsync(context, cancellationToken)));
var failures = results.SelectMany(x => x.Errors)
.Where(x => x is not null)
.ToArray();
if (failures.Length > 0)
throw new ValidationException(failures);
return await next();
}
}
Parallel validator execution is safe only if validators do not share a non-thread-safe dependency such as one EF DbContext. Many validators should be synchronous/pure shape checks; database-dependent business checks may belong in the handler/domain because data can change after prevalidation.
20. Transaction ownership and cancellation
One command should normally define one local transaction boundary. EF Core SaveChanges uses a transaction for its changes where required. Add an explicit transaction when multiple saves or coordinated local operations require it, but keep it short.
Do not call remote pricing, email or broker services while holding SQL locks. Gather external prerequisites before the transaction where their staleness is acceptable, or redesign around asynchronous workflow.
Cancellation is safe before commitment if no required side effect occurred. After commit, reporting cancellation to the API can make a successful submission look failed. A command handler should understand its point of no cancellation. The outbox ensures subsequent publication can continue independent of the request token.
If SaveChangesAsync throws an optimistic concurrency exception, translate it to the version-conflict result at the persistence/application boundary. Do not retry blindly: reapplying Submit to changed declarations may require user confirmation.
Use execution retries for known transient SQL faults only when the whole operation can be replayed safely and the transaction strategy is correctly configured. Idempotency and deterministic domain logic help, but external effects inside the retry boundary remain dangerous.
21. Architecture tests and dependency evidence
A diagram can drift. Add tests that inspect assembly references/namespaces according to the chosen rules:
[Fact]
public void Domain_DoesNotReference_OuterLayers()
{
var result = Types.InAssembly(typeof(LoanApplication).Assembly)
.ShouldNot()
.HaveDependencyOnAny(
"Microsoft.EntityFrameworkCore",
"Microsoft.AspNetCore",
"Infrastructure",
"Api")
.GetResult();
Assert.True(result.IsSuccessful, result.ToString());
}
The exact architecture-test library syntax varies. More important is selecting rules that correspond to real boundaries rather than folder naming aesthetics.
Other useful checks:
- API does not expose Infrastructure entity types;
- Domain has no framework/persistence reference;
- feature handlers do not depend on presentation DTOs;
- only Infrastructure implements persistence ports;
- integration event contracts live in the agreed boundary.
22. Testing by layer without mocking the architecture
Domain tests
Construct LoanApplication and call Submit. Prove valid transition, already-submitted behaviour, missing/current declaration versions and emitted domain fact. No mocking framework or database is needed.
Application tests
Test handler orchestration with focused fakes where useful, but do not assert every method call. Prove unauthorised actor cannot submit, a version conflict avoids mutation, idempotent replay returns the original result and the unit of work is invoked only for a state change.
Infrastructure integration tests
Use real SQL Server semantics (container/test database) for EF mappings, constraints, optimistic concurrency, transactions, outbox and query SQL. An in-memory provider cannot prove relational translation, locking or SQL Server types.
API/host tests
Run the ASP.NET Core pipeline. Prove authentication, routing, validation/problem details, policy, status mapping and cancellation. Override external dependencies deliberately, not the component under test.
End-to-end tests
Keep a few high-value journeys: submit a real draft, replay the same request, submit from two versions, lose a simulated response and observe downstream outbox processing.
Junior: If the domain has perfect unit tests, do we need database tests?>
Senior: Yes. Domain tests prove rules in memory. Database tests prove mapping, concurrency and transaction assumptions. They support different claims.
23. Organise by feature as well as layer
A solution can respect dependency direction without forcing developers to navigate giant global folders:
src/
Lending.Domain/
Applications/
LoanApplication.cs
LoanApplicationStatus.cs
Events/
Lending.Application/
Applications/
Submit/
SubmitLoanApplicationCommand.cs
SubmitLoanApplicationHandler.cs
SubmitLoanApplicationValidator.cs
GetReview/
GetApplicationReviewQuery.cs
ApplicationReviewModel.cs
Lending.Infrastructure/
Persistence/
Configurations/
Repositories/
Outbox/
Lending.Api/
Endpoints/
LoanApplications/
Layers govern dependency direction; vertical slices improve locality. They are not mutually exclusive.
Do not create separate class-library projects for every conceptual folder. Project boundaries add build/dependency control but also overhead. A modular monolith may use one or several projects depending on team/solution scale. Namespaces and architecture tests can enforce some boundaries cheaply.
24. Security and privacy across the layers
Presentation authenticates and parses the request. Application authorises the use case/resource. Domain protects business invariants. Infrastructure applies least-privilege data access and safe persistence. Each layer contributes; no one layer “owns all security.”
The current actor abstraction should expose only trusted, required claims—not the full mutable HTTP object. Background message handlers have workload identity and recorded actor context, not an active HTTP user.
Avoid logging command objects through a generic behaviour because commands may contain personal/financial data. Use command-name, request ID, safe resource ID, duration and outcome category. Apply explicit redaction.
Encrypt secrets outside configuration files and rotate. Use separate SQL credentials/permissions by application responsibility. A clean repository interface does not prevent an overprivileged connection string.
Threat-model ID substitution, replay, stale version, declaration tampering, tenant crossover, mass assignment and error disclosure. Generate security tests at the API and handler/database boundaries.
25. Observability without breaking dependency direction
Application can express observability through standard abstractions or decorators without depending on a vendor SDK. Infrastructure/configuration chooses OpenTelemetry/exporters and log providers.
Useful command telemetry:
- command name and duration;
- outcome category;
- validation/conflict/forbidden counts;
- SQL dependency duration and concurrency failure;
- outbox age and publication attempts;
- end-to-end submission age;
- idempotent replay count.
Create a runbook that traces request ID → idempotency record → loan application version/status → outbox message → consumer result. Clean code that operations cannot reconcile is incomplete.
26. Diagnose three architecture incidents
Incident one: duplicate downstream decision requests
The outbox publisher sends, crashes before marking dispatched and sends again. The receiving service creates two workflows.
Do not attempt to make the EF repository call the broker “exactly once.” Keep outbox at-least-once delivery and make the consumer idempotent using message ID/inbox in its local transaction. Add a crash-window integration test.
Incident two: list endpoint times out
The query handler calls a generic repository GetAllIncluding and maps a full aggregate graph in memory. Inspect generated SQL, rows and allocations. Replace with a server-side projection, bounded pagination and appropriate index. Clean Architecture does not justify inefficient abstraction.
Incident three: applicant submits someone else's draft
The endpoint checks [Authorize], then the handler loads by body ID without resource authorisation. Authentication is not ownership. Authorise against actor and aggregate/tenant, avoid trusting client applicant ID, and add direct API/handler cross-user tests. Treat exposure as a security incident.
27. When to simplify or stop
Clean Architecture is a means. For a small internal CRUD tool, a well-structured ASP.NET Core project with feature folders and EF Core may be enough. Every command does not need an interface, mediator, validator, repository and domain event.
Signs of ceremony:
- handlers only call matching repository methods;
- mapping duplicates identical shapes across five layers;
- interfaces have one implementation and no boundary value;
- developers cannot follow one request without opening twenty files;
- generic pipelines hide transaction/security behaviour;
- project references matter more in review than business correctness.
Signs stronger boundaries are justified:
- complex domain invariants and workflow;
- multiple entry points (API, messages, jobs);
- long-lived product/team ownership;
- volatile infrastructure/integrations;
- regulatory/audit needs;
- independent testing of business policy;
- modular extraction or deployment pressure.
Junior: How do I know if an interface is useful?>
Senior: Name the boundary and the reason for substitution or dependency inversion. “Because Clean Architecture” is not enough.
28. A migration path from a controller-heavy application
Do not rewrite everything.
- Select one painful use case.
- Characterise current behaviour with API/integration tests.
- Extract explicit request/result contracts.
- Move business transition into a domain type or policy.
- Move orchestration into a handler/application service.
- Put EF querying/persistence behind the chosen seam.
- Add authorisation, concurrency and idempotency evidence.
- Keep the endpoint as translation only.
- Measure delivery/defect impact before repeating.
Refactor feature by feature. A partially improved system with explicit seams is safer than a big-bang architecture rewrite whose business behaviour cannot be compared.
29. Record architecture decisions and their consequences
Clean Architecture discussions become unproductive when every choice is called a rule. Record the decisions specific to the solution.
Decision: Application references a read-context abstraction
Context: Most read handlers need EF Core projections and pagination. Creating one repository method per screen adds pass-through code.
Choice: Application query handlers depend on IReadDbContext exposing selected IQueryable sets. Infrastructure implements it with EF Core.
Consequences: Queries remain concise and testable against SQL Server, but Application knows LINQ/query abstractions and handlers can construct inefficient SQL. Code review and integration/performance tests govern that risk.
Alternative: Put every query implementation in Infrastructure behind a feature-specific query interface. This isolates EF fully but creates more ports and can separate query intent from its slice.
Neither answer is universally cleaner. The record makes the trade visible.
Decision: repositories only for aggregate writes
Context: Command handlers need domain-oriented loading with correct related state and concurrency.
Choice: Use focused repositories for write aggregates; use projections for reads.
Consequences: Repository APIs remain small and meaningful. Developers must understand two persistence styles. This is acceptable because reads and writes have different goals.
Decision: domain events become outbox messages at persistence
Context: Submission must reliably notify downstream decisioning.
Choice: Collect domain events, map approved ones to versioned integration events, and persist them with aggregate changes.
Consequences: At-least-once publication and idempotent consumers are required. Domain events must not contain infrastructure objects. Outbox retention, ordering and operational ownership become system concerns.
Every ADR should state what evidence would cause reconsideration. If read handlers repeatedly need provider-specific features that the abstraction fights, move them. If the domain remains trivial, reduce the aggregate/repository ceremony. Architecture should be reviewable, not sacred.
30. Bounded contexts before project layers
One enterprise solution may contain Applications, Decisioning, Documents and Payments. A single global Domain project can become a shared model where every term means several things.
Prefer bounded module contexts:
Modules/
Applications/
Domain/
Application/
Infrastructure/
Api/
Decisioning/
Domain/
Application/
Infrastructure/
Api/
This is a conceptual tree; project count should match solution needs. The important rule is that Decisioning does not manipulate the Applications aggregate or tables directly. It receives a contract/fact and owns its model.
Shared Kernel should be tiny and consciously governed: perhaps strongly defined currency semantics or a base result primitive. Do not put every entity, DTO and utility into Shared. Shared code couples release and meaning.
Translate between contexts through an anti-corruption layer when external language differs. If an identity provider calls a user “subject” and the lending domain calls them “applicant,” map explicitly rather than infecting domain names with provider terminology.
Junior: Should every bounded context become a microservice?>
Senior: No. A bounded context is a model/ownership boundary. It can live as a module in one deployable application. Extract only when independent deployment, scale, team or data ownership justifies distributed cost.Enforce module data ownership even in one SQL Server: separate schemas, context mappings and permissions where appropriate. Cross-module reporting can use replicated projections or approved views, not arbitrary writes.
31. Model domain services and policies carefully
Not every business rule naturally belongs to one entity. A policy comparing a loan request with product limits can be a domain service:
public sealed class LoanEligibilityPolicy
{
public EligibilityResult Evaluate(
RequestedLoan loan,
ProductTerms product,
VerifiedAffordability affordability)
{
if (loan.Amount > product.MaximumAmount)
return EligibilityResult.Rejected("amount_above_product_maximum");
if (affordability.MonthlyDisposableIncome < product.MinimumBuffer)
return EligibilityResult.Referred("affordability_buffer");
return EligibilityResult.Eligible();
}
}
It remains pure and speaks domain language. An application service loads terms/affordability through ports, then invokes the policy. Do not call an external credit API inside the policy and pretend it is domain purity.
Specification objects can express reusable domain predicates, but avoid building a generic expression-tree framework unless multiple consumers truly need composable translation. A named method such as CanBeSubmittedBy(ApplicantId) may be clearer.
Policies can require version identity for audit. Store which policy/rule-set produced an outcome. Do not hide frequently changing regulated rules inside a pipeline behaviour or controller.
32. Migrations and rolling deployments cross every layer
A new feature may add SubmissionChannel to the domain and database. A clean entity change still fails if old application instances cannot use the new schema.
Use expand-and-contract:
- Add a nullable/default-compatible column through a reviewed migration.
- Deploy code that reads old null and writes the new value.
- Backfill in bounded, restartable batches.
- Verify null count, distribution and performance.
- Deploy code that requires the value.
- Add the
NOT NULLconstraint when every writer is compatible. - Remove transitional logic later.
Large index creation or backfill can lock/block and grow the transaction log. Rehearse with representative size, use platform-supported online/resumable options where licensed/available and monitor replication/availability groups.
Rollback is often application rollback plus forward-compatible schema, not reversing a destructive migration. Never drop a column in the same release that stops using it.
Integration event changes also require compatibility. Add fields, support old/new consumer versions and measure adoption before removing. The database and broker contracts are part of deployment architecture.
33. Background workers are presentation/adapters too
Clean Architecture is often drawn with an API as the outer entry point, but a message consumer or scheduled worker also invokes use cases.
public sealed class DecisionRecordedConsumer(
IServiceScopeFactory scopeFactory,
ILogger<DecisionRecordedConsumer> logger)
{
public async Task Handle(
LoanDecisionRecordedV1 message,
CancellationToken cancellationToken)
{
await using var scope = scopeFactory.CreateAsyncScope();
var handler = scope.ServiceProvider
.GetRequiredService<IIntegrationMessageHandler<LoanDecisionRecordedV1>>();
await handler.Handle(message, cancellationToken);
}
}
The adapter deserialises/version-validates, establishes correlation/workload context and invokes Application. Application does not depend on the broker SDK.
Message acknowledgement must follow durable handling. If processing commits but acknowledgement is lost, redelivery occurs; inbox/idempotency handles it. Dead-lettering needs reason, alert, inspection and replay policy. Do not catch/log/ack every exception or the message is lost.
Background workers create DI scopes per unit of work and use short-lived DbContext. They honour shutdown: stop fetching, finish/abandon safely and let redelivery occur. The same domain/application use case can be invoked from API or message, but actor/authorisation semantics may differ and must be explicit.
34. Cache outside the domain truth
Caching is infrastructure/application-query policy. An entity should not know Redis. Cache read models or stable reference data, not mutable aggregate instances with tracking state.
Cache keys include tenant, query parameters, contract version and permission-relevant context. A missing tenant key is a security defect. Avoid caching personalised responses where invalidation/security cannot be guaranteed.
For submission, correctness comes from SQL Server and concurrency/idempotency—not a cache lock. After commit, invalidate/update projections. Event-driven invalidation is eventually consistent, so the UI/API should understand freshness.
Cache-aside stampede protection, TTL and stale-on-error are product decisions. Serving stale product terms during submission may be unacceptable even when stale list content is fine. Measure hit rate, load time and staleness; do not cache by reflex.
35. Map errors once, retain meaning
Define error categories across boundaries:
| Application meaning | HTTP | Message consumer | Telemetry |
|---|---|---|---|
| Validation | 400/422 by API policy | Reject/dead-letter contract defect | validation |
| Not authenticated | 401 | Invalid workload identity | unauthenticated |
| Forbidden | 403/possibly concealed 404 | Reject and audit | forbidden |
| Not found | 404 | Ignore/reconcile by contract | not_found |
| Version conflict | 409/412 | Retry/reconcile by message rules | conflict |
| Transient dependency | 503/problem | Retry with backoff | dependency_unavailable |
| Unexpected defect | 500 | Retry then intervention | unexpected |
Preserve causality in logs/traces. Catch at a boundary that can recover or translate. Logging the same exception in repository, handler, pipeline and middleware creates noise.
Use result types for expected alternatives and exceptions for unexpected inability to fulfil a method according to the solution convention. Consistency matters more than forcing one style everywhere.
36. Review a deliberately overengineered endpoint
Suppose GET /api/countries passes through controller → query → handler → ICountryRepository → CountryRepository → EF, with four identical DTOs and AutoMapper. Countries are read-only reference data.
Ask what each boundary protects. If the handler only forwards, the repository only calls ToListAsync, and shapes are identical, a feature endpoint/query service can project directly through a read context. Keep authorisation, caching and tests if needed. Remove ceremony, not safeguards.
Contrast submission: it has ownership, declarations, transition, concurrency, idempotency, outbox and audit. Its explicit handler/domain/repository boundaries protect meaningful change.
This comparison teaches proportional architecture. The same solution can use a direct vertical query for simple reads and rich domain modelling for complex writes. Consistency does not require identical class counts.
37. Production readiness review
Before launching submission, demonstrate:
- valid submission and authoritative receipt;
- unauthorised and cross-tenant rejection;
- stale-version conflict preserving the applicant's draft;
- same request ID returning one result;
- same request ID with different body being rejected;
- SQL commit plus outbox atomicity;
- publisher duplicate plus consumer idempotency;
- instance shutdown during handler and publisher work;
- mixed application/schema versions;
- trace/runbook reconciliation from request to downstream decision.
The architecture is ready when the team can operate failure, not when projects compile in the desired direction.
38. Senior architecture review checklist
- Is the business invariant written and owned by the domain/policy?
- Does the use-case contract express intent without HTTP/EF concerns?
- Is identity trusted rather than copied from request data?
- Is resource authorisation enforced in the application path?
- Does one command have a clear local transaction boundary?
- Are version conflict and idempotency deliberate?
- Are external side effects kept outside SQL locks and made reliable?
- Are domain and integration events distinguished?
- Does outbox publication assume duplicate delivery?
- Do read queries project and paginate rather than load aggregates?
- Are EF mapping/concurrency assumptions tested on SQL Server?
- Are pipeline behaviours ordered and restricted to suitable requests?
- Are logs/metrics free of sensitive command bodies?
- Can old/new deployments coexist with the schema/contracts?
- Is each abstraction protecting an identified boundary?
39. Mentoring exercises
Exercise one: domain transition
Implement Submit using explicit outcomes. Test current declaration versions, invalid status and already-submitted replay. Keep EF and mocks out of the tests.
Exercise two: concurrent submission
Load one draft through two SQL Server contexts. Submit both with the same version. Prove one commits and the other becomes a meaningful conflict. Then replay the winner's request ID and return the original receipt.
Exercise three: outbox failure window
Persist submission/outbox, publish, then simulate a crash before marking dispatched. Restart and prove a duplicate message produces one downstream business effect.
Exercise four: query code review
Replace a generic repository list with a projection. Compare generated SQL, rows, allocations and execution plan. Document why the read-side coupling choice is acceptable.
Exercise five: dependency test
Add an accidental EF Core reference to Domain and an API DTO reference to Application. Make architecture tests fail with helpful output, then remove the violations.
Exercise six: simplify
Take a trivial lookup endpoint with command/handler/validator/repository layers. Propose a simpler vertical slice that preserves security, tests and query efficiency. Explain which abstractions you removed and which risk did not increase.
40. Continue the learning path
Read Pragmatic TDD with C# and .NET for boundary-aligned evidence, EF Core Best Practices and SQL Server and Relational Databases for persistence depth, Anatomy of an ASP.NET Core Web Application for the transport pipeline, and Microservices with .NET for outbox, contracts and distributed consistency. The Web Security and HTTP guides strengthen the outer-boundary design.
Clean Architecture connects those concerns through dependency direction. It does not replace their specialised knowledge.
Teach the submission slice back to me
Without opening the solution, draw the path from HTTP request to downstream integration event. For each arrow, name the contract, owner, transaction and failure behaviour.
Your explanation should cover:
- why the API request is not reused as the application command;
- how trusted actor identity reaches resource authorisation;
- why transition/declaration rules belong with the domain;
- why the handler coordinates rather than owning those rules;
- how expected version and SQL concurrency work together;
- how request idempotency survives concurrent duplicates;
- why aggregate and outbox commit locally together;
- why the message can still be delivered twice;
- why a read list bypasses aggregate loading;
- which tests prove each guarantee.
Finally, change one requirement: applicants may withdraw after submission until decisioning begins. Trace that change through domain state, command/result, authorisation, concurrency, idempotency, integration event, read models, API status mapping, migration and tests. The domain should change for business meaning; EF and HTTP should adapt around it. If the change requires editing every layer's duplicate status logic, dependency direction alone has not produced good modelling.
Definition of done
The guide's production slice is complete when another developer can implement and operate it without relying on hidden conventions. The solution must prove inward source dependencies, domain transitions, resource authorisation, relational mappings, optimistic concurrency, idempotency, outbox/inbox behaviour, efficient projections and compatible rollout. Telemetry and runbooks must reconcile an uncertain submission without exposing applicant data.
The architecture decision record must also name where the design intentionally compromises purity—for example, Application query handlers using EF abstractions—and why the productivity benefit outweighs coupling. Clean Architecture is trustworthy when exceptions are explicit and reviewed, not when teams pretend none exist.
That is the senior standard: protect business meaning, expose operational reality, and spend abstraction only where it buys understandable change.
Keep one final measure after release: how easily the team can change the submission policy without coordinated edits to unrelated adapters. Compare lead time, defect rate, query performance and incident recovery with the baseline that justified the architecture. If boundaries slow every harmless change or hide production behaviour, refine them. If they let the team alter rules confidently while preserving contracts and evidence, they are earning their cost. Architecture is a continuing hypothesis about change, not a trophy awarded when the initial solution structure is created.
Review that hypothesis after major features and incidents. Preserve decisions that still protect the domain, retire abstractions whose reason disappeared, and leave the solution clearer for the next developer than the template you originally copied. Clarity is the lasting deliverable.
41. Interview-ready Clean Architecture answer
If someone asks you, “What is Clean Architecture?”, say:
“Clean Architecture is an approach where the core business logic is kept independent from technical details such as databases, frameworks, UI, external APIs and infrastructure. The Domain layer contains business entities and rules. The Application layer contains use cases, commands, queries, validation and interfaces. The Infrastructure layer implements technical concerns like EF Core, SQL Server, repositories, email, storage and messaging. The API layer handles HTTP requests and maps them to application use cases. The dependency direction points inward, so outer layers depend on inner layers, but the Domain does not depend on EF Core, ASP.NET Core or SQL Server.”
If they ask, “Where does CQRS fit?”, say:
“CQRS fits in the Application layer. Commands represent operations that change state, and queries represent read operations. Command handlers usually load domain entities, enforce business rules and save changes. Query handlers usually project data into DTOs optimized for screens or reports. This separation helps because writes and reads often have different performance and modelling needs.”
If they ask, “Where does EF Core belong?”, say:
“EF Core belongs in Infrastructure. The DbContext, entity configurations, migrations and repository implementations are technical details. The Application layer may depend on abstractions such as repositories or an application DbContext interface, but it should not depend directly on infrastructure implementation.”
If they ask, “Do you always use repositories with EF Core?”, say:
“No. EF Core already provides repository and unit-of-work-like behaviour through DbSet and DbContext. I use repositories when they express domain-oriented persistence needs, especially on the command side. For read-side CQRS queries, I often prefer direct projection using an application DbContext abstraction because it keeps queries efficient and clear.”
Final mentoring summary
Clean Architecture is not about folder names. It is about responsibility.
The Domain layer protects business rules. The Application layer coordinates use cases. The Infrastructure layer handles technical details. The API layer exposes the system to the outside world.
CQRS helps separate writes from reads.
Commands are about intent and business state change. Queries are about data shape and performance.
EF Core is a tool, not your architecture. SQL Server is a storage engine, not your business model. Controllers are transport endpoints, not workflow engines. Repositories are useful when they protect domain persistence, but harmful when they hide powerful querying behind weak generic methods.
The senior mindset is this:
Keep business rules close to the business model.
Keep use cases clear and testable.
Keep infrastructure replaceable.
Keep controllers thin.
Keep read models efficient.
Keep SQL intentional.
Avoid overengineering simple problems.
Avoid underengineering serious systems.
That is the balance.
A junior developer asks, “Where should I put this class?”
A senior developer asks, “What responsibility does this code have, what should it depend on, and what future change am I protecting against?”
That is Clean Architecture.
