What Is Onion Architecture? Structuring Code from the Core Out

Software systems grow increasingly complex. How do we keep them maintainable?
Onion Architecture offers a powerful solution to this common challenge. Created by Jeffrey Palermo in 2008, this architectural pattern organizes code in concentric layers with dependencies pointing inward. The result? A system that’s more testable, maintainable, and adaptable to change.
Unlike traditional layered architecture approaches, Onion Architecture places domain entities at the core, surrounded by application services, with infrastructure and UI at the outermost layers. This dependency inversion creates a separation that protects business logic from external concerns.
In this guide, you’ll learn:
- Core principles that make Onion Architecture effective
- Implementation strategies for new and existing projects
- Practical examples showing the pattern in action
- Real-world benefits from actual applications
Whether you’re building web apps or enterprise systems, understanding Onion Architecture provides a solid foundation for creating software that can evolve with your business needs.
What Is Onion Architecture?
Onion Architecture is a software design pattern that emphasizes a layered structure with the core domain at the center. It promotes separation of concerns by organizing code into concentric circles: domain models at the core, then application services, and finally infrastructure and UI at the outer layers.

The Core Layer: Domain Model
The Domain Model forms the center of Onion Architecture. Here’s what makes it special.
Domain Entities
Domain entities represent the most critical business objects in your system. They capture real-world concepts that exist regardless of any technical implementation details.
When creating rich domain models, focus on:
- Behavior over data: Entities should encapsulate both state and behavior
- Business rules enforcement: Validate invariants within the entity itself
- Identity preservation: Maintain a consistent identity through the object lifecycle
Entity design requires strict adherence to object-oriented design principles. Your entities must remain persistence-ignorant, meaning they have no knowledge of how they’re stored in databases. This separation of concerns creates a more maintainable codebase and allows for better testable architecture.
// Example of a persistence-ignorant entity
public class Order
{
private readonly List<OrderLine> _orderLines = new List<OrderLine>();
public Guid Id { get; private set; }
public Customer Customer { get; private set; }
public IReadOnlyCollection<OrderLine> OrderLines => _orderLines.AsReadOnly();
public OrderStatus Status { get; private set; }
// Constructor enforces business rules
public Order(Customer customer)
{
if (customer == null) throw new ArgumentNullException(nameof(customer));
Id = Guid.NewGuid();
Customer = customer;
Status = OrderStatus.Draft;
}
// Behavior that enforces business rules
public void AddProduct(Product product, int quantity)
{
if (Status != OrderStatus.Draft)
throw new InvalidOperationException("Cannot modify a finalized order");
// Business logic here
_orderLines.Add(new OrderLine(this, product, quantity));
}
}
This approach follows the domain-driven design philosophy closely, keeping the core domain logic free from infrastructure concerns.
Value Objects
Value objects complement entities by representing concepts that have no identity beyond their attributes. They’re defined by what they are, not who they are.
Key characteristics include:
- Immutability: Once created, they never change
- Value equality: Two value objects with the same properties are considered equal
- Self-validation: They validate their own consistency
Money, dates, addresses, and coordinates make perfect examples of value objects. By implementing them properly, you reduce primitive obsession and create more expressive models.
// Example value object
public sealed class Money : IEquatable<Money>
{
public decimal Amount { get; }
public string Currency { get; }
public Money(decimal amount, string currency)
{
if (string.IsNullOrWhiteSpace(currency))
throw new ArgumentException("Currency code is required", nameof(currency));
Amount = amount;
Currency = currency.ToUpperInvariant();
}
// Value objects define equality based on properties
public bool Equals(Money other)
{
if (other is null) return false;
if (ReferenceEquals(this, other)) return true;
return Amount == other.Amount && Currency == other.Currency;
}
public override bool Equals(object obj) => Equals(obj as Money);
public override int GetHashCode() => (Amount, Currency).GetHashCode();
// Value objects can define domain operations
public Money Add(Money other)
{
if (other.Currency != Currency)
throw new InvalidOperationException("Cannot add different currencies");
return new Money(Amount + other.Amount, Currency);
}
}
The immutability principle prevents many bugs and enhances code readability. It’s a cornerstone of clean architecture approaches.
Domain Services
Some business operations don’t naturally fit within entities or value objects. Domain services handle these cases, typically when:
- Operations span multiple entities
- The process is stateless
- The functionality doesn’t belong conceptually to any single entity
Well-designed domain services:
- Focus exclusively on domain concepts
- Have clear, descriptive names matching business terminology
- Remain free of infrastructure concerns
- Operate on domain objects, not primitive types
Domain services help maintain high cohesion within your entities while providing a home for important business logic.
Domain Events
Domain events represent significant happenings within your domain model. They capture facts that have occurred and can trigger reactions throughout the system.
Benefits include:
- Decoupling components: Event producers don’t need to know about consumers
- Auditability: Events create a natural history of system behavior
- Business insight: Events often map directly to business processes
Domain events work particularly well in event-driven architecture patterns. They provide a powerful mechanism for building loosely coupled systems while maintaining the domain model’s integrity.
Application Services Layer
The Application Services Layer orchestrates the flow between the domain core and external systems. Let’s examine its components.
Use Cases and Commands
Application services typically implement a single use case or command. Each represents a distinct operation your application performs.
Best practices include:
- Thin orchestration: Application services should coordinate rather than implement business logic
- Single responsibility: Each service should focus on one cohesive task
- Domain interaction: Services work with domain objects, not raw data
The command pattern fits naturally here, with each command representing a discrete action in your system.
// Example application service
public class CreateOrderService
{
private readonly IOrderRepository _orderRepository;
private readonly ICustomerRepository _customerRepository;
private readonly IUnitOfWork _unitOfWork;
public CreateOrderService(
IOrderRepository orderRepository,
ICustomerRepository customerRepository,
IUnitOfWork unitOfWork)
{
_orderRepository = orderRepository;
_customerRepository = customerRepository;
_unitOfWork = unitOfWork;
}
public async Task<OrderDto> CreateOrder(CreateOrderCommand command)
{
// 1. Get domain objects
var customer = await _customerRepository.GetByIdAsync(command.CustomerId);
if (customer == null)
throw new NotFoundException($"Customer {command.CustomerId} not found");
// 2. Execute domain logic
var order = new Order(customer);
// 3. Persist changes
await _orderRepository.AddAsync(order);
await _unitOfWork.CommitAsync();
// 4. Return result
return new OrderDto
{
Id = order.Id,
CustomerName = order.Customer.Name,
Status = order.Status.ToString()
};
}
}
This pattern allows for clear separation of concerns while maintaining focus on solving specific business problems.
Application DTOs
Data Transfer Objects (DTOs) serve as data containers between layers. They:
- Prevent domain model leakage outside the core
- Define clear contracts for data exchange
- Isolate changes between layers
Mapping between domain objects and DTOs requires careful consideration. Tools like AutoMapper help reduce boilerplate, but manual mapping often provides better control.
// Example DTO
public class OrderDto
{
public Guid Id { get; set; }
public string CustomerName { get; set; }
public string Status { get; set; }
public List<OrderLineDto> Lines { get; set; } = new List<OrderLineDto>();
public decimal TotalAmount { get; set; }
}
DTOs should contain only the data needed for specific use cases, avoiding unnecessary coupling to domain structures.
Validation
Validation occurs at multiple levels in Onion Architecture:
- Input validation: Verifies that commands contain valid data
- Domain validation: Ensures business rules and invariants
- Infrastructure validation: Handles system-specific constraints
Application services typically handle input validation before passing data to domain objects. This creates a clear separation between technical validation (format, length, etc.) and business validation (rules, policies, etc.).
Popular libraries like FluentValidation help implement robust validation pipelines:
// Example validator
public class CreateOrderCommandValidator : AbstractValidator<CreateOrderCommand>
{
public CreateOrderCommandValidator()
{
RuleFor(x => x.CustomerId).NotEmpty();
RuleFor(x => x.ProductItems).NotEmpty();
RuleForEach(x => x.ProductItems).ChildRules(item => {
item.RuleFor(x => x.ProductId).NotEmpty();
item.RuleFor(x => x.Quantity).GreaterThan(0);
});
}
}
Proper validation strategies enhance system robustness and user experience.
Cross-Cutting Concerns
Application services often need to handle concerns that cut across multiple components:
- Logging: Recording system activity
- Exception handling: Converting domain exceptions to appropriate responses
- Transaction management: Ensuring data consistency
These concerns should be implemented consistently across services. Techniques like aspect-oriented programming, decorators, or middleware help achieve this without cluttering service code.
Dependency injection heavily supports managing these cross-cutting concerns by providing the right components at runtime.
// Example decorator for transaction management
public class TransactionalOrderService : IOrderService
{
private readonly IOrderService _inner;
private readonly IUnitOfWork _unitOfWork;
public TransactionalOrderService(IOrderService inner, IUnitOfWork unitOfWork)
{
_inner = inner;
_unitOfWork = unitOfWork;
}
public async Task<OrderDto> CreateOrder(CreateOrderCommand command)
{
try
{
var result = await _inner.CreateOrder(command);
await _unitOfWork.CommitAsync();
return result;
}
catch
{
await _unitOfWork.RollbackAsync();
throw;
}
}
}
By applying software design patterns like the Decorator pattern shown above, you can cleanly separate concerns while maintaining single responsibility principles.
This layered approach creates a clear boundary between your domain core and external systems, allowing each to evolve independently while maintaining a cohesive application architecture.
Infrastructure Services Layer
The Infrastructure Services Layer forms the outer ring of Onion Architecture. It provides technical capabilities to your application without contaminating the domain core.
Persistence Implementation
Persistence represents one of the most critical infrastructure concerns. The Repository pattern creates a clean abstraction for data access:
// Repository interface (defined in domain layer)
public interface IOrderRepository
{
Task<Order> GetByIdAsync(Guid id);
Task<IEnumerable<Order>> GetByCustomerIdAsync(Guid customerId);
Task AddAsync(Order order);
Task UpdateAsync(Order order);
}
// Implementation (in infrastructure layer)
public class OrderRepository : IOrderRepository
{
private readonly DbContext _dbContext;
public OrderRepository(DbContext dbContext)
{
_dbContext = dbContext;
}
public async Task<Order> GetByIdAsync(Guid id)
{
return await _dbContext.Orders
.Include(o => o.Customer)
.Include(o => o.OrderLines)
.ThenInclude(ol => ol.Product)
.FirstOrDefaultAsync(o => o.Id == id);
}
// Other implementation methods...
}
When working with Object-Relational Mapping (ORM) tools like Entity Framework, several strategies help maintain proper separation:
- Separate mapping configuration from domain objects
- Use projections for read-heavy operations
- Encapsulate queries behind meaningful repository methods
The key is preventing database access patterns from leaking into your domain model. A properly implemented persistence layer improves testability and allows your domain to evolve independently from storage technology.
External Services Integration
Modern applications rarely exist in isolation. They connect with other systems through APIs, message queues, and various protocols. The infrastructure layer handles these integrations.
For API clients, consider:
- Resilience patterns like circuit breakers and retries
- Mapping functions between external DTOs and your domain
- Authentication handling specific to each service
public class PaymentGatewayClient : IPaymentService
{
private readonly HttpClient _httpClient;
private readonly ITokenProvider _tokenProvider;
public PaymentGatewayClient(HttpClient httpClient, ITokenProvider tokenProvider)
{
_httpClient = httpClient;
_tokenProvider = tokenProvider;
}
public async Task<PaymentResult> ProcessPaymentAsync(PaymentRequest request)
{
// Map domain object to API-specific DTO
var apiRequest = new PaymentGatewayRequest
{
Amount = request.Amount.ToString(),
Currency = request.Currency,
CardToken = request.CardInfo.Token,
Description = request.Description
};
// Handle authentication
var token = await _tokenProvider.GetTokenAsync();
_httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
// Make API call with resilience
var response = await _httpClient.PostAsJsonAsync("/api/payments", apiRequest);
response.EnsureSuccessStatusCode();
var apiResponse = await response.Content.ReadFromJsonAsync<PaymentGatewayResponse>();
// Map API response back to domain
return new PaymentResult
{
TransactionId = apiResponse.Id,
Status = MapStatus(apiResponse.Status),
Timestamp = apiResponse.Timestamp
};
}
private PaymentStatus MapStatus(string gatewayStatus)
{
// Mapping logic
}
}
Third-party adapters shield your application from external changes and provide a place to handle service-specific requirements.
Identity and Authentication
User identity often involves complex infrastructure concerns:
- User credential storage and validation
- Token generation and validation
- Permission management
- Integration with external identity providers
The key insight is separating identity concerns:
- Domain identity: Who users are in your business context
- Authentication identity: How users prove who they are
// Domain representation of a user
public class User
{
public Guid Id { get; private set; }
public string Email { get; private set; }
public string DisplayName { get; private set; }
public UserRole Role { get; private set; }
// Domain behavior...
}
// Infrastructure implementation
public class IdentityService : IIdentityService
{
private readonly UserManager<IdentityUser> _userManager;
private readonly IJwtFactory _jwtFactory;
// Implementation methods...
public async Task<User> GetCurrentUserAsync()
{
// Map from authentication identity to domain user
}
}
This architecture enables flexible authentication strategies without domain contamination.
Messaging and Event Infrastructure
Messaging infrastructure powers event-driven systems. This includes:
- Message queues for asynchronous processing
- Event buses for pub/sub patterns
- Integration with domain events
The infrastructure layer connects domain events to actual messaging systems:
public class RabbitMqEventBus : IEventBus
{
private readonly IConnection _connection;
private readonly IModel _channel;
private readonly IDomainEventSerializer _serializer;
// Implementation...
public async Task PublishAsync<T>(T @event) where T : IDomainEvent
{
var message = _serializer.Serialize(@event);
var body = Encoding.UTF8.GetBytes(message);
_channel.BasicPublish(
exchange: "domain-events",
routingKey: typeof(T).Name,
basicProperties: null,
body: body);
}
}
This approach enables loose coupling between components while maintaining a clean domain model.
UI/Presentation Layer
The UI/Presentation Layer forms the outermost ring of Onion Architecture. It’s where user interaction occurs.
API Design
Modern applications often expose APIs as their primary interface. Whether building REST, GraphQL, or RPC-style APIs, these principles apply:
- Resource-oriented design for clear navigation
- Consistent response formatting
- Proper error handling
- Versioning strategy
Here’s how a REST controller might look:
[ApiController]
[Route("api/v1/orders")]
public class OrdersController : ControllerBase
{
private readonly IMediator _mediator;
public OrdersController(IMediator mediator)
{
_mediator = mediator;
}
[HttpGet("{id}")]
public async Task<ActionResult<OrderDto>> GetOrder(Guid id)
{
var query = new GetOrderByIdQuery(id);
var result = await _mediator.Send(query);
if (result == null)
return NotFound();
return Ok(result);
}
[HttpPost]
public async Task<ActionResult<OrderDto>> CreateOrder(CreateOrderCommand command)
{
var result = await _mediator.Send(command);
return CreatedAtAction(
nameof(GetOrder),
new { id = result.Id },
result);
}
}
The presentation layer should remain thin, delegating to application services for actual work. This approach works equally well for REST APIs, GraphQL resolvers, or gRPC services.
UI Components
For applications with user interfaces, view models help maintain proper separation:
// React component example
interface OrderDetailsProps {
orderId: string;
}
const OrderDetails: React.FC<OrderDetailsProps> = ({ orderId }) => {
const [order, setOrder] = useState<OrderViewModel | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function loadOrder() {
try {
const response = await fetch(`/api/v1/orders/${orderId}`);
if (!response.ok) throw new Error('Failed to load order');
const data = await response.json();
setOrder(data);
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
}
loadOrder();
}, [orderId]);
if (loading) return <Spinner />;
if (!order) return <NotFound message="Order not found" />;
return (
<div className="order-details">
<h2>Order #{order.orderNumber}</h2>
<div className="customer-info">
<h3>Customer</h3>
<p>{order.customerName}</p>
</div>
<OrderItems items={order.items} />
<OrderSummary total={order.totalAmount} />
</div>
);
};
Regardless of your front-end development framework, view models should:
- Contain only what the UI needs
- Format data for display
- Exclude domain concerns
This separation allows your UI to evolve independently from the domain model.
User Input Processing
The presentation layer must handle user input carefully:
- Capture input from forms or commands
- Validate basic formats
- Transform into application commands
- Dispatch to appropriate handlers
- Present results or errors
Form processing often follows this pattern:
interface CreateOrderForm {
customerId: string;
products: Array<{
productId: string;
quantity: number;
}>;
shippingAddress: {
street: string;
city: string;
postalCode: string;
country: string;
};
}
async function handleSubmit(form: CreateOrderForm) {
try {
// Basic validation
if (!form.products.length) {
setError('Please add at least one product');
return;
}
// Transform to command
const command = {
customerId: form.customerId,
productItems: form.products.map(p => ({
productId: p.productId,
quantity: p.quantity
})),
shippingAddressDto: {
street: form.shippingAddress.street,
city: form.shippingAddress.city,
postalCode: form.shippingAddress.postalCode,
country: form.shippingAddress.country
}
};
// Dispatch
setSubmitting(true);
const response = await fetch('/api/v1/orders', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(command)
});
if (!response.ok) {
const errorData = await response.json();
setError(errorData.message || 'Failed to create order');
return;
}
// Handle success
const createdOrder = await response.json();
router.navigate(`/orders/${createdOrder.id}`);
} catch (err) {
setError('An unexpected error occurred');
console.error(err);
} finally {
setSubmitting(false);
}
}
Proper user feedback mechanisms enhance the experience, showing validation errors, processing states, and success confirmations.
Whether building web apps or mobile application development projects, these patterns provide a solid foundation for clean UI architecture.
The combination of well-designed infrastructure and presentation layers completes the Onion Architecture pattern. Each layer maintains clear responsibilities while the entire system remains focused on the core domain model. This approach yields maintainable, flexible applications that can evolve with changing business needs.
Dependency Injection and IoC
Dependency Injection (DI) forms the backbone of Onion Architecture, enabling the inversion of control that keeps dependencies pointing inward.
DI Container Setup
A properly configured DI container lets you wire up dependencies without tight coupling. Here’s how to approach it:
public void ConfigureServices(IServiceCollection services)
{
// Domain services - typically singletons
services.AddSingleton<IDomainEventDispatcher, DomainEventDispatcher>();
// Application services - typically scoped to request
services.AddScoped<IOrderService, OrderService>();
services.AddScoped<ICustomerService, CustomerService>();
// Repositories - typically scoped to request
services.AddScoped<IOrderRepository, OrderRepository>();
services.AddScoped<ICustomerRepository, CustomerRepository>();
// Infrastructure services
services.AddScoped<IUnitOfWork, EntityFrameworkUnitOfWork>();
services.AddHttpClient<IPaymentGateway, StripePaymentGateway>();
// Cross-cutting concerns
services.AddScoped<ICurrentUserAccessor, HttpContextCurrentUserAccessor>();
services.AddScoped<IDateTime, SystemDateTime>();
}
Component registration strategies include:
- By convention: Register components based on naming patterns
- By scanning: Auto-detect implementations based on attributes or interfaces
- Manual registration: Explicitly map interfaces to implementations
The lifecycle management of dependencies matters significantly. Consider these common lifetimes:
- Transient: Created each time requested (for stateless, lightweight services)
- Scoped: Created once per scope, such as a web request (for repositories, application services)
- Singleton: Created once for application lifetime (for configuration, caching)
Choosing the wrong lifetime can cause unexpected behavior or memory leaks. For example, don’t inject scoped services into singletons.
Several DI frameworks work well with Onion Architecture:
- Microsoft.Extensions.DependencyInjection: Standard for ASP.NET Core
- Autofac: More advanced features like assembly scanning
- Ninject: Lightweight with good extensibility
- Castle Windsor: Mature option with many features
Interface Design
Clean interfaces drive the dependency inversion principle. Follow these guidelines:
- Keep interfaces focused: Each should do one thing well
- Design for consumers: Interfaces should reflect how they’ll be used
- Hide implementation details: Expose only what’s necessary
// Poor interface design - too broad
public interface IRepository
{
Task<T> GetByIdAsync<T>(Guid id) where T : Entity;
Task<IEnumerable<T>> GetAllAsync<T>() where T : Entity;
Task<IEnumerable<T>> FindAsync<T>(Expression<Func<T, bool>> predicate) where T : Entity;
Task AddAsync<T>(T entity) where T : Entity;
Task UpdateAsync<T>(T entity) where T : Entity;
Task DeleteAsync<T>(T entity) where T : Entity;
}
// Better interface design - focused and specific
public interface IOrderRepository
{
Task<Order> GetByIdAsync(Guid id);
Task<IEnumerable<Order>> GetByCustomerIdAsync(Guid customerId);
Task<IEnumerable<Order>> GetRecentOrdersAsync(int count);
Task AddAsync(Order order);
Task UpdateAsync(Order order);
}
The Interface Segregation Principle (part of SOLID principles) prevents interface pollution. Better to have many small, focused interfaces than one large one. This approach helps with:
- Testing: Easier to mock smaller interfaces
- Flexibility: Implementations can vary without affecting consumers
- Maintainability: Changes impact fewer components
Factory Patterns
Sometimes DI containers aren’t enough. Factories help with:
- Dynamic creation: When type depends on runtime conditions
- Complex initialization: When creation logic is non-trivial
- Scoping control: When managing object lifecycles manually
Abstract factories provide powerful flexibility:
public interface IPaymentProcessorFactory
{
IPaymentProcessor CreateProcessor(PaymentMethod method);
}
public class PaymentProcessorFactory : IPaymentProcessorFactory
{
private readonly IServiceProvider _serviceProvider;
public PaymentProcessorFactory(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
}
public IPaymentProcessor CreateProcessor(PaymentMethod method)
{
return method switch
{
PaymentMethod.CreditCard => _serviceProvider.GetRequiredService<ICreditCardProcessor>(),
PaymentMethod.BankTransfer => _serviceProvider.GetRequiredService<IBankTransferProcessor>(),
PaymentMethod.DigitalWallet => _serviceProvider.GetRequiredService<IDigitalWalletProcessor>(),
_ => throw new ArgumentException($"Unsupported payment method: {method}")
};
}
}
Factory implementation strategies include:
- Simple factory: Static methods that return implementations
- Factory method: Virtual methods in base classes
- Abstract factory: Interface-based approach for families of objects
The code refactoring of factories often happens as applications mature and creation logic grows more complex.
Testing the Onion

