Skip to main content

DevSweep to .NET 10: The Domain Foundations

2026-02-1512 min
.NETOpen SourceClean CodeDDDDeveloper Tools

This article is the first in a series where I'll document the migration of DevSweep from Bash to .NET 10 with AOT. If you don't know DevSweep, it's a CLI tool born from a real need: my 256GB MacBook M1 was constantly running out of space due to development caches (abandoned node_modules, orphaned Docker images, Gradle/Maven caches...). What started as a Bash script became an open source project with more than 150 tests and a fairly decent modular structure.

If you want to know more, this post explains the birth of the tool.

But Bash has its limits. It's not cross-platform, error handling is artisanal, and testing shell scripts is... creative, to put it mildly. So I decided that DevSweep v2.x would live in .NET 10, compiled with AOT to generate native binaries without needing a runtime. And in the process, take the opportunity to apply everything I've been exploring in .NET lately and document the entire journey.

The Migration Plan

Before writing a single line of C#, I designed a roadmap divided into phases. I didn't want to port functionality 1:1 from Bash; I wanted to reimagine the tool with an architecture that scales:

  • Phase 0 was to prepare the repository so both versions coexist: Bash scripts in bash/, the .NET project in net/, and separate CI for each.

  • Phase 1, which this article covers, focuses on establishing the domain foundations: the Result type, value objects, entities, and errors. All pure, no external dependencies, no I/O, no framework.

Subsequent phases will add the application layer, infrastructure adapters, specific cleanup modules, the CLI with Spectre.Console, and finally E2E tests and cross-platform polishing. The complete migration planning is in the project's issues list.

Why Not Use ErrorOr?

If you followed the ExpenseReimbursementContext article, you'll know I used ErrorOr for error handling there. It's a great library and I recommend it for web projects with ASP.NET. But DevSweep has an important constraint: it compiles with Native AOT.

AOT means there's no runtime reflection, and any dependency you use has to be compatible. Also, for a CLI tool where every byte of the binary counts, I wanted to minimize external dependencies. So I decided to implement my own Result type with Railway-Oriented Programming.

There was also the option of using the Either from Language Extensions, but for this use case and leveraging AI's potential, implementing our own "Monad" was the better option.

Railway Oriented Programming in C#

The Railway pattern idea is simple: your code has two tracks, the success track and the error track. Each operation can continue on the success track or divert to the error track, and once on the error track, you stay there without executing the following operations.

c#
public readonly record struct Result<TValue, TError>
{
    private readonly TValue? value;
    private readonly TError? error;
    private readonly bool isSuccess;

    private Result(TValue value)
    {
        this.value = value;
        error = default;
        isSuccess = true;
    }

    private Result(TError error)
    {
        value = default;
        this.error = error;
        isSuccess = false;
    }

    public static Result<TValue, TError> Success(TValue value) => new(value);
    public static Result<TValue, TError> Failure(TError error) => new(error);

    public Result<TOut, TError> Map<TOut>(Func<TValue, TOut> mapper) =>
        isSuccess
            ? Result<TOut, TError>.Success(mapper(value!))
            : Result<TOut, TError>.Failure(error!);

    public Result<TOut, TError> Bind<TOut>(Func<TValue, Result<TOut, TError>> binder) =>
        isSuccess
            ? binder(value!)
            : Result<TOut, TError>.Failure(error!);

    public TResult Match<TResult>(
        Func<TValue, TResult> onSuccess,
        Func<TError, TResult> onFailure) =>
        isSuccess ? onSuccess(value!) : onFailure(error!);
}

There are several design decisions here worth commenting on.

  • It's a readonly record struct, not a class. This means it lives on the stack, not the heap, and doesn't generate garbage collector pressure. For a CLI tool that can process thousands of files, this matters.

  • Constructors are private. You can only create a Result through Success() or Failure(), making it impossible to have a result in an invalid state.

  • The Map, Bind, and Match operations are the pieces that enable functional chaining. Map transforms the value on success, Bind allows chaining operations that also return Result, and Match forces you to handle both paths explicitly. If you come from Rust, this will remind you of map, and_then, and match on Result<T, E>.

