DEV Community

Cover image for Clean Architecture vs Vertical Slice: Pragmatism Over Dogma in Modern Software Design
Harry Lo
Harry Lo

Posted on

Clean Architecture vs Vertical Slice: Pragmatism Over Dogma in Modern Software Design

Why This Article Is Part of My Portfolio Journey

This piece is part of my effort to build a public engineering portfolio focused on sustainable architecture, engineering judgment, and long-term system design.

Rather than showcasing isolated code samples, I’m documenting how I think about system design, trade-offs, and real-world engineering decisions β€” because architecture is as much about reasoning as it is about implementation.

This article represents the type of engineering thinking I aim to bring into the systems I build.

Pragmatism over Dogma: Engineering Judgment in Modern Software Architecture

This article isn’t about choosing Clean Architecture or Vertical Slice as the β€œcorrect” approach.

It’s about understanding how engineering evolves β€” from structure and theory, through judgment and balance, toward pragmatic delivery.

Clean Architecture and Vertical Slice represent two ends of that spectrum.

The real skill isn’t choosing sides β€” it’s knowing how to balance them responsibly in real systems.

Software Architecture Patterns: Clean Architecture vs Vertical Slice Architecture – A Comprehensive Analysis

Author: Harry Lo

Date: January 12, 2026

Document Purpose: Research and analysis comparing architectural approaches for modern software development, with a focus on extensibility, maintainability, and avoiding technical debt.

Copyright Notice: Β© 2026 Harry Lo. This work is licensed under Creative Commons Attribution 4.0 International License (CC BY 4.0). You are free to share and adapt this material with proper attribution.


Table of Contents

  1. Architectural Comparison Overview

  2. Extensibility and Feature Addition

  3. Documentation and Knowledge Management

  4. Helper Functions and Technical Debt

  5. Open-Closed Principle in Practice

  6. Hybrid Approach

  7. Recommendations and Conclusions

  8. References

  9. About the Author


1. Architectural Comparison Overview

1.1 Clean Architecture

Definition: A layered architecture emphasizing separation of concerns with core business logic at the center, surrounded by concentric layers for application operations, interfaces, and external systems.

Strengths

  • Long-term Maintainability: Core business logic isolated from external dependencies (frameworks, databases, UI)
  • High Testability: Business rules can be tested independently without infrastructure
  • Technology Independence: Easy to swap databases, frameworks, or external services
  • Clear Separation of Concerns: Defined layers with strict dependency rules (dependencies point inward)
  • Complex Business Logic: Ideal for rich domain models and complex rules
  • Team Scalability: Clear boundaries help new developers understand structure

Weaknesses

  • High Initial Overhead: Requires significant upfront design and setup
  • Over-engineering Risk: Can be overkill for simple applications
  • Boilerplate Code: More interfaces, abstractions, and mapping between layers
  • Steep Learning Curve: Team needs to understand SOLID principles, dependency inversion
  • Potential Performance Overhead: Multiple layers can introduce latency

Best Suited For

  • Large, complex enterprise applications
  • Long-term projects (3+ years lifespan)
  • Systems with complex business rules
  • Projects requiring multiple integrations
  • Teams with experienced architects

1.2 Vertical Slice Architecture

Definition: Organizes code around specific features or use cases, treating each as a self-contained "slice" spanning all necessary layers from UI to database.

Strengths

  • Rapid Development: Features can be built end-to-end quickly
  • Feature-based Organization: All code for a feature lives together (UI β†’ DB)
  • Team Parallelization: Multiple teams can work on different slices with minimal conflicts
  • Reduced Cognitive Load: Developers only need to understand one slice at a time
  • Agile-friendly: Aligns perfectly with user stories and sprint planning
  • Early Risk Detection: Complete features reveal integration issues sooner
  • Lower Initial Complexity: Easier to start compared to Clean Architecture

Weaknesses

  • Code Duplication: Validation, error handling, and other logic may be repeated across slices
  • Cross-cutting Concerns: Handling logging, security, caching consistently requires discipline
  • Potential Inconsistency: Different slices might implement similar features differently
  • Learning Curve: Shift in mindset from horizontal layers to vertical features
  • Scaling Complexity: Managing dependencies between slices becomes challenging as they increase

Best Suited For

  • Agile/Scrum teams with frequent releases
  • Microservices or modular monoliths
  • Projects with independent features
  • Startups and MVP development
  • Teams that value speed over long-term abstractions

1.3 Key Differences

