Most software projects do not fail because of the wrong programming language or framework. They fail because the codebase becomes so tangled that every change is expensive, risky, and slow. Business logic leaks into controllers. Database queries appear in UI code. Changing the email provider requires modifying twenty files across three layers. The system works, but the cost of maintaining it increases with every feature added.
Clean architecture, formalized by Robert C. Martin (Uncle Bob), provides a structural answer to this problem. It organizes code into concentric layers with a strict dependency rule: source code dependencies always point inward, toward higher-level policies. The result is a system where business logic is independent of frameworks, databases, and delivery mechanisms -- and therefore easier to test, maintain, and evolve.
This guide moves beyond the theoretical diagrams and into practical implementation. We will look at how clean architecture maps to real project structures, how SOLID principles support the dependency rule, and how to implement these patterns in C# and TypeScript with code you can adapt to your own projects.
The Dependency Rule and Concentric Layers
Clean architecture is often depicted as a set of concentric circles, each representing a layer of the system. The critical insight is not the number of layers -- it is the direction of dependencies.
The dependency rule states: source code dependencies must point inward. An inner layer never knows anything about an outer layer. The domain layer does not import from the infrastructure layer. Use cases do not reference controllers. This rule is absolute. Every violation undermines the architecture's benefits.
The layers, from innermost to outermost:
Entities (Enterprise Business Rules). These represent the core business objects and rules that exist independently of any application. In an e-commerce system, the concept that "an order must have at least one line item" and "a discount cannot exceed the order total" are entity-level rules. Entities are plain objects with no dependencies on frameworks, databases, or external libraries.
Use Cases (Application Business Rules). Use cases orchestrate the flow of data to and from entities and direct entities to apply their business rules. A use case like "place an order" coordinates validating the cart, checking inventory, calculating totals, processing payment, and sending confirmation. Use cases depend on entities and on abstractions (interfaces) for external concerns like persistence and messaging.
Interface Adapters. This layer converts data between the format most convenient for use cases and entities and the format required by external agencies like databases and web frameworks. Controllers, presenters, and gateways live here. A repository implementation that maps between database rows and domain entities is an interface adapter.
Frameworks and Drivers. The outermost layer contains the framework-specific code, database drivers, HTTP servers, and third-party integrations. This layer is intentionally thin -- it wires things together and delegates to inner layers.
The key mechanism that enables this dependency direction is dependency inversion. Use cases define interfaces for what they need (e.g., "I need to save an order"), and the outer layers provide implementations (e.g., "here is a PostgreSQL implementation of the order repository"). The use case depends on the abstraction it defines, not on the concrete implementation.
SOLID Principles in Context
Clean architecture does not work without SOLID principles. They are not separate concerns -- they are the engineering discipline that makes the dependency rule enforceable in practice.
Single Responsibility Principle. A class should have one reason to change. In clean architecture, this maps naturally to the layers: an entity changes when business rules change, a use case changes when application workflow changes, a controller changes when the API contract changes. When a single class has reasons to change from multiple layers, it violates both SRP and the dependency rule.
Open/Closed Principle. Software entities should be open for extension but closed for modification. Use case interactors are closed for modification -- you do not change existing use cases to add new behavior. Instead, you create new use cases or extend existing ones through composition. Adding a new notification channel should not require modifying the order placement use case.
Liskov Substitution Principle. Subtypes must be substitutable for their base types. When your use case depends on an IOrderRepository interface, any implementation -- PostgreSQL, MongoDB, in-memory for testing -- must honor the contract. If your PostgreSQL implementation throws on null inputs while your in-memory version silently ignores them, you have a substitution violation that will cause subtle bugs.
Interface Segregation Principle. Clients should not be forced to depend on methods they do not use. Rather than a single IRepository<T> with every possible data access method, define focused interfaces: IOrderReader for queries, IOrderWriter for commands. Use cases that only read orders should not depend on an interface that includes write methods.
Dependency Inversion Principle. High-level modules should not depend on low-level modules; both should depend on abstractions. This is the principle that directly enables the dependency rule. The use case layer defines interfaces, and the infrastructure layer implements them. The compile-time dependency points inward (toward the interface), while the runtime dependency points outward (toward the implementation).
Practical Project Structure
Theory becomes useful when it maps to a project structure you can navigate and maintain. Here is a clean architecture layout for a C# solution:
src/
MyApp.Domain/ # Entities layer
Entities/
Order.cs
OrderLineItem.cs
Customer.cs
ValueObjects/
Money.cs
EmailAddress.cs
OrderStatus.cs
Exceptions/
DomainException.cs
InsufficientInventoryException.cs
MyApp.Application/ # Use Cases layer
Common/
Interfaces/
IOrderRepository.cs
IPaymentGateway.cs
IEmailService.cs
IUnitOfWork.cs
Behaviors/
ValidationBehavior.cs
LoggingBehavior.cs
Orders/
Commands/
PlaceOrder/
PlaceOrderCommand.cs
PlaceOrderHandler.cs
PlaceOrderValidator.cs
CancelOrder/
CancelOrderCommand.cs
CancelOrderHandler.cs
Queries/
GetOrderById/
GetOrderByIdQuery.cs
GetOrderByIdHandler.cs
OrderDto.cs
DependencyInjection.cs
MyApp.Infrastructure/ # Interface Adapters + Frameworks
Persistence/
ApplicationDbContext.cs
Configurations/
OrderConfiguration.cs
Repositories/
OrderRepository.cs
Services/
StripePaymentGateway.cs
SendGridEmailService.cs
DependencyInjection.cs
MyApp.WebApi/ # Frameworks & Drivers
Controllers/
OrdersController.cs
Middleware/
ExceptionHandlingMiddleware.cs
Program.cs
DependencyInjection.cs
Notice that MyApp.Domain has no project references -- it depends on nothing. MyApp.Application references only MyApp.Domain. MyApp.Infrastructure references MyApp.Application (to implement its interfaces). MyApp.WebApi references everything because it is the composition root where dependency injection wires all layers together.
Code Examples: Clean Architecture in C#
Let us trace a complete feature through all layers. We will implement the "place an order" use case.
Domain entity (MyApp.Domain/Entities/Order.cs):
public class Order
{
private readonly List<OrderLineItem> _lineItems = new();
public Guid Id { get; private set; }
public Guid CustomerId { get; private set; }
public OrderStatus Status { get; private set; }
public Money Total { get; private set; }
public DateTime CreatedAt { get; private set; }
public IReadOnlyList<OrderLineItem> LineItems => _lineItems.AsReadOnly();
private Order() { } // EF Core
public static Order Create(Guid customerId, List<OrderLineItem> lineItems)
{
if (lineItems == null || lineItems.Count == 0)
throw new DomainException("An order must have at least one line item.");
var order = new Order
{
Id = Guid.NewGuid(),
CustomerId = customerId,
Status = OrderStatus.Pending,
CreatedAt = DateTime.UtcNow
};
foreach (var item in lineItems)
{
order._lineItems.Add(item);
}
order.Total = order.CalculateTotal();
return order;
}
public void Confirm()
{
if (Status != OrderStatus.Pending)
throw new DomainException($"Cannot confirm an order with status {Status}.");
Status = OrderStatus.Confirmed;
}
public void Cancel()
{
if (Status == OrderStatus.Shipped)
throw new DomainException("Cannot cancel a shipped order.");
Status = OrderStatus.Cancelled;
}
private Money CalculateTotal()
{
var sum = _lineItems.Sum(li => li.Price.Amount * li.Quantity);
return new Money(sum, _lineItems.First().Price.Currency);
}
}
The entity contains business rules: an order must have line items, confirmation requires pending status, shipped orders cannot be cancelled. No framework dependencies, no database annotations, no HTTP concerns.
Use case interface (MyApp.Application/Common/Interfaces/IOrderRepository.cs):
public interface IOrderRepository
{
Task<Order?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
Task AddAsync(Order order, CancellationToken cancellationToken = default);
Task UpdateAsync(Order order, CancellationToken cancellationToken = default);
}
Use case handler (MyApp.Application/Orders/Commands/PlaceOrder/PlaceOrderHandler.cs):
public record PlaceOrderCommand(
Guid CustomerId,
List<OrderItemRequest> Items
) : IRequest<PlaceOrderResult>;
public record OrderItemRequest(Guid ProductId, int Quantity, decimal UnitPrice, string Currency);
public record PlaceOrderResult(Guid OrderId, decimal Total);
public class PlaceOrderHandler : IRequestHandler<PlaceOrderCommand, PlaceOrderResult>
{
private readonly IOrderRepository _orderRepository;
private readonly IPaymentGateway _paymentGateway;
private readonly IEmailService _emailService;
private readonly IUnitOfWork _unitOfWork;
public PlaceOrderHandler(
IOrderRepository orderRepository,
IPaymentGateway paymentGateway,
IEmailService emailService,
IUnitOfWork unitOfWork)
{
_orderRepository = orderRepository;
_paymentGateway = paymentGateway;
_emailService = emailService;
_unitOfWork = unitOfWork;
}
public async Task<PlaceOrderResult> Handle(
PlaceOrderCommand command,
CancellationToken cancellationToken)
{
var lineItems = command.Items.Select(item =>
new OrderLineItem(
item.ProductId,
item.Quantity,
new Money(item.UnitPrice, item.Currency)
)).ToList();
var order = Order.Create(command.CustomerId, lineItems);
var paymentResult = await _paymentGateway.ChargeAsync(
order.CustomerId, order.Total, cancellationToken);
if (!paymentResult.Success)
throw new PaymentFailedException(paymentResult.ErrorMessage);
order.Confirm();
await _orderRepository.AddAsync(order, cancellationToken);
await _unitOfWork.SaveChangesAsync(cancellationToken);
await _emailService.SendOrderConfirmationAsync(
order.CustomerId, order.Id, cancellationToken);
return new PlaceOrderResult(order.Id, order.Total.Amount);
}
}
The handler orchestrates the workflow: create the domain object, process payment, confirm the order, persist it, and send notification. It depends only on interfaces defined in the Application layer and entities from the Domain layer. It has no idea whether payment goes through Stripe or PayPal, whether persistence uses PostgreSQL or MongoDB, or whether emails go through SendGrid or SMTP.
Infrastructure implementation (MyApp.Infrastructure/Persistence/Repositories/OrderRepository.cs):
public class OrderRepository : IOrderRepository
{
private readonly ApplicationDbContext _context;
public OrderRepository(ApplicationDbContext context)
{
_context = context;
}
public async Task<Order?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
{
return await _context.Orders
.Include(o => o.LineItems)
.FirstOrDefaultAsync(o => o.Id == id, cancellationToken);
}
public async Task AddAsync(Order order, CancellationToken cancellationToken = default)
{
await _context.Orders.AddAsync(order, cancellationToken);
}
public async Task UpdateAsync(Order order, CancellationToken cancellationToken = default)
{
_context.Orders.Update(order);
await Task.CompletedTask;
}
}
API controller (MyApp.WebApi/Controllers/OrdersController.cs):
[ApiController]
[Route("api/[controller]")]
public class OrdersController : ControllerBase
{
private readonly ISender _mediator;
public OrdersController(ISender mediator)
{
_mediator = mediator;
}
[HttpPost]
public async Task<IActionResult> PlaceOrder(
[FromBody] PlaceOrderCommand command,
CancellationToken cancellationToken)
{
var result = await _mediator.Send(command, cancellationToken);
return CreatedAtAction(
nameof(GetOrder),
new { id = result.OrderId },
result);
}
[HttpGet("{id:guid}")]
public async Task<IActionResult> GetOrder(Guid id, CancellationToken cancellationToken)
{
var result = await _mediator.Send(
new GetOrderByIdQuery(id), cancellationToken);
return result is not null ? Ok(result) : NotFound();
}
}
The controller is thin. It receives the HTTP request, dispatches it to the appropriate use case handler through MediatR, and returns the HTTP response. No business logic, no data access, no direct service calls.
Testing Benefits and Practical Tradeoffs
The most immediate payoff of clean architecture is testability. Because use cases depend on interfaces rather than concrete implementations, testing is straightforward:
[Fact]
public async Task PlaceOrder_WithValidItems_ShouldConfirmAndPersist()
{
// Arrange
var orderRepo = new Mock<IOrderRepository>();
var paymentGateway = new Mock<IPaymentGateway>();
var emailService = new Mock<IEmailService>();
var unitOfWork = new Mock<IUnitOfWork>();
paymentGateway
.Setup(pg => pg.ChargeAsync(It.IsAny<Guid>(), It.IsAny<Money>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new PaymentResult { Success = true });
var handler = new PlaceOrderHandler(
orderRepo.Object, paymentGateway.Object,
emailService.Object, unitOfWork.Object);
var command = new PlaceOrderCommand(
Guid.NewGuid(),
new List<OrderItemRequest>
{
new(Guid.NewGuid(), 2, 29.99m, "USD")
});
// Act
var result = await handler.Handle(command, CancellationToken.None);
// Assert
Assert.NotEqual(Guid.Empty, result.OrderId);
Assert.Equal(59.98m, result.Total);
orderRepo.Verify(r => r.AddAsync(It.Is<Order>(o =>
o.Status == OrderStatus.Confirmed &&
o.LineItems.Count == 1
), It.IsAny<CancellationToken>()), Times.Once);
unitOfWork.Verify(u => u.SaveChangesAsync(It.IsAny<CancellationToken>()), Times.Once);
emailService.Verify(e => e.SendOrderConfirmationAsync(
It.IsAny<Guid>(), It.IsAny<Guid>(), It.IsAny<CancellationToken>()), Times.Once);
}
This test runs in milliseconds with no database, no HTTP server, and no external services. You are testing pure business logic and workflow orchestration.
When clean architecture is overkill. Not every project needs this level of structure. A small internal tool with a single developer and a stable feature set gains little from the ceremony of separate projects, interfaces, and dependency injection. A prototype or proof of concept that may be thrown away does not benefit from architectural rigor. CRUD applications with minimal business logic are often better served by a simpler layered architecture or even a single project. Apply clean architecture when the system is complex enough that the cost of maintaining it without structure exceeds the cost of the structure itself. For most production business applications with a development team and a multi-year lifespan, that threshold is met early.
Common implementation mistakes. Anemic domain models are the most frequent failure mode -- entities that are just data bags with getters and setters, while all business logic lives in use case handlers. This defeats the purpose of the domain layer. Another mistake is creating interfaces for everything, including classes that will never have more than one implementation and do not cross architectural boundaries. Interfaces exist to enable the dependency rule, not as a reflex. Finally, leaking infrastructure concerns into inner layers through framework-specific attributes, ORM annotations, or serialization concerns on domain entities violates the dependency rule subtly but corrosively.
Clean architecture is an investment that pays compound returns. The initial setup cost is higher than throwing everything into a single project, but the ongoing cost of change, testing, and onboarding new developers decreases significantly. For systems that need to evolve over years with changing requirements and growing teams, it is one of the most impactful architectural decisions you can make.
At Maranatha Technologies, we design and build software systems using clean architecture and SOLID principles that stand the test of time. Whether you are starting a new project or restructuring an existing codebase that has become difficult to maintain, our software architecture services bring the expertise to establish the right foundation. Contact our team to discuss how we can help you build software that scales with your business.