Domain Errors

Instead of using loose strings or exceptions, domain errors have their own type:

c#
public readonly record struct DomainError
{
    private readonly string code;
    private readonly string message;

    private DomainError(string code, string message)
    {
        this.code = code;
        this.message = message;
    }

    public static DomainError Validation(string message) =>
        new("VALIDATION_ERROR", message);

    public static DomainError NotFound(string entity, string id) =>
        new("NOT_FOUND", $"{entity} with ID {id} not found");

    public static DomainError InvalidOperation(string message) =>
        new("INVALID_OPERATION", message);
}

Each error has a code and a message. The factory methods (Validation, NotFound, InvalidOperation) ensure they're always created with a consistent code. And being a readonly record struct, it also lives on the stack and has structural equality by default.

Value Objects

DevSweep constantly works with file paths and sizes. In the Bash version, these were strings and numbers with no validation. In .NET, they are value objects that validate themselves upon creation:

c#
public readonly record struct FilePath
{
    private readonly string value;

    private FilePath(string value) => this.value = value;

    public static Result<FilePath, DomainError> Create(string path)
    {
        if (string.IsNullOrWhiteSpace(path))
            return Result<FilePath, DomainError>.Failure(
                DomainError.Validation("File path cannot be empty"));

        if (path.Length > 260)
            return Result<FilePath, DomainError>.Failure(
                DomainError.Validation(
                    "File path exceeds maximum length of 260 characters"));

        return Result<FilePath, DomainError>.Success(new FilePath(path));
    }

    public string FileName() => Path.GetFileName(value);
    public string DirectoryPath() => Path.GetDirectoryName(value) ?? string.Empty;
    public string Extension() => Path.GetExtension(value);

    public override string ToString() => value;
}

Notice that Create returns a Result, not throws an exception. If the path is empty or too long, you get a Failure with a descriptive error. And since the constructor is private, it's impossible to create an invalid FilePath. This pattern is called "make illegal states unrepresentable" and is one of my favorite pillars of domain design.

FileSize follows the same philosophy, but also implements IComparable<FileSize> and comparison operators to sort and compare sizes naturally:

c#
public readonly record struct FileSize : IComparable<FileSize>
{
    private readonly long bytes;
    private const decimal BytesPerKilobyte = 1024m;

    private FileSize(long bytes) => this.bytes = bytes;

    public static Result<FileSize, DomainError> Create(long bytes)
    {
        if (bytes < 0)
            return Result<FileSize, DomainError>.Failure(
                DomainError.Validation("File size cannot be negative"));

        return Result<FileSize, DomainError>.Success(new FileSize(bytes));
    }

    public decimal InKilobytes() => bytes / BytesPerKilobyte;
    public decimal InMegabytes() => bytes / (BytesPerKilobyte * BytesPerKilobyte);
    public decimal InGigabytes() =>
        bytes / (BytesPerKilobyte * BytesPerKilobyte * BytesPerKilobyte);

    public FileSize Add(FileSize other) => new(bytes + other.bytes);

    public override string ToString() => bytes switch
    {
        < 1024 => $"{bytes} B",
        < 1024 * 1024 => $"{InKilobytes():F2} KB",
        < 1024 * 1024 * 1024 => $"{InMegabytes():F2} MB",
        _ => $"{InGigabytes():F2} GB"
    };
}

The ToString() with pattern matching is a detail I love: depending on the value's range, it automatically formats in bytes, KB, MB, or GB. No dragging that if-else throughout the entire application.

CleanupResult is a value object that encapsulates the deleted files, freed space, and any errors that occurred, with a Combine method for accumulating partial results.

The Entities

With value objects defined, domain entities are built on top of them. CleanableItem represents something that can potentially be cleaned (a cache, an abandoned node_modules directory, an orphaned Docker image...):

c#
public record CleanableItem
{
    private CleanableItem(
        FilePath path, FileSize size,
        CleanupModuleName moduleType,
        bool isSafeToDelete, string? reason)
    {
        Path = path;
        Size = size;
        ModuleType = moduleType;
        IsSafeToDelete = isSafeToDelete;
        Reason = reason;
    }

