Clean Architecture en .NET (sin dogmas)

Clean Architecture en .NET (sin dogmas)

No necesitas MediatR ni frameworks complejos para aplicar Clean Architecture. Lo que necesitas son lĂ­mites claros y buen criterio.


Qué es Clean Architecture (y por qué los equipos la usan)

Clean Architecture es una forma de estructurar el cĂłdigo para que las reglas de negocio queden protegidas de lo que cambia frecuentemente: frameworks, bases de datos, interfaces de usuario y servicios externos.

La idea central es sencilla:

  • Las dependencias apuntan hacia adentro (las reglas de negocio no deben depender de detalles externos).
  • Los casos de uso son explĂ­citos (la lĂłgica de tu aplicaciĂłn no está “escondida” dentro de controladores o repositorios).
  • La infraestructura es reemplazable (puedes cambiar SQL por Postgres, memoria por Redis, o un cliente HTTP real por uno falso en los tests).

Por eso los equipos la adoptan: facilita que los sistemas evolucionen de forma segura a medida que crecen.

Las 4 capas (explicadas como humanos)

Clean Architecture suele representarse como círculos concéntricos. En la práctica, la mayoría de proyectos .NET la organizan en cuatro capas:

Clean Architecture Layers

1) API (o PresentaciĂłn)

Qué hace: se ocupa únicamente de HTTP.

  • recibe peticiones
  • valida la forma del request (validaciĂłn básica)
  • devuelve respuestas y cĂłdigos de estado
  • delega el trabajo de negocio a la capa de Application

Qué NO debe hacer: reglas de negocio, llamadas a base de datos, orquestación compleja.

La capa de API debe ser un mecanismo de entrega delgado, no el cerebro del sistema.

2) Application (Casos de uso)

Qué hace: el comportamiento de la aplicación (los “verbos” de tu sistema).

  • CreateMovie
  • GetMovieDetails
  • SearchMovies

Esta capa orquesta el trabajo: llama a repositorios, invoca servicios, aplica polĂ­ticas y retorna resultados.

Objetivo principal: que los casos de uso sean fáciles de leer y fáciles de testear.

3) Domain (Reglas de negocio)

Qué hace: la “verdad” del negocio.

  • entidades
  • invariantes
  • value objects
  • reglas de dominio que deben cumplirse siempre

Objetivo principal: cero dependencias de frameworks o infraestructura.

Si tu dominio importa EF Core, ASP.NET o cualquier cosa relacionada con web… no es una capa de dominio.

4) Infrastructure (Detalles)

Qué hace: implementación de detalles externos.

  • repositorios de base de datos
  • almacenamiento de archivos
  • brokers de mensajerĂ­a
  • clientes HTTP

Infrastructure depende de Application y Domain, nunca al revés.

Problemas que resuelve Clean Architecture

1) “Todo está acoplado con todo”

Cuando los controladores hablan directamente con el DbContext de EF, tu API queda casada con tu estrategia de persistencia. Cambiar la base de datos se vuelve aterrador.

Clean Architecture ayuda porque la API habla con un caso de uso, y el caso de uso habla con una abstracciĂłn.

2) El conocimiento queda atrapado en la infraestructura

Si tu “lógica de negocio” vive dentro de queries SQL, stored procedures, controladores u ORMs, se vuelve más difícil de testear y más fácil de romper.

Clean Architecture te obliga a nombrar tus comportamientos (casos de uso) y mantenerlos en un solo lugar.

3) El testing se vuelve realista (y más rápido)

Los casos de uso se pueden testear unitariamente sin levantar el servidor web, la base de datos ni un entorno completo.

Eso no es “testear por diversión” — es reducir riesgo.

Malas prácticas comunes (y cómo los equipos arruinan la arquitectura sin darse cuenta)

1) Demasiadas capas que no hacen nada ❌

Un antipatrón clásico es el “teatro de Clean Architecture”:

  • CreateMovieHandler
  • CreateMovieService
  • CreateMovieManager
  • CreateMovieProcessor
  • CreateMovieCoordinator

…donde cada clase simplemente reenvía la llamada a la siguiente.

Si una capa no añade una responsabilidad clara, no es arquitectura — es ruido.

Regla de oro: Si puedes eliminar una capa y nada significativo cambia, esa capa no merecĂ­a existir.