Aspect Clean Architecture Vertical Slice Architecture
Organization By technical responsibilities (layers) By business features (slices)
Dependencies Strict inward dependency rules Feature-specific dependencies
Development Approach Centralized business logic Distributed across features
Project Size Large, complex Small to medium
Timeline Long-term (years) Short to medium
Team Experience Senior developers Mixed experience
Business Complexity High domain complexity Feature-focused
Change Frequency Stable requirements Frequent pivots
Development Speed Slower initial, faster later Fast throughout
Maintainability Layer reuse, separation Feature independence

1.4 Decision Matrix

Choose Clean Architecture when:

  • Building large-scale applications where long-term maintainability is paramount
  • Complex business rules require strict separation
  • Technology stack may change over time
  • Multiple teams need clear architectural boundaries
  • Enterprise requirements demand high flexibility

Choose Vertical Slice when:

  • Rapid development and deployment are priorities
  • Features are relatively independent
  • Agile methodologies are core to workflow
  • Team prefers feature-complete iterations
  • Startup or MVP environment

Consider Hybrid when:

  • Want feature-based organization with engineering rigor
  • Need some shared domain logic
  • Cross-cutting concerns require centralization
  • Long-term project but with agile delivery

2. Extensibility and Feature Addition

2.1 The "Just Add a Slice" Advantage

One of Vertical Slice Architecture's strongest benefits is the ability to add new features by simply creating a new slice without touching existing code.

Example Structure

Features/
  β”œβ”€β”€ UserRegistration/        ← Existing
  β”œβ”€β”€ OrderProcessing/          ← Existing
  β”œβ”€β”€ PaymentHandling/          ← Existing
  └── ProductReviews/           ← NEW! Just add this slice
      β”œβ”€β”€ AddReview.cs
      β”œβ”€β”€ GetReviews.cs
      β”œβ”€β”€ DeleteReview.cs
      └── Models/
Enter fullscreen mode Exit fullscreen mode

Why This Is Powerful

1. Zero Impact on Existing Code

  • You don't touch other slices
  • No risk of breaking existing features
  • Each slice is independent

2. Parallel Development

  • Team A works on "Product Reviews"
  • Team B works on "Loyalty Program"
  • Zero merge conflicts

3. Faster Onboarding

  • New developers can study one complete example slice
  • Then copy the pattern for new features
  • Complete flow visible in one location

4. Progressive Complexity

  • Start simple: new slice = new feature
  • Grow organically: add more slices as needed
  • No upfront architectural decisions required

5. Easy Feature Flags & A/B Testing

// Enable/disable entire features easily
if (featureFlags.IsEnabled("ProductReviews"))
{
    services.AddProductReviewsSlice();
}
Enter fullscreen mode Exit fullscreen mode

2.2 Comparison with Clean Architecture

In Clean Architecture, adding a new feature requires touching multiple layers:

Domain/
  └── Entities/
      └── Review.cs              ← Add here

Application/
  └── UseCases/
      β”œβ”€β”€ AddReview/
      β”‚   β”œβ”€β”€ AddReviewUseCase.cs  ← Add here
      β”‚   └── IReviewRepository.cs ← Add here
      └── GetReviews/
          └── GetReviewsUseCase.cs   ← Add here

Infrastructure/
  └── Repositories/
      └── ReviewRepository.cs      ← Add here

Presentation/
  └── Controllers/
      └── ReviewsController.cs     ← Add here
Enter fullscreen mode Exit fullscreen mode

Challenges:

  • Navigate between 4-5 different folders
  • Each layer needs awareness of the new feature
  • More places for things to go wrong
  • Harder to see the complete feature at once

2.3 Real-World Scenarios

Scenario: E-commerce Platform Evolution

Adding features over 6 months:

With Vertical Slice:

Features/
  β”œβ”€β”€ ProductReviews/      ← Sprint 1: Just add this
  β”œβ”€β”€ Wishlist/            ← Sprint 2: Just add this
  β”œβ”€β”€ GiftCards/           ← Sprint 3: Just add this
  β”œβ”€β”€ LoyaltyPoints/       ← Sprint 4: Just add this
  β”œβ”€β”€ ProductComparison/   ← Sprint 5: Just add this
  └── RecentlyViewed/      ← Sprint 6: Just add this
Enter fullscreen mode Exit fullscreen mode

Each sprint = one new folder. Clean. Isolated. Simple.

With Clean Architecture:
Every sprint requires updating:

  • Domain layer (entities, value objects)
  • Application layer (use cases, interfaces)
  • Infrastructure layer (repositories, services)
  • Presentation layer (controllers, DTOs)

Netflix Example

The Netflix API famously uses a similar approach:

  • Each team owns complete feature slices
  • Teams deploy independently
  • Minimal coordination needed
  • Can add hundreds of features without architectural paralysis

2.4 Managing Slice Dependencies

Concern: Code Duplication

Problem: Each slice might duplicate validation logic, error handling, etc.

