StackPractices
intermediate Por Mathias Paulenko

Guía de Arquitectura Onion: Diseño Centrado en el Dominio

Guía práctica de Arquitectura Onion: organiza código alrededor del dominio, fuerza dependencias hacia adentro y aísla infraestructura. Incluye ejemplos en C#.

Descripción General

La Arquitectura Onion, introducida por Jeffrey Palermo en 2008, organiza una aplicación como capas concéntricas con el modelo de dominio en el centro. En un diseño por capas tradicional, las dependencias apuntan hacia abajo: la UI depende de la lógica de negocio, que depende de la base de datos. Onion invierte esa dirección. Cada capa depende de las más cercanas al centro, nunca al revés. La infraestructura, la UI y los servicios externos están en el borde exterior y dependen de abstracciones definidas en el núcleo del dominio. Esto mantiene el modelo de dominio libre de frameworks, bases de datos y mecanismos de entrega.

La primera vez que me crucé con Arquitectura Onion fue en un proyecto .NET donde el equipo había construido un sistema de pedidos moderadamente complejo sobre Entity Framework. Las reglas de negocio estaban enterradas en configuraciones de EF y acciones de controladores. Cuando tuvimos que cambiar SQL Server por PostgreSQL durante una migración a la nube, descubrimos que la lógica de dominio estaba tan enredada con EF Core que la migración tomó seis semanas en lugar de la esperada. Arquitectura Onion habría aislado ese cambio en la capa de infraestructura. La lección quedó: el dominio no debería saber ni importarle qué base de datos hay detrás.

El patrón toma su nombre de la metáfora de una cebolla. Pelas las capas exteriores y encontrás más capas, cada una más central y más estable que la anterior. En el centro exacto está el modelo de dominio, la parte del sistema que codifica las reglas de negocio y que cambia menos. Todo lo demás es un detalle que se puede cambiar: la base de datos, el framework web, el bus de mensajes, la capa de caché. Al mantener esos detalles en los bordes y depender de abstracciones, te comprás la flexibilidad de cambiarlos sin reescribir el núcleo.

Cuándo Usarla

Usala cuando el modelo de dominio deba sobrevivir a las decisiones de framework, cuando las reglas de negocio sean complejas y cambien frecuentemente, y cuando quieras retrasar decisiones sobre la base de datos, el framework web o la UI. También ayuda cuando necesitás tests rápidos y determinísticos de reglas de negocio sin levantar una base de datos o servidor web, y cuando ya estás aplicando Domain-Driven Design.

Los equipos que mantienen aplicaciones de larga vida son los que más se benefician. Si tu proyecto va a durar tres a cinco años o más, los frameworks que elegiste al inicio probablemente serán reemplazados o actualizados. Onion mantiene esas decisiones reversibles porque el dominio no tiene referencias a frameworks. Trabajé en proyectos donde migramos de ASP.NET MVC a ASP.NET Core Web API, y después a Minimal APIs, sin tocar una sola línea de código de dominio. Ese es el retorno. En un proyecto, cambiamos NHibernate por EF Core en un fin de semana. Los tests de dominio no cambiaron nada.

También brilla en industrias reguladas donde el modelo de dominio debe ser auditable y testeable en aislamiento. Si trabajás en fintech, salud o seguros, esas reglas de negocio son el producto. Onion te permite probar esas reglas con tests unitarios puros, sin base de datos ni servidor web. Los auditores y los equipos de QA pueden verificar el comportamiento determinísticamente, algo difícil cuando las reglas están dispersas entre controladores y stored procedures.

Cuándo Evitarla

Evitala para CRUD simples o prototipos descartables donde la capa extra cueste más de lo que aporta. Si el equipo no está cómodo con la inversión de dependencias o el testeo a través de interfaces, la estructura puede pesar. También es una mala elección cuando los plazos importan más que la mantenibilidad a largo plazo y el dominio no vaya a cambiar. Heredé un proyecto donde alguien había agregado cuatro capas Onion a una herramienta admin simple con tres entidades y sin lógica de negocio. Navegar cuatro proyectos para agregar un solo campo a un formulario tomaba más tiempo que escribir el feature.