2) Convertir cada petición en un mini framework ❌

No necesitas un framework para implementar Clean Architecture. Necesitas lĂ­mites claros.

Muchos equipos añaden MediatR (o similar) por defecto. El trade-off es real:

  • âś… los pipelines y behaviors pueden ser Ăştiles
  • ❌ el flujo se vuelve más difĂ­cil de seguir
  • ❌ debuggear se siente indirecto
  • ❌ los nuevos miembros del equipo tienen más carga mental

Puedes aplicar Clean Architecture con interfaces y clases simples — de forma clara y predecible.

El ejemplo: una API de pelĂ­culas (sin MediatR)

Vamos a construir una pequeña API de películas:

  • POST /api/movies → crear una pelĂ­cula
  • GET /api/movies/{id} → obtener detalles

La mantendremos intencionalmente pequeña para que la estructura sea fácil de ver.

Estructura del proyecto

src/
 ├── Movies.Api
 │   ├── Controllers
 │   └── Program.cs
 ├── Movies.Application
 │   ├── Abstractions
 │   ├── UseCases
 │   └── DTOs
 ├── Movies.Domain
 │   └── Entities
 └── Movies.Infrastructure
     └── Repositories

Por qué esto importa: hace obvio dónde debe vivir cada pieza de código.

Capa de Domain: la entidad Movie

namespace Movies.Domain.Entities;

public class Movie
{
    public Guid Id { get; private set; }
    public string Title { get; private set; }

    public Movie(string title)
    {
        Id = Guid.NewGuid();
        Title = title;
    }
}

Qué observar

  • Sin tipos de ASP.NET.
  • Sin atributos de EF Core.
  • Sin preocupaciones de base de datos.

Esta entidad es válida sin importar si tu almacenamiento es SQL, NoSQL o un archivo plano.

El dominio es la parte que quieres mantener estable por años.

Capa de Application: definir el comportamiento

DTO (entrada)

namespace Movies.Application.DTOs;

public record CreateMovieRequest(string Title);

¿Por qué un DTO? Porque el contrato de tu API no es lo mismo que tu modelo de dominio. Un DTO es un objeto de frontera.

Contrato del repositorio

namespace Movies.Application.Abstractions;

using Movies.Domain.Entities;

public interface IMovieRepository
{
    Task AddAsync(Movie movie);
    Task<Movie?> GetByIdAsync(Guid id);
}

¿Por qué una interfaz aquí? Porque la capa de Application no debe saber cómo funciona la persistencia — solo qué necesita.

Esto hace que Infrastructure sea reemplazable y el testing más sencillo.

Caso de uso: CreateMovie

namespace Movies.Application.UseCases;

using Movies.Application.Abstractions;
using Movies.Application.DTOs;
using Movies.Domain.Entities;

public interface ICreateMovieUseCase
{
    Task ExecuteAsync(CreateMovieRequest request);
}

public class CreateMovieUseCase : ICreateMovieUseCase
{
    private readonly IMovieRepository _repository;

    public CreateMovieUseCase(IMovieRepository repository)
    {
        _repository = repository;
    }

    public async Task ExecuteAsync(CreateMovieRequest request)
    {
        var movie = new Movie(request.Title);
        await _repository.AddAsync(movie);
    }
}

Qué observar

  • El caso de uso se lee como una oraciĂłn: crea una pelĂ­cula, guárdala.
  • El caso de uso es testeable con un repositorio falso.
  • No hay lĂłgica HTTP aquĂ­. Sin cĂłdigos de estado. Sin controladores.

Los casos de uso son donde viven los “verbos” de tu sistema.

Capa de Infrastructure: una implementaciĂłn simple del repositorio

namespace Movies.Infrastructure.Repositories;

using Movies.Application.Abstractions;
using Movies.Domain.Entities;

public class InMemoryMovieRepository : IMovieRepository
{
    private readonly List<Movie> _movies = new();

    public Task AddAsync(Movie movie)
    {
        _movies.Add(movie);
        return Task.CompletedTask;
    }

    public Task<Movie?> GetByIdAsync(Guid id)
    {
        return Task.FromResult(_movies.FirstOrDefault(m => m.Id == id));
    }
}

Qué observar

  • Infrastructure implementa el contrato que la capa de Application pidiĂł.
  • Cambiar esto por EF Core despuĂ©s está aislado en Infrastructure.