Solutions:

// Shared behaviors via MediatR pipelines
public class ValidationBehavior<TRequest, TResponse> 
    : IPipelineBehavior<TRequest, TResponse>
{
    // All slices automatically get validation!
}

// Shared abstractions
Common/
  β”œβ”€β”€ Behaviors/
  β”‚   β”œβ”€β”€ ValidationBehavior.cs
  β”‚   β”œβ”€β”€ LoggingBehavior.cs
  β”‚   └── TransactionBehavior.cs
  └── Abstractions/
      └── IRepository<T>.cs
Enter fullscreen mode Exit fullscreen mode

Concern: Slices Need to Communicate

Problem: Product Reviews needs Product data

Solutions:

// Option 1: Direct dependency (acceptable for queries)
Features/ProductReviews/
  └── GetReviewsQuery.cs
      // Can query Products directly

// Option 2: Events (for decoupling)
Features/OrderProcessing/
  └── OrderCompleted.cs β†’ raises event

Features/LoyaltyPoints/
  └── OrderCompletedHandler.cs β†’ listens for event
Enter fullscreen mode Exit fullscreen mode

Concern: Shared Domain Logic

Problem: Multiple slices need same business rules

Solution:

Core/
  └── Domain/
      β”œβ”€β”€ Product.cs
      β”œβ”€β”€ Order.cs
      └── Rules/          ← Shared business rules
          └── PricingRules.cs

Features/
  β”œβ”€β”€ OrderProcessing/   ← Uses PricingRules
  └── QuoteGeneration/   ← Uses PricingRules
Enter fullscreen mode Exit fullscreen mode

3. Documentation and Knowledge Management

3.1 Co-located Documentation Benefits

Documentation becomes significantly easier with Vertical Slice Architecture because docs can live right next to the code they describe.

Co-located Structure

Features/
  β”œβ”€β”€ ProductReviews/
  β”‚   β”œβ”€β”€ README.md                    ← Feature-specific docs!
  β”‚   β”œβ”€β”€ AddReview.cs
  β”‚   β”œβ”€β”€ GetReviews.cs
  β”‚   └── DeleteReview.cs
  β”œβ”€β”€ OrderProcessing/
  β”‚   β”œβ”€β”€ README.md                    ← All order docs here!
  β”‚   β”œβ”€β”€ CreateOrder.cs
  β”‚   └── CancelOrder.cs
  └── PaymentHandling/
      β”œβ”€β”€ README.md                    ← Payment docs here!
      └── ProcessPayment.cs
Enter fullscreen mode Exit fullscreen mode

Benefits:

  • Docs live right next to the code they describe
  • When you work on a feature, docs are immediately visible
  • No hunting through multiple folders or wikis

Self-Documenting Structure

Each slice tells a complete story in one place. Everything you need to know about "Product Reviews" is in ONE location.


3.2 Documentation Templates

Create a standard template for all slices to ensure consistency:

_FEATURE_TEMPLATE/README.md

# [Feature Name]

## Overview
[Brief description]

## User Stories
- As a [user], I want to [action], so that [benefit]

## Use Cases / Commands / Queries
- [ ] Command/Query name - Description

## API Endpoints
- [ ] METHOD /path - Description

## Business Rules
- [ ] Rule description

## Dependencies
- **Requires:** [Other features needed]
- **Publishes:** [Events this feature raises]
- **Consumes:** [Events this feature listens to]

## Database Changes
- [ ] Tables/Collections affected

## Configuration
- [ ] Settings required

## Testing
- [ ] Test scenarios

## Deployment Notes
- [ ] Special considerations
Enter fullscreen mode Exit fullscreen mode

Every new slice copies this template = consistent documentation across all features!


3.3 Living Documentation Practice

Documentation updates become part of feature work:

Pull Request Structure:

Features/GiftCards/
  β”œβ”€β”€ README.md          ← Updated as part of PR
  β”œβ”€β”€ PurchaseGiftCard.cs
  β”œβ”€β”€ RedeemGiftCard.cs
  └── CheckBalance.cs
Enter fullscreen mode Exit fullscreen mode

Code Review Checklist:

  • βœ… Code implements feature
  • βœ… Tests pass
  • βœ… README.md is updated

Because docs are co-located, developers naturally update them!


3.4 Onboarding Advantages

Comparison: Understanding "Product Reviews"

Vertical Slice Approach:

"Open Features/ProductReviews/ folder and read the README"
Enter fullscreen mode Exit fullscreen mode

Done in 5 minutes! βœ…

Clean Architecture Approach:

