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
// Puerto — definido en el dominiotype 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.gotype 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
// Puerto — interfaz en el dominioexport 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
// Puerto — abstract class en el dominioabstract 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.dartclass 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:
| Capa | Contenido | Dependencias permitidas |
|---|---|---|
| Dominio | Entidades, puertos (interfaces), errores de dominio, servicios de dominio | Solo stdlib / paquetes del lenguaje base |
| Aplicación | Casos de uso, DTOs, puertos de aplicación | Solo dominio |
| Infraestructura | Repositorios, gateways, workers, storage | Dominio + aplicación + librerías externas |
| Presentación | Handlers HTTP, middleware, rutas | Aplicación + dominio/shared |
Verificación de invariantes
En Go se verifica con grep que ninguna capa viole sus restricciones:
# Debe producir cero resultadosgrep -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 HTTPfunc 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 datosdescribe("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.