DEV Community

Muhammad Salem
Muhammad Salem

Posted on

The thought process and best practices for handling responses across each layer

I'll walk you through a comprehensive example of handling responses across the layers of the Onion Architecture, focusing on fetching a list of records with optional filters. This approach will cover best practices, error handling, and the rationale behind various decisions. Let's break it down step by step, going through each layer of the architecture.

  1. Domain Layer

First, let's define our domain model:

public class Product
{
    public int Id { get; set; }
    public string Name { get; set; }
    public decimal Price { get; set; }
    public string Category { get; set; }
}
Enter fullscreen mode Exit fullscreen mode
  1. Application Layer

In the application layer, we'll define our use case and the corresponding interface:

public interface IProductService
{
    Task<IEnumerable<Product>> GetProductsAsync(ProductFilter filter);
}

public class ProductFilter
{
    public string? CategoryName { get; set; }
    public decimal? MinPrice { get; set; }
    public decimal? MaxPrice { get; set; }
}
Enter fullscreen mode Exit fullscreen mode

The application layer will contain the business logic for fetching products:

public class ProductService : IProductService
{
    private readonly IProductRepository _productRepository;

    public ProductService(IProductRepository productRepository)
    {
        _productRepository = productRepository;
    }

    public async Task<IEnumerable<Product>> GetProductsAsync(ProductFilter filter)
    {
        var products = await _productRepository.GetProductsAsync(filter);

        // Additional business logic can be applied here if needed
        // For example, applying discounts or other transformations

        return products;
    }
}
Enter fullscreen mode Exit fullscreen mode
  1. Infrastructure Layer

The infrastructure layer will implement the repository interface:

public interface IProductRepository
{
    Task<IEnumerable<Product>> GetProductsAsync(ProductFilter filter);
}

public class ProductRepository : IProductRepository
{
    private readonly DbContext _context;

    public ProductRepository(DbContext context)
    {
        _context = context;
    }

    public async Task<IEnumerable<Product>> GetProductsAsync(ProductFilter filter)
    {
        var query = _context.Products.AsQueryable();

        if (!string.IsNullOrEmpty(filter.CategoryName))
        {
            query = query.Where(p => p.Category == filter.CategoryName);
        }

        if (filter.MinPrice.HasValue)
        {
            query = query.Where(p => p.Price >= filter.MinPrice.Value);
        }

        if (filter.MaxPrice.HasValue)
        {
            query = query.Where(p => p.Price <= filter.MaxPrice.Value);
        }

        return await query.ToListAsync();
    }
}
Enter fullscreen mode Exit fullscreen mode
  1. Presentation Layer (API)

Finally, let's implement the API controller:

[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
    private readonly IProductService _productService;

    public ProductsController(IProductService productService)
    {
        _productService = productService;
    }

    [HttpGet]
    public async Task<ActionResult<IEnumerable<Product>>> GetProducts(
        [FromQuery] string? category,
        [FromQuery] decimal? minPrice,
        [FromQuery] decimal? maxPrice)
    {
        var filter = new ProductFilter
        {
            CategoryName = category,
            MinPrice = minPrice,
            MaxPrice = maxPrice
        };

        var products = await _productService.GetProductsAsync(filter);

        if (!products.Any())
        {
            return NoContent();
        }

        return Ok(products);
    }
}
Enter fullscreen mode Exit fullscreen mode

Now, let's discuss the thought process and best practices for handling responses across each layer:

  1. Domain Layer:

    • The domain layer defines the core business entities (Product in this case).
    • It should be independent of other layers and contain no dependencies.
  2. Application Layer:

    • This layer contains the business logic and orchestrates the use of domain entities.
    • We return IEnumerable<Product> instead of List<Product> to provide a more flexible interface.
    • We use a ProductFilter object to encapsulate filter parameters, making it easier to add or modify filters in the future.
    • The service method always returns an IEnumerable<Product>, even if it's empty. This is because an empty collection is a valid result and doesn't indicate an error condition.
  3. Infrastructure Layer:

    • The repository implements the query logic, applying filters as needed.
    • We use LINQ to build the query dynamically based on the provided filters.
    • The repository also returns an IEnumerable<Product>, maintaining consistency with the application layer.
    • We don't return null from the repository, as an empty collection is a more appropriate representation of "no results found".
  4. Presentation Layer (API):

    • The controller maps query parameters to the ProductFilter object.
    • We return different status codes based on the result:
      • 200 OK with the product list if products are found.
      • 204 No Content if no products match the criteria.
    • We don't return 404 Not Found because the absence of matching products is not an error condition for a list endpoint.

Handling different scenarios:

  1. No matching records:

    • The repository returns an empty list.
    • The service passes this empty list to the controller.
    • The controller returns a 204 No Content status.
    • Rationale: This approach provides a clear indication that the request was successful, but no data matched the criteria.
  2. Invalid filter parameters:

    • Validation should be added in the API layer to check for invalid parameters (e.g., negative prices).
    • If invalid, return a 400 Bad Request with details about the validation errors.
  3. Database errors:

    • Exceptions should be caught and logged in the infrastructure layer.
    • The application layer can wrap these in domain-specific exceptions if needed.
    • The API layer should catch any unhandled exceptions and return appropriate error responses (e.g., 500 Internal Server Error).
  4. Performance considerations:

    • For large datasets, consider implementing pagination in the API and all layers.
    • Use async/await throughout to ensure non-blocking I/O operations.

By following these practices, we ensure:

  1. Clear separation of concerns: Each layer has a specific responsibility.
  2. Encapsulation of business logic: The application layer handles business rules.
  3. Consistency: We use IEnumerable<Product> throughout, providing a uniform interface.
  4. Robustness: We handle various scenarios gracefully, providing appropriate responses.
  5. Flexibility: The use of interfaces and dependency injection allows for easy testing and future modifications.

