Skip to content

Arquitectura Hexagonal (Ports & Adapters)

La Arquitectura Hexagonal, propuesta por Alistair Cockburn bajo el nombre Ports & Adapters, organiza un sistema de software en dos zonas claramente delimitadas: el interior —el dominio y la lógica de aplicación— y el exterior —la infraestructura, los frameworks y los sistemas externos. La regla fundamental es que el interior no conoce ni depende del exterior. Nunca.

Esta arquitectura es el estándar de organización interna para todos los servicios de GM Transport. Su adopción en GM Fiscal Backend (Go) lo ilustra de forma concreta y sirve como referencia para los demás servicios del ecosistema.

El problema que resuelve

El problema clásico del software acoplado a infraestructura: la lógica de negocio solo puede ejecutarse si la base de datos está disponible, si el servidor HTTP está activo, si la cola de mensajes responde. Los tests requieren toda la infraestructura levantada. Cambiar de PostgreSQL a MongoDB implica tocar la lógica de negocio. Cambiar el framework HTTP rompe los casos de uso.

La Arquitectura Hexagonal rompe este acoplamiento definiendo que el dominio no sabe qué tecnología lo rodea. Solo conoce interfaces.

Estructura de la arquitectura

graph TD
    subgraph exterior["Exterior (Adaptadores)"]
        HTTP["Handler HTTP\n(gorilla/mux, NestJS, FastAPI)"]
        DB["Repositorio GORM\n(PostgreSQL, SQLite)"]
        GRPC["Cliente gRPC\n(Hades Bóveda Fiscal)"]
        BLOB["Azure Blob Storage"]
        WS["WebSocket Manager"]
    end

    subgraph interior["Interior"]
        APP["Casos de Uso\n(Aplicación)"]
        DOM["Dominio\n(Entidades + Puertos)"]
    end

    HTTP -->|llama| APP
    APP -->|usa interfaces| DOM
    DB -->|implementa| DOM
    GRPC -->|implementa| DOM
    BLOB -->|implementa| DOM
    WS -->|implementa| DOM

El hexágono no tiene seis lados por una razón matemática: representa que el sistema puede tener múltiples puertos de entrada y de salida, todos igualmente intercambiables.

Puertos y adaptadores

Un puerto es una interfaz definida por el dominio que describe qué operaciones necesita, sin mencionar cómo se implementan.

Un adaptador es la implementación concreta de un puerto para una tecnología específica.

Ejemplo en Go

src/domain/solicitud/repository.go
// Puerto — definido en el dominio
type SolicitudRepository interface {
FindByUUID(uuid string) (Solicitud, error)
Save(s Solicitud) error
UpdateEstatus(uuid string, estatus string) error
}
// Adaptador — implementado en infraestructura
// src/infrastructure/persistence/gorm/solicitud_repo.go
type GormSolicitudRepository struct {
db *gorm.DB
}
func (r *GormSolicitudRepository) FindByUUID(uuid string) (solicitud.Solicitud, error) {
var model models.Solicitud
if err := r.db.Where("uuid = ?", uuid).First(&model).Error; err != nil {
return solicitud.Solicitud{}, solicitud.ErrNotFound(uuid)
}
return toDomain(model), nil
}

Ejemplo en NestJS/TypeScript

src/domain/empresa/empresa.repository.ts
// Puerto — interfaz en el dominio
export interface EmpresaRepository {
findByRFC(rfc: string): Promise<Empresa | null>;
save(empresa: Empresa): Promise<void>;
}
// Adaptador — implementación con TypeORM
// src/infrastructure/persistence/typeorm/empresa.typeorm-repository.ts
@Injectable()
export class EmpresaTypeORMRepository implements EmpresaRepository {
constructor(
@InjectRepository(EmpresaEntity)
private readonly repo: Repository<EmpresaEntity>,
) {}
async findByRFC(rfc: string): Promise<Empresa | null> {
const entity = await this.repo.findOneBy({ rfc });
return entity ? EmpresaMapper.toDomain(entity) : null;
}
}

Ejemplo en Flutter/Dart