Vi equipos que agregaban cuatro proyectos (Domain, Application, Infrastructure, Presentation) a una herramienta admin interna simple con tres entidades y sin reglas de negocio. El overhead de navegar cuatro proyectos para agregar un campo a un formulario era peor que el problema que Onion resuelve. Si tu app es principalmente entrada de datos con lógica mínima, la arquitectura por slices verticales es un mejor encaje.

Conceptos Clave

Las Capas

La arquitectura divide al sistema en cuatro capas, cada una con una responsabilidad clara. Las repaso de adentro hacia afuera. El Núcleo de Dominio está en el centro y contiene entidades, objetos de valor, eventos de dominio y reglas de negocio, y se mantiene libre de dependencias externas. Los Servicios de Dominio contienen operaciones que no caben naturalmente dentro de una entidad, y solo dependen del Núcleo de Dominio. Los Servicios de Aplicación coordinan casos de uso, mapean DTOs y manejan objetos de dominio, apoyándose en el Núcleo de Dominio y en los Servicios de Dominio. La Infraestructura llena las interfaces definidas por las capas interiores, como repositorios, buses de mensajes, almacenamiento de archivos y APIs externas, y se conecta con la capa de Aplicación a través de esas interfaces. La Presentación contiene controladores, manejadores CLI o vistas, y depende de los Servicios de Aplicación. Este orden deja al dominio como la parte más estable del sistema.

La Regla de Dependencia

Todas las dependencias apuntan hacia adentro. Las capas exteriores dependen de las interiores a través de interfaces que viven en las capas interiores. El dominio se mantiene alejado de Entity Framework, ASP.NET, RabbitMQ y cualquier otro framework. En cambio, la capa de infraestructura referencia el dominio y llena interfaces como IOrderRepository o IEventBus.

Esta regla es lo que hace funcionar al patrón. Sin ella, tenés arquitectura por capas disfrazada. La primera vez que revisé un codebase “Onion” que no cumplía esta regla, encontré IOrderRepository definido en el proyecto de Infraestructura. El dominio tenía que referenciar Infraestructura para usarlo. Eso no es Onion; es arquitectura por capas con proyectos extra.

Puertos y Adaptadores

Las interfaces definidas por las capas interiores son puertos. Las implementaciones concretas en las capas exteriores son adaptadores. La aplicación declara lo que necesita, y la infraestructura lo satisface. Este desacoplamiento permite cambiar SQL Server por PostgreSQL, REST por gRPC, o un bus real por un fake en memoria sin tocar el dominio.

Este concepto se solapa con la arquitectura hexagonal (también llamada Puertos y Adaptadores). La diferencia es mayormente de nombres y énfasis: Onion nombra sus capas explícitamente, mientras que hexagonal se enfoca en la metáfora de puertos y adaptadores. En la práctica, la mayoría de los equipos con los que trabajé usan los términos indistintamente y eligen el enfoque que mejor le calce al equipo.

Diagrama de Capas