This approach creates a harmonious interaction between layers, resulting in a smooth user experience and a maintainable codebase.

Certainly. Handling the case of retrieving a single entity that doesn't exist is a common scenario in application development. Let's go through a comprehensive guide on how to handle this case across all layers of the Onion Architecture, adhering to best practices.

  1. Domain Layer

First, let's assume we're working with a single Product entity:

public class Product
{
    public int Id { get; set; }
    public string Name { get; set; }
    public decimal Price { get; set; }
    public string Category { get; set; }
}
Enter fullscreen mode Exit fullscreen mode
  1. Application Layer

In the application layer, we'll define our use case:

public interface IProductService
{
    Task<Product?> GetProductByIdAsync(int id);
}

public class ProductService : IProductService
{
    private readonly IProductRepository _productRepository;

    public ProductService(IProductRepository productRepository)
    {
        _productRepository = productRepository;
    }

    public async Task<Product?> GetProductByIdAsync(int id)
    {
        return await _productRepository.GetProductByIdAsync(id);
    }
}
Enter fullscreen mode Exit fullscreen mode
  1. Infrastructure Layer

The infrastructure layer will implement the repository interface:

public interface IProductRepository
{
    Task<Product?> GetProductByIdAsync(int id);
}

public class ProductRepository : IProductRepository
{
    private readonly DbContext _context;

    public ProductRepository(DbContext context)
    {
        _context = context;
    }

    public async Task<Product?> GetProductByIdAsync(int id)
    {
        return await _context.Products.FindAsync(id);
    }
}
Enter fullscreen mode Exit fullscreen mode
  1. Presentation Layer (API)

Finally, let's implement the API controller:

[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
    private readonly IProductService _productService;

    public ProductsController(IProductService productService)
    {
        _productService = productService;
    }

    [HttpGet("{id}")]
    public async Task<ActionResult<Product>> GetProduct(int id)
    {
        var product = await _productService.GetProductByIdAsync(id);

        if (product == null)
        {
            return NotFound();
        }

        return Ok(product);
    }
}
Enter fullscreen mode Exit fullscreen mode

Now, let's discuss the best practices and reasoning behind this approach:

  1. Use of Nullable Types:

    • We use Product? as the return type in both the service and repository interfaces.
    • This clearly communicates that the method might not find a product, making the API more self-documenting.
  2. Consistent Return Types:

    • The repository and service layers both return Product?, maintaining consistency across layers.
    • This approach allows each layer to decide how to handle the null case.
  3. No Exceptions for Not Found:

    • We don't throw exceptions for a "not found" scenario. Exceptions should be reserved for exceptional circumstances, not for expected outcomes like a missing entity.
  4. Null Propagation:

    • We allow null to propagate through the layers up to the API controller.
    • This gives the presentation layer full control over how to respond to the client.
  5. API Response:

    • In the API controller, we return a 404 Not Found status when the product is null.
    • This adheres to HTTP semantics, where 404 indicates that the requested resource doesn't exist.
  6. Clear API Contract:

    • The API method returns ActionResult<Product>, which allows for different types of responses (OK with product, or NotFound).
  7. Separation of Concerns:

    • The repository is responsible for data access.
    • The service layer could add business logic if needed (e.g., checking if the product is active).
    • The API layer handles the HTTP-specific concerns (status codes, etc.).
  8. Async All the Way:

    • We use async/await throughout all layers for consistent asynchronous programming.

Additional Considerations:

  1. Validation:
    • Add input validation in the API layer. For example, ensure the ID is positive:
   if (id <= 0)
   {
       return BadRequest("Invalid product ID");
   }
Enter fullscreen mode Exit fullscreen mode
  1. Logging:
    • Consider adding logging, especially in the service layer:
   public async Task<Product?> GetProductByIdAsync(int id)
   {
       var product = await _productRepository.GetProductByIdAsync(id);
       if (product == null)
       {
           _logger.LogInformation("Product with ID {ProductId} not found", id);
       }
       return product;
   }
Enter fullscreen mode Exit fullscreen mode
  1. Caching:
    • If performance is a concern, consider adding caching in the service layer:
   public async Task<Product?> GetProductByIdAsync(int id)
   {
       var cachedProduct = await _cache.GetAsync<Product>(id.ToString());
       if (cachedProduct != null)
       {
           return cachedProduct;
       }

       var product = await _productRepository.GetProductByIdAsync(id);
       if (product != null)
       {
           await _cache.SetAsync(id.ToString(), product, TimeSpan.FromMinutes(10));
       }
       return product;
   }
Enter fullscreen mode Exit fullscreen mode
  1. Error Details:
    • In a production environment, you might want to provide more detailed error responses:
   [HttpGet("{id}")]
   public async Task<ActionResult<Product>> GetProduct(int id)
   {
       var product = await _productService.GetProductByIdAsync(id);

       if (product == null)
       {
           return NotFound(new { message = $"Product with ID {id} not found" });
       }

       return Ok(product);
   }
Enter fullscreen mode Exit fullscreen mode
  1. API Versioning:

    • Consider implementing API versioning to allow for future changes without breaking existing clients.
  2. Documentation:

    • Use tools like Swagger/OpenAPI to document your API, including possible response codes.

By following these practices, your application will handle the "entity not found" scenario gracefully across all layers. This approach ensures:

  1. Clear communication of intent through method signatures.
  2. Consistent handling across layers.
  3. Appropriate use of HTTP status codes.
  4. Separation of concerns between data access, business logic, and API concerns.
  5. Flexibility for future enhancements (like caching or detailed logging).

Remember, the key is to make your code expressive, consistent, and flexible enough to handle various scenarios while adhering to the principles of clean architecture.

Top comments (0)