"Read these docs:
1. Domain/Entities.md (Review entity)
2. Application/UseCases.md (Review use cases)
3. Infrastructure/Repositories.md (Review storage)
4. API/Endpoints.md (Review API)
5. Domain/Events.md for ReviewSubmitted event
6. Application/EventHandlers.md for handlers"
Enter fullscreen mode Exit fullscreen mode

Takes 30+ minutes πŸ˜“

Documentation Comparison Table

Aspect Clean Architecture Vertical Slice
Doc location Scattered across layers Co-located with feature
Finding docs Search multiple folders One folder = one feature
Completeness Often incomplete Easier to keep complete
Onboarding time 30-60 minutes per feature 10-20 minutes per feature
Update likelihood Often forgotten Updated with PRs
Template reuse Hard to standardize Easy template per slice
Feature overview Requires reading multiple docs Single README

4. Helper Functions and Technical Debt

4.1 The Helper Function Decay Pattern

A common anti-pattern occurs when developers try to make a helper function serve multiple purposes by repeatedly modifying it.

Evolution of Decay

// Stage 1: Initial extraction (seems good!)
public static string FormatUserName(User user)
{
    return $"{user.FirstName} {user.LastName}";
}

// Stage 2: One place needs something slightly different
public static string FormatUserName(User user, bool includeTitle = false)
{
    if (includeTitle)
        return $"{user.Title} {user.FirstName} {user.LastName}";
    return $"{user.FirstName} {user.LastName}";
}

// Stage 3: Another place needs another variation
public static string FormatUserName(User user, bool includeTitle = false, bool lastNameFirst = false)
{
    if (lastNameFirst && includeTitle)
        return $"{user.Title} {user.LastName}, {user.FirstName}";
    if (lastNameFirst)
        return $"{user.LastName}, {user.FirstName}";
    if (includeTitle)
        return $"{user.Title} {user.FirstName} {user.LastName}";
    return $"{user.FirstName} {user.LastName}";
}

// Stage 4: TECHNICAL DEATH πŸ’€
public static string FormatUserName(
    User user, 
    bool includeTitle = false, 
    bool lastNameFirst = false,
    bool includeMiddleName = false,
    bool abbreviateMiddle = false,
    bool uppercase = false,
    NameFormat format = NameFormat.Standard)
{
    // 50+ lines of conditional logic
    // Nobody dares to change it anymore
    // Everyone is afraid it will break something
    // New developers avoid touching it
}
Enter fullscreen mode Exit fullscreen mode

4.2 Why This Leads to Technical Death

1. High Cognitive Load

  • Developers must understand ALL variations to make ANY change
  • The function serves too many masters
  • Testing becomes exponentially complex

2. Shotgun Surgery

  • A bug in one use case might require touching the shared helper
  • That change could break OTHER use cases
  • Ripple effects across the codebase

3. Fear-Driven Development

  • "Don't touch it, it works"
  • Developers add MORE parameters instead of refactoring
  • The problem compounds over time

4. Loss of Intent

  • The original purpose is obscured by all the variations
  • Code becomes about "what" not "why"
  • Business logic is hidden in boolean flags

The Root Cause: Misapplied DRY Principle

The Mistake:

"We have similar code in 3 places, let's extract a helper function!"

The Reality:

  • Code that LOOKS similar might serve DIFFERENT purposes
  • Different contexts have different reasons to change
  • "Duplication is far cheaper than the wrong abstraction" β€” Sandi Metz

4.3 When Duplication Is Better Than Abstraction

The "Rule of Three"

  1. First occurrence: Write the code inline
  2. Second occurrence: Still write it inline (just 2 places)
  3. Third occurrence: NOW consider extracting IF:
    • βœ… The logic is truly identical
    • βœ… It changes for the SAME reasons
    • βœ… It serves the SAME purpose
    • βœ… It has the SAME business context

Example: When NOT to Extract

BAD Extraction (similar code, different purposes):

// DON'T extract this - different purposes!
// Invoice: needs title for legal reasons
FormatUserName(user, includeTitle: true);

// Welcome email: needs friendly name for UX
FormatUserName(user, includeTitle: false);

// These LOOK similar but will evolve differently!
Enter fullscreen mode Exit fullscreen mode

GOOD Extraction (truly identical, same purpose):

// DO extract this - same purpose
public static string ToUpperSnakeCase(string input)
{
    return string.Join("_", input.Split(' '))
                 .ToUpper();
}

// Used consistently for environment variable names
var dbVar = ToUpperSnakeCase("database url");
var apiVar = ToUpperSnakeCase("api key");
Enter fullscreen mode Exit fullscreen mode

How Vertical Slice Prevents This