One of Onion Architecture’s greatest strengths is testability. Each layer can be tested in isolation.
Unit Testing Strategies
Domain model tests focus on business rules and invariants:
[Fact]
public void Order_Cannot_Be_Canceled_When_Already_Shipped()
{
// Arrange
var customer = new Customer("John Doe", "john@example.com");
var order = new Order(customer);
order.MarkAsShipped();
// Act & Assert
var exception = Assert.Throws<InvalidOperationException>(() =>
order.Cancel("Customer request")
);
Assert.Contains("shipped", exception.Message);
}
Application service tests verify orchestration logic:
[Fact]
public async Task CreateOrder_Should_Save_New_Order()
{
// Arrange
var customerId = Guid.NewGuid();
var customer = new Customer("Test Customer", "test@example.com") { Id = customerId };
var customerRepository = Substitute.For<ICustomerRepository>();
customerRepository.GetByIdAsync(customerId).Returns(customer);
var orderRepository = Substitute.For<IOrderRepository>();
var unitOfWork = Substitute.For<IUnitOfWork>();
var service = new CreateOrderService(orderRepository, customerRepository, unitOfWork);
var command = new CreateOrderCommand
{
CustomerId = customerId,
ProductItems = new List<ProductItemDto>
{
new() { ProductId = Guid.NewGuid(), Quantity = 2 }
}
};
// Act
await service.CreateOrder(command);
// Assert
await orderRepository.Received(1).AddAsync(Arg.Any<Order>());
await unitOfWork.Received(1).CommitAsync();
}
When testing, consider the mock vs. stub approach:
- Mocks verify behavior (method calls, parameters)
- Stubs provide canned answers to calls
Libraries like Moq, NSubstitute, or FakeItEasy make this easier. Choose based on your team’s preferences and needs.
Integration Testing
Integration tests verify that components work together:
[Fact]
public async Task GetOrderById_Returns_Correct_Order()
{
// Arrange
await using var context = new TestDbContext();
var customer = new Customer("Integration Test", "test@example.com");
context.Customers.Add(customer);
var order = new Order(customer);
context.Orders.Add(order);
await context.SaveChangesAsync();
var repository = new OrderRepository(context);
// Act
var result = await repository.GetByIdAsync(order.Id);
// Assert
Assert.NotNull(result);
Assert.Equal(order.Id, result.Id);
Assert.Equal(customer.Id, result.Customer.Id);
}
Database integration testing strategies include:
- In-memory database: Fast but less realistic
- Docker containers: Isolated and realistic
- Test database: Most realistic but slower
For external service mocking, consider tools like WireMock.NET or service virtualizers. These simulate API responses without actual external calls.
End-to-End Testing
End-to-end tests validate the entire application stack:
[Fact]
public async Task Create_Order_API_Returns_Created_Order()
{
// Arrange
var client = _factory.CreateClient();
var request = new CreateOrderRequest
{
CustomerId = _testCustomerId,
Products = new[]
{
new ProductRequest { Id = _testProductId, Quantity = 3 }
}
};
// Act
var response = await client.PostAsJsonAsync("/api/orders", request);
// Assert
response.EnsureSuccessStatusCode();
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
var content = await response.Content.ReadFromJsonAsync<OrderResponse>();
Assert.NotNull(content);
Assert.NotEqual(Guid.Empty, content.Id);
// Verify in database
using var scope = _factory.Services.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var savedOrder = await dbContext.Orders
.Include(o => o.OrderLines)
.FirstOrDefaultAsync(o => o.Id == content.Id);
Assert.NotNull(savedOrder);
Assert.Single(savedOrder.OrderLines);
Assert.Equal(_testProductId, savedOrder.OrderLines.First().ProductId);
}
Full stack testing approaches include:
- WebApplicationFactory: For in-process testing
- TestServer: For HTTP testing without browser
- Playwright/Selenium: For UI automation
Test data management becomes crucial with end-to-end tests. Consider these strategies:
- Shared fixtures: Reuse test data across tests
- Per-test data: Create fresh data for each test
- Snapshot approach: Compare results to known good states
For web development IDE compatibility, ensure your test framework has good tooling support. Visual feedback and debugging capabilities make testing much more productive.
A comprehensive test suite for Onion Architecture typically includes:
- Many unit tests for domain and application logic
- Moderate integration tests for infrastructure concerns
- Few end-to-end tests for critical paths
This testing pyramid balances coverage with execution speed. Unit tests run fast and cover most code, while slower integration and E2E tests verify cross-component behavior.
The app lifecycle testing strategy should evolve with your application. Start with core domain tests, add integration tests as infrastructure stabilizes, and add end-to-end tests for critical user journeys.
Testing microservices built with Onion Architecture requires additional considerations for service boundaries and contracts. Contract testing tools like Pact help ensure services evolve compatibly.
Migration and Implementation
Adopting Onion Architecture for existing systems requires careful planning. Success depends on methodical execution and realistic expectations.
Transitioning Legacy Systems
Legacy systems rarely permit full architectural rewrites. Instead, use these incremental strategies:
- Strangler pattern: Gradually replace legacy components with new implementations
- Anti-corruption layers: Create interfaces that translate between old and new systems
- Domain isolation: Extract core business logic first
// Anti-corruption layer example
public class LegacyCustomerAdapter : ICustomerRepository
{
private readonly LegacyDatabase _legacyDb;
public LegacyCustomerAdapter(LegacyDatabase legacyDb)
{
_legacyDb = legacyDb;
}
public async Task<Customer> GetByIdAsync(Guid id)
{
// Convert modern GUID to legacy integer ID
var legacyId = GuidToLegacyId(id);
// Call legacy system
var legacyCustomer = await _legacyDb.ExecuteReaderAsync(
"SELECT * FROM Customers WHERE CustomerID = @ID",
new { ID = legacyId }
);
if (legacyCustomer == null)
return null;
// Map from legacy model to domain model
return new Customer(
legacyCustomer.Name,
legacyCustomer.Email
)
{
Id = id,
// Map other properties
};
}
// Other repository methods
}
Parallel implementations let teams migrate functionality piece by piece:
- Identify bounded contexts in the legacy system
- Implement one context at a time in the new architecture
- Create integration points between old and new components
- Gradually shift traffic to new implementations
- Decommission legacy code when safe
This approach reduces project risk while providing incremental value. Teams can learn from early migration efforts and adjust their approach for later phases.
During migration, focus on risk mitigation:
- Feature toggles: Ability to switch between old and new implementations
- Comprehensive testing: Verify functional equivalence
- Monitoring: Compare behavior between systems
- Rollback plans: Quick return to legacy code if issues arise
The lean software development philosophy applies perfectly here: deliver small, valuable increments rather than big-bang rewrites.
Team Organization
Onion Architecture impacts how teams organize and communicate. Consider these patterns:
- Vertical teams: Ownership across all layers for specific features
- Horizontal teams: Specialized in specific layers (domain, infrastructure)
- Core domain team: Focuses on the domain model with high expertise
Communication patterns become vital when multiple teams contribute:
Domain Core Team ↔ Application Services Team ↔ Infrastructure Teams ↔ UI Teams
This structure requires clear interface contracts and coordination mechanisms. Regular architecture reviews help maintain consistency across teams.
Knowledge sharing becomes essential when implementing Onion Architecture:
- Domain workshops: Share business understanding
- Architecture reviews: Maintain consistent patterns
- Code reviews: Enforce layering principles
- Documentation: Capture key decisions and patterns
Teams must understand not just how to implement the architecture but why its principles matter. Without this shared understanding, developers may take shortcuts that undermine architectural integrity.
A solid software development plan aligned with architectural boundaries helps teams stay focused on delivering value while maintaining architectural integrity.
Common Pitfalls
Even well-intentioned implementations can go astray. Watch for these common issues:
Over-engineering
Signs include:
- Excessive abstraction layers
- Too many small interfaces
- Complex dependency chains
Solution: Follow YAGNI (You Aren’t Gonna Need It) and start simple. Add complexity only when justified by concrete requirements.
Performance issues
Potential problems:
- Multiple repository calls for related data
- Excessive object mapping
- Chatty API calls
Solution: Use appropriate patterns like:
- Read models and projections for queries
- Batch operations for write operations
- Caching for frequently used data
// Performance optimization example - projection query
public async Task<OrderSummaryDto> GetOrderSummaryAsync(Guid orderId)
{
// Direct projection query instead of loading full entities
return await _dbContext.Orders
.Where(o => o.Id == orderId)
.Select(o => new OrderSummaryDto
{
Id = o.Id,
CustomerName = o.Customer.Name,
OrderDate = o.CreatedAt,
TotalAmount = o.OrderLines.Sum(l => l.Quantity * l.UnitPrice),
Status = o.Status.ToString()
})
.FirstOrDefaultAsync();
}
Pragmatic compromises
Sometimes architectural purity must yield to practical concerns:
- Legacy integration requirements
- Performance-critical paths
- Third-party constraints
- Project deadlines
The key is making these compromises consciously and documenting them. Consider them technical debt to be addressed when appropriate.
Keep track of architectural decisions using lightweight ADR (Architecture Decision Record) documents:
# Architecture Decision Record: Direct Database Access for Reporting
## Context
The reporting module needs to generate complex reports from data across multiple domain entities. Using standard repository patterns results in unacceptable performance.
## Decision
For reporting queries only, we will allow direct database access using read-only projections.
## Consequences
- Reporting queries will bypass domain logic
- Schema changes may affect reporting queries
- Need to maintain separate read models
- Performance improvement justifies the architectural compromise
These records help future team members understand not just what was decided but why.
Real-World Case Studies
Onion Architecture has proven successful across various domains. These case studies showcase its versatility.
Enterprise Applications
Financial systems often adopt Onion Architecture due to:
- Complex domain rules
- Long application lifespans
- Integration with multiple external systems
- Strict audit requirements
A major banking system refactored to Onion Architecture reported:
- 45% reduction in defects related to business rule implementation
- Improved test coverage from 40% to 85%
- Ability to replace outdated ORM without modifying business logic
ERP system implementations benefit from domain isolation. A manufacturing ERP project using Onion Architecture achieved:
- Clear separation between industry-specific logic and technical concerns
- Flexibility to adapt to different deployment environments
- Better accommodation of customer-specific extensions
Large-scale deployments reveal important lessons:
- Domain modeling takes time but pays dividends throughout the project
- Infrastructure diversity becomes manageable with proper abstractions
- Layering discipline requires ongoing team commitment
Enterprise architecture efforts succeed when Onion Architecture principles guide the overall system design rather than just individual applications.
Web Applications
E-commerce platforms leverage Onion Architecture for:
- Adaptability to changing business models
- Integration with multiple payment providers
- Complex pricing and promotion rules
- High performance requirements
A successful online retailer rebuilt their platform using Onion Architecture and reported:
- 30% faster feature delivery after initial investment
- Simplified A/B testing of business rules
- Improved resilience to third-party service outages
Content management systems built with Onion Architecture achieve greater flexibility:
// Example of content strategy pattern in CMS
public interface IContentRenderer
{
Task<string> RenderContentAsync(ContentItem item, RenderContext context);
}
// Multiple implementations for different content types
public class MarkdownRenderer : IContentRenderer
{
public async Task<string> RenderContentAsync(ContentItem item, RenderContext context)
{
// Markdown-specific rendering
}
}
public class VideoRenderer : IContentRenderer
{
private readonly IVideoService _videoService;
public VideoRenderer(IVideoService videoService)
{
_videoService = videoService;
}
public async Task<string> RenderContentAsync(ContentItem item, RenderContext context)
{
// Video-specific rendering
}
}
API-driven applications particularly benefit from clean separation between domain logic and API contracts. This allows API versions to evolve while core logic remains stable.
For progressive web apps, the presentation layer can adapt to different client capabilities without affecting business logic.
Mobile and Desktop
Cross-platform architecture becomes more manageable with Onion Architecture. Teams can share core domain and application layers across platforms while implementing platform-specific UI and infrastructure.
iOS development and Android development projects can share business logic through a common core:
┌─────────────────┐
│ │
│ Domain Model │
│ │
└────────┬────────┘
│
┌────────┴────────┐
│ │
│ Application │
│ Services │
│ │
└────────┬────────┘
│
┌───────────────┴───────────────┐
│ │
┌──────────┴──────────┐ ┌──────────┴──────────┐
│ │ │ │
│ iOS Infrastructure │ │ Android Infrastructure │
│ │ │ │
└──────────┬──────────┘ └──────────┬──────────┘
│ │
┌──────────┴──────────┐ ┌──────────┴──────────┐
│ │ │ │
│ iOS UI Layer │ │ Android UI Layer │
│ │ │ │
└─────────────────────┘ └─────────────────────┘
This approach enables significant code reuse while respecting platform-specific UI patterns and capabilities.
For projects requiring cross-platform app development, frameworks like React Native or Xamarin can implement the presentation layer while still maintaining clean domain separation.
Client-side considerations for Onion Architecture include:
- State management: Isolate UI state from domain state
- Offline capabilities: Handle synchronization cleanly
- Platform APIs: Abstract platform-specific features behind interfaces
Offline-first implementations particularly benefit from strong domain modeling:
// Application service with offline support
public class OfflineAwareOrderService : IOrderService
{
private readonly IOrderRepository _repository;
private readonly ISynchronizationQueue _syncQueue;
private readonly IConnectivityService _connectivity;
// Implementation methods with offline handling
public async Task<OrderResult> CreateOrderAsync(CreateOrderCommand command)
{
var order = new Order(command.CustomerId, command.Items);
// Save locally first
await _repository.SaveAsync(order);
// If online, synchronize immediately
if (await _connectivity.IsOnlineAsync())
{
await SynchronizeOrderAsync(order);
return new OrderResult(order.Id, OrderResultStatus.Synchronized);
}
// Otherwise queue for later sync
_syncQueue.Enqueue(new SyncOrderOperation(order.Id));
return new OrderResult(order.Id, OrderResultStatus.PendingSync);
}
}
The clean separation provided by Onion Architecture enables teams to handle complex client requirements without sacrificing maintainability or testability.
Whether for financial systems, web platforms, or mobile applications, Onion Architecture provides a solid foundation for applications that need to evolve over time and adapt to changing business requirements. The initial investment in proper architecture pays dividends throughout the application lifecycle.
FAQ on Onion Architecture
What is the core principle of Onion Architecture?
The core principle is dependency inversion – all dependencies point inward toward the domain model. Inner layers define interfaces that outer layers implement. This creates a system where business rules remain isolated from infrastructure concerns, ensuring better separation of concerns and maintainability.
How does Onion Architecture differ from traditional layered architecture?
Traditional layered architecture typically stacks components (UI, business logic, data access) with dependencies flowing downward. Onion Architecture places domain entities at the center with dependencies pointing inward. Infrastructure depends on application services, not the reverse, creating more flexible and testable code.
What are the main layers in Onion Architecture?
From inside out:
- Domain Layer: Core business entities and logic
- Application Service Layer: Orchestrates domain objects to perform use cases
- Infrastructure Layer: Implements technical concerns (persistence, messaging, etc.)
- UI/Presentation Layer: Handles user interaction
What are the key benefits of implementing Onion Architecture?
Benefits include:
- Enhanced testability through proper isolation
- Improved maintainability as layers can evolve independently
- Better protection of business logic from external changes
- Cleaner codebase with explicit dependencies
- Future-proofing against technology changes
How does Onion Architecture support testing?
Onion Architecture makes testing straightforward by isolating domain logic from external dependencies. Unit tests can focus on business rules without database connections or API calls. Infrastructure implementations can be easily replaced with test doubles, supporting comprehensive test coverage across all layers.
Is Onion Architecture suitable for microservices?
Yes. Onion Architecture works well with microservices by providing clear boundaries and separation of concerns. Each microservice can implement its own onion, with the domain model reflecting its specific bounded context. This creates cohesive, loosely coupled services with well-defined interfaces.
How does Onion Architecture relate to Domain-Driven Design?
Onion Architecture complements domain-driven design by providing a structural framework that emphasizes the importance of the domain model. DDD concepts like entities, value objects, and domain services fit naturally in the core layers, while bounded contexts help define architectural boundaries.
What challenges might teams face when implementing Onion Architecture?
Common challenges include:
- Initial learning curve and development overhead
- Determining appropriate layer boundaries
- Resisting shortcuts that violate dependency rules
- Managing database access efficiently
- Adapting to legacy system integration
How does Onion Architecture compare to Clean Architecture?
Onion Architecture and clean architecture share fundamental principles like dependency inversion and domain-centricity. Clean Architecture, developed by Robert Martin, adds more specific rules about component organization and data flow. They’re often used interchangeably as they pursue similar goals.
Can Onion Architecture be used for both backend and frontend development?
Absolutely. While often associated with back-end development, Onion Architecture principles apply equally to front-end development. Frontend implementations can isolate business logic from UI frameworks, making applications more maintainable and testable regardless of the presentation technology.
Conclusion
Understanding what is Onion Architecture gives developers a powerful tool for creating maintainable software architecture solutions. By organizing code in concentric layers with dependencies pointing inward, systems become more resilient to change and better equipped to handle complexity.
The real power of Onion Architecture lies in its practical benefits:
- Business logic protection from infrastructure concerns
- Simplified testing through proper isolation
- Independent evolution of components
- Reduced technical debt over time
When implemented thoughtfully, this architectural pattern supports scalable custom app development across diverse platforms and business domains. Whether in monolithic architecture or distributed systems, the principles remain valuable.
Remember that Onion Architecture isn’t a rigid framework but a set of guidelines. Adapt it to your specific needs while honoring its core principles. The journey toward better architecture is continuous, but the investment pays dividends in more robust, adaptable systems that can evolve with changing business requirements.
- What Is a Bare Repository? When and Why to Use One - June 11, 2025
- What Is Git Bisect? Debugging with Binary Search - June 10, 2025
- What Is Upstream in Git? Explained with Examples - June 9, 2025