N-Layered Architecture in .NET: The Foundation You Need to Master
N-Layered architecture is probably the first pattern you encountered as a .NET developer. It is everywhere: legacy codebases, tutorials, enterprise projects. Before you move to Clean Architecture or Vertical Slicing, you need to truly understand this one. Not just follow it blindly, but know why it exists, where it holds up, and when it starts hurting you.
A bit of history #
Before diving into the theory, a bit of history matters. In the early 2000s, Microsoft’s own tutorials, the default Visual Studio project templates (WebForms, and later the first MVC scaffolding), and most of the official docs actively pushed developers toward mixing concerns. You would drop a SqlDataSource straight into your .aspx markup, wire business rules inside a button’s code-behind, and sprinkle raw ADO.NET calls inside what would later become controllers. It shipped fast, it demoed well, and it rotted just as fast the moment the app grew past a handful of screens. N-Layered architecture did not fall from an ivory tower of abstract theory: it emerged as the community’s pragmatic response to that mess, a way to draw clear lines between what the user sees, what the business decides, and what the database stores. Understanding that origin makes the rest of this article click.
Why it exists: the real problem it solves #
Imagine you join a project. The codebase is a single Web project. Controllers query the database directly. Business logic lives inside if blocks in action methods. A bug in the billing calculation forces you to touch the same file that renders the invoice HTML. A new developer breaks payment logic while fixing a UI label.
That’s spaghetti. N-Layered architecture exists to prevent exactly this.
The idea is simple: split responsibilities into horizontal layers that can only talk to the layer directly below them. Each layer has one job.
It gives you:
- Separation of concerns, so UI code never touches the database directly
- Testability, so business logic is isolated and can be unit tested without spinning up HTTP
- Replaceability, so you can swap Entity Framework for Dapper without touching your service layer
Overview: the layers #
Controllers / Minimal API] --> B[Service Layer
Business Logic] B --> C[Repository Layer
Data Access] C --> D[Database
SQL Server / PostgreSQL] E[Domain / Models
Entities + DTOs] -.-> A E -.-> B E -.-> C
| Layer | Responsibility | Typical contents |
|---|---|---|
| Presentation | Handle HTTP, map DTOs, return responses | Controllers, Minimal API endpoints |
| Service | Orchestrate business rules | OrderService, InvoiceService |
| Repository | Abstract data access | IOrderRepository, EF Core / Dapper impl |
| Domain/Models | Shared contracts | Entities, DTOs, Enums, Interfaces |
Each layer in detail #
Domain / Models: the shared contract #
This is not really a “layer” in the strict sense. It is a shared project that everyone references. Keep it lean: entities, DTOs, enums, and repository/service interfaces.
// Domain/Entities/Order.cs
public class Order
{
public Guid Id { get; init; }
public string CustomerId { get; init; } = default!;
public List<OrderLine> Lines { get; init; } = new();
public OrderStatus Status { get; init; }
public decimal Total => Lines.Sum(l => l.Quantity * l.UnitPrice);
}
// Domain/DTOs/CreateOrderRequest.cs
public record CreateOrderRequest(
string CustomerId,
List<OrderLineDto> Lines
);
DTOs vs Entities: drawing the line at the boundary #
A DTO (Data Transfer Object) and an Entity look similar on a UML diagram, but they live completely different lives. An Entity is the shape your persistence layer cares about: it mirrors your database schema, it is tracked by EF Core’s change tracker, it carries navigation properties, and its lifecycle is tied to a DbContext. A DTO is the shape your API contract cares about: it is a flat, serializable, intent-specific payload that crosses the HTTP boundary and nothing else. Same fields sometimes, never the same job.
Returning EF Core entities directly from your controllers feels like a shortcut. It is actually four bugs waiting to happen:
- Over-posting: a client POSTs
{"id": 42, "isAdmin": true, "total": 0}and your model binder happily populates fields the caller should never be allowed to touch. - Lazy-loading serialization: the JSON serializer walks a navigation property, triggers a query outside the original scope, and you either get an
ObjectDisposedExceptionor a surprise N+1 storm in production. - Schema leaks: every column you add to the table instantly becomes part of your public API. Rename a field in the DB, break every client.
- Versioning hell: you cannot evolve the entity and the contract independently. A pure refactor on the data side becomes a breaking API change.
The fix is boring and effective: accept a request DTO, map it to an entity inside the service, persist, then map the result back to a response DTO.
DTO] Req --> Ctrl[Controller] Ctrl --> Svc[OrderService] Svc -->|map| Ent[Order
Entity] Ent --> Repo[OrderRepository] Repo --> DB[(Database)] DB --> Ent2[Order
Entity] Ent2 -->|map| Res[OrderResponse
DTO] Res --> Ctrl Ctrl -->|200 OK| Client subgraph API_Boundary[API boundary: DTOs only] Req Res end subgraph Domain[Domain and persistence: entities only] Ent Repo DB Ent2 end style Req fill:#d4f1d4,stroke:#2a7a2a style Res fill:#d4f1d4,stroke:#2a7a2a style Ent fill:#f1d4d4,stroke:#7a2a2a style Ent2 fill:#f1d4d4,stroke:#7a2a2a
Explicit mapping, no AutoMapper, no reflection magic:
public sealed record OrderResponse(
Guid Id,
string CustomerEmail,
decimal Total,
string Status,
DateTime CreatedAt,
IReadOnlyList<OrderLineResponse> Lines);
public sealed record OrderLineResponse(
string Sku,
int Quantity,
decimal UnitPrice);
internal static class OrderMappings
{
public static OrderResponse ToResponse(this Order order) =>
new(
Id: order.Id,
CustomerEmail: order.Customer.Email,
Total: order.Lines.Sum(l => l.Quantity * l.UnitPrice),
Status: order.Status.ToString(),
CreatedAt: order.CreatedAt,
Lines: order.Lines
.Select(l => new OrderLineResponse(l.Sku, l.Quantity, l.UnitPrice))
.ToList());
}
✅ Good practice : keep DTOs per use case, not per entity.
CreateOrderRequest,UpdateOrderStatusRequest,OrderSummaryResponse, andOrderDetailsResponseare four small, intention-revealing types. One giantOrderDtoreused in six endpoints always ends up with half its fields nullable, half its fields ignored, and a comment that reads “do not set this field when calling X”. That is not a DTO, that is a trap.
Repository Layer: data access only #
The repository pattern wraps your data access technology. The interface lives in Domain, the implementation lives in Infrastructure.
// Domain/Interfaces/IOrderRepository.cs
public interface IOrderRepository
{
Task<Order?> GetByIdAsync(Guid id, CancellationToken ct = default);
Task<IReadOnlyList<Order>> GetByCustomerAsync(string customerId, CancellationToken ct = default);
Task AddAsync(Order order, CancellationToken ct = default);
Task SaveChangesAsync(CancellationToken ct = default);
}
// Infrastructure/Repositories/OrderRepository.cs
public class OrderRepository : IOrderRepository
{
private readonly AppDbContext _db;
public OrderRepository(AppDbContext db) => _db = db;
public async Task<Order?> GetByIdAsync(Guid id, CancellationToken ct = default)
=> await _db.Orders
.AsNoTracking()
.Include(o => o.Lines)
.FirstOrDefaultAsync(o => o.Id == id, ct);
public async Task<IReadOnlyList<Order>> GetByCustomerAsync(
string customerId, CancellationToken ct = default)
=> await _db.Orders
.AsNoTracking()
.Where(o => o.CustomerId == customerId)
.ToListAsync(ct);
public async Task AddAsync(Order order, CancellationToken ct = default)
=> await _db.Orders.AddAsync(order, ct);
public Task SaveChangesAsync(CancellationToken ct = default)
=> _db.SaveChangesAsync(ct);
}
✅ Good practice : Always use
AsNoTracking()for read-only queries. EF Core won’t track the entity in the change tracker, which reduces memory overhead and speeds up reads.
❌ Never do this : Don’t expose
IQueryable<T>from your repository interface. It leaks your ORM abstraction upward and makes your service layer dependent on EF Core internals.
Service Layer: business logic lives here #
This is where your rules live. Not in controllers, not in repositories. The service receives a request, validates it, applies business rules, calls the repository, and returns a result.
// Application/Services/OrderService.cs
public class OrderService
{
private readonly IOrderRepository _orders;
private readonly ILogger<OrderService> _logger;
public OrderService(IOrderRepository orders, ILogger<OrderService> logger)
{
_orders = orders;
_logger = logger;
}
public async Task<Guid> CreateOrderAsync(
CreateOrderRequest request, CancellationToken ct = default)
{
if (!request.Lines.Any())
throw new ValidationException("An order must have at least one line.");
var order = new Order
{
Id = Guid.NewGuid(),
CustomerId = request.CustomerId,
Status = OrderStatus.Pending,
Lines = request.Lines.Select(l => new OrderLine
{
ProductId = l.ProductId,
Quantity = l.Quantity,
UnitPrice = l.UnitPrice
}).ToList()
};
await _orders.AddAsync(order, ct);
await _orders.SaveChangesAsync(ct);
_logger.LogInformation("Order {OrderId} created for customer {CustomerId}",
order.Id, order.CustomerId);
return order.Id;
}
}
⚠️ It works, but… : Throwing
ValidationExceptiondirectly in the service is acceptable for simple cases. In larger codebases, consider the Result pattern to avoid using exceptions for control flow. See the Error Handling series for a deep dive.
Presentation Layer: thin controllers only #
Controllers should be thin. Their only job: receive the HTTP request, call the service, map the result to an HTTP response. Zero business logic here.
// Api/Controllers/OrdersController.cs
[ApiController]
[Route("api/[controller]")]
public class OrdersController : ControllerBase
{
private readonly OrderService _orderService;
public OrdersController(OrderService orderService)
=> _orderService = orderService;
[HttpPost]
[ProducesResponseType(typeof(CreateOrderResponse), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> CreateOrder(
[FromBody] CreateOrderRequest request, CancellationToken ct)
{
var orderId = await _orderService.CreateOrderAsync(request, ct);
return CreatedAtAction(nameof(GetOrder), new { id = orderId },
new CreateOrderResponse(orderId));
}
[HttpGet("{id:guid}")]
public async Task<IActionResult> GetOrder(Guid id, CancellationToken ct)
{
var order = await _orderService.GetOrderAsync(id, ct);
return order is null ? NotFound() : Ok(order);
}
}
✅ Good practice : Use
CancellationTokenin every async controller action and pass it all the way down to the database call. When a user cancels their request or a load balancer times out, EF Core will cancel the query rather than letting it run to completion for nothing.
Solution structure #
MyApp.sln
├── src/
│ ├── MyApp.Api/ ← Presentation (Controllers, Program.cs, DI setup)
│ ├── MyApp.Application/ ← Services (business logic)
│ ├── MyApp.Infrastructure/ ← Repositories, EF Core, external integrations
│ └── MyApp.Domain/ ← Entities, DTOs, Interfaces (no dependencies)
└── tests/
├── MyApp.Application.Tests/
└── MyApp.Infrastructure.Tests/
💡 Info :
MyApp.Domainshould have zero external NuGet dependencies. If you find yourself adding EF Core or any framework package to Domain, something is wrong with your dependency direction.
Where N-Layered starts hurting #
This architecture works very well for small to medium applications. It starts showing cracks when your codebase grows:
- Anemic services: you end up with a
ProductServicethat has 25 methods, one per use case. It becomes impossible to navigate. - Fat repositories: repositories accumulate custom query methods until they become unmaintainable.
- Cross-feature coupling: adding a new feature requires touching every layer, every time.
This is not a reason to avoid N-Layered, it is a signal to evolve. The natural next step is Clean Architecture (which enforces dependency direction) or Vertical Slicing (which organizes by feature instead of by layer).
Wrap-up #
You now understand what N-Layered architecture is, how each layer relates to the others, and how to implement it correctly in a real .NET solution. You can structure a new project from scratch, keep controllers thin, isolate business logic in services, and abstract data access behind repository interfaces.
Ready to level up your next project or share it with your team? See you in the next one, Clean Architecture is waiting.