Features/
β”œβ”€β”€ CustomerInvoice/
β”‚   └── FormatCustomerName.cs
β”‚       // Formats: "Title FirstName LastName" for invoices
β”‚       // This is for LEGAL/BILLING purposes
β”‚
β”œβ”€β”€ EmailNotifications/
β”‚   └── FormatFriendlyName.cs
β”‚       // Formats: "FirstName" for friendly emails
β”‚       // This is for USER EXPERIENCE
β”‚
└── AdminUserList/
    └── FormatDisplayName.cs
        // Formats: "LastName, FirstName" for sorting
        // This is for ADMINISTRATIVE purposes
Enter fullscreen mode Exit fullscreen mode

Benefits:

  • βœ… Each slice has its OWN formatting logic
  • βœ… Changes in one don't affect others
  • βœ… Intent is clear from context
  • βœ… If they diverge over time, no problem!
  • βœ… No boolean flags or complex conditionals

4.4 Better Alternatives

Option 1: Multiple Specific Functions

// Clear purpose, no flags
public static string FormatForInvoice(User user) 
    => $"{user.Title} {user.FirstName} {user.LastName}";

public static string FormatForEmail(User user) 
    => user.FirstName;

public static string FormatForAdminList(User user) 
    => $"{user.LastName}, {user.FirstName}";
Enter fullscreen mode Exit fullscreen mode

Option 2: Strategy Pattern

public interface INameFormatter
{
    string Format(User user);
}

public class InvoiceNameFormatter : INameFormatter
{
    public string Format(User user) 
        => $"{user.Title} {user.FirstName} {user.LastName}";
}

public class EmailNameFormatter : INameFormatter
{
    public string Format(User user) => user.FirstName;
}
Enter fullscreen mode Exit fullscreen mode

Option 3: Extension Methods (Context-Specific)

// In Features/Invoice/
public static class UserExtensions
{
    public static string ToInvoiceName(this User user)
        => $"{user.Title} {user.FirstName} {user.LastName}";
}

// In Features/Email/
public static class UserExtensions
{
    public static string ToFriendlyName(this User user)
        => user.FirstName;
}
Enter fullscreen mode Exit fullscreen mode

Option 4: Keep It Inline (Often the Best!)

// In Features/Invoice/GenerateInvoice.cs
var customerName = $"{user.Title} {user.FirstName} {user.LastName}";

// In Features/Email/SendWelcome.cs
var greeting = $"Hi {user.FirstName}!";

// Just 1 line each - no need to extract!
Enter fullscreen mode Exit fullscreen mode

Red Flags: When Your Helper Function Is Dying

🚩 More than 3 boolean parameters

FormatUserName(user, true, false, true, false) // What does this mean??
Enter fullscreen mode Exit fullscreen mode

🚩 Parameter names with "Or" or "Also"

FormatUserName(user, includeTitleOrPrefix: true)
Enter fullscreen mode Exit fullscreen mode

🚩 Comments explaining parameter combinations

// Use includeTitle=true and lastNameFirst=false for invoices
// Use includeTitle=false and lastNameFirst=true for admin
Enter fullscreen mode Exit fullscreen mode

🚩 Nested conditionals based on parameters

🚩 The function name is generic

ProcessData()
HandleStuff()
DoTheThing()
Enter fullscreen mode Exit fullscreen mode

5. Open-Closed Principle in Practice

5.1 OCP Applied to Helper Functions

Open-Closed Principle:

"Software entities should be open for extension, but closed for modification" β€” Bertrand Meyer

In plain English:

  • You should be able to ADD new behavior (open for extension)
  • WITHOUT changing existing code (closed for modification)

The Bad Pattern (Violates OCP)

// Initial version
public static string FormatUserName(User user)
{
    return $"{user.FirstName} {user.LastName}";
}

// ❌ Modification #1 - VIOLATES OCP
public static string FormatUserName(User user, bool includeTitle = false)
{
    if (includeTitle)
        return $"{user.Title} {user.FirstName} {user.LastName}";
    return $"{user.FirstName} {user.LastName}";
}

// ❌ Modification #2 - VIOLATES OCP AGAIN
public static string FormatUserName(User user, bool includeTitle = false, bool lastNameFirst = false)
{
    // More conditional logic...
}
Enter fullscreen mode Exit fullscreen mode

Why this violates OCP:

  • Every new requirement MODIFIES the existing function
  • Existing callers are at risk (regression bugs)
  • The function is NOT closed for modification
  • You're changing working code repeatedly

The Good Pattern (Follows OCP)

Multiple Specific Functions:

// Original function - NEVER modified again βœ…
public static string FormatUserName(User user)
{
    return $"{user.FirstName} {user.LastName}";
}

// New requirement? ADD new function, don't modify existing βœ…
public static string FormatUserNameWithTitle(User user)
{
    return $"{user.Title} {user.FirstName} {user.LastName}";
}

