Infrastructure Layer Should Always Be Logic-Free: Why?

Pierre Belin
Pierre Belin
Infrastructure Layer Should Always Be Logic-Free: Why?
Table of Contents
Table of Contents

Learn why your infrastructure layer should contain zero business logic and how to properly structure your .NET applications using Clean Architecture principles. Includes practical examples and common pitfalls to avoid.

Infrastructure code should be boring. This may sound controversial, but it's one of the key principles of clean architecture.

The way we structure our code has a direct impact on its maintainability, testability, and overall quality. One crucial aspect often overlooked is how we handle business logic in relation to our infrastructure layer. Let's explore why and how to achieve this through practical examples.

Understanding Clean Architecture Layers

Clean Architecture divides applications into distinct layers, each with specific responsibilities:

  • Domain: The core business logic and rules
  • Application: Orchestrates the flow of data and coordinates domain operations
  • Infrastructure: Handles external interactions (databases, APIs, file systems)
  • Presentation: Manages user interface and data display

The critical aspect of this architecture is the dependency rule: outer layers can depend on inner layers, but never the reverse. This means infrastructure, being an outer layer, should depend on domain and application layers, ensuring your business logic remains pure and unaffected by external concerns.

The External Nature of Infrastructure

The infrastructure layer serves as a bridge between your application's core logic and external systems. It handles database operations, API communications, file system interactions, and message queue operations.

Because it deals with external systems, this layer is inherently more volatile and prone to change based on factors outside your control. Understanding this external nature helps explain why keeping it simple and focused is crucial for maintaining a healthy codebase.

Why Minimize Logic in Infrastructure?

While this article provides guidelines rather than strict rules, minimizing logic in your infrastructure layer offers several significant benefits. Each piece of business logic in infrastructure becomes tightly coupled to external systems, making it harder to test and maintain. When business rules live in infrastructure, they become scattered across your codebase, making them difficult to discover, understand, and modify.

A Practical Example: The Goat Database

Let's examine a common scenario: saving a new goat to a database. We'll start with a problematic implementation and then improve it.

Here's what many developers might write:

public class GoatRepository
{
    public async Task AddGoat(string name)
    {
        var goat = new Goat
        {
            Id = Guid.NewGuid(), // Business decision: ID generation strategy
            Name = name,
            Status = GoatStatus.Created // Business decision: Initial status
        };
        
        await _dbContext.Goats.AddAsync(goat);
    }
}

This code has two business decisions in the infrastructure layer:

  1. Generating the Id
  2. Setting the initial Status

While it might seem convenient, this approach violates the separation of concerns principle and creates several problems we'll explore.

The Testing Challenge

Testing code with business logic in the infrastructure layer presents significant challenges. With our previous implementation, testing whether the Id and Status are set correctly requires a running database instance. This requirement transforms what should be simple unit tests into more complex integration tests, introducing several problems:

// This requires a database connection
public async Task TestAddGoat()
{
    var repository = new GoatRepository(dbContext);
    await repository.AddGoat("Billy");
    
    var goat = await dbContext.Goats.FirstOrDefaultAsync();
    Assert.NotNull(goat.Id);           // Testing ID generation
    Assert.Equal("CREATED", goat.Status); // Testing status
}

The only aspect we can test in isolation is the Name property, severely limiting our ability to verify business rules through unit tests. This testing difficulty is a symptom of a deeper architectural issue: our business logic isn't where it belongs.

The implementation above creates several issues:

  1. You can't test ID generation and status without a database
  2. Business rules are scattered across layers

It's not just about potential infrastructure changes - it's about maintainability and testability.

A Better Approach

Let's refactor our code to properly separate concerns and move business decisions to the domain layer:

// Domain Layer
public class Goat
{
    public Guid Id { get; private set; }
    public string Name { get; private set; }
    public GoatStatus Status { get; private set; }

    public static Goat Create(string name)
    {
        return new Goat
        {
            Id = Guid.NewGuid(),
            Name = name,
            Status = GoatStatus.Created
        };
    }
}

// Infrastructure Layer
public class GoatRepository
{
    private readonly DbContext _context;

    public async Task Add(Goat goat)
    {
        // No business decisions here - just mapping and storage
        var entity = new GoatEntity
        {
            Id = goat.Id,
            Name = goat.Name,
            Status = goat.Status
        };
        
        await _context.Goats.AddAsync(entity);
    }
}

This change is minimal but transformative. The infrastructure layer now has a single, clear responsibility: persisting the data. All business decisions reside in the domain layer, where they can be easily tested and modified. Notice how the Create method in the domain layer now encapsulates all the logic for creating a valid goat entity.

Going Further: Dependency Injection

Another common pitfall is injecting services into the infrastructure layer that add unnecessary complexity. Take the example of adding a creation date to our goats:

// Problematic approach
public class GoatRepository
{
    private readonly DbContext _context;
    private readonly IDateTimeFormatter _dateFormatter;

    public async Task Add(Goat goat)
    {
        var entity = new GoatEntity
        {
            Id = goat.Id,
            Name = goat.Name,
            Status = goat.Status,
            CreatedDate = _dateFormatter.GetUtcNow() // Injected date directly in infra
        };
        
        await _context.Goats.AddAsync(entity);
    }
}

This implementation is problematic because it places the date logic in the infrastructure layer. This information should also be part of the object that is added to the infrastructure layer, in this case Goat.

A better approach is to apply this injection in the Application layer:

// Application Layer
public class AddGoatHandler
{
    private readonly IGoatRepository _repository;
    private readonly IDateTimeFormatter _dateFormatter;
    
    public async Task Handle(AddGoatCommand command)
    {
        // Inject date inside the application layer when creating the goat
        var goat = Goat.Create(command.Name, _dateFormatter.GetUtcNow());    
        // Now, you can have property CreeatedDate inside Goat to use in repo
        await _repository.AddGoat(goat);
    }
}

In this enhanced version, the repository simply retrieves the raw data from the database. Date calculation is handled in the application layer, where it logically belongs. This change :

  • Simplifies infrastructure code
  • Places logic in the right place
  • Facilitates testing by isolating responsibilities
  • Allows date management to be modified without affecting the infrastructure

This clear separation of responsibilities makes code more maintainable and easier to evolve.

Conclusion

Keeping your infrastructure layer clean isn't just about preparing for potential changes to external systems - it's about creating a maintainable, testable codebase where business rules are centralized and easy to understand. The modifications required are often minimal, but they significantly improve code organization and long-term maintainability.

Remember that these are guidelines rather than strict rules. There might be specific cases where having some logic in your infrastructure layer makes sense. The key is to be intentional about these decisions and understand their implications for your codebase's maintainability and testability.

Have a goat day 🐐



Join the conversation.

Great! Check your inbox and click the link
Great! Next, complete checkout for full access to Goat Review
Welcome back! You've successfully signed in
You've successfully subscribed to Goat Review
Success! Your account is fully activated, you now have access to all content
Success! Your billing info has been updated
Your billing was not updated