The Layered Architecture Tax
Most developers start by learning the “N-Tier” stack. You know the pattern: Controllers talk to Services, Services talk to Repositories, and Repositories talk to the Database. For decades, this has been the default choice for keeping code “organized.”
However, I spent years fighting a consistent bottleneck in every enterprise project. Imagine you need to add a single ‘DiscountCode’ field to an order. In a layered project, you’re forced to perform what engineers call “Shotgun Surgery.” You open the Order entity, the OrderDTO, the OrderRepository, the IOrderService, the implementation, and finally the Controller. I once timed this; it took 15 minutes of tab-switching just to add one database column.
The code is decoupled by layer, but it’s tightly coupled by feature. This mismatch is why development feels sluggish. You spend more time navigating a 40-folder deep structure than writing business logic. Vertical Slice Architecture (VSA) fixes this by grouping code around “what it does” instead of “what it is technically.”
Moving to Slices in Under 10 Minutes
In a standard project, your folders look like a library index, scattering logic across the entire solution:
/Controllers
- ProductController.cs
/Services
- ProductService.cs
/Repositories
- ProductRepository.cs
/Models
- Product.cs
- ProductDto.cs
In Vertical Slice Architecture, we stop grouping by technical role. Instead, we group by business capability. A quick refactor looks like this:
/Features
/Products
/GetProduct
- GetProductEndpoint.cs
- GetProductHandler.cs
- GetProductResponse.cs
/CreateProduct
- CreateProductEndpoint.cs
- CreateProductCommand.cs
- CreateProductHandler.cs
- CreateProductValidator.cs
Now, everything required to create a product lives in one place. If the business changes the creation rules, you open one folder. No more hunting through five different layers to find where the validation logic is hidden.
Why Slices Outperform Layers
Clean Architecture often relies on “just-in-case” abstractions. We create interfaces for every service because “we might swap the database.” In reality, I’ve seen teams spend 100+ hours maintaining interfaces for a database migration that never happened in five years. Business requirements, however, change every single week.
Building in slices allows each feature to be unique. If one feature is a basic CRUD operation, let the handler talk directly to the database using an ORM. If another involves complex pricing logic, use a rich domain model. You aren’t forced into a “one-size-fits-all” coffin for every API endpoint.
A Practical Python/FastAPI Example
Instead of a bloated 2,000-line services.py, we create a specific file for the feature. Here is a slice for updating a user profile:
# features/users/update_profile.py
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from sqlalchemy.orm import Session
from db import get_db, User
router = APIRouter()
class UpdateProfileRequest(BaseModel):
display_name: str
bio: str
@router.put("/users/me")
def handle_update_profile(request: UpdateProfileRequest, db: Session = Depends(get_db)):
# Logic, validation, and persistence happen right here.
user = db.query(User).filter(User.id == current_user_id).first()
user.display_name = request.display_name
user.bio = request.bio
db.commit()
return {"status": "success"}
By putting the schema and logic in one file, I’ve seen teams reduce their “time-to-first-commit” for new features by nearly 40%. You keep the entire context of the change in your head without jumping between 12 open tabs.
Handling Shared Logic Without the Mess
The first question I always get is: “Don’t I end up repeating myself?” If two slices both need to calculate a specific tax rate, you don’t copy-paste the code.
The solution is to move truly shared rules into a Domain or Shared folder. But here is the catch: don’t share code just because it looks similar today. Two features might look identical on Monday but evolve into completely different animals by Friday. In VSA, we prefer a few lines of duplication over a brittle, “god-object” abstraction that breaks everything when it changes.
Using the Mediator Pattern (C#)
For larger projects, the Mediator pattern (like the MediatR library) keeps controllers thin. The controller simply broadcasts a message, and the specific handler in the slice picks it up.
// Features/Orders/CreateOrder/CreateOrderHandler.cs
public class CreateOrderHandler : IRequestHandler<CreateOrderCommand, Guid>
{
private readonly AppDbContext _db;
public CreateOrderHandler(AppDbContext db) => _db = db;
public async Task<Guid> Handle(CreateOrderCommand request, CancellationToken ct)
{
var order = new Order(request.CustomerId);
_db.Orders.Add(order);
await _db.SaveChangesAsync(ct);
return order.Id;
}
}
Actionable Tips for Your Next Sprint
- Start Small: Don’t refactor the whole monolith. Create a
/Featuresfolder and build your next ticket as a vertical slice. - Enforce Boundaries: Slices shouldn’t call each other directly. Use events or shared domain models if they need to communicate.
- Delete the Repository: If a slice only needs one SQL query, just write the query in the handler. You don’t need a three-layer abstraction for a simple
SELECTstatement. - Refactor by Split: If a folder like
/Ordersgets crowded, split it./Orders/Canceland/Orders/Refundare much easier to manage than one massiveOrderService.
Switching from layers to features lowers your cognitive load. It might feel “messy” to see a database query next to a validation rule, but the productivity boost is undeniable. You’re no longer a folder navigator; you’re a feature builder who ships faster.