Esta es la capa de “detalles”. Los detalles pueden cambiar libremente.

Capa de API: los controladores deben ser delgados

namespace Movies.Api.Controllers;

using Microsoft.AspNetCore.Mvc;
using Movies.Application.DTOs;
using Movies.Application.UseCases;

[ApiController]
[Route("api/movies")]
public class MoviesController : ControllerBase
{
    private readonly ICreateMovieUseCase _createMovie;

    public MoviesController(ICreateMovieUseCase createMovie)
    {
        _createMovie = createMovie;
    }

    [HttpPost]
    public async Task<IActionResult> Create([FromBody] CreateMovieRequest request)
    {
        await _createMovie.ExecuteAsync(request);
        return StatusCode(201);
    }
}

Qué observar

  • El controlador coordina Ăşnicamente HTTP.
  • Delega el trabajo real al caso de uso.

Esto hace que los controladores sean fáciles de mantener y refactorizar sin romper reglas de negocio.

Conectando todo (InyecciĂłn de Dependencias)

builder.Services.AddScoped<ICreateMovieUseCase, CreateMovieUseCase>();
builder.Services.AddScoped<IMovieRepository, InMemoryMovieRepository>();
Por qué la DI explícita es buena aquí

Puedes “ver el sistema” en un solo lugar. Sin escaneo mágico necesario.

CQRS (y por qué encaja naturalmente con Clean Architecture)

CQRS significa separar:

  • Commands (operaciones de escritura que cambian estado)
  • Queries (operaciones de lectura que retornan datos)

No tienes que ir “full CQRS” para obtener beneficios. Incluso un enfoque simplificado ayuda:

  • Los casos de uso de escritura se enfocan en correctitud y transacciones
  • Los casos de uso de lectura se enfocan en queries rápidas y view models especĂ­ficos

Esto es especialmente valioso cuando las lecturas son pesadas y las escrituras necesitan ser seguras.

Réplicas de lectura vs escrituras transaccionales (la ventaja en el mundo real)

En muchos sistemas de producciĂłn:

  • Las escrituras van a una base de datos transaccional primaria (consistencia fuerte, locks, constraints).
  • Las lecturas pueden ir a una rĂ©plica de lectura (escalada para carga de queries).

Por qué esto es útil

  • Mantiene la BD primaria más saludable (las escrituras no pelean con lecturas pesadas).
  • Mejora el rendimiento para endpoints con muchas lecturas.
  • Permite escalar las lecturas horizontalmente.

El trade-off (seamos honestos)

Las réplicas de lectura pueden estar ligeramente atrasadas (lag de replicación), así que podrías no ver una escritura inmediatamente.

Por eso CQRS no es “gratis”. Es un intercambio.

Un enfoque práctico: usa réplicas para páginas con muchas lecturas donde “segundos de atraso” es aceptable, y enruta las lecturas críticas de consistencia a la primaria.

Caso de uso: GetMovieDetails

Ahora implementemos el lado de lectura. Primero, el DTO de respuesta:

namespace Movies.Application.DTOs;

public record MovieDetailsResponse(Guid Id, string Title);

El caso de uso:

namespace Movies.Application.UseCases;

using Movies.Application.Abstractions;
using Movies.Application.DTOs;

public interface IGetMovieDetailsUseCase
{
    Task<MovieDetailsResponse?> ExecuteAsync(Guid id);
}

public class GetMovieDetailsUseCase : IGetMovieDetailsUseCase
{
    private readonly IMovieRepository _repository;

    public GetMovieDetailsUseCase(IMovieRepository repository)
    {
        _repository = repository;
    }

    public async Task<MovieDetailsResponse?> ExecuteAsync(Guid id)
    {
        var movie = await _repository.GetByIdAsync(id);

        if (movie is null)
            return null;

        return new MovieDetailsResponse(movie.Id, movie.Title);
    }
}

Y el endpoint del controlador:

[HttpGet("{id:guid}")]
public async Task<IActionResult> GetById(Guid id)
{
    var movie = await _getMovieDetails.ExecuteAsync(id);

    if (movie is null)
        return NotFound();

    return Ok(movie);
}

Qué observar

  • El caso de uso retorna un DTO, no la entidad de dominio.
  • El controlador maneja la semántica HTTP (404 para no encontrado).
  • El caso de uso permanece testeable y reutilizable.

