For decades, the dominant approach to structuring applications has been Layered Architecture, also known as N-Tier or Horizontal Slice Architecture. In this model, you organize your code by technical concern:

  • Controllers/ (Presentation)
  • Services/ (Business Logic)
  • Repositories/ or DAL/ (Data Access)
  • Models/ (Shared Data Structures)

To add a new feature, like “Add a comment to a post,” you have to touch multiple layers. You add a method to the PostController, the PostService, and the PostRepository. This is so common we often don’t even question it.

But what if there’s a better way? Vertical Slice Architecture challenges this convention. Instead of organizing code by horizontal layers, you organize it by vertical feature slices.

The Problem with Layered Architecture

While layers are great for separation of concerns, they often lead to:

  1. Low Cohesion: The code for a single feature is smeared across multiple folders. The PostController.ts and PostService.ts files might contain logic for a dozen different features (create post, edit post, add comment, delete post, etc.), making them large and hard to navigate.
  2. High Coupling: The layers are often tightly coupled. A small change in the PostService might require changes up in the PostController and down in the PostRepository. You can’t easily change one part of a feature without understanding the entire stack.
  3. “Fat” Service Layers: The business logic layer often becomes a dumping ground for any logic that doesn’t fit neatly into the controller or repository, leading to massive, god-like service classes.
  4. Difficult Navigation: When working on a specific feature, you have to jump between many different folders (Controllers, Services, Repositories) instead of having all the relevant code in one place.

The Vertical Slice Approach

A vertical slice contains all the code needed to implement a single feature, from the UI to the database. Instead of grouping by type, you group by feature.

Your folder structure might look like this:

/src
  /features
    /posts
      /create-post
        - CreatePostCommand.ts
        - CreatePostHandler.ts
        - CreatePostController.ts
        - CreatePostValidator.ts
        - IPostRepository.ts // An interface specific to this slice's needs
      /add-comment
        - AddCommentCommand.ts
        - AddCommentHandler.ts
        - AddCommentRequest.ts
        - AddCommentResponse.ts
      /get-post-by-id
        - GetPostByIdQuery.ts
        - GetPostByIdHandler.ts
        - PostViewModel.ts
    /users
      /register-user
        - ...
      /get-user-profile
        - ...
  /shared
    - DatabaseContext.ts
    - ILogger.ts

Key Characteristics

  • Feature-Focused: Each slice is self-contained and implements a single business use case.
  • High Cohesion: All the code that changes together now lives together. When you work on the “add comment” feature, you’re working in one folder.
  • Low Coupling: Slices should be independent of each other. The “add comment” slice should not directly call the “create post” slice. They communicate through a mediator or a message bus, or by invoking each other’s commands/queries.
  • No Shared “Service” Layer: The business logic for a feature lives within the slice itself, typically in a “Handler” class. This avoids the “fat service” problem.

TypeScript Example: Creating a Post

Let’s see how a “Create Post” feature might be implemented in a vertical slice. This approach pairs extremely well with patterns like CQRS and tools like MediatR (in .NET) or mediatr-ts (in TypeScript).

1. The Command (The Request) This object defines the data needed for the feature.

// /features/posts/create-post/CreatePostCommand.ts
export class CreatePostCommand {
  constructor(
    public readonly authorId: string,
    public readonly title: string,
    public readonly content: string
  ) {}
}

2. The Handler (The Logic) This is where the business logic for this specific feature lives.

// /features/posts/create-post/CreatePostHandler.ts
import { Post } from '../../../shared/Post'; // A shared entity
import { IDatabase } from '../../../shared/IDatabase';

export class CreatePostHandler {
  constructor(private readonly db: IDatabase) {}

  public async handle(command: CreatePostCommand): Promise<string> {
    // 1. Validation
    if (!command.title || command.title.length < 5) {
      throw new Error("Title must be at least 5 characters long.");
    }
    
    // 2. Business Logic
    const newPost = new Post(command.authorId, command.title, command.content);
    newPost.publish(); // Some domain logic on the entity
    
    // 3. Persistence
    await this.db.posts.add(newPost);
    await this.db.saveChanges();
    
    return newPost.id;
  }
}

3. The Controller (The Entry Point) The controller becomes very thin. Its only job is to receive the request, send the corresponding command/query, and return the response.

// /features/posts/create-post/CreatePostController.ts
// Assuming a mediator pattern is used to dispatch commands
app.post('/posts', async (req, res) => {
  const command = new CreatePostCommand(
    req.body.authorId,
    req.body.title,
    req.body.content
  );
  
  // The controller doesn't know about the handler, it just dispatches the command.
  const postId = await mediator.send(command); 
  
  res.status(201).send({ id: postId });
});

Notice how we’ve avoided a generic PostService. The logic for creating a post is right here in the CreatePostHandler.

Python Example with Flask

The same principles apply in Python.

# /features/posts/create_post.py

# --- The Command ---
class CreatePostCommand:
    def __init__(self, author_id: int, title: str, content: str):
        self.author_id = author_id
        self.title = title
        self.content = content

# --- The Handler ---
class CreatePostHandler:
    def __init__(self, db_session):
        self._db = db_session

    def handle(self, command: CreatePostCommand) -> int:
        # Validation
        if len(command.title) < 5:
            raise ValueError("Title must be at least 5 characters long.")
            
        # Business Logic & Persistence
        new_post = Post(author_id=command.author_id, title=command.title, content=command.content)
        self._db.add(new_post)
        self._db.commit()
        return new_post.id

# --- The Controller/Endpoint ---
from flask import Blueprint, request

posts_blueprint = Blueprint('posts', __name__)

@posts_blueprint.route('/posts', methods=['POST'])
def create_post_endpoint():
    # In a real app, you'd get the handler via dependency injection
    db_session = get_db_session()
    handler = CreatePostHandler(db_session)
    
    command = CreatePostCommand(
        author_id=request.json['author_id'],
        title=request.json['title'],
        content=request.json['content']
    )
    
    post_id = handler.handle(command)
    return {"id": post_id}, 201

Benefits of Vertical Slices

  • Easier to Understand: When you need to understand a feature, all the relevant code is in one place. You don’t have to trace calls through multiple layers.
  • Optimized for Change: Features are added, changed, and removed more often than layers are. This structure aligns your codebase with the way your business actually evolves.
  • Increased Team Autonomy: Different teams or developers can “own” different features with fewer merge conflicts, as they are working in separate, isolated folders.
  • Tailored Implementations: Each slice can have its own tailored implementation. One feature might use raw SQL for performance, while another uses a full ORM. You’re not locked into a one-size-fits-all approach dictated by a generic repository layer.

Vertical Slice Architecture is not about abandoning separation of concerns. It’s about redefining the axis of that separation. Instead of separating by technical layer, you separate by business feature, leading to a more modular, maintainable, and business-aligned codebase.