// Another requirement? ADD another function βœ…
public static string FormatUserNameLastFirst(User user)
{
    return $"{user.LastName}, {user.FirstName}";
}

// Extension through ADDITION, not MODIFICATION βœ…
Enter fullscreen mode Exit fullscreen mode

Strategy Pattern (Classic OCP):

// Interface defines the contract - rarely changes βœ…
public interface IUserNameFormatter
{
    string Format(User user);
}

// Original formatter - NEVER modified βœ…
public class StandardNameFormatter : IUserNameFormatter
{
    public string Format(User user) 
        => $"{user.FirstName} {user.LastName}";
}

// New requirement? ADD new class (extension) βœ…
public class TitledNameFormatter : IUserNameFormatter
{
    public string Format(User user) 
        => $"{user.Title} {user.FirstName} {user.LastName}";
}
Enter fullscreen mode Exit fullscreen mode

5.2 Violations and Warning Signs

OCP as a Decision Tool

"Am I MODIFYING existing code, or ADDING new code?"

Scenario Violates OCP? Technical Debt Risk
Adding boolean parameter to helper βœ… YES HIGH
Adding conditional logic to helper βœ… YES HIGH
Creating new specific function ❌ NO LOW
Creating new strategy class ❌ NO LOW
Inline code in feature slice ❌ NO NONE

Red Flags for OCP Violations

🚩 Growing parameter list

FormatUserName(user, flag1, flag2, flag3, flag4...)
// Each new param = OCP violation
Enter fullscreen mode Exit fullscreen mode

🚩 Increasing cyclomatic complexity

if (flag1)
    if (flag2)
        if (flag3)
            // OCP violated repeatedly
Enter fullscreen mode Exit fullscreen mode

🚩 Fear of changing the function

// "What if I break something?"
// This fear means OCP was already violated
Enter fullscreen mode Exit fullscreen mode

🚩 Function serves multiple "masters"

// Used by: Invoice, Email, Admin, Report, Dashboard
// Each master has different needs
// Function can't be "closed" because needs conflict
Enter fullscreen mode Exit fullscreen mode

5.3 Extension Over Modification

Real-World Example: Email Formatting Evolution

The Wrong Way (Violates OCP):

// Week 1: Initial implementation
public static string FormatEmail(string template, User user)
{
    return template.Replace("{NAME}", user.FirstName);
}

// Week 3: "We need HTML emails too!"
// ❌ MODIFYING existing function
public static string FormatEmail(string template, User user, bool isHtml = false)
{
    var name = user.FirstName;
    if (isHtml)
        name = $"<strong>{name}</strong>";
    return template.Replace("{NAME}", name);
}

// Week 8: Now it's unmaintainable
public static string FormatEmail(
    string template, 
    User user, 
    bool isHtml = false, 
    bool includeLastName = false,
    string locale = "en-US")
{
    // 50 lines of conditional logic
    // OCP completely violated
}
Enter fullscreen mode Exit fullscreen mode

The Right Way (Follows OCP):

// Base abstraction - CLOSED for modification βœ…
public interface IEmailFormatter
{
    string Format(string template, User user);
}

// Week 1: Text email formatter
public class TextEmailFormatter : IEmailFormatter
{
    public string Format(string template, User user)
        => template.Replace("{NAME}", user.FirstName);
}

// Week 3: HTML email formatter - ADDED, not modified βœ…
public class HtmlEmailFormatter : IEmailFormatter
{
    public string Format(string template, User user)
        => template.Replace("{NAME}", $"<strong>{user.FirstName}</strong>");
}

// Week 8: Localized formatter - ADDED, not modified βœ…
public class LocalizedEmailFormatter : IEmailFormatter
{
    private readonly string _locale;

    public string Format(string template, User user)
    {
        var name = _locale switch
        {
            "ja-JP" => $"{user.LastName} {user.FirstName}",
            "en-US" => $"{user.FirstName} {user.LastName}",
            _ => user.FirstName
        };
        return template.Replace("{NAME}", name);
    }
}
Enter fullscreen mode Exit fullscreen mode

What changed over 8 weeks?

  • ❌ Text email formatter? NEVER modified
  • ❌ HTML email formatter? NEVER modified
  • ❌ Original interface? NEVER modified
  • βœ… System capabilities? Expanded through extension

5.4 Decision Framework

When Considering Modifying a Helper Function