El patrĂłn Result (manejando errores sin excepciones)

Retornar null funciona para casos simples, pero ¿qué pasa con errores de validación, violaciones de reglas de negocio o diferentes modos de fallo?

El patrón Result hace explícitos el éxito y el fracaso:

namespace Movies.Application.Common;

public class Result<T>
{
    public T? Value { get; }
    public string? Error { get; }
    public bool IsSuccess => Error is null;
    public bool IsFailure => !IsSuccess;

    private Result(T? value, string? error)
    {
        Value = value;
        Error = error;
    }

    public static Result<T> Success(T value) => new(value, null);
    public static Result<T> Failure(string error) => new(default, error);
}

Ahora los casos de uso pueden comunicar por qué algo falló:

public interface ICreateMovieUseCase
{
    Task<Result<Guid>> ExecuteAsync(CreateMovieRequest request);
}

public class CreateMovieUseCase : ICreateMovieUseCase
{
    private readonly IMovieRepository _repository;

    public CreateMovieUseCase(IMovieRepository repository)
    {
        _repository = repository;
    }

    public async Task<Result<Guid>> ExecuteAsync(CreateMovieRequest request)
    {
        if (string.IsNullOrWhiteSpace(request.Title))
            return Result<Guid>.Failure("El tĂ­tulo es requerido");

        if (request.Title.Length > 200)
            return Result<Guid>.Failure("El tĂ­tulo debe tener 200 caracteres o menos");

        var movie = new Movie(request.Title);
        await _repository.AddAsync(movie);

        return Result<Guid>.Success(movie.Id);
    }
}

El controlador traduce resultados a HTTP:

[HttpPost]
public async Task<IActionResult> Create([FromBody] CreateMovieRequest request)
{
    var result = await _createMovie.ExecuteAsync(request);

    if (result.IsFailure)
        return BadRequest(new { error = result.Error });

    return CreatedAtAction(nameof(GetById), new { id = result.Value }, null);
}

Por qué esto importa

  • Sin excepciones ocultas para fallos esperados.
  • La firma del caso de uso te dice que puede fallar.
  • Los controladores se convierten en traductores simples.
  • El testing es directo: verificar IsSuccess o IsFailure.

ValidaciĂłn con FluentValidation

Para reglas de validaciĂłn complejas, las verificaciones inline se vuelven desordenadas. FluentValidation mantiene las reglas organizadas y reutilizables.

Primero, instala el paquete:

dotnet add package FluentValidation.DependencyInjectionExtensions

Define un validador:

namespace Movies.Application.Validators;

using FluentValidation;
using Movies.Application.DTOs;

public class CreateMovieRequestValidator : AbstractValidator<CreateMovieRequest>
{
    public CreateMovieRequestValidator()
    {
        RuleFor(x => x.Title)
            .NotEmpty().WithMessage("El tĂ­tulo es requerido")
            .MaximumLength(200).WithMessage("El tĂ­tulo debe tener 200 caracteres o menos")
            .Must(NoContenerCaracteresEspeciales).WithMessage("El título contiene caracteres inválidos");
    }

    private bool NoContenerCaracteresEspeciales(string title)
    {
        return !title.Any(c => c == '<' || c == '>' || c == '&');
    }
}

Intégralo con el caso de uso:

public class CreateMovieUseCase : ICreateMovieUseCase
{
    private readonly IMovieRepository _repository;
    private readonly IValidator<CreateMovieRequest> _validator;

    public CreateMovieUseCase(
        IMovieRepository repository,
        IValidator<CreateMovieRequest> validator)
    {
        _repository = repository;
        _validator = validator;
    }

    public async Task<Result<Guid>> ExecuteAsync(CreateMovieRequest request)
    {
        var validation = await _validator.ValidateAsync(request);

        if (!validation.IsValid)
        {
            var errors = string.Join("; ", validation.Errors.Select(e => e.ErrorMessage));
            return Result<Guid>.Failure(errors);
        }

        var movie = new Movie(request.Title);
        await _repository.AddAsync(movie);

        return Result<Guid>.Success(movie.Id);
    }
}

Registra los validadores en DI:

builder.Services.AddValidatorsFromAssemblyContaining<CreateMovieRequestValidator>();

