Disposable Code from the Architect's Perspective

Disposable Code from the Architect's Perspective
by Brad Jolicoeur
04/07/2026

I throw away a lot of cups. Not because they're bad cups or because I regret using them—they served their purpose perfectly. I grabbed a disposable cup, it held my coffee for twenty minutes, and then I tossed it. Zero drama. Nobody calls it a "failed cup" or "cup debt" when it ends up in the recycling bin.

So why do we treat code like it should live forever?

I've been thinking about this a lot lately, particularly as I watch teams struggle with features that no longer fit their systems. The code isn't bad—it worked fine when it was written. But requirements shifted, and now that feature is an awkward appendage nobody wants to touch. Removing it means untangling dependencies across layers, hunting down references in services that shouldn't have known about it in the first place, and inevitably breaking something unexpected.

What if we designed features to be disposable from the start?

The Disposable Code Mindset

Disposable code isn't technical debt—that's the first thing to get straight. Technical debt is code that should have been fixed but wasn't. Shortcuts taken, quality sacrificed, promises to "come back and fix it later" that never get kept. Disposable code is the opposite: it's well-built for its moment, and when that moment passes, it goes away clean.

Think about the disposable cup again. It's not a flimsy version of a ceramic mug that you'll replace with the "real thing" later. It's designed for a specific use case: portability, convenience, no cleanup. When that use case ends, you discard it without regret.

Code can work the same way. A feature designed as a discrete, self-contained unit can be removed when business requirements change without leaving a mess behind. You're not removing it because it failed—you're removing it because its job is done, or because the business moved in a different direction.

The problem is that most codebases aren't structured for this. Traditional layered architectures actively work against it.

Why Greenfield Feels So Fast

Every developer knows this feeling: you start a new project and the first few features fly in. Controllers, services, repositories—everything falls into place. You're productive, the code is clean, and you feel like a wizard.

Then the project grows. Adding a new feature now means touching four layers, updating interfaces in three different projects, and trying not to break the subtle coupling between features that seemed unrelated but somehow share a validation rule. The velocity you had at the start evaporates.

Here's what I realized: greenfield development feels fast because nothing is coupled yet. Each feature you add is essentially independent. But as the codebase grows under a traditional layered architecture, coupling creeps in. Not because developers are careless, but because the architecture encourages it.

If your code is organized by technical layer—all controllers here, all services there, all data access somewhere else—then features inherently become cross-cutting concerns. The "Create Order" feature isn't in one place; it's scattered across Controllers/OrderController.cs, Services/OrderService.cs, Repositories/OrderRepository.cs, and probably a few other spots. That scattering is what kills velocity and makes code impossible to dispose of cleanly.

What if you could keep that greenfield feeling at the feature level, even as your system grows? That's the core idea: if each feature is isolated from every other feature, adding the tenth feature feels exactly like adding the first. No coupling to untangle, no shared services to update, no existing code to tiptoe around.

Vertical Slice Architecture: Features as Islands

Vertical Slice Architecture organizes code by feature, not by technical layer. Instead of grouping all controllers together, you group everything needed for a specific feature together. Each "slice" is vertically integrated—it owns its controller endpoint, its business logic, its data access, its validation, everything.

Here's what that looks like in practice. In a traditional layered architecture, "Create Order" might span these files:

Controllers/
  OrderController.cs          // CreateOrder endpoint
Services/
  OrderService.cs             // CreateOrder business logic
Repositories/
  OrderRepository.cs          // Order data access
Models/
  CreateOrderRequest.cs       // DTO
  Order.cs                    // Domain model
Validators/
  CreateOrderValidator.cs     // Validation rules

To understand how "Create Order" works, you're navigating six different folders. To delete it when that feature gets replaced, you need to find and remove pieces from six different places, then hope you didn't miss anything.

Here's the same feature as a vertical slice using the feature folder pattern:

