Vertical Slice Architecture: Organizing Code by Features, Not Layers

Vertical Slice Architecture: Organizing Code by Features, Not Layers

The Problem with Traditional Layered Architecture

Most applications follow layered architecture: controllers/routes at the top, business logic in the middle, data access at the bottom. Each layer talks only to the adjacent layer. It feels organized and follows separation of concerns.

But here’s what actually happens:

The core issue: We optimize for technical separation (presentation, business logic, data) instead of feature cohesion. But most changes happen within a single feature, not across all features in one layer.

What is Vertical Slice Architecture?

Vertical Slice Architecture organizes code by feature or use case rather than by technical layer. Each feature contains everything needed to execute that specific use case - from handling the request to database access.

Think of it as slicing your application vertically through all layers, with each slice representing one complete feature or user capability.

Core Principles

  1. High Cohesion: Everything related to a feature lives together
  2. Low Coupling: Features are independent and rarely affect each other
  3. Minimal Abstraction: Don’t abstract until you have concrete duplication
  4. Feature-Focused: Organize around business capabilities, not technical concerns

When to Use Vertical Slice Architecture

Best for:

Less suitable for:

Implementation Examples

Go Implementation

// Traditional layered structure (what we're avoiding)
// app/
//   controllers/
//   services/
//   repositories/
//   models/

// Vertical slice structure
// features/
//   users/
//     register/
//     login/
//     update-profile/
//   orders/
//     create-order/
//     fulfill-order/
//     cancel-order/

// Example: Create Order Feature
// features/orders/create_order/handler.go
package createorder

import (
    "context"
    "net/http"
    "github.com/youapp/pkg/database"
    "github.com/youapp/pkg/events"
)

// Command represents the input for creating an order
type Command struct {
    UserID    string    `json:"user_id"`
    Items     []Item    `json:"items"`
    AddressID string    `json:"address_id"`
}

type Item struct {
    ProductID string `json:"product_id"`
    Quantity  int    `json:"quantity"`
}

// Handler contains all logic for creating an order
type Handler struct {
    db     *database.DB
    events events.Publisher
}

func NewHandler(db *database.DB, events events.Publisher) *Handler {
    return &Handler{db: db, events: events}
}

// Handle processes the create order request
func (h *Handler) Handle(ctx context.Context, cmd Command) (*OrderCreated, error) {
    // Validation
    if err := validateCommand(cmd); err != nil {
        return nil, err
    }
    
    // Business logic - all in one place for this feature
    tx, err := h.db.BeginTx(ctx)
    if err != nil {
        return nil, err
    }
    defer tx.Rollback()
    
    // Check inventory
    for _, item := range cmd.Items {
        available, err := checkInventory(ctx, tx, item.ProductID, item.Quantity)
        if err != nil {
            return nil, err
        }
        if !available {
            return nil, ErrInsufficientInventory
        }
    }
    
    // Create order record
    orderID, err := insertOrder(ctx, tx, cmd.UserID, cmd.AddressID)
    if err != nil {
        return nil, err
    }
    
    // Insert order items
    if err := insertOrderItems(ctx, tx, orderID, cmd.Items); err != nil {
        return nil, err
    }
    
    // Reserve inventory
    if err := reserveInventory(ctx, tx, cmd.Items); err != nil {
        return nil, err
    }
    
    if err := tx.Commit(); err != nil {
        return nil, err
    }
    
    // Publish event (asynchronous)
    result := &OrderCreated{OrderID: orderID, UserID: cmd.UserID}
    h.events.Publish(ctx, "order.created", result)
    
    return result, nil
}

// Data access functions specific to this feature
func checkInventory(ctx context.Context, tx database.Tx, productID string, quantity int) (bool, error) {
    var available int
    err := tx.QueryRow(ctx, 
        "SELECT quantity FROM inventory WHERE product_id = $1", 
        productID,
    ).Scan(&available)
    return available >= quantity, err
}

