DEV Community

Cover image for .NET Application Architectures: Complete Guide to Monolithic, Layered, Clean, and More
Vikrant Bagal
Vikrant Bagal

Posted on

.NET Application Architectures: Complete Guide to Monolithic, Layered, Clean, and More

Choosing the right application architecture is one of the most critical decisions you'll make when building a .NET application. The architecture you choose will impact everything from development speed to long-term maintainability.

In this comprehensive guide, we'll explore the most popular .NET application architecture patterns, their pros and cons, and when to use each one.

.NET Application Architectures


Introduction

With .NET 10, developers have more options than ever for structuring their applications. Whether you're building a simple CRUD app or a complex enterprise system, understanding the different architecture patterns will help you make the right choice.

We'll cover:

  • Monolithic Architecture - The classic approach
  • Layered (N-Tier) Architecture - Traditional enterprise pattern
  • Clean/Onion Architecture - Domain-driven design approach
  • Vertical Slice Architecture - Feature-centric organization
  • Hexagonal Architecture - Ports and Adapters pattern

Let's dive in!


1. Monolithic Architecture

What Is It?

A monolithic application is entirely self-contained. All logic runs within a single process and the entire application is deployed as a single unit.

Key Characteristics

  • Single deployment unit (executable or single web application)
  • All code typically lives in one or more projects within the same solution
  • Horizontal scaling requires duplicating the entire application

When to Use It

  • Simple applications with limited complexity
  • Small to medium-sized projects
  • When deployment simplicity is prioritized
  • Small development teams

Pros

  • ✅ Simple deployment and development
  • ✅ Easy to debug and test
  • ✅ Low operational overhead
  • ✅ No network latency between components

Cons

  • ❌ Tight coupling of components
  • ❌ Difficult to scale individual components
  • ❌ Technology lock-in
  • ❌ Difficult to maintain as application grows

Implementation Example

MyApp/
├── Controllers/
│   └── ProductsController.cs
├── Models/
│   └── Product.cs
├── Views/
│   └── Products/
├── Services/
│   └── ProductService.cs
└── Data/
    └── ApplicationDbContext.cs
Enter fullscreen mode Exit fullscreen mode

Best For

Simple CRUD applications, prototypes, and small projects where simplicity is more important than scalability.


2. Layered (N-Tier) Architecture

What Is It?

Traditional layered architecture organizes code into distinct layers, each with specific responsibilities. The most common organization is UI → Business Logic → Data Access.

Key Layers

  1. Presentation Layer (UI) - Handles user interaction
  2. Business Logic Layer (BLL) - Contains business rules and logic
  3. Data Access Layer (DAL) - Handles data persistence

Key Characteristics

  • Top-down dependencies (UI → BLL → DAL)
  • Separation of concerns
  • Reusable components across layers

When to Use It

  • Enterprise applications with clear separation needs
  • When you need to enforce strict layer boundaries
  • When different teams work on different layers

Pros

  • ✅ Clear separation of concerns
  • ✅ Easier to understand and maintain
  • ✅ Good for teams with different specialties
  • ✅ Testable layers

Cons

  • ❌ Top-down dependencies can create tight coupling
  • ❌ Changes in lower layers affect upper layers
  • ❌ Can lead to anemic domain model
  • ❌ Difficult to test business logic without database

Implementation in .NET

// Data Access Layer - Repository Pattern
public class ProductRepository : IProductRepository
{
    private readonly ApplicationDbContext _context;

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

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

// Business Logic Layer
public class ProductService
{
    private readonly IProductRepository _repository;

    public ProductService(IProductRepository repository)
    {
        _repository = repository;
    }

    public async Task<ProductDto> GetProductAsync(int id)
    {
        var product = await _repository.GetByIdAsync(id);
        return MapToDto(product);
    }
}

// Presentation Layer
[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
    private readonly ProductService _service;

    public ProductsController(ProductService service)
    {
        _service = service;
    }

    [HttpGet("{id}")]
    public async Task<IActionResult> GetProduct(int id)
    {
        var product = await _service.GetProductAsync(id);
        return Ok(product);
    }
}
Enter fullscreen mode Exit fullscreen mode

Best For

Traditional enterprise applications where clear separation between presentation, business logic, and data access is required.


3. Clean/Onion Architecture

What Is It?

Clean Architecture (also known as Onion Architecture) places business logic at the center of the application, with dependencies flowing inward toward the core.

Key Layers

