Infrastructure Layer: Boundaries, Best Practices, and Pitfalls
In today’s article, we’ll take a deep dive into the Infrastructure Layer — what it really is, why it exists, and why it’s often misunderstood. We’ll walk through how infrastructure evolved from classic N-layered systems to Hexagonal, Onion, and Clean Architecture, clarify where interfaces actually belong, and explore common traps like smart repositories, domain-leaking utilities, and framework-driven design.
Stick with me — this article is written to unblock juniors, challenge seniors, and hopefully save everyone a few future refactors 🙃.
The Infrastructure Layer Explained
The infrastructure layer is the part of an application that deals with technical and external concerns.
It is often considered the most boring layer, yet many developers — including myself — actually enjoy building technical utilities 🙃.
The responsibility of this layer is to implement interactions with the outside world: databases, message brokers, file systems, external services, caching, logging, and other frameworks or tools the system depends on.
By isolating these details, the infrastructure layer keeps the core of the system clean, testable, and independent from specific technologies, allowing implementations to be replaced or evolved with minimal impact on business logic.
Where should you put Repository Interfaces?
It took a long time for developers to understand how to properly define the infrastructure layer.
The question of where to place infrastructure interfaces and their implementations is not limited to repositories — it applies to all infrastructure concerns. But the database is the simplest concern to understand, therefore it will be used as an example😅.
Let’s briefly review the history to see how we got here, where these elements belong, and why we define them the way we do.
— N-Layered
When the N-layered architecture was introduced, the infrastructure layer was very last in the line.
This made sense at the time: applications were mostly simple CRUD systems with little business logic, so allowing the business layer to directly access and operate on database objects was quite typical.
Additionally, infrastructure was simple back then. Typically, it was only a data access layer.
Let’s have a closer look at the relationship between Business Logic and Data Access layers.
In N-layered architecture, both interfaces and implementations reside in the Infrastructure Layer.
While straightforward, trying to adapt this approach to modern tech will quickly lead to the following issues:
- reduced modularity — business logic can’t stand alone without infrastructure assemblies
- infrastructure leakage— infrastructure concepts (DB models, ORM details, HTTP clients) bleed into higher layers
- implementation-driven interfaces — when an interface has only one implementation, it tends to mirror that implementation, leaking implementation details
- fragile clients — replacing an implementation requires modifying/updating the NuGet’s version/redeploying all layers that depend on it, and not just the composition root
- uncontrolled dependencies — since interfaces are
public, other layers can use them, bypassing intended architectural boundaries
— Hexagon (Ports and Adapters)
Ports and Adapters architecture applies the Dependency Inversion Principle, reversing the dependency flow so infrastructure depends on business logic, not the other way around.
This becomes quite handy as applications become more complex and we start using more infrastructure like email services, file storage, and so on.
In the Ports and Adapters architecture, infrastructure interfaces are defined alongside the Business Logic, while their implementations live in the Infrastructure Layer.
This makes stuff better and fixes issues from above, as Domain is now a central component of the system that does not have any dependencies on the Infrastructure.
However, in this architecture, the business layer often ends up containing interfaces it doesn’t really need. Ideally, the business layer should only include concepts that are meaningful to domain experts. Exposing abstractions like ICache, IMessageQueue, or IHttpClient introduces foreign infrastructure concerns, cluttering the layer.
Another problem is that your infrastructure may reference business-logic services and use them in unintended ways.
Let’s see what we have in the next architecture.
— Onion
Onion Architecture introduces a new layer — the Application Layer — responsible for orchestrating use cases, managing application behaviour like thread coordination, sessions, transaction boundaries, etc
For our infrastructure, this means that interfaces can be moved into the Business or Application layers, while their implementations stay in the Infrastructure layer.
Repository interfaces remain in the domain layer, since domain services frequently need to access data from the database.
— Clean Architecture
Clean Architecture implies that you don’t have domain services and anemic models, but rich entities with both data and behaviour.
Since we don't have services in our domain layer, all infrastructure interfaces are moved to the Application layer.
Even if you define services in the business layer, data should be fetched in the application layer and all required records passed to the domain service, emphasizing the combination of purity and encapsulation.
class UserService : IUserService
{
public void IsEmailFree(
IReadOnlyList<User> users, // no DB dependency
string email)
{
var isValid = email.Contains("@");
if (!isValid) throw new InvalidArgumentException();
var isAlreadyInUse = users.Any(u => u.Email == email);
return !isAlreadyInUse;
}
}Writing Infrastructure Code
Here are a few guidelines that can help you write better infrastructure code:
Dependency Rules
From the section above, you should take away the following dependency rule: Infrastructure should depend on Application/Domain, never the other way around.
High-level modules (Domain) should not depend on low-level modules (Infrastructure); instead, both depend on abstractions, ensuring that business logic remains unaware of concrete implementations.
This separation preserves inversion of control, prevents dependency cycles, and allows infrastructure components to be replaced or evolved without impacting the core of the system.
Should You Split Infrastructure?
As already discussed, interfaces should live in the application or domain layer, while infrastructure stays in its own project.
Additionally, infrastructure is often grouped into cohesive folders— persistence, messaging, integrations — each following screaming architecture, where the structure reflects the separate technologies it uses.
However, when infrastructure grows significantly, it can be practical to separate each implementation into its own project. The same applies to the interfaces.
Quite often, the data access layer is the first to be extracted into a separate project, as it is usually the largest and most complex part of the system.
In fact, you can extract each infrastructure per project. Having multiple small modules is what low coupling and high cohesion are about:
Low Coupling — refers to the degree of dependency between different components in a software system.
It is desirable to avoid dependencies for independent modules with minimal interactions.
High Cohesion — refers to the degree to which the elements within a module or component are related to each other.
Classes that share the same responsibilities should belong to one module.
Of course, it is up to you whether to keep all infrastructure in one project or to split it. The benefits of this setup are:
- smaller, focused projects are easier to understand, refactor, and update independently
- project boundaries are stronger than folder organization. While folders cannot prevent one infrastructure module from accessing another, separate projects make these dependencies explicit and easier to control
- higher-level layers never depend on concrete implementations
- you can replace a concrete implementation without modifying all dependent packages (open/closed principle) — only the composition root needs to change (the host responsible for composing dependencies)
- if your code lives in a separate NuGet, when split, implementations may be packaged separately to support multiple implementations (such as
AzureMessageBusorLoopbackMessageBus) while still keeping the interface in the Domain - if parts of infrastructure evolve at different rates (e.g., database vs messaging), separate projects allow independent versioning and deployment
- you are less likely to use infrastructure that you don’t need if they live in separate projects
As you can see, it largely depends on the complexity of your domain, your specific needs, preferences, and future plans.
Think about your code as if you are writing a Third-Party Library
As your infrastructure depends on the Domain, it is natural to pass domain types across that boundary. You may feel tempted to simplify your infrastructure code and start using entities or domain terminology more freely.
// Infrastructure layer
public class CacheService : ICacheService
{
// ⚠️ knows about domain entities
public User? GetUserById(UserId userId) { . . . }
public void SetUser(User user) { . . . }
}While it is inevitable for stuff like repositories, but try designing your code as if you are writing a third-party library.
// Infrastructure layer
public class CacheService : ICacheService
{
// ✅ сan be packaged and reused anywhere
public Task<T?> GetAsync<T>(string key) { . . . }
public Task SetAsync<T>(string key, T value, TimeSpan ttl) { . . . }
}By doing so, your utilities (e.g. EmailService, EventBus, Cache) become independent, loosely coupled, reusable, domain-agnostic components, ideally packaged in a separate library.
Make your Infrastructure Domain Agnostic
Even with clear layering, mistakes can still happen — such as injecting domain services, rules, or constraints into infrastructure utilities.
Even though, these issues are usually junior-level mistakes and are easy to spot and fix during pull request reviews, the project references technically allow this.
Get iamprovidence’s stories in your inbox
Join Medium for free to get updates from this writer.
However, this should be clear that infrastructure should remain domain-agnostic.
Avoid Making Infrastructure “Smart”
You may not inject domain rules into the Infrastructure directly, but another common mistake is giving infrastructure components responsibilities they don’t belong to — such as embedding business logic in repositories or making infrastructure “smart.”
interface IOrderRepository
{
OrderStatistic CalculateOrderStatistic(int orderId); // implement business layer calculation at the SQL level
void UpdateStatus(int id, OrderStatus newStatus); // update record directly, bypassing business invariants
}Repositories, message handlers, and other technical implementations should only perform technical tasks: fetching, saving, sending, or logging data.
Adding business rules or decision-making to these layers breaks the separation of concerns, creates hidden dependencies, and makes the system harder to test and maintain.
Keep infrastructure simple, predictable, and focused on how to do things, not what the system should do.
Persistence Ignorance Principle
The persistence ignorance principle implies that domain models should not know or care about how data is stored, retrieved, or persisted.
This aligns with how references are meant to work: the domain defines the rules, and infrastructure follows them.
In practice, however, this principle does not always work 😅.
Persistence, as one of the most crucial concerns in a system, will indirectly affect your domain entities. The choice of ORM — EF, Dapper, Hibernate — shapes how you think and define your entities and relationships between them. This is usually not a problem as long as the influence is minimal and controlled.
// Domain model
public class Order
{
public Guid Id { get; private set; }
// ⚠️ EF navigation properties
// ⚠️ virtual used for EF
public virtual ICollection<OrderItem> Items { get; set; } = new List<OrderItem>();
// ⚠️ EF parameterless constructor
protected Order() { }
public Order(Guid id)
{
Id = id;
}
public decimal Total => Items.Sum(i => i.Price * i.Quantity);
}Your goal is to design your domain entities first, based on business meaning, rather than shaping them to make infrastructure easier to implement.
Try also applying this principle beyond persistence to other infrastructure concerns.
Avoid Direct Infrastructure Calls From the UI
In case your infrastructure’s interfaces are not internal and defined outside the Domain project, or you simply define layers in a different order, it becomes possible to call your infrastructure from the UI.
This is an issue because it breaks the intended dependency direction. The Presentation should only interact with the Application layer, never with the Infrastructure directly.
Similarly to the previous issues, it can be fixed in a few ways:
- establishing clear conventions and documenting allowed practices helps developers understand how layers should interact
- perform code reviews to enforce architecture in practice
- enforce dependency rules,
public/internalboundaries, and layer separation with architecture tests - split components and define dependency in a way that your supreme architecture does not allow wrong usage
- prevent reference propagations with project configuration
<!-- Application Layer -->
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="Infrastructure.Interfaces.csproj">
<!-- Prevent Infrastructure from being implicitly accessed by other layers -->
<ExcludeAssets>compile</ExcludeAssets>
</ProjectReference>
</ItemGroup>
</Project>- make interfaces internal and explicitly select trusted projects with
[InternalsVisibleTo]attribute
// Infrastructure.Interfaces project
// Allow only the Application layer (or specific test projects) to access internal members
[assembly: InternalsVisibleTo("Application")]
[assembly: InternalsVisibleTo("Infrastructure.Tests")]
. . .
// Infrastructure.Implementation project
[assembly: InternalsVisibleTo("Presentation")] // Composition RootUltimately, clean architecture is not enforced by structure alone; it requires discipline, reviews, and deliberate boundary protection.
Testing Infrastructure Code
Of course, you can mock infrastructure in unit tests to isolate specific behaviors and focus on verifying domain rules.
However, if your goal is to test Infrastructure code, you should primarily rely on integration tests, since they verify interactions with external systems like databases, message brokers, or APIs.
Dockerize Your Infrastructure
Installing and configuring infrastructure components (SQL Server, Redis instance, etc) manually can be time-consuming, error-prone, and inconsistent across machines.
When developing locally, it’s better to run them in Docker containers rather than installing them directly on your machine to mirror production, avoid polluting your system with multiple versions, and make it easy to reset or reproduce issues.
What Else do You Count as Infrastructure?
For the final, let’s discuss what else counts as Infrastructure, as with some components, teams often struggle to decide where they truly belong.
Let’s briefly review common elements that are considered infrastructure details.
— Database
While it becomes more obvious over time that the database is nothing more than a tool, many developers still reject this and treat it as the main component of their system.
Even though it was like that in the past, technologies and requirements have shifted, but some developers’ brains have not 😬.
When the database is in the center, business logic, use cases, and sometimes even the UI become tightly coupled to a relational schema, allowing the database to dictate application behavior.
The database may be large and dominant, but it remains just a tool — used indirectly by business rules and isolated from the core logic.
Business logic doesn’t need to know about schemas, query languages, or storage details — they only need a set of methods to load data at the beginning of a use case, and to persist it at the end for future use.
— Frameworks
Your architecture should be framework-independent. That framework you use should not shape your system. That means frameworks are also nothing more than just an infrastructure tool.
Frameworks commonly promote inheriting from their base classes, embedding framework functionality into business objects, and maximizing coupling to the framework.
// Domain
public class User : IdentityUser<Guid> // ⚠️ framework dependent
{
public string Name { get; set; }
}Heavy frameworks should be wrapped to limit their impact; otherwise, when a newer, better framework emerges, you won’t be able to adopt it without rewriting the entire system.
// Infrastructure (ASP.NET)
public sealed class AspHostCurrentUser : ICurrentUser
{
. . .
public UserId GetCurrentUserId()
{
return _httpContextAccessor
.HttpContext
.User
.FindFirst(ClaimTypes.NameIdentifier);
}
}The only exception I would make is Entity Framework 😅 . However, avoid attribute-based configuration. Keep database mapping in the infrastructure layer with use of the Fluent API.
— Host / UI
The user interface, as the most volatile component, should be changed without affecting the rest of the system. It should be possible to expose the same system as a console, web app, desktop, or even pure API in a library.
How? To achieve that, you should treat your WebHost as another tool. And where do we place tools 😅? Yeap, your UI is just another infrastructure.
If so, given a diagram with Presentation and Infrastructure layers, which one should contain the code?
My advice is as follows:
- infrastructure code related to the UI, host configuration, presenters, and so on, can live in the Presentation layer (you can even create an Infrastructure folder in the host, as Microsoft did)
- if you need to reuse middlewares,
ActionResultor other host settings, add that to the Infrastructure layer
— Cloud
Cloud platforms like Azure, AWS, and Google Cloud provide many useful tools — message brokers, databases, blob storages, and more. It’s common to hide these behind interfaces, keeping business logic abstracted while placing the concrete behavior in the infrastructure layer.
However, a cloud provider can also play the role of the presentation layer. For example, Azure Functions can act as the host that starts and triggers your application. In this case, the cloud runtime is just another delivery mechanism.
At the same time, you must prevent cloud-specific details from leaking into the core of your system — for example, using cloud specific classes to send messages directly to a cloud queue from business logic.
public static class PlaceOrderFunction
{
[FunctionName("PlaceOrder")]
public static async Task Run(
[HttpTrigger(AuthorizationLevel.Function, "post")]
HttpRequest req,
[ServiceBus(queueName: "orders-queue", Connection = "ServiceBusConnection")]
IAsyncCollector<OrderPlacedEvent> output)
{
// ⚠️ application logic mixed with infrastructure concerns
var userId = new UserId(Guid.Parse(req.HttpContext.User.Value)); // ⚠️
var order = new Order(OrderId.New(), userId);
dbContext.Orders.Add(order);
await dbContext.SaveChangesAsync();
await output.AddAsync(new OrderPlacedEvent(order.Id, userId)); // ⚠️
}
}Instead, define application-level abstractions and let infrastructure handle the cloud integration.
// Presentation Layer
public class PlaceOrderFunction
{
private readonly PlaceOrderUseCase _useCase;
public PlaceOrderFunction(PlaceOrderUseCase useCase)
{
_useCase = useCase;
}
[FunctionName("PlaceOrder")]
public async Task Run(
[HttpTrigger(AuthorizationLevel.Function, "post")]
HttpRequest req
// ✅ do not rely on Azure Functions–specific bindings in your core logic
/*IAsyncCollector<OrderPlacedEvent> output*/)
{
await _useCase.ExecuteAsync();
}
}
------------------------------------------------------------------------------------
// Application Layer
public class PlaceOrderUseCase
{
private readonly ICurrentUserService _currentUserService;
private readonly IDbContext _dbContext;
private readonly IMessageBus _messageBus;
public PlaceOrderUseCase(
ICurrentUserService currentUserService
IDbContext dbContext,
IMessageBus messageBus)
{
_currentUserService = currentUserService;
_dbContext = dbContext;
_messageBus = messageBus;
}
public async Task ExecuteAsync()
{
// ✅ application logic uses abstractions
var userId = _currentUserService.GetCurrentUserId(); // ✅
var order = new Order(OrderId.New(), userId);
_dbContext.Orders.Add(order);
await _dbContext.SaveChangesAsync();
await _messageBus.AddAsync(new OrderPlacedEvent(order.Id, userId)); // ✅
}
}From an architectural perspective, cloud-specific SDKs, configurations, and service integrations belong to the infrastructure layer and should be kept behind clear boundaries.
Treating the cloud as infrastructure preserves portability, keeps business logic clean, and prevents cloud providers from leaking into the core of your system.
— Utilities
Sometimes you write code that extends your runtime or platform like extension methods, missing data structures, or helper utilities.
Although it may seem natural to place such code in the infrastructure layer, a better choice is a Utilities layer (as long as this code is completely domain-agnostic and merely enhances your programming language capabilities):
namespace Utilities
{
public static class DictionaryExtensions
{
public static TValue GetOrDefault<TKey, TValue>(this IDictionary<TKey, Value> source, TKey key, TValue defaultValue = default!)
{
return source.TryGetValue(key, out var value)
? value
: defaultValue;
}
}
}This layer can sit deeper in the stack and be reused across all other layers — domain, application, and host — without introducing unwanted dependencies.
Summary
The Infrastructure Layer provides concrete implementations of interfaces defined in the application or domain layers, focusing purely on technical execution, dealing with databases, external services, messaging systems, and frameworks.
Keep this code concrete, replaceable, and cohesive: implement well-defined technical behavior, avoid embedding or influencing business rules, minimize assumptions about use cases, and keep related responsibilities together.
Well-written infrastructure code is predictable, isolated, and easy to swap when technology inevitably changes.
Let me know in the comment section where do you put your infrastructure interfaces? Do you split infrastructure and implementation or keep them in the same module? What insights do you get from building utils ?💬 Clap if you like the article 👋 Support me with a coffee ☕ And follow to receive more ✅