Features/
  CreateOrder/
    CreateOrderEndpoint.cs    // Endpoint<TRequest, TResponse> (routing + handler logic)
    CreateOrderModels.cs      // Request/Response records
    CreateOrderValidator.cs   // Validator<TRequest>

Everything for "Create Order" lives in Features/CreateOrder/. To understand it, you open one folder. To delete it when requirements change? Delete the folder. That's disposable code.

To set this up in an ASP.NET Core project, create a Features/ folder at your project root. Each subfolder under Features/ contains everything for one feature—endpoint, models, validation. With FastEndpoints, you call builder.Services.AddFastEndpoints() and app.UseFastEndpoints() in Program.cs, and endpoints are automatically discovered via assembly scanning. Each feature folder is a self-contained unit.

A Concrete Example: FastEndpoints Vertical Slice

In the .NET world, this pattern pairs naturally with FastEndpoints, a NuGet package that implements the REPR (Request-Endpoint-Response) pattern. Unlike traditional approaches that separate routing from business logic, FastEndpoints colocates both in a single endpoint class—the endpoint is the handler. This makes features genuinely isolated: all the logic for a feature lives in one place, with no shared mediator infrastructure to untangle when you need to delete it. Install it with dotnet add package FastEndpoints, and here's what a complete vertical slice looks like:

// Features/CreateOrder/CreateOrderModels.cs
namespace MyApp.Features.CreateOrder;

public record CreateOrderRequest
{
    public string CustomerId { get; init; } = string.Empty;
    public List<OrderLineItem> Items { get; init; } = new();
}

public record OrderLineItem
{
    public string ProductId { get; init; } = string.Empty;
    public int Quantity { get; init; }
}

public record CreateOrderResponse(string OrderId, decimal TotalAmount);
// Features/CreateOrder/CreateOrderEndpoint.cs
using FastEndpoints;
using Microsoft.EntityFrameworkCore;

namespace MyApp.Features.CreateOrder;

public class CreateOrderEndpoint : Endpoint<CreateOrderRequest, CreateOrderResponse>
{
    private readonly AppDbContext _db;

    public CreateOrderEndpoint(AppDbContext db)
    {
        _db = db;
    }

    public override void Configure()
    {
        Post("/api/orders");
        AllowAnonymous(); // or use Roles(), Policies(), etc.
    }

    public override async Task HandleAsync(CreateOrderRequest req, CancellationToken ct)
    {
        // All business logic and data access lives here in the endpoint
        var order = new Order
        {
            Id = Guid.NewGuid().ToString(),
            CustomerId = req.CustomerId,
            CreatedAt = DateTime.UtcNow
        };

        foreach (var item in req.Items)
        {
            var product = await _db.Products
                .FirstOrDefaultAsync(p => p.Id == item.ProductId, ct);
            
            if (product == null) throw new InvalidOperationException($"Product {item.ProductId} not found");

            order.Lines.Add(new OrderLine
            {
                ProductId = item.ProductId,
                Quantity = item.Quantity,
                UnitPrice = product.Price
            });
        }

        _db.Orders.Add(order);
        await _db.SaveChangesAsync(ct);

        var total = order.Lines.Sum(l => l.Quantity * l.UnitPrice);
        await SendAsync(new CreateOrderResponse(order.Id, total), cancellation: ct);
    }
}
// Features/CreateOrder/CreateOrderValidator.cs
using FastEndpoints;
using FluentValidation;

namespace MyApp.Features.CreateOrder;

public class CreateOrderValidator : Validator<CreateOrderRequest>
{
    public CreateOrderValidator()
    {
        RuleFor(x => x.CustomerId).NotEmpty();
        RuleFor(x => x.Items).NotEmpty();
        RuleForEach(x => x.Items).ChildRules(item =>
        {
            item.RuleFor(x => x.ProductId).NotEmpty();
            item.RuleFor(x => x.Quantity).GreaterThan(0);
        });
    }
}