flowchart diagram: subgraph Outer[

El diagrama muestra la idea clave: las flechas solo apuntan hacia adentro. La infraestructura implementa puertos definidos en el dominio, pero el dominio nunca referencia infraestructura. La presentación llama a servicios de aplicación, pero los servicios de aplicación no saben nada de controladores.

Ejemplo de Implementación

Los snippets de C# más abajo muestran un pequeño sistema de pedidos: el dominio define una entidad Order y un puerto IOrderRepository, la capa de aplicación realiza un pedido, y la capa de infraestructura construye el repositorio con Entity Framework Core. El código completo con tests está disponible en el repositorio companion.

Núcleo de Dominio

El dominio no tiene dependencias externas. Ni EF Core, ni ASP.NET, ni framework de logging — nada. Define entidades, objetos de valor, eventos y las interfaces (puertos) que la infraestructura implementará.

// Núcleo de Dominio — sin dependencias externas
public interface IOrderRepository
{
    Task<Order> GetByIdAsync(OrderId id);
    Task SaveAsync(Order order);
}

public class Order
{
    public OrderId Id { get; private set; }
    public Money Total { get; private set; }
    private List<OrderLine> _lines = new();

    public void AddLine(Product product, int quantity)
    {
        if (quantity <= 0) throw new DomainException("Quantity must be positive");
        _lines.Add(new OrderLine(product, quantity));
        RecalculateTotal();
    }

    private void RecalculateTotal() =>
        Total = _lines.Aggregate(Money.Zero, (sum, line) => sum + line.Subtotal);
}

Notá que Order aplica sus propios invariantes. El método AddLine valida la cantidad y recalcula el total. No es un modelo anémico; la entidad contiene comportamiento. La interfaz IOrderRepository es un puerto definido en el dominio, no en infraestructura.

Capa de Aplicación

La capa de aplicación orquesta casos de uso. Depende de interfaces del dominio, no de implementaciones concretas. Acá es donde la inyección de dependencias cablea todo en la raíz de composición.

// Capa de Aplicación — orquesta casos de uso
public class PlaceOrderHandler
{
    private readonly IOrderRepository _orderRepository;
    private readonly IProductRepository _productRepository;
    private readonly IEventBus _eventBus;

    public PlaceOrderHandler(
        IOrderRepository orderRepository,
        IProductRepository productRepository,
        IEventBus eventBus)
    {
        _orderRepository = orderRepository;
        _productRepository = productRepository;
        _eventBus = eventBus;
    }

    public async Task<OrderId> Handle(PlaceOrderCommand command)
    {
        var order = new Order();
        foreach (var item in command.Items)
        {
            var product = await _productRepository.GetByIdAsync(item.ProductId);
            order.AddLine(product, item.Quantity);
        }
        await _orderRepository.SaveAsync(order);
        await _eventBus.PublishAsync(new OrderPlacedEvent(order.Id, order.Total));
        return order.Id;
    }
}

El handler no sabe si el repositorio usa SQL Server, MongoDB o una lista en memoria. Solo habla con IOrderRepository. Eso es lo que deja a la capa de aplicación testeable con mocks y rápida de ejecutar. En un proyecto en el que trabajé, teníamos más de 200 tests de aplicación corriendo en menos de 8 segundos porque ninguno tocaba una base de datos.

Capa de Infraestructura

La infraestructura implementa los puertos del dominio. Referencia EF Core, clientes de RabbitMQ, APIs del sistema de archivos o cualquier tecnología externa necesaria.

// Capa de Infraestructura — implementa interfaces del dominio
public class SqlOrderRepository : IOrderRepository
{
    private readonly AppDbContext _dbContext;

    public SqlOrderRepository(AppDbContext dbContext) => _dbContext = dbContext;

    public async Task<Order> GetByIdAsync(OrderId id) =>
        await _dbContext.Orders
            .Include(o => o.Lines)
            .FirstAsync(o => o.Id == id);

    public async Task SaveAsync(Order order)
    {
        _dbContext.Orders.Add(order);
        await _dbContext.SaveChangesAsync();
    }
}

Estructura de la Solución

Una solución .NET típica se ve así:

src/
  Domain/
    Entities/Order.cs
    ValueObjects/Money.cs
    Events/OrderPlacedEvent.cs
    Interfaces/IOrderRepository.cs
  Application/
    Orders/PlaceOrder/PlaceOrderHandler.cs
    DTOs/OrderDto.cs
  Infrastructure/
    Persistence/Repositories/SqlOrderRepository.cs
    Messaging/RabbitMqEventBus.cs
  Presentation/
    Controllers/OrdersController.cs

Forzando Dependencias en CI

Las reglas de dependencia se pueden forzar en CI con un test como este usando NetArchTest o ArchUnit:

var result = Types.InAssembly(typeof(Order).Assembly)
    .Should().NotHaveDependencyOn("Infrastructure")
    .And().NotHaveDependencyOn("Presentation")
    .And().NotHaveDependencyOn("Microsoft.EntityFrameworkCore")
    .GetResult();

result.IsSuccessful.Should().BeTrue();

Considero los tests de arquitectura innegociables para proyectos Onion. Sin ellos, alguien va a agregar una referencia a Microsoft.EntityFrameworkCore en el dominio “solo esta vez” y la regla de dependencia se rompe silenciosamente. El test cuesta cinco minutos de escribir y te ahorra semanas de refactoring después.

Estrategia de Testing

Una de las mayores ventajas de Arquitectura Onion es la testeabilidad. Cada capa tiene un enfoque de testing distinto. Las desgloso una por una.

Los tests del Núcleo de Dominio son unitarios puros sin mocks, sin base de datos, sin I/O. Corren en milisegundos y cubren reglas de negocio. Acá es donde obtenés el feedback más rápido sobre cambios de lógica. Si un test de dominio es lento o necesita un mock, algo está mal con la dirección de dependencias.

Los tests de Servicios de Aplicación usan puertos mockeados (IOrderRepository, IEventBus). Verificás que el handler llame a los métodos correctos en el orden correcto, que aplique reglas de negocio y publique eventos. Estos tests son rápidos porque los mocks son interfaces simples.

Los tests de Infraestructura levantan una base de datos real o un contenedor de prueba. Normalmente uso Testcontainers para estos, porque me da una instancia descartable de PostgreSQL o SQL Server por cada corrida de tests. Verifican que SqlOrderRepository persista y recupere pedidos correctamente, que los mapeos funcionen y que las migraciones apliquen limpio. Son más lentos pero detectan problemas de integración.

Los tests de Presentación corren contra el host completo de la API usando WebApplicationFactory o similar. Verifican códigos de estado HTTP, serialización, routing y autenticación.

El principio clave: cuanto más hacia adentro vayas, más rápidos y determinísticos deberían ser los tests. Si tus tests de dominio necesitan base de datos, filtraste infraestructura hacia adentro.

Una configuración de testing práctica que usé en múltiples proyectos: los tests de dominio corren en cada save (toman menos de 500ms para todo el suite), los tests de aplicación corren en cada push (toman 2-5 segundos con mocks), y los tests de infraestructura corren solo en PRs porque necesitan un contenedor Docker para la base de datos. Este enfoque por niveles te da feedback rápido donde más importa (reglas de negocio) y verificación exhaustiva donde hace falta (integración). La separación solo es posible porque Onion mantiene las capas aisladas; en una app por capas tradicional, cada test necesitaría el stack completo.

Mejores Prácticas

Mantené el Núcleo de Dominio puro asegurándote de que nunca referencie un framework, ORM ni librería externa. Definí interfaces de repositorio, bus y unit-of-work en el dominio o en la capa de aplicación, no en infraestructura. Cableá adaptadores concretos a través de inyección de dependencias en la raíz de composición, normalmente en Program.cs o un módulo de inicio. Forzá los límites de capa con tests de arquitectura en CI, porque un build que pasa no alcanza si una referencia nueva se cuela hacia adentro. Mapeá explícitamente entre entidades y DTOs, y nunca expongas objetos de dominio directamente desde los controladores. Mantené las reglas de negocio dentro de entidades y servicios de dominio, y deja que los servicios de aplicación solo coordinen.

Descubrí que el patrón repository funciona naturalmente acá porque el dominio define la interfaz e infraestructura provee la implementación. No saltees la abstracción del repositorio incluso si usás EF Core directamente; la abstracción es lo que mantiene al dominio testeable.

Una práctica que recomiendo: mantené el proyecto de dominio físicamente chico. Si crece más allá de unos cientos de archivos, considerá dividir por bounded context. Un dominio que entra en una carpeta de solución es más fácil de razonar que uno disperso entre docenas de subcarpetas. Lo mismo aplica a la capa de aplicación; si tenés más de 20 handlers de casos de uso, probablemente te falte un límite de bounded context.

Errores Comunes

Filtrar detalles del ORM al dominio es un error común; la configuración de mapeo y los atributos del framework pertenecen a infraestructura. Vi atributos [Table("Orders")] y [Column("total")] en entidades de dominio, lo que ata el dominio a un ORM específico y rompe la portabilidad que Onion debería proveer. Usá configuración fluida en infraestructura.

Poner lógica de negocio en servicios de aplicación también rompe el modelo, porque las reglas van en el dominio mientras el código de aplicación coordina. Si tu PlaceOrderHandler contiene lógica de validación, cálculos de descuento o transiciones de estado, movelas a la entidad Order o a un servicio de dominio. El handler debería ser delgado: cargar, llamar método de dominio, guardar, publicar.

Las dependencias circulares entre capas se pueden detectar temprano con tests de arquitectura. Construir un modelo de dominio anémico, donde las entidades sean solo bolsas de datos con getters y setters, pierde el punto. Agregar todas las capas a una app CRUD pequeña es exceso; el patrón solo rinde cuando la complejidad del dominio es genuina.

Otro error que veo seguido: los equipos crean un proyecto Domain pero después ponen IUnitOfWork en infraestructura “porque es sobre bases de datos.” No. IUnitOfWork es un puerto. Pertenece al dominio o a la capa de aplicación. La infraestructura lo implementa. Si ponés la interfaz en infraestructura, el dominio tiene que referenciar infraestructura para usarla, lo que invierte la regla de dependencia.

Un error más sutil es sobre-abstraer el dominio. Algunos equipos crean interfaces para cada entidad, cada objeto de valor y cada servicio, convirtiendo el dominio en una red de abstracciones más difícil de leer que el problema original. El dominio debería ser concreto: clases reales, métodos reales, comportamiento real. Las interfaces pertenecen a los límites (repositorios, buses de eventos, servicios externos), no dentro del modelo de dominio. Cuando reviso proyectos Onion, busco código de dominio que se lea como una descripción del negocio. Si se lee como un framework, algo salió mal.

Onion vs. Clean vs. Hexagonal

Estas tres arquitecturas son primas cercanas. Comparten el mismo ADN. La Arquitectura Clean de Robert C. Martin usa la misma regla de dependencia hacia adentro pero dibuja anillos concéntricos genéricos sin nombrar las capas. Arquitectura Onion (Jeffrey Palermo) da nombres explícitos: Dominio, Aplicación, Infraestructura, Presentación. La Arquitectura Hexagonal (Alistair Cockburn) se enfoca en la metáfora de puertos y adaptadores sin prescribir nombres de capas.

En la práctica, todas aplican el mismo principio: las dependencias apuntan hacia adentro, el dominio es agnóstico al framework y la infraestructura es intercambiable. Elegí el enfoque que resuene con tu equipo. Trabajé en proyectos que se llamaban “Clean Architecture” pero eran estructuralmente idénticos a Onion y viceversa. El nombre importa menos que la disciplina de forzar la regla de dependencia.

Una pregunta que surge: ¿se puede mezclar Onion con CQRS o event sourcing? Sí. Onion define el layering; CQRS define cómo fluyen comandos y queries a través de esas capas. Son ortogonales. Vi equipos usar Onion para el lado de comandos (modelo de escritura) y un modelo de lectura más simple para queries, lo que es esencialmente CQRS dentro de una estructura Onion. La clave es que CQRS no reemplaza la regla de dependencia; agrega una separación entre caminos de lectura y escritura encima de ella.

Resumen

La Arquitectura Onion organiza el código como capas concéntricas con el dominio en el centro. Las dependencias siempre apuntan hacia adentro: la infraestructura depende de abstracciones del dominio, nunca al revés. Esto mantiene al dominio libre de frameworks, bases de datos y mecanismos de entrega, haciéndolo portable, testeable en aislamiento y resiliente al cambio de tecnología. Usalo cuando las reglas de negocio sean complejas y de larga vida; saltealo para apps CRUD simples donde el overhead de capas excede el beneficio. Forzá la regla de dependencia con tests de arquitectura en CI, mantené la lógica de negocio en entidades y servicios de dominio, y dejá que los servicios de aplicación solo coordinen. El patrón se combina naturalmente con Domain-Driven Design, el patrón repository y la inyección de dependencias. El costo inicial es real: escribís más interfaces y más proyectos de los que una app CRUD simple necesita. Pero el retorno llega cuando cambiás una base de datos, reemplazás un framework o agregás un canal de entrega nuevo sin tocar las reglas de negocio.

See Also

La serie original de Arquitectura Onion de Jeffrey Palermo sigue siendo la referencia canónica. La guía de arquitectura .NET de Microsoft cubre patrones de clean architecture en el ecosistema .NET con ejemplos prácticos. Para forzar reglas de arquitectura en tests, NetArchTest provee una API fluida para verificación de dependencias de assemblies .NET, y ArchUnit hace lo mismo para Java. Si estás comparando enfoques, la guía de clean architecture y la guía de arquitectura hexagonal cubren los patrones relacionados con trade-offs. El patrón de inyección de dependencias explica cómo cablear adaptadores en la raíz de composición, lo cual es esencial para que Onion funcione en la práctica.

Preguntas frecuentes

¿Cuál es la diferencia entre Onion y Clean Architecture?

Ambas usan la misma regla de dependencia hacia adentro. Onion nombra explícitamente las capas: Dominio, Aplicación, Infraestructura, Presentación. Clean Architecture dibuja la misma idea como anillos concéntricos genéricos. Son funcionalmente iguales.

¿Puedo usar Arquitectura Onion en un monolito?

Sí. Funciona a nivel de módulo o aplicación. Un monolito puede contener varios módulos con estructura Onion, cada uno con su propio núcleo de dominio.

¿Qué ORM funciona mejor?

Cualquiera que permita usar entidades POCO o POJO sin clases base o atributos. EF Core con Fluent API, Dapper, Hibernate con mapeos XML y SQLAlchemy con base declarativa funcionan bien.

¿Cómo empiezo en un proyecto existente?

Elegí un bounded context o servicio y aplicá el layering ahí. Mové el código de framework hacia afuera, definí puertos en el dominio y agregá un adaptador. Medí antes de expandirte.

¿Cómo manejo transacciones?

Definí IUnitOfWork en el dominio o en la capa de aplicación. La infraestructura lo resuelve con EF Core o Dapper. El handler de aplicación abre la unidad de trabajo, ejecuta operaciones de dominio y hace commit. El dominio no sabe nada de transacciones.

¿Cómo testeo cada capa?

Los tests del Núcleo de Dominio son unitarios puros y sin mocks. Los tests de los Servicios de Aplicación usan puertos mockeados. Los tests de Infraestructura corren contra una base de datos real o un contenedor de prueba. Los tests de Presentación corren contra el host completo de la API.

¿Debería usar Onion para microservicios?

Depende de la complejidad del servicio. Para microservicios CRUD simples, Onion agrega overhead sin beneficio. Para servicios con reglas de negocio ricas, Onion a nivel de servicio mantiene el dominio limpio y hace el servicio independientemente testeable. Muchos equipos usan Onion por servicio en una arquitectura de microservicios.