  1. Domain Layer - Entities, value objects, domain services
  2. Application Layer - Use cases, commands, queries, services
  3. Infrastructure Layer - External concerns (database, email, etc.)
  4. Presentation Layer - UI or API endpoints

Key Characteristics

  • Dependencies flow inward (inversion of control)
  • Business logic has no dependencies on infrastructure
  • Use interfaces/abstractions at boundaries
  • Highly testable business logic
  • Framework-agnostic core

When to Use It

  • Complex business domains
  • Long-lived applications
  • When you need maximum testability
  • When you want to avoid framework lock-in

Pros

  • ✅ Framework-agnostic business logic
  • ✅ Highly testable (no database needed for unit tests)
  • ✅ Easy to swap implementations
  • ✅ Clear separation of concerns
  • ✅ Scalable for complex domains

Cons

  • ❌ More projects/files to manage
  • ❌ Steeper learning curve
  • ❌ More boilerplate code
  • ❌ Can be overkill for simple applications

Implementation Structure

Solution/
├── Domain/
│   ├── Entities/
│   ├── Interfaces/
│   └── ValueObjects/
├── Application/
│   ├── Features/
│   │   ├── Commands/
│   │   └── Queries/
│   ├── Interfaces/
│   └── Services/
├── Infrastructure/
│   ├── Persistence/
│   └── Services/
├── Presentation/
│   └── API/
└── Tests/
Enter fullscreen mode Exit fullscreen mode

Example Code

// Domain Layer - Pure Business Logic
namespace MyApp.Domain.Entities
{
    public class Product
    {
        public int Id { get; private set; }
        public string Name { get; private set; }
        public decimal Price { get; private set; }

        private Product() { } // For EF Core

        public Product(string name, decimal price)
        {
            if (string.IsNullOrWhiteSpace(name))
                throw new ArgumentException("Name is required");
            if (price < 0)
                throw new ArgumentException("Price cannot be negative");

            Name = name;
            Price = price;
        }

        public void UpdatePrice(decimal newPrice)
        {
            if (newPrice < 0)
                throw new ArgumentException("Price cannot be negative");
            Price = newPrice;
        }
    }
}

// Domain Interface
namespace MyApp.Domain.Interfaces
{
    public interface IProductRepository
    {
        Task<Product> GetByIdAsync(int id);
        Task<IEnumerable<Product>> GetAllAsync();
        Task<Product> AddAsync(Product product);
        Task UpdateAsync(Product product);
    }
}

// Application Layer - Use Cases
namespace MyApp.Application.Features.Products.Commands
{
    public class CreateProductCommand : IRequest<int>
    {
        public string Name { get; set; }
        public decimal Price { get; set; }
    }

    public class CreateProductCommandHandler : IRequestHandler<CreateProductCommand, int>
    {
        private readonly IProductRepository _repository;

        public CreateProductCommandHandler(IProductRepository repository)
        {
            _repository = repository;
        }

        public async Task<int> Handle(CreateProductCommand request, CancellationToken cancellationToken)
        {
            var product = new Product(request.Name, request.Price);
            await _repository.AddAsync(product);
            return product.Id;
        }
    }
}

// Infrastructure Layer - Implementation
namespace MyApp.Infrastructure.Persistence.Repositories
{
    public class ProductRepository : IProductRepository
    {
        private readonly ApplicationDbContext _context;

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

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