Qué observar

  • Las reglas de validaciĂłn viven en un solo lugar.
  • Las reglas son testeables de forma independiente.
  • El caso de uso se mantiene enfocado en la orquestaciĂłn.

Testing unitario del caso de uso

Aquí es donde Clean Architecture da sus frutos. El testing es simple porque las dependencias están abstraídas.

namespace Movies.Tests.UseCases;

using FluentValidation;
using FluentValidation.Results;
using Moq;
using Movies.Application.Abstractions;
using Movies.Application.DTOs;
using Movies.Application.UseCases;
using Movies.Domain.Entities;

public class CreateMovieUseCaseTests
{
    private readonly Mock<IMovieRepository> _repositoryMock;
    private readonly Mock<IValidator<CreateMovieRequest>> _validatorMock;
    private readonly CreateMovieUseCase _useCase;

    public CreateMovieUseCaseTests()
    {
        _repositoryMock = new Mock<IMovieRepository>();
        _validatorMock = new Mock<IValidator<CreateMovieRequest>>();
        _useCase = new CreateMovieUseCase(_repositoryMock.Object, _validatorMock.Object);
    }

    [Fact]
    public async Task ExecuteAsync_ConRequestValido_RetornaExitoConId()
    {
        // Arrange
        var request = new CreateMovieRequest("Inception");
        _validatorMock
            .Setup(v => v.ValidateAsync(request, default))
            .ReturnsAsync(new ValidationResult());

        // Act
        var result = await _useCase.ExecuteAsync(request);

        // Assert
        Assert.True(result.IsSuccess);
        Assert.NotEqual(Guid.Empty, result.Value);
        _repositoryMock.Verify(r => r.AddAsync(It.IsAny<Movie>()), Times.Once);
    }

    [Fact]
    public async Task ExecuteAsync_ConRequestInvalido_RetornaFallo()
    {
        // Arrange
        var request = new CreateMovieRequest("");
        var validationFailure = new ValidationFailure("Title", "El tĂ­tulo es requerido");
        _validatorMock
            .Setup(v => v.ValidateAsync(request, default))
            .ReturnsAsync(new ValidationResult(new[] { validationFailure }));

        // Act
        var result = await _useCase.ExecuteAsync(request);

        // Assert
        Assert.True(result.IsFailure);
        Assert.Contains("El tĂ­tulo es requerido", result.Error);
        _repositoryMock.Verify(r => r.AddAsync(It.IsAny<Movie>()), Times.Never);
    }

    [Fact]
    public async Task ExecuteAsync_LlamaAlRepositorioConTituloCorrecto()
    {
        // Arrange
        var request = new CreateMovieRequest("The Matrix");
        Movie? capturedMovie = null;

        _validatorMock
            .Setup(v => v.ValidateAsync(request, default))
            .ReturnsAsync(new ValidationResult());

        _repositoryMock
            .Setup(r => r.AddAsync(It.IsAny<Movie>()))
            .Callback<Movie>(m => capturedMovie = m);

        // Act
        await _useCase.ExecuteAsync(request);

        // Assert
        Assert.NotNull(capturedMovie);
        Assert.Equal("The Matrix", capturedMovie.Title);
    }
}

Qué observar

  • Sin base de datos, sin servidor HTTP, sin dependencias externas.
  • Los tests corren rápido (milisegundos).
  • Cada test se enfoca en un comportamiento.
  • Los mocks verifican interacciones, no detalles de implementaciĂłn.

HATEOAS (Hypermedia as the Engine of Application State)

HATEOAS hace que tu API sea autodescriptiva al incluir enlaces a acciones relacionadas.

Primero, crea un modelo de enlace:

namespace Movies.Application.DTOs;

public record Link(string Href, string Rel, string Method);

public record MovieDetailsResponse(
    Guid Id,
    string Title,
    List<Link> Links
);

Construye los enlaces en el controlador (donde conoces las rutas):

[HttpGet("{id:guid}")]
public async Task<IActionResult> GetById(Guid id)
{
    var movie = await _getMovieDetails.ExecuteAsync(id);

    if (movie is null)
        return NotFound();

    var response = new MovieDetailsResponse(
        movie.Id,
        movie.Title,
        Links: BuildLinks(movie.Id)
    );

    return Ok(response);
}