lib/domain/auth/auth_repository.dart
// Puerto — abstract class en el dominio
abstract class AuthRepository {
Future<Either<Failure, User>> login(String rfc, String password);
Future<Either<Failure, User>> refreshToken(String token);
}
// Adaptador — implementación HTTP
// lib/infrastructure/auth/auth_http_repository.dart
class AuthHttpRepository implements AuthRepository {
final Dio _dio;
AuthHttpRepository(this._dio);
@override
Future<Either<Failure, User>> login(String rfc, String password) async {
try {
final response = await _dio.post('/api/auth/login', data: {
'rfc': rfc,
'password': password,
});
return Right(UserMapper.fromJson(response.data));
} on DioException catch (e) {
return Left(ServerFailure(e.message));
}
}
}

Las cuatro capas

La implementación de GM Transport organiza cada servicio en cuatro capas:

CapaContenidoDependencias permitidas
DominioEntidades, puertos (interfaces), errores de dominio, servicios de dominioSolo stdlib / paquetes del lenguaje base
AplicaciónCasos de uso, DTOs, puertos de aplicaciónSolo dominio
InfraestructuraRepositorios, gateways, workers, storageDominio + aplicación + librerías externas
PresentaciónHandlers HTTP, middleware, rutasAplicación + dominio/shared

Verificación de invariantes

En Go se verifica con grep que ninguna capa viole sus restricciones:

Terminal window
# Debe producir cero resultados
grep -r "infrastructure" src/domain/
grep -r 'gorm:"' src/domain/
grep -r "infrastructure" src/application/
grep -r "\.db\." src/presentation/

En NestJS la misma lógica se aplica con reglas de ESLint o con módulos que restringen las importaciones por directorio.

Testabilidad como consecuencia directa

El beneficio más tangible de la arquitectura hexagonal es que los casos de uso son completamente testeables sin infraestructura. Se pasan mocks de los puertos:

// Test en Go — sin base de datos, sin HTTP
func TestConciliarSolicitud_EstadoInvalido_RetornaError(t *testing.T) {
repo := &solicitudmocks.MockSolicitudRepository{}
repo.On("FindByUUID", "sol-123").Return(
solicitud.Solicitud{Estatus: "Conciliada"},
nil,
)
uc := solicitud_app.NewConciliarSolicitudUseCase(repo)
_, err := uc.Execute(solicitud_app.ConciliarCommand{UUID: "sol-123"})
require.Error(t, err)
assert.True(t, shared.IsInvalidTransition(err))
repo.AssertExpectations(t)
}
// Test en NestJS/Jest — sin base de datos
describe("ConciliarSolicitudUseCase", () => {
it("debe retornar error si la solicitud ya está Conciliada", async () => {
const mockRepo: EmpresaRepository = {
findByRFC: jest
.fn()
.mockResolvedValue(new Solicitud({ estatus: "Conciliada" })),
};
const useCase = new ConciliarSolicitudUseCase(mockRepo);
await expect(useCase.execute({ uuid: "sol-123" })).rejects.toThrow(
InvalidTransitionError,
);
});
});

Cuándo aplicar Arquitectura Hexagonal

La arquitectura hexagonal tiene un costo inicial: más archivos, más interfaces, más estructura. Este costo se justifica cuando:

  • El sistema tiene lógica de negocio no trivial que debe ser verificable en tests.
  • Se espera que la infraestructura cambie (proveedor de base de datos, proveedor cloud, protocolo de comunicación).
  • Varios desarrolladores trabajan en el mismo servicio y necesitan fronteras claras de responsabilidad.
  • El sistema debe mantenerse durante años, no semanas.

Para un script utilitario o un prototipo de corta vida, la arquitectura hexagonal agrega complejidad innecesaria.

Resumen

  • La Arquitectura Hexagonal divide el sistema en interior (dominio + aplicación) y exterior (infraestructura). El interior nunca importa el exterior.
  • Los puertos son interfaces definidas por el dominio; los adaptadores son las implementaciones concretas en infraestructura.
  • El patrón aplica en todo el stack: Go, NestJS/TypeScript, Flutter/Dart, Python/FastAPI.
  • La testabilidad completa sin infraestructura es una consecuencia directa: los casos de uso se prueban con mocks de los puertos.
  • El costo inicial de estructura se amortiza en proyectos con lógica de negocio no trivial, equipos medianos/grandes y ciclo de vida largo.