func insertOrder(ctx context.Context, tx database.Tx, userID, addressID string) (string, error) {
    // Implementation specific to create order feature
    // May differ from how other features work with orders
}

// Register HTTP handler
// features/orders/create_order/routes.go
func RegisterRoutes(router *http.ServeMux, handler *Handler) {
    router.HandleFunc("/api/orders", func(w http.ResponseWriter, r *http.Request) {
        var cmd Command
        if err := json.NewDecoder(r.Body).Decode(&cmd); err != nil {
            http.Error(w, err.Error(), http.StatusBadRequest)
            return
        }
        
        result, err := handler.Handle(r.Context(), cmd)
        if err != nil {
            http.Error(w, err.Error(), http.StatusInternalServerError)
            return
        }
        
        json.NewEncoder(w).Encode(result)
    })
}

Python Implementation with FastAPI

# features/orders/create_order/handler.py
from dataclasses import dataclass
from typing import List
from sqlalchemy.ext.asyncio import AsyncSession
from fastapi import APIRouter, Depends, HTTPException

# Command pattern for the feature
@dataclass
class CreateOrderCommand:
    user_id: str
    items: List['OrderItem']
    address_id: str

@dataclass
class OrderItem:
    product_id: str
    quantity: int

@dataclass
class OrderCreated:
    order_id: str
    user_id: str
    total_amount: float

# All logic for creating an order in one handler
class CreateOrderHandler:
    def __init__(self, db: AsyncSession, event_bus: EventBus):
        self.db = db
        self.event_bus = event_bus
    
    async def handle(self, command: CreateOrderCommand) -> OrderCreated:
        """Complete logic for order creation in one place"""
        
        # Validation
        self._validate_command(command)
        
        async with self.db.begin():
            # Check inventory availability
            for item in command.items:
                available = await self._check_inventory(
                    item.product_id, 
                    item.quantity
                )
                if not available:
                    raise InsufficientInventoryError(item.product_id)
            
            # Calculate total (could be complex with discounts, taxes)
            total = await self._calculate_total(command.items)
            
            # Create order
            order_id = await self._insert_order(
                command.user_id,
                command.address_id,
                total
            )
            
            # Create order items
            await self._insert_order_items(order_id, command.items)
            
            # Reserve inventory
            await self._reserve_inventory(command.items)
        
        # Publish event asynchronously
        result = OrderCreated(order_id, command.user_id, total)
        await self.event_bus.publish("order.created", result)
        
        return result
    
    # Private methods for data access - specific to this feature
    async def _check_inventory(self, product_id: str, quantity: int) -> bool:
        result = await self.db.execute(
            "SELECT quantity FROM inventory WHERE product_id = :pid",
            {"pid": product_id}
        )
        available = result.scalar_one_or_none()
        return available is not None and available >= quantity
    
    async def _calculate_total(self, items: List[OrderItem]) -> float:
        # Logic specific to order creation
        # Different features might calculate totals differently
        pass
    
    async def _insert_order(self, user_id: str, address_id: str, total: float) -> str:
        # Direct SQL or ORM - whatever makes sense for THIS feature
        pass

# features/orders/create_order/routes.py
router = APIRouter(prefix="/api/orders", tags=["orders"])

@router.post("/", response_model=OrderCreated)
async def create_order(
    command: CreateOrderCommand,
    handler: CreateOrderHandler = Depends(get_handler)
):
    """Create a new order"""
    try:
        return await handler.handle(command)
    except InsufficientInventoryError as e:
        raise HTTPException(status_code=400, detail=str(e))
    except Exception as e:
        raise HTTPException(status_code=500, detail="Order creation failed")

ReactJS Implementation

// features/orders/CreateOrder/CreateOrderForm.tsx
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';

// Everything for the "create order" feature in one place
interface CreateOrderCommand {
  userId: string;
  items: OrderItem[];
  addressId: string;
}

interface OrderItem {
  productId: string;
  quantity: number;
}

// API client specific to this feature
async function createOrder(command: CreateOrderCommand): Promise<OrderCreated> {
  const response = await fetch('/api/orders', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(command),
  });
  
  if (!response.ok) {
    const error = await response.json();
    throw new Error(error.detail || 'Order creation failed');
  }
  
  return response.json();
}

// Complete feature component
export function CreateOrderForm({ userId, onSuccess }: Props) {
  const [items, setItems] = useState<OrderItem[]>([]);
  const [addressId, setAddressId] = useState('');
  const queryClient = useQueryClient();
  
  // Mutation handling specific to this feature
  const mutation = useMutation({
    mutationFn: createOrder,
    onSuccess: (data) => {
      // Invalidate related queries
      queryClient.invalidateQueries({ queryKey: ['orders'] });
      queryClient.invalidateQueries({ queryKey: ['inventory'] });
      
      // Feature-specific success handling
      toast.success(`Order ${data.orderId} created successfully`);
      onSuccess?.(data);
    },
    onError: (error: Error) => {
      // Feature-specific error handling
      if (error.message.includes('inventory')) {
        toast.error('Some items are out of stock');
      } else {
        toast.error('Failed to create order');
      }
    },
  });
  
  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    
    // Validation specific to this feature
    if (items.length === 0) {
      toast.error('Add at least one item');
      return;
    }
    
    mutation.mutate({ userId, items, addressId });
  };
  
  return (
    <form onSubmit={handleSubmit}>
      {/* Form fields */}
      <ItemSelector items={items} onChange={setItems} />
      <AddressSelector value={addressId} onChange={setAddressId} />
      
      <button 
        type="submit" 
        disabled={mutation.isPending}
      >
        {mutation.isPending ? 'Creating...' : 'Create Order'}
      </button>
      
      {mutation.error && (
        <ErrorMessage error={mutation.error} />
      )}
    </form>
  );
}

// features/orders/CreateOrder/index.ts
export { CreateOrderForm } from './CreateOrderForm';
export type { CreateOrderCommand, OrderCreated } from './types';

Key Patterns and Practices

1. Command/Query Pattern

Each feature is a command (write) or query (read) with clear input/output contracts:

// Command
type CreateOrderCommand struct { ... }
type OrderCreated struct { ... }

// Query  
type GetOrderQuery struct { ... }
type OrderDetails struct { ... }

2. Feature Composition

When features need to share logic, extract to a shared package, but keep it minimal:

features/
  orders/create_order/
  orders/cancel_order/
pkg/
  domain/       # Shared domain models
  events/       # Event infrastructure
  validation/   # Common validation

3. Testing Strategy

Each feature is independently testable:

func TestCreateOrder(t *testing.T) {
    // Setup: test database, test event bus
    db := setupTestDB(t)
    events := &mockEventBus{}
    handler := NewHandler(db, events)
    
    // Execute: test the complete feature
    cmd := Command{
        UserID: "user-1",
        Items: []Item{{ProductID: "prod-1", Quantity: 2}},
    }
    result, err := handler.Handle(context.Background(), cmd)
    
    // Assert: verify the complete behavior
    assert.NoError(t, err)
    assert.NotEmpty(t, result.OrderID)
    assert.Equal(t, "order.created", events.LastEvent)
}

Trade-offs and Considerations

Advantages

Disadvantages

When to Abstract

Don’t abstract immediately, but DO extract shared logic when:

Migration Strategy

Moving from layered to vertical slice architecture:

  1. Start with new features: Implement new features as vertical slices
  2. Extract hot spots: Move frequently-changed features to vertical slices
  3. Leave stable code: Don’t refactor stable, working code just for consistency
  4. Gradual extraction: Both patterns can coexist during migration

Conclusion

Vertical Slice Architecture optimizes for feature development velocity and team independence rather than technical purity. It works exceptionally well for:

The key insight: organize around change patterns, not technical layers. Most changes affect a single feature end-to-end, so organize code to make those changes easy.

Start experimenting with vertical slices for new features. You’ll likely find development becomes faster, testing becomes simpler, and understanding the codebase becomes easier.