I have started building a side project called Strata, a multi-tenant document collaboration platform on .NET, ASP.NET Core, and Azure.
My first real decision was a small one: how does the application layer talk to the database?
This post expands on ADR 0001 in the repository.
Clean Architecture
Almost every Clean Architecture tutorial in .NET emphasises defining repository interfaces in the application layer:
public interface IDocumentRepository
{
Task<Document?> GetByIdAsync(Guid id, CancellationToken cancellationToken);
Task AddAsync(Document document, CancellationToken cancellationToken);
}
Infrastructure implements this interface with EF Core, whereas the application layer stays free of EF Core types.
But what exactly is this abstraction protecting me from?
What the rule actually asks for
Clean Architecture is about keeping dependencies pointing inward. In other words, your business logic should not depend directly on infrastructure such as databases or frameworks.
Repository interfaces are not a requirement of Clean Architecture. They are one of many ways to implement this separation. Other approaches, such as defining abstractions around your database access, can also achieve the same goal.
The distinction is important because EF Core already provides abstractions that cover much of what a custom repository would implement. DbSet<T> provides repository-like functionality for querying and persisting entities, while DbContext serves as the unit of work by tracking changes and coordinating persistence.
Where the Repository Abstraction Breaks Down
The main problem with a repository abstraction is query composition.
A repository method has to return something. Return a materialised List<Document> and the query is finished at the interface boundary. Every filter, sort, and page must be decided inside the repository. So the interface grows a method per query shape:
Task<List<Document>> GetByFolderAsync(...);
Task<List<Document>> GetByFolderPagedAsync(...);
Task<List<Document>> GetByFolderPagedWithSharesAsync(...);
Return IQueryable<Document> instead and composition survives — but an IQueryable is a live handle on EF Core’s provider. Passing one through an interface whose job is hiding EF Core does not hide EF Core. It leaks the thing it claims to abstract, just less visibly.
What Strata does instead
Strata.Application declares a thin interface over the parts of the context it uses:
public interface IApplicationDbContext
{
DbSet<Document> Documents { get; }
DbSet<DocumentShare> DocumentShares { get; }
DbSet<Folder> Folders { get; }
Task<int> SaveChangesAsync(CancellationToken cancellationToken);
}
AppDbContext implements this interface in Infrastructure. Application can therefore compose EF Core queries directly while still depending on an abstraction rather than the Infrastructure layer.
var documents = await _context.Documents
.Where(d => d.FolderId == folderId)
.Include(d => d.Shares)
.OrderByDescending(d => d.CreatedAt)
.ToListAsync(cancellationToken);
The trade-off is that Application still knows about EF Core abstractions such as DbSet<T>. The goal is not zero EF Core awareness, but keeping the concrete database implementation and Infrastructure dependencies outside the Application layer.
Architecture tests enforce these dependency boundaries so they cannot be accidentally violated as the project grows.