Notice what just happened: everything for "Create Order" is in one folder. The endpoint class owns both routing configuration and business logic—there's no separate handler file to maintain. All data access happens directly in the endpoint's HandleAsync method. Validation is colocated with the feature, using FastEndpoints' built-in FluentValidation integration.

If the business decides to replace this ordering workflow with a completely different approach, you delete Features/CreateOrder/ and you're done. No hunting through layers. No worrying about what else might break. No mediator infrastructure to untangle. The feature was disposable by design.

This is the difference between disposable code and technical debt in practice. Technical debt accumulates because removing code is hard. Disposable code doesn't accumulate because removing it is trivial—like tossing that cup.

The Architect's Perspective: Why This Matters

Why should an architect care about disposable code? Three reasons I've seen play out in real projects.

First, systems need to evolve, and evolution in a tightly-coupled codebase is risky and expensive. When features are isolated vertical slices, you can evolve—or completely replace—individual features without system-wide rewrites. I saw this firsthand: a client needed to replace their payment processing feature because they switched providers. In their layered architecture, this touched controllers, services, repositories, DTOs, validation, and configuration across a dozen files. It took weeks. In a vertical slice architecture, it would have been: delete Features/ProcessPayment/, add Features/ProcessPaymentV2/, update routing. Days, not weeks.

Second, isolated features mean teams can actually work in parallel. One team works in Features/CustomerOrders/, another in Features/Inventory/, another in Features/Billing/. Merge conflicts drop. Cognitive load drops. New developers can be productive faster because they don't need to understand the entire layered structure—they just need to understand one feature folder. That's how you maintain greenfield velocity as the system grows.

And here's the hard truth: most features have a lifespan. Business requirements change. Products pivot. Regulations shift. Features that were critical last year become vestigial this year. In a traditional architecture, legacy features become technical debt by default—you can't remove them cleanly, so they accumulate. With disposable code, when a feature's business purpose ends, its code ends. Your codebase stays lean and reflects current business reality, not historical baggage.

When Does This Actually Work?

Vertical slices aren't free—organizing by feature instead of layer adds some upfront thinking. You need clear boundaries. So when's the payoff real?

When I've seen this work: Line-of-business apps where the business pivots faster than the architecture can keep up. Features with natural boundaries—orders, inventory, billing—where the edges of a feature are obvious. Teams that need to ship independently without merge conflicts every sprint. And specifically: projects where you've already felt the pain of trying to remove a feature and couldn't.

When I'd skip it: Highly algorithmic systems where features are really just different entry points to shared math. Tiny applications where the overhead of feature folders doesn't pay off. And occasionally, domains where cross-cutting concerns are so pervasive that isolation is more ceremony than substance—though that's rarer than people think.

The key question: Can you imagine deleting a feature cleanly? If the answer is "absolutely not, everything is too tangled," that's exactly the problem vertical slices solve. If you never delete features—if your system only ever grows—then this might not be your fight.

Coming Back to the Cup

The disposable cup isn't a failure. It's a design choice optimized for a specific context. When that context ends, you dispose of it without drama.

Code can work the same way. Features designed as vertical slices—discrete, self-contained, isolated—can be removed when their purpose ends. Not because they failed, but because their job is done.

Most line-of-business applications aren't building foundational abstractions. They're solving today's business problem, and tomorrow's will be different. Code designed for that reality—code that can be cleanly removed when its purpose ends—looks very different from code designed to last forever.

When that happens, wouldn't you rather delete a folder than untangle a web of dependencies?

I threw away a cup this morning. It held coffee, it served its purpose, and then I moved on. No regret, no mess, no drama.

That's what good architecture should feel like.

You May Also Like


Heisenbug Hunting in Async .NET Systems

heisenbug-hunting.png
Brad Jolicoeur - 04/07/2026
Read

Using Copilot Squad with Copilot CLI for Building .NET Web Applications

copilot-squad.png
Brad Jolicoeur - 04/06/2026
Read

Leveling Up Local Dev with .NET Aspire & AI

aspire-with-copilot.png
Brad Jolicoeur - 03/22/2026
Read