Layered (N-Tier) Architecture in .NET Core

Layered, or N-Tier Architecture, is a traditional software design pattern that organizes an application into logical layers, each with a specific responsibility. This approach improves the separation of concerns, making the application easier to manage, test, and extend. Each layer in the architecture typically only interacts with the layer directly above or below it.

In this blog, we'll explore how to build a Product and Order service using Layered Architecture in .NET Core. We'll break down each layer, explain its purpose, and provide code examples to demonstrate how the layers interact.

What is Layered (N-Tier) Architecture?

Layered Architecture divides an application into layers, where each layer has a specific role and responsibility. The layers in a typical N-Tier architecture might include.

  • Presentation Layer: Manages user interactions, typically through a web interface or API.
  • Application Layer: Coordinates application activities, processing business logic, and handling transactions.
  • Business Logic Layer (BLL): Encapsulates core business rules and logic.
  • Data Access Layer (DAL): Handles database operations and data persistence.
  • Database Layer: The actual database where data is stored.

This layered approach helps ensure that each part of the application is focused on a single aspect of functionality, improving maintainability and testability.

Setting Up the Project

Let's build a Product and Order service using a Layered Architecture. We'll create a .NET Core solution with four projects to represent each layer.

  1. Presentation Layer: Exposes API endpoints.
  2. Application Layer: Manages the application's workflows.
  3. Business Logic Layer (BLL): Contains business logic.
  4. Data Access Layer (DAL): Interfaces with the database.

1. Presentation Layer

The Presentation Layer is responsible for handling user interactions. In our case, this is a Web API that exposes endpoints for managing products and orders.

API Controllers

using Microsoft.AspNetCore.Mvc;
using ProductOrder.Application.Interfaces;
using ProductOrder.Application.DTOs;

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

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

        [HttpGet("{id}")]
        public async Task<IActionResult> GetProduct(Guid id)
        {
            var product = await _productService.GetProductByIdAsync(id);
            if (product == null) return NotFound();
            return Ok(product);
        }

        [HttpPost]
        public async Task<IActionResult> CreateProduct([FromBody] ProductDto productDto)
        {
            var product = await _productService.AddProductAsync(productDto);
            return CreatedAtAction(nameof(GetProduct), new { id = product.Id }, product);
        }
        
        // Additional actions...
    }

    [Route("api/[controller]")]
    [ApiController]
    public class OrdersController : ControllerBase
    {
        private readonly IOrderService _orderService;

        public OrdersController(IOrderService orderService)
        {
            _orderService = orderService;
        }

        [HttpPost]
        public async Task<IActionResult> CreateOrder(Guid productId, int quantity)
        {
            var order = await _orderService.CreateOrderAsync(productId, quantity);
            return CreatedAtAction(nameof(GetOrder), new { id = order.Id }, order);
        }

        [HttpGet("{id}")]
        public async Task<IActionResult> GetOrder(Guid id)
        {
            var order = await _orderService.GetOrderByIdAsync(id);
            if (order == null) return NotFound();
            return Ok(order);
        }
        
        // Additional actions...
    }
}

2. Application Layer

The Application Layer manages the workflows of the application. It handles the orchestration of business logic and data access.

Application Services

using ProductOrder.Application.Interfaces;
using ProductOrder.Application.DTOs;
using ProductOrder.BLL.Interfaces;

namespace ProductOrder.Application.Services
{
    public class ProductService : IProductService
    {
        private readonly IProductManager _productManager;

        public ProductService(IProductManager productManager)
        {
            _productManager = productManager;
        }

        public async Task<ProductDto> GetProductByIdAsync(Guid id)
        {
            var product = await _productManager.GetByIdAsync(id);
            return new ProductDto
            {
                Id = product.Id,
                Name = product.Name,
                Price = product.Price
            };
        }

        public async Task<ProductDto> AddProductAsync(ProductDto productDto)
        {
            var product = new Product
            {
                Name = productDto.Name,
                Price = productDto.Price
            };

            await _productManager.AddAsync(product);

            return new ProductDto
            {
                Id = product.Id,
                Name = product.Name,
                Price = product.Price
            };
        }
        
        // Additional methods...
    }

    public class OrderService : IOrderService
    {
        private readonly IOrderManager _orderManager;
        private readonly IProductManager _productManager;

        public OrderService(IOrderManager orderManager, IProductManager productManager)
        {
            _orderManager = orderManager;
            _productManager = productManager;
        }

        public async Task<OrderDto> CreateOrderAsync(Guid productId, int quantity)
        {
            var product = await _productManager.GetByIdAsync(productId);
            if (product == null) throw new Exception("Product not found");

            var order = new Order
            {
                ProductId = productId,
                Quantity = quantity,
                OrderDate = DateTime.UtcNow,
                Product = product
            };

            await _orderManager.AddAsync(order);

            return new OrderDto
            {
                Id = order.Id,
                ProductId = order.ProductId,
                Quantity = order.Quantity,
                Total = order.Total,
                OrderDate = order.OrderDate
            };
        }
        
