Este artículo es el primero de una serie en la que voy a ir documentando la migración de DevSweep de Bash a .NET 10 con AOT. Si no conoces DevSweep, es una herramienta CLI que nació de una necesidad real: mi MacBook M1 de 256GB se quedaba sin espacio constantemente por culpa de caches de desarrollo (node_modules abandonados, imágenes Docker huérfanas, caches de Gradle/Maven...). Lo que empezó como un script Bash se convirtió en un proyecto open source con más de 150 tests y una estructura modular bastante decente.
Si quieres saber más, en este post explico un poco más el nacimiento de la herramienta.
Pero Bash tiene sus límites. No es multiplataforma, el manejo de errores es artesanal, y testear scripts de shell es... creativo, por decirlo de alguna forma. Así que decidí que DevSweep v2.x viviría en .NET 10, compilado con AOT para generar binarios nativos sin necesidad de runtime. Y de paso, aprovechar para aplicar las cosas que llevo explorando en .NET últimamente y documentar todo el camino.
El plan de migración
Antes de escribir una sola línea de C#, diseñé un roadmap dividido en fases. No quería lanzarme a portar funcionalidad 1:1 desde Bash; quería reimaginar la herramienta con una arquitectura que escale:
-
La Fase 0 fue preparar el repositorio para que ambas versiones convivan: los scripts Bash en
bash/, el proyecto .NET ennet/, y CI separada para cada uno. -
La Fase 1, que es la que cubre este artículo, se centra en establecer los cimientos del dominio: el tipo
Result, los value objects, las entidades y los errores. Todo puro, sin dependencias externas, sin I/O, sin framework.
Las fases siguientes irán añadiendo la capa de aplicación, los adaptadores de infraestructura, los módulos de limpieza específicos, la CLI con Spectre.Console, y finalmente los tests E2E y el pulido cross-platform. En la lista de issues del proyecto esta todo el planning de migración.
¿Por qué no usar ErrorOr?
Si seguiste el artículo de ExpenseReimbursementContext, sabrás que allí usé ErrorOr para el manejo de errores. Es una librería genial y la recomiendo para proyectos web con ASP.NET. Pero DevSweep tiene una restricción importante: se compila con Native AOT.
AOT significa que no hay reflexión en runtime, y cualquier dependencia que uses tiene que ser compatible. Además, para una herramienta CLI donde cada byte del binario cuenta, quería minimizar las dependencias externas. Así que decidí implementar mi propio tipo Result con programación orientada a railway (Railway-Oriented Programming).
También existía la opción de usar el Either de Language Extensions, pero para este caso de uso y apoyado en el potencial de la IA, era mejor opción implementar nuestra propia “Monada”.
Railway Oriented programming en C#
La idea del patrón Railway es simple: tu código tiene dos vías, la del éxito y la del error. Cada operación puede continuar por la vía del éxito o desviarse a la del error, y una vez en la vía del error, te quedas ahí sin ejecutar las operaciones siguientes.
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!);
}Hay varias decisiones de diseño aquí que merece la pena comentar.
-
Es un
readonly record struct, no una clase. Esto significa que vive en el stack, no en el heap, y no genera presión sobre el garbage collector. Para una herramienta CLI que puede procesar miles de archivos, esto importa. -
Los constructores son privados. Solo puedes crear un
Resulta través deSuccess()oFailure(), lo que hace imposible tener un resultado en un estado inválido. -
Las operaciones
Map,BindyMatchson las piezas que habilitan el encadenamiento funcional.Maptransforma el valor si hay éxito,Bindpermite encadenar operaciones que también devuelvenResult, yMatchte fuerza a manejar ambos caminos explícitamente. Si vienes de Rust, esto te sonará amap,and_thenymatchsobreResult<T, E>.
Quizás mas adelante explicaré esto con más detalle en otro artículo.
Los errores de dominio
En lugar de usar strings sueltos o excepciones, los errores del dominio tienen su propio tipo:
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);
}Cada error tiene un código y un mensaje. Los factory methods (Validation, NotFound, InvalidOperation) aseguran que siempre se crean con un código consistente. Y al ser un readonly record struct, también vive en el stack y tiene igualdad estructural por defecto.
Value Objects
DevSweep trabaja constantemente con rutas de archivo y tamaños. En la versión Bash, estos eran strings y números sin ninguna validación. En .NET, son value objects que se validan al crearse:
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;
}Fíjate en que Create devuelve un Result, no lanza una excepción. Si la ruta está vacía o es demasiado larga, obtienes un Failure con un error descriptivo. Y como el constructor es privado, es imposible crear un FilePath inválido. Este patrón se llama "make illegal states unrepresentable" y es uno de los pilares del diseño de dominio que más me gusta.
FileSize sigue la misma filosofía, pero además implementa IComparable<FileSize> y operadores de comparación para poder ordenar y comparar tamaños de forma natural:
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"
};
}El ToString() con pattern matching es un detalle que me encanta: según el rango del valor, formatea en bytes, KB, MB o GB automáticamente. Nada de arrastrar ese if-else por toda la aplicación.
CleanupResult es un value object que encapsula los archivos borrados, el espacio liberado y los errores que hayan ocurrido, con un método Combine para ir acumulando resultados parciales.
public readonly record struct CleanupResult
{
private readonly int filesDeleted;
private readonly FileSize spaceFreed;
private readonly IReadOnlyList<string> errors;
private const int MinimumFilesDeleted = 0;
private CleanupResult(int filesDeleted, FileSize spaceFreed, IReadOnlyList<string> errors)
{
this.filesDeleted = filesDeleted;
this.spaceFreed = spaceFreed;
this.errors = errors;
}
public static Result<CleanupResult, DomainError> Create(
int filesDeleted,
FileSize bytesFreed)
{
if (filesDeleted < MinimumFilesDeleted)
return Result<CleanupResult, DomainError>.Failure(
DomainError.Validation("Files deleted cannot be negative"));
return Result<CleanupResult, DomainError>.Success(
new CleanupResult(filesDeleted, bytesFreed, []));
}
public static Result<CleanupResult, DomainError> CreateWithErrors(
int filesDeleted,
FileSize bytesFreed,
IReadOnlyList<string> errors)
{
if (filesDeleted < MinimumFilesDeleted)
return Result<CleanupResult, DomainError>.Failure(
DomainError.Validation("Files deleted cannot be negative"));
return Result<CleanupResult, DomainError>.Success(
new CleanupResult(filesDeleted, bytesFreed, errors));
}
public int TotalFilesDeleted() => filesDeleted;
public FileSize TotalSpaceFreed() => spaceFreed;
public bool HasErrors() => errors.Count > 0;
public IReadOnlyList<string> ErrorMessages() => errors;
public CleanupResult Combine(CleanupResult other) => new(
filesDeleted + other.filesDeleted,
spaceFreed.Add(other.spaceFreed),
[.. errors, .. other.errors]);
}Las entidades
Con los value objects definidos, las entidades del dominio se construyen sobre ellos. CleanableItem representa algo que potencialmente se puede limpiar (una cache, un directorio de node_modules abandonado, una imagen Docker huérfana...):
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);
}
}La distinción entre CreateSafe y CreateUnsafe es clave. En la versión Bash, la decisión de si algo era seguro de borrar estaba dispersa por los módulos. Aquí, cada item sabe desde su creación si es seguro o no, y el método MarkForDeletion previene que se intente borrar algo inseguro.
CleanupSummary agrega los resultados de un módulo de limpieza.
public record CleanupSummary(
CleanupModuleName Module,
int TotalItemsScanned,
int SafeItemsFound,
CleanupResult Result)
{
public static Result<CleanupSummary, DomainError> Create(
CleanupModuleName module,
IReadOnlyList<CleanableItem> items,
CleanupResult result)
{
ArgumentNullException.ThrowIfNull(items);
if (items.Count == 0)
return Result<CleanupSummary, DomainError>.Failure(
DomainError.InvalidOperation("Cannot create summary with no items"));
var safeCount = items.Count(item => item.IsSafeToDelete);
return Result<CleanupSummary, DomainError>.Success(
new CleanupSummary(module, items.Count, safeCount, result));
}
}La estructura del proyecto
El resultado de la Fase 1 es una estructura hexagonal dentro de un solo .csproj:
net/
├── src/DevSweep/
│ ├── Domain/
│ │ ├── Common/ → Result<T,E>
│ │ ├── Entities/ → CleanableItem, CleanupSummary
│ │ ├── Enums/ → CleanupModuleName, InteractionStrategy, OutputStrategy
│ │ ├── Errors/ → DomainError
│ │ └── ValueObjects/ → FilePath, FileSize, CleanupResult
│ ├── Application/ → (vacío, Fase 2)
│ └── Infrastructure/ → (vacío, Fase 3+)
├── tests/DevSweep.Tests/
│ └── Domain/
│ ├── Common/ → ResultSuccessTests, ResultMapTests, ResultBindTests...
│ ├── Entities/ → CleanableItemTests, CleanupSummaryTests
│ ├── Errors/ → DomainErrorTests
│ └── ValueObjects/ → FilePathTests, FileSizeTests, CleanupResultTests
└── DevSweep.slnx → Formato SLNX (nuevo en .NET 10)La capa Application e Infrastructure están vacíos. Son placeholders para las siguientes fases. Pero ya están ahí para que la estructura sea clara desde el principio.
Un detalle técnico: usamos el nuevo formato de solución SLNX que llegó con .NET 10. Es más limpio y legible que el .sln clásico, y funciona de primera con el CLI de .NET.
El stack técnico
El .csproj tiene PublishAot a true desde el día uno. Esto condiciona todas las decisiones de dependencias:
<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 para el parsing de CLI (usa source generators, cero reflexión, perfecto para AOT), Spectre.Console para la UI rica en terminal (tablas, colores, spinners, todo compatible con AOT), y Microsoft.Extensions.Configuration.Json para la configuración. Nada de System.Text.Json con reflexión: EnableConfigurationBindingGenerator genera el binding en compilación.
Lo que me llevo de esta fase
La Fase 1 puede parecer poco espectacular desde fuera: no limpia nada, no tiene CLI, no hace I/O. Pero es la inversión más importante del proyecto.
El dominio puro te libera. Al no tener dependencias, esta capa se testea en milisegundos, se razona sin contexto externo, y no cambiará cuando cambie el framework de CLI o la forma de acceder al sistema de archivos.
Los value objects eliminan bugs antes de que existan. En Bash, era perfectamente posible pasar un string vacío como ruta o un número negativo como tamaño. Aquí, si tienes un FilePath en la mano, sabes que es válido. Punto.
Railway-oriented programming es adictivo. Una vez que te acostumbras a encadenar Map y Bind en lugar de anidar if-else, no quieres volver atrás. El flujo de datos se lee de arriba a abajo sin ramificaciones escondidas.
¿Qué viene en la Fase 2?
La siguiente entrega se centrará en la capa de aplicación: definir las interfaces de los puertos (qué necesita cada módulo de limpieza del sistema de archivos, de la consola, del entorno), y los casos de uso que orquestan el flujo de escaneo → análisis → confirmación → limpieza.
Si quieres seguir el progreso commit a commit, el repositorio está en GitHub y el PROGRESS.md se actualiza con cada fase completada.
Nos vemos en la Fase 2.