Is the function used in multiple places?
β”œβ”€ NO β†’ βœ… Safe to modify (low coupling)
└─ YES β†’ Is this a bug fix or refactor (same behavior)?
    β”œβ”€ YES β†’ βœ… Probably safe to modify
    └─ NO β†’ Are you adding conditional logic/parameters?
        β”œβ”€ YES β†’ ❌ STOP! Violates OCP
        β”‚         β†’ Consider: new function or strategy pattern
        └─ NO β†’ Are all callers okay with the change?
            β”œβ”€ YES β†’ ⚠️ Modify with caution
            └─ NO β†’ ❌ STOP! Violates OCP
                    β†’ Definitely use extension approach
Enter fullscreen mode Exit fullscreen mode

When Modification Is Actually OK

OCP isn't absolute. Modification is acceptable when:

1. Fixing a Bug

// βœ… OK to modify - it was WRONG before
public static string FormatUserName(User user)
{
    // Was: return $"{user.FirstName} {user.LastName}";
    return $"{user.FirstName?.Trim()} {user.LastName?.Trim()}";
}
Enter fullscreen mode Exit fullscreen mode

2. Refactoring Internal Implementation

// βœ… OK to modify - external behavior unchanged
public static string FormatUserName(User user)
{
    // Optimized implementation, same result
    return string.Join(" ", user.FirstName, user.LastName);
}
Enter fullscreen mode Exit fullscreen mode

3. The Function Has Only ONE Caller

// βœ… OK to modify - no other dependencies
// Features/Invoice/InvoiceHelpers.cs
private static string FormatForInvoice(User user)
{
    // Only used by Invoice feature, safe to evolve
}
Enter fullscreen mode Exit fullscreen mode

4. Breaking Change Is Intentional & Communicated

// βœ… OK to modify - with proper versioning
[Obsolete("Use FormatUserNameV2 instead")]
public static string FormatUserName(User user) { }

public static string FormatUserNameV2(User user) { }
Enter fullscreen mode Exit fullscreen mode

6. Hybrid Approach

Many modern teams combine the best of both architectures:

Structure

Features/
  β”œβ”€β”€ UserRegistration/
  β”‚   β”œβ”€β”€ Commands/
  β”‚   β”œβ”€β”€ Validators/
  β”‚   β”œβ”€β”€ Handlers/
  β”‚   └── Models/
  β”œβ”€β”€ OrderProcessing/
  └── PaymentHandling/
Common/
  β”œβ”€β”€ Behaviors/ (cross-cutting concerns)
  └── Infrastructure/
Enter fullscreen mode Exit fullscreen mode

Benefits

  • Use Vertical Slices for feature organization (folder structure)
  • Apply Clean Architecture principles within each slice (separation of concerns, dependency inversion)
  • Centralize cross-cutting concerns using middleware/pipelines (MediatR pattern in .NET)
  • Share common domain logic in a core layer

OCP + Vertical Slice = Natural Fit

Vertical Slice Architecture naturally encourages OCP:

Features/
β”œβ”€β”€ InvoiceGeneration/             ← Closed for modification
β”œβ”€β”€ WelcomeEmail/                  ← Closed for modification
β”œβ”€β”€ AdminDashboard/                ← Closed for modification
└── CustomerReport/                ← NEW! Extension via addition
Enter fullscreen mode Exit fullscreen mode

This is OCP at the feature level!


7. Recommendations and Conclusions

Key Principles

  1. "Duplication is far cheaper than the wrong abstraction" β€” Sandi Metz

    • Don't rush to extract helper functions
    • Different contexts may have different reasons to change
    • Duplication across feature boundaries is acceptable and healthy
  2. "Optimize for change, not for avoiding duplication"

    • Make it easy to understand and modify
    • Loose coupling is more valuable than DRY
    • Consider future evolution, not just current state
  3. Open-Closed Principle is your guide

    • If you can't extend without modifying, your abstraction is wrong
    • Repeated modification of a helper function is a red flag
    • Extension through addition prevents technical debt
  4. Vertical Slice naturally prevents common anti-patterns

    • Feature isolation prevents over-sharing
    • Co-location improves understanding
    • Independent evolution reduces coupling

Practical Guidelines

For Helper Functions

Before extracting, ask:

  1. ❓ Does this code change for the SAME reason?
  2. ❓ Is it truly identical, or just similar?
  3. ❓ Will future requirements diverge these use cases?
  4. ❓ Is the extraction making the code EASIER to understand?

If you answered "No" or "Maybe" to any β†’ DON'T extract yet.

When duplication is better:

  • Code serves different business purposes
  • Different contexts have different evolution paths
  • Extracting would introduce coupling
  • The code is simple enough to keep inline

For Architecture Choice

Choose Clean Architecture when:

  • Building enterprise systems with complex business rules
  • Long-term maintainability (3+ years) is critical
  • Technology stack may change
  • Team has senior architects