        // Additional methods...
    }
}

3. Business Logic Layer (BLL)

The Business Logic Layer (BLL) contains the core business rules and logic. It focuses on the problem domain and implements the business rules without worrying about how data is stored or presented.

Business Managers

using ProductOrder.BLL.Interfaces;
using ProductOrder.DAL.Interfaces;
using ProductOrder.Domain.Entities;

namespace ProductOrder.BLL.Managers
{
    public class ProductManager : IProductManager
    {
        private readonly IProductRepository _productRepository;

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

        public async Task<Product> GetByIdAsync(Guid id)
        {
            return await _productRepository.GetByIdAsync(id);
        }

        public async Task AddAsync(Product product)
        {
            await _productRepository.AddAsync(product);
        }
        
        // Additional methods...
    }

    public class OrderManager : IOrderManager
    {
        private readonly IOrderRepository _orderRepository;

        public OrderManager(IOrderRepository orderRepository)
        {
            _orderRepository = orderRepository;
        }

        public async Task<Order> GetByIdAsync(Guid id)
        {
            return await _orderRepository.GetByIdAsync(id);
        }

        public async Task AddAsync(Order order)
        {
            await _orderRepository.AddAsync(order);
        }
        
        // Additional methods...
    }
}

4. Data Access Layer (DAL)

The Data Access Layer (DAL) is responsible for interacting with the database. It contains repository classes that encapsulate the logic for retrieving and storing data.

Repositories

using Microsoft.EntityFrameworkCore;
using ProductOrder.Domain.Entities;
using ProductOrder.DAL.Interfaces;

namespace ProductOrder.DAL.Repositories
{
    public class ProductRepository : IProductRepository
    {
        private readonly ApplicationDbContext _context;

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

        public async Task<Product> GetByIdAsync(Guid id)
        {
            return await _context.Products.FindAsync(id);
        }

        public async Task<IEnumerable<Product>> GetAllAsync()
        {
            return await _context.Products.ToListAsync();
        }

        public async Task AddAsync(Product product)
        {
            await _context.Products.AddAsync(product);
            await _context.SaveChangesAsync();
        }

        public async Task UpdateAsync(Product product)
        {
            _context.Products.Update(product);
            await _context.SaveChangesAsync();
        }

        public async Task DeleteAsync(Guid id)
        {
            var product = await GetByIdAsync(id);
            if (product != null)
            {
                _context.Products.Remove(product);
                await _context.SaveChangesAsync();
            }
        }
    }

    public class OrderRepository : IOrderRepository
    {
        private readonly ApplicationDbContext _context;

        public OrderRepository(ApplicationDbContext context)
        {
            _context = context;
        }

        public async Task<Order> GetByIdAsync(Guid id)
        {
            return await _context.Orders.Include(o => o.Product).FirstOrDefaultAsync(o => o.Id == id);
        }

        public async Task<IEnumerable<Order>> GetAllAsync()
        {
            return await _context.Orders.Include(o => o.Product).ToListAsync();
        }

        public async Task AddAsync(Order order)
        {
            await _context.Orders.AddAsync(order);
            await _context.SaveChangesAsync();
        }

        public async Task UpdateAsync(Order order)
        {
            _context.Orders.Update(order);
            await _context.SaveChangesAsync();
        }

        public async Task DeleteAsync(Guid id)
        {
            var order = await GetByIdAsync(id);
            if (order != null)
            {
                _context.Orders.Remove(order);
                await _context.SaveChangesAsync();
            }
        }
    }
}

5. Dependency Injection and Startup Configuration

Finally, we configure the dependency injection and set up the middleware pipeline in the Startup.cs file.

public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        // Database Context
        services.AddDbContext<ApplicationDbContext>(options =>
            options.UseSqlServer(Configuration.GetConnectionString("DefaultConnection")));

        // Repositories
        services.AddScoped<IProductRepository, ProductRepository>();
        services.AddScoped<IOrderRepository, OrderRepository>();

        // Managers (BLL)
        services.AddScoped<IProductManager, ProductManager>();
        services.AddScoped<IOrderManager, OrderManager>();

        // Services (Application Layer)
        services.AddScoped<IProductService, ProductService>();
        services.AddScoped<IOrderService, OrderService>();

        // Controllers
        services.AddControllers();
    }

    public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
    {
        if (env.IsDevelopment())
        {
            app.UseDeveloperExceptionPage();
        }

        app.UseRouting();
        app.UseEndpoints(endpoints =>
        {
            endpoints.MapControllers();
        });
    }
}

Conclusion

Layered (N-Tier) Architecture provides a clear separation of concerns by dividing the application into logical layers, each with a specific responsibility. This architecture is ideal for building applications that are easy to maintain, test, and extend.

In this example, we built a Product and Order service using Layered Architecture in .NET Core. Each layer (Presentation, Application, Business Logic, and Data Access) was implemented to interact with the layers directly above and below it, ensuring a clean and organized codebase.

Layered Architecture is well-suited for medium to large-scale applications where you want to ensure code modularity and maintainability.