        public async Task AddAsync(Product product)
        {
            _context.Products.Add(product);
            await _context.SaveChangesAsync();
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Best For

Complex business applications where maintainability, testability, and long-term evolution are critical.


4. Vertical Slice Architecture

What Is It?

Vertical Slice Architecture organizes code by features (slices) rather than by technical layers. Each slice contains all the code needed for a specific feature, from presentation to data access.

Key Characteristics

  • Code organized by feature, not by layer
  • Each feature is independent
  • Reduced coupling between features
  • Easier to understand and modify specific features
  • Encourages DDD patterns within slices

When to Use It

  • Feature-rich applications
  • When you need to isolate features
  • When features have different lifecycle requirements
  • When you want to avoid horizontal layer coupling

Pros

  • ✅ Feature isolation
  • ✅ Easier to understand specific features
  • ✅ Reduced coupling between features
  • ✅ Better alignment with business domains
  • ✅ Easier to remove or modify features

Cons

  • ❌ Code duplication between features
  • ❌ Less reuse of infrastructure code
  • ❌ Can be harder to see overall architecture
  • ❌ May require more boilerplate per feature

Implementation Structure

Solution/
├── Features/
│   ├── Products/
│   │   ├── Create/
│   │   │   ├── Endpoint.cs
│   │   │   ├── Validator.cs
│   │   │   └── Request.cs
│   │   ├── Get/
│   │   │   ├── Endpoint.cs
│   │   │   └── Request.cs
│   │   └── Models/
│   │       └── ProductDto.cs
│   ├── Orders/
│   │   ├── Create/
│   │   ├── Get/
│   │   └── Models/
│   └── Customers/
├── Infrastructure/
│   ├── Persistence/
│   └── Services/
└── API/
    └── Program.cs
Enter fullscreen mode Exit fullscreen mode

Example Code

// Features/Products/Create/Endpoint.cs
public static class CreateProduct
{
    public class Endpoint : IEndpoint
    {
        public void MapEndpoint(IEndpointRouteBuilder app)
        {
            app.MapPost("/api/products", HandleAsync)
               .WithName("CreateProduct")
               .WithOpenApi();
        }

        public static async Task<IResult> HandleAsync(
            CreateProductRequest request,
            IMediator mediator)
        {
            var command = new CreateProductCommand(request.Name, request.Price);
            var productId = await mediator.Send(command);
            return Results.Created($"/api/products/{productId}", new { id = productId });
        }
    }
}

// Features/Products/Create/Request.cs
public record CreateProductRequest(string Name, decimal Price);

// Features/Products/Create/Command.cs
public record CreateProductCommand(string Name, decimal Price) : IRequest<int>;

public class CreateProductCommandHandler : IRequestHandler<CreateProductCommand, int>
{
    private readonly ApplicationDbContext _context;

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

    public async Task<int> Handle(CreateProductCommand request, CancellationToken cancellationToken)
    {
        var product = new Product(request.Name, request.Price);
        _context.Products.Add(product);
        await _context.SaveChangesAsync(cancellationToken);
        return product.Id;
    }
}

// Features/Products/Get/Endpoint.cs
public static class GetProduct
{
    public class Endpoint : IEndpoint
    {
        public void MapEndpoint(IEndpointRouteBuilder app)
        {
            app.MapGet("/api/products/{id}", HandleAsync)
               .WithName("GetProduct")
               .WithOpenApi();
        }

        public static async Task<IResult> HandleAsync(
            int id,
            IMediator mediator)
        {
            var query = new GetProductQuery(id);
            var product = await mediator.Send(query);
            return product is not null ? Results.Ok(product) : Results.NotFound();
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Best For

Feature-rich applications where features have different lifecycles and need to be isolated from each other.


5. Hexagonal Architecture (Ports and Adapters)

What Is It?

Hexagonal Architecture (Ports and Adapters) focuses on isolating the application core from external concerns through ports (interfaces) and adapters (implementations).

Key Concepts

  • Ports - Interfaces defining how the application interacts with the outside world
  • Adapters - Implementations of ports that connect to external systems
  • Application Core - Business logic that uses ports
  • Primary Adapters - Driving adapters (UI, API endpoints)
  • Secondary Adapters - Driven adapters (databases, external services)

When to Use It

  • When you need to support multiple external systems
  • When you need to isolate core logic from infrastructure
  • When you need to test without external dependencies
  • When you need to support multiple interfaces to the same core

Pros

  • ✅ Maximum flexibility
  • ✅ Easy to test in isolation
  • ✅ Easy to add new external systems
  • ✅ Clear boundary definitions

Cons

  • ❌ Can introduce complexity
  • ❌ Requires careful interface design
  • ❌ May be overkill for simple applications

Example Code

// Ports (Interfaces)
public interface IPaymentProcessor
{
    Task<PaymentResult> ProcessAsync(PaymentRequest request);
}

public interface IOrderRepository
{
    Task<Order> GetByIdAsync(int id);
    Task SaveAsync(Order order);
}

// Application Core (Uses Ports)
public class OrderService
{
    private readonly IPaymentProcessor _paymentProcessor;
    private readonly IOrderRepository _orderRepository;

    public OrderService(IPaymentProcessor paymentProcessor, IOrderRepository orderRepository)
    {
        _paymentProcessor = paymentProcessor;
        _orderRepository = orderRepository;
    }

    public async Task<PaymentResult> ProcessOrderPayment(int orderId)
    {
        var order = await _orderRepository.GetByIdAsync(orderId);
        var request = new PaymentRequest(order.TotalAmount, order.CustomerId);

        var result = await _paymentProcessor.ProcessAsync(request);

        if (result.Success)
        {
            order.MarkAsPaid();
            await _orderRepository.SaveAsync(order);
        }

        return result;
    }
}

// Adapters (Implementations)
public class StripePaymentProcessor : IPaymentProcessor
{
    private readonly IStripeClient _stripeClient;

    public StripePaymentProcessor(IStripeClient stripeClient)
    {
        _stripeClient = stripeClient;
    }

    public async Task<PaymentResult> ProcessAsync(PaymentRequest request)
    {
        var charge = await _stripeClient.Charge.CreateAsync(new ChargeCreateOptions
        {
            Amount = (long)(request.Amount * 100),
            Currency = "usd",
            Customer = request.CustomerId
        });

        return new PaymentResult(charge.Status == "succeeded", charge.Id);
    }
}
Enter fullscreen mode Exit fullscreen mode

Best For

Applications with multiple external integrations or when maximum flexibility and testability are required.


Architecture Comparison Table

Architecture Complexity Testability Flexibility Best For
Monolithic Low Medium Low Simple apps, small teams
Layered Medium Medium Medium Enterprise apps, clear separation
Clean/Onion High High High Complex domains, long-lived apps
Vertical Slice Medium-High High High Feature-rich apps, DDD
Hexagonal High High Very High Multiple external systems

When to Choose Which Architecture

Choose Monolithic when:

  • Building a simple application
  • Small development team
  • Limited complexity
  • Quick time to market is priority
  • No need for microservices

Choose Layered Architecture when:

  • Building traditional enterprise applications
  • Need clear separation of concerns
  • Different teams work on different layers
  • Application has moderate complexity

Choose Clean/Onion Architecture when:

  • Building complex business domains
  • Long-term maintainability is critical
  • Need maximum testability
  • Want to avoid framework lock-in
  • Application has complex business rules

Choose Vertical Slice Architecture when:

  • Building feature-rich applications
  • Features have different lifecycles
  • Want feature isolation
  • Following DDD principles

Choose Hexagonal Architecture when:

  • Supporting multiple external systems
  • Need maximum flexibility
  • Core logic must be isolated from infrastructure
  • Testing without external dependencies is critical

Migration Paths

From Monolithic to Layered:

  1. Identify logical layers in existing code
  2. Create separate projects for each layer
  3. Move code to appropriate projects
  4. Introduce interfaces between layers

From Layered to Clean Architecture:

  1. Extract domain entities from services
  2. Create domain project with entities and interfaces
  3. Move application logic to Application project
  4. Move data access to Infrastructure project
  5. Introduce dependency injection

From Clean to Vertical Slice:

  1. Group code by feature instead of layer
  2. Create feature folders with all related code
  3. Keep domain entities but organize around features
  4. Reduce horizontal dependencies

Best Practices for .NET 10

  1. Use Dependency Injection - Built into ASP.NET Core, essential for Clean Architecture
  2. Follow SOLID Principles - Especially Dependency Inversion for architecture
  3. Use CQRS Pattern - Command Query Responsibility Segregation for complex operations
  4. Implement Mediator Pattern - For decoupling requests and handlers
  5. Use Repository Pattern - For data access abstraction
  6. Implement Specification Pattern - For complex queries
  7. Use Value Objects - For immutable data structures
  8. Implement Domain Events - For loose coupling between domain components

Conclusion

Choosing the right architecture depends on your specific needs:

  • For simple apps → Monolithic or Layered
  • For complex domains → Clean/Onion Architecture
  • For feature-rich apps → Vertical Slice
  • For maximum flexibility → Hexagonal

Remember: The best architecture is the one that serves your application's needs today and can evolve as your requirements change. Start simple and refactor as needed.


Further Reading


Connect with me on LinkedIn: https://www.linkedin.com/in/vikrant-bagal

Top comments (0)