private List<Link> BuildLinks(Guid movieId)
{
    return new List<Link>
    {
        new(Url.Action(nameof(GetById), new { id = movieId })!, "self", "GET"),
        new(Url.Action(nameof(Update), new { id = movieId })!, "update", "PUT"),
        new(Url.Action(nameof(Delete), new { id = movieId })!, "delete", "DELETE"),
        new(Url.Action(nameof(GetAll))!, "all-movies", "GET")
    };
}

La respuesta ahora incluye navegaciĂłn:

{
  "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "title": "Inception",
  "links": [
    {
      "href": "/api/movies/3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "rel": "self",
      "method": "GET"
    },
    {
      "href": "/api/movies/3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "rel": "update",
      "method": "PUT"
    },
    {
      "href": "/api/movies/3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "rel": "delete",
      "method": "DELETE"
    },
    { "href": "/api/movies", "rel": "all-movies", "method": "GET" }
  ]
}

Por qué HATEOAS importa

  • Los clientes descubren acciones disponibles dinámicamente.
  • Los cambios en la API no rompen clientes que siguen los enlaces.
  • La documentaciĂłn está embebida en las respuestas.

El trade-off

  • Respuestas más verbosas.
  • No todos los clientes necesitan o usan esto.
  • ConsidĂ©ralo para APIs pĂşblicas; omĂ­telo para APIs internas.

Versionado de API

Las APIs evolucionan. El versionado te permite introducir cambios incompatibles sin romper clientes existentes.

Instala el paquete:

dotnet add package Asp.Versioning.Mvc

Configura en Program.cs:

builder.Services.AddApiVersioning(options =>
{
    options.DefaultApiVersion = new ApiVersion(1, 0);
    options.AssumeDefaultVersionWhenUnspecified = true;
    options.ReportApiVersions = true;
    options.ApiVersionReader = ApiVersionReader.Combine(
        new UrlSegmentApiVersionReader(),
        new HeaderApiVersionReader("X-Api-Version")
    );
})
.AddApiExplorer(options =>
{
    options.GroupNameFormat = "'v'VVV";
    options.SubstituteApiVersionInUrl = true;
});

Versiona tus controladores:

namespace Movies.Api.Controllers.V1;

[ApiController]
[ApiVersion("1.0")]
[Route("api/v{version:apiVersion}/movies")]
public class MoviesController : ControllerBase
{
    // ImplementaciĂłn V1
}

Crea V2 con cambios incompatibles:

namespace Movies.Api.Controllers.V2;

[ApiController]
[ApiVersion("2.0")]
[Route("api/v{version:apiVersion}/movies")]
public class MoviesController : ControllerBase
{
    [HttpGet("{id:guid}")]
    public async Task<IActionResult> GetById(Guid id)
    {
        // V2 retorna una forma de respuesta diferente
        var movie = await _getMovieDetails.ExecuteAsync(id);

        if (movie is null)
            return NotFound();

        // V2 incluye rating y año de estreno
        return Ok(new
        {
            movie.Id,
            movie.Title,
            Rating = "PG-13",      // Nuevo en V2
            ReleaseYear = 2010     // Nuevo en V2
        });
    }
}

Los clientes ahora pueden llamar:

  • GET /api/v1/movies/{id} → respuesta original
  • GET /api/v2/movies/{id} → respuesta mejorada

O usar headers:

GET /api/movies/{id}
X-Api-Version: 2.0

Estrategias de versionado

EstrategiaEjemploVentajasDesventajas
Ruta URL/api/v1/moviesExplícito, fácil de testearLas URLs cambian
HeaderX-Api-Version: 1URLs limpiasMenos descubrible
Query string?api-version=1Fácil de agregarEnsucia las URLs

RecomendaciĂłn: Usa ruta URL para APIs pĂşblicas (explĂ­cito y cacheable), headers para APIs internas.

Reflexiones finales: Clean Architecture es criterio, no ceremonia

Si tu arquitectura:

  • oculta el flujo,
  • añade capas sin propĂłsito,
  • o asusta a tu equipo…

…no es limpia.

Clean Architecture debe hacer que el sistema sea:

  • más fácil de entender
  • más fácil de testear
  • más fácil de cambiar

No necesitas un framework para lograrlo. Necesitas lĂ­mites claros y buen criterio de ingenierĂ­a.