Choose Vertical Slice when:

  • Rapid feature delivery is priority
  • Features are relatively independent
  • Agile/iterative development
  • Team values pragmatism over purity

Consider Hybrid when:

  • Want feature organization with engineering discipline
  • Need some shared domain logic
  • Long-term project with agile delivery

For Documentation

Vertical Slice advantages:

  • Co-locate docs with code
  • Use templates for consistency
  • Update docs as part of feature PRs
  • One folder = complete feature understanding

The Core Insight

Helper Function Decay β†’ OCP Violation β†’ Technical Death

The pattern is clear:

  1. Helper function created (seems reusable)
  2. New requirement β†’ function MODIFIED (violates OCP)
  3. Another requirement β†’ MODIFIED again (repeated OCP violation)
  4. Function becomes complex, fragile, scary (technical death)

OCP violation is the root cause of technical death in helper functions.

Final Thoughts

  • Architecture is about trade-offs, not absolutes
  • Choose patterns that fit your team and project
  • Be willing to duplicate code when it prevents coupling
  • Extend through addition, not modification
  • Keep features independent and well-documented
  • Trust the Open-Closed Principle as your guide

Mature engineering isn’t defined by the frameworks we use, but by the decisions we make. Clean Architecture and Vertical Slice are just tools. What matters is the judgment to apply them wisely and the discipline to avoid turning principles into dogma.

"Make it work, make it right, make it fast" β€” Kent Beck

Start simple, refactor when patterns emerge, and always prefer the simplest solution that could work.


References

Books and Publications

  1. Martin, Robert C. (2017). Clean Architecture: A Craftsman's Guide
    to Software Structure and Design
    . Prentice Hall.

  2. Meyer, Bertrand (1988). Object-Oriented Software Construction.
    Prentice Hall.

  3. Metz, Sandi (2016). The Wrong Abstraction. Sandi Metz Blog.\
    Available at:
    https://sandimetz.com/blog/2016/1/20/the-wrong-abstraction

  4. Beck, Kent (2004). Extreme Programming Explained: Embrace Change
    (2nd ed.). Addison-Wesley Professional.

  5. Hunt, Andrew & Thomas, David (1999). The Pragmatic Programmer: From
    Journeyman to Master
    . Addison-Wesley.

Architecture & Design Articles

  1. Bogard, Jimmy (2018). Vertical Slice Architecture.\
    Available at:
    https://www.jimmybogard.com/vertical-slice-architecture/

  2. Jovanović, Milan (2023). Vertical Slice Architecture in Modern
    Applications
    .\
    Available at:
    https://www.milanjovanovic.tech/blog/vertical-slice-architecture

  3. Ozkaya, Mehmet (2023). Vertical Slice Architecture and Comparison
    with Clean Architecture
    . Medium.\
    Available at:
    https://mehmetozkaya.medium.com/vertical-slice-architecture-and-comparison-with-clean-architecture-76f813e3dab6

  4. Ngom, Issa (2024). Clean Architecture vs Vertical Slice. LinkedIn
    Pulse.\
    Available at:
    https://fr.linkedin.com/pulse/clean-architecture-vs-vertical-slice-issa-ngom-76bhe

  5. Asmak9 (2025). Vertical Slice Architecture in ASP.NET Core.\
    Available at:
    https://www.asmak9.com/2025/07/vertical-slice-architecture-in-aspnet.html

  6. Onishi, Yuki (2024). Benefits and Drawbacks of Adopting Clean
    Architecture
    . Dev.to.\
    Available at:
    https://dev.to/yukionishi1129/benefits-and-drawbacks-of-adopting-clean-architecture-2pd1

  7. Fowler, Martin (2018). Modular Monoliths.\
    Available at: https://martinfowler.com/bliki/ModularMonolith.html

Official Documentation & Principles

  1. Microsoft Learn (2025). Extension Methods (C# Programming Guide).\
    Available at:
    https://learn.microsoft.com/en-us/dotnet/csharp/programming-guide/classes-and-structs/extension-methods

  2. Martin, Robert C. (n.d.). The Open-Closed Principle. The
    Principles of OOD.\
    Available at:
    https://butunclebob.com/ArticleS.UncleBob.PrinciplesOfOod

Community Resources

  1. Monday.com (2024). Vertical Slice Architecture.\ Available at: https://monday.com/blog/rnd/

About the Author

Harry Lo is a passionate software developer focused on improving software development environments and engineering practices. Through experience, he has observed that even good principles can lead to negative outcomes when misapplied or overused. His work is driven by a desire to find the right balance between structure and pragmatism in building sustainable, developer-friendly systems.

For questions, feedback, or discussions about this article, please feel free to reach out.


End of Document

Top comments (0)