Onion Architecture is a clean, modular approach to building applications, where the core business logic is at the center, and all other layers (like data access, UI, etc.) are built around it. This architecture helps in achieving separation of concerns, testability, and maintainability by organizing the application into distinct layers.
In this blog, we'll explore Onion Architecture by building a simple Product and Order service in .NET Core. We'll break down each layer, explain its purpose, and provide code examples to demonstrate how they interact with one another.
What is Onion Architecture?
Onion Architecture, introduced by Jeffrey Palermo, is structured in concentric layers, where.
- The core layer contains the business logic and domain entities.
- The infrastructure layer handles database access, external services, and other technical concerns.
- The application layer is responsible for service implementations, use cases, and coordinating data flow between the core and infrastructure.
- The presentation layer deals with the user interface or API endpoints.
Here's a visual representation.
Setting Up the Project
Let's build a Product and Order service using Onion Architecture. We'll structure the solution into four projects.
- Domain: Contains entities, repository interfaces, and domain services.
- Application: Implements use cases, commands, queries, and application services.
- Infrastructure: Manages data access, external service integrations, and repository implementations.
- Presentation: Exposes API endpoints for external clients.
1. Domain Layer
The Domain Layer is the core of the Onion Architecture. It contains the business logic, entities, and interfaces that define the domain of your application.
Entities: Product and Order.
namespace ProductOrder.Domain.Entities
{
public class Product
{
public Guid Id { get; set; }
public string Name { get; set; }
public decimal Price { get; set; }
}
public class Order
{
public Guid Id { get; set; }
public Guid ProductId { get; set; }
public int Quantity { get; set; }
public DateTime OrderDate { get; set; }
public decimal Total => Quantity * Product.Price; // Business logic example
public Product Product { get; set; }
}
}
Repositories: Interfaces for Data Access.
namespace ProductOrder.Domain.Interfaces
{
public interface IProductRepository
{
Task<Product> GetByIdAsync(Guid id);
Task<IEnumerable<Product>> GetAllAsync();
Task AddAsync(Product product);
Task UpdateAsync(Product product);
Task DeleteAsync(Guid id);
}
public interface IOrderRepository
{
Task<Order> GetByIdAsync(Guid id);
Task<IEnumerable<Order>> GetAllAsync();
Task AddAsync(Order order);
Task UpdateAsync(Order order);
Task DeleteAsync(Guid id);
}
}
2. Application Layer
The Application Layer coordinates the data flow between the domain entities and the infrastructure. It implements the application's use cases and is where the business logic meets the infrastructure.
Services: Product and Order Management.
namespace ProductOrder.Application.Services
{
public class ProductService
{
private readonly IProductRepository _productRepository;
public ProductService(IProductRepository productRepository)
{
_productRepository = productRepository;
}
public async Task<Product> GetProductByIdAsync(Guid id)
{
return await _productRepository.GetByIdAsync(id);
}
public async Task AddProductAsync(Product product)
{
await _productRepository.AddAsync(product);
}
// Additional methods...
}
public class OrderService
{
private readonly IOrderRepository _orderRepository;
private readonly IProductRepository _productRepository;
public OrderService(IOrderRepository orderRepository, IProductRepository productRepository)
{
_orderRepository = orderRepository;
_productRepository = productRepository;
}
public async Task<Order> CreateOrderAsync(Guid productId, int quantity)
{
var product = await _productRepository.GetByIdAsync(productId);
if (product == null) throw new Exception("Product not found");
var order = new Order
{
Id = Guid.NewGuid(),
ProductId = productId,
Quantity = quantity,
OrderDate = DateTime.UtcNow,
Product = product
};
await _orderRepository.AddAsync(order);
return order;
}
// Additional methods...
}
}
3. Infrastructure Layer
The Infrastructure Layer is responsible for communicating with external systems like databases, file systems, and web services. It implements the repository interfaces defined in the domain layer.
Implementing Repositories
using Microsoft.EntityFrameworkCore;
using ProductOrder.Domain.Entities;
using ProductOrder.Domain.Interfaces;
namespace ProductOrder.Infrastructure.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();
}
}
}
}
ApplicationDbContext
using Microsoft.EntityFrameworkCore;
using ProductOrder.Domain.Entities;
namespace ProductOrder.Infrastructure
{
public class ApplicationDbContext : DbContext
{
public DbSet<Product> Products { get; set; }
public DbSet<Order> Orders { get; set; }
public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
: base(options)
{
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
// Fluent API configurations can go here
}
}
}
4. Presentation Layer
The Presentation Layer exposes the API endpoints that clients will interact with. This layer interacts with the application layer to fulfill client requests.
API Controllers
using Microsoft.AspNetCore.Mvc;
using ProductOrder.Application.Services;
using ProductOrder.Domain.Entities;
namespace ProductOrder.Api.Controllers
{
[Route("api/[controller]")]
[ApiController]
public class ProductsController : ControllerBase
{
private readonly ProductService _productService;
public ProductsController(ProductService 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] Product product)
{
await _productService.AddProductAsync(product);
return CreatedAtAction(nameof(GetProduct), new { id = product.Id }, product);
}
// Additional actions...
}
[Route("api/[controller]")]
[ApiController]
public class OrdersController : ControllerBase
{
private readonly OrderService _orderService;
public OrdersController(OrderService 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...
}
}
Dependency Injection and Startup Configuration
In the Startup.cs file, configure dependency injection and middleware.
public class Startup
{
public void ConfigureServices(IServiceCollection services)
{
services.AddDbContext<ApplicationDbContext>(options =>
options.UseSqlServer(Configuration.GetConnectionString("DefaultConnection")));
services.AddScoped<IProductRepository, ProductRepository>();
services.AddScoped<IOrderRepository, OrderRepository>();
services.AddScoped<ProductService>();
services.AddScoped<OrderService>();
services.AddControllers();
}
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
app.UseRouting();
app.UseEndpoints(endpoints =>
{
endpoints.MapControllers();
});
}
}
Conclusion
Onion Architecture is an effective way to organize your application by clearly separating concerns. By using this architecture, we can ensure that our business logic remains at the core of the application, making it easier to maintain, test, and extend. The Product and Order service example demonstrates how each layer interacts with others, maintaining the integrity of the architecture.
This architecture is particularly useful for larger applications where maintainability and testability are crucial. By adhering to Onion Architecture principles, you can build robust, scalable applications in .NET Core that are well-organized and easy to maintain over time.