    public static CleanableItem CreateSafe(
        FilePath path, FileSize size,
        CleanupModuleName moduleType, string reason) =>
        new(path, size, moduleType, true, reason);

    public static CleanableItem CreateUnsafe(
        FilePath path, FileSize size,
        CleanupModuleName moduleType, string reason) =>
        new(path, size, moduleType, false, reason);

    public Result<CleanableItem, DomainError> MarkForDeletion()
    {
        if (!IsSafeToDelete)
            return Result<CleanableItem, DomainError>.Failure(
                DomainError.InvalidOperation(
                    "Cannot mark unsafe item for deletion"));

        return Result<CleanableItem, DomainError>.Success(this);
    }
}

The distinction between CreateSafe and CreateUnsafe is key. In the Bash version, the decision of whether something was safe to delete was scattered throughout the modules. Here, each item knows from its creation whether it's safe or not, and the MarkForDeletion method prevents attempting to delete something unsafe.

Project Structure

The result of Phase 1 is a hexagonal structure within a single .csproj:

plain
net/
├── src/DevSweep/
│   ├── Domain/
│   │   ├── Common/        → Result<T,E>
│   │   ├── Entities/      → CleanableItem, CleanupSummary
│   │   ├── Enums/         → CleanupModuleName, InteractionStrategy, OutputStrategy
│   │   ├── Errors/        → DomainError
│   │   └── ValueObjects/  → FilePath, FileSize, CleanupResult
│   ├── Application/       → (empty, Phase 2)
│   └── Infrastructure/    → (empty, Phase 3+)
├── tests/DevSweep.Tests/
│   └── Domain/
│       ├── Common/        → ResultSuccessTests, ResultMapTests, ResultBindTests...
│       ├── Entities/      → CleanableItemTests, CleanupSummaryTests
│       ├── Errors/        → DomainErrorTests
│       └── ValueObjects/  → FilePathTests, FileSizeTests, CleanupResultTests
└── DevSweep.slnx          → SLNX format (new in .NET 10)

The Application and Infrastructure layers are empty — they're placeholders for future phases. But they're already there so the structure is clear from the start.

A technical detail: we use the new SLNX solution format that arrived with .NET 10. It's cleaner and more readable than the classic .sln, and works seamlessly with the .NET CLI.

The Technical Stack

The .csproj has PublishAot set to true from day one. This conditions all dependency decisions:

xml
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <PublishAot>true</PublishAot>
    <InvariantGlobalization>false</InvariantGlobalization>
    <EnableConfigurationBindingGenerator>true</EnableConfigurationBindingGenerator>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="DotMake.CommandLine" Version="2.0.0" />
    <PackageReference Include="Spectre.Console" Version="0.49.1" />
    <PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.0" />
  </ItemGroup>
</Project>

DotMake.CommandLine for CLI parsing (uses source generators, zero reflection, perfect for AOT), Spectre.Console for rich terminal UI (tables, colors, spinners, all AOT-compatible), and Microsoft.Extensions.Configuration.Json for configuration. No System.Text.Json with reflection: EnableConfigurationBindingGenerator generates the binding at compile time.

What I Take Away from This Phase

Phase 1 may seem unspectacular from the outside: it cleans nothing, has no CLI, does no I/O. But it's the most important investment of the project.

A pure domain sets you free. With no dependencies, this layer tests in milliseconds, reasons without external context, and won't change when the CLI framework or file system access changes.

Value objects eliminate bugs before they exist. In Bash, it was perfectly possible to pass an empty string as a path or a negative number as a size. Here, if you have a FilePath in hand, you know it's valid. Period.

Railway-oriented programming is addictive. Once you get used to chaining Map and Bind instead of nesting if-else, you don't want to go back. Data flow reads top to bottom without hidden branching.

What's Coming in Phase 2?

The next installment will focus on the application layer: defining port interfaces (what each cleanup module needs from the file system, from the console, from the environment), and the use cases that orchestrate the scan → analysis → confirmation → cleanup flow.

If you want to follow the progress commit by commit, the repository is on GitHub and PROGRESS.md is updated with each completed phase.

See you in Phase 2.

Sources