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:
- Adding a new feature requires touching multiple layers (controller, service, repository, model)
- Changes ripple across layers, requiring coordination between files far apart in the codebase
- Shared services become bloated as different features add their requirements
- Testing requires mocking through multiple layers
- Understanding a single feature requires jumping between directories and layers
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
- High Cohesion: Everything related to a feature lives together
- Low Coupling: Features are independent and rarely affect each other
- Minimal Abstraction: Don’t abstract until you have concrete duplication
- Feature-Focused: Organize around business capabilities, not technical concerns
When to Use Vertical Slice Architecture
Best for:
- Applications with many distinct features that evolve independently
- Teams practicing agile with feature-based iterations
- CQRS-style systems where reads and writes differ significantly
- Microservices where each service handles specific bounded contexts
- Projects where different features have different performance/security requirements
Less suitable for:
- Simple CRUD applications with uniform behavior across entities
- Projects where cross-cutting technical concerns dominate
- Small applications where the organizational overhead exceeds benefits
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
- Faster development: Add features without touching existing code
- Easier testing: Test complete features in isolation
- Better understanding: All code for a feature is in one place
- Independent deployment: Features can be deployed separately
- Team scaling: Different teams can own different features
Disadvantages
- Code duplication: Same data access logic might appear in multiple features
- Less reuse: Shared abstractions are discouraged, leading to repetition
- Requires discipline: Developers must resist premature abstraction
- Harder to enforce standards: Each feature might do things differently
When to Abstract
Don’t abstract immediately, but DO extract shared logic when:
- Same code appears in 3+ features (rule of three)
- Business rule must be consistent across features (e.g., tax calculation)
- Infrastructure concerns are truly cross-cutting (logging, auth)
Migration Strategy
Moving from layered to vertical slice architecture:
- Start with new features: Implement new features as vertical slices
- Extract hot spots: Move frequently-changed features to vertical slices
- Leave stable code: Don’t refactor stable, working code just for consistency
- 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:
- Feature-rich applications
- Autonomous teams
- Rapidly evolving products
- Systems where features have different non-functional requirements
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.