Arquitectura
Exploración profunda de la implementación de arquitectura hexagonal de iMirly y la estructura de módulos
Visión General de la Arquitectura
iMirly sigue la Arquitectura Hexagonal (también conocida como Puertos y Adaptadores), un patrón de diseño que desacopla completamente la lógica de negocio de las dependencias externas como bases de datos, frameworks y UI. Esto asegura que el dominio permanezca puro, testable y agnóstico al framework.
¿Por qué Arquitectura Hexagonal?
Las arquitecturas tradicionales en capas acoplan fuertemente la lógica de negocio a las preocupaciones de infraestructura. La arquitectura hexagonal invierte estas dependencias:
Capas Tradicionales
La lógica de negocio depende de las implementaciones de base de datos y framework. Difícil de probar, difícil de cambiar.
Hexagonal (Puertos & Adaptadores)
La lógica de negocio define interfaces (puertos). La infraestructura las implementa (adaptadores). Fácil de probar, intercambiar implementaciones.
Principios Core
-
1
Independencia del Dominio
La lógica de negocio tiene cero dependencias en Spring, JPA o cualquier biblioteca externa
-
2
Inversión de Control
El dominio define interfaces (puertos), la infraestructura las implementa
-
3
Testabilidad
La lógica pura de dominio puede probarse sin bases de datos ni frameworks
-
4
Flexibilidad
Intercambia base de datos, framework o UI sin tocar las reglas de negocio
Arquitectura de Alto Nivel
┌───────────────────────────────────────────────────────────┐
│ Android Frontend │
│ (Kotlin + Jetpack Compose) │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ UI Views │→ │ViewModels│→ │ Retrofit │ │
│ └──────────┘ └──────────┘ └─────┬────┘ │
└────────────────────────────────────┼──────────────────────┘
│ HTTP/JSON
↓
┌────────────────────────────────────────────────────────────┐
│ Spring Boot Backend (Java 21) │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ INFRASTRUCTURE LAYER │ │
│ │ (Adapters - Input & Output) │ │
│ │ │ │
│ │ INPUT (Driving) OUTPUT (Driven) │ │
│ │ ┌──────────────┐ ┌──────────────┐ │ │
│ │ │ REST │ │ JPA │ │ │
│ │ │ Controllers │ │ Repositories │ │ │
│ │ │ │ │ │ │ │
│ │ │ JWT Filters │ │ Security │ │ │
│ │ └──────┬───────┘ └──────▲───────┘ │ │
│ └─────────┼────────────────────┼──────────────────────┘ │
│ │ │ │
│ ↓ uses InPorts │ implements OutPorts │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ APPLICATION LAYER │ │
│ │ (Use Cases / Services) │ │
│ │ │ │
│ │ ┌──────────────────┐ ┌──────────────────┐ │ │
│ │ │ RegisterUser │ │ CreateAnuncio │ │ │
│ │ │ Service │ │ Service │ │ │
│ │ │ │ │ │ │ │
│ │ │ implements │ │ implements │ │ │
│ │ │ RegisterUserUse │ │ ManageAnuncio │ │ │
│ │ │ Case (InPort) │ │ UseCase (InPort) │ │ │
│ │ └────────┬─────────┘ └────────┬─────────┘ │ │
│ └───────────┼──────────────────────┼──────────────────┘ │
│ │ uses Domain │ │
│ ↓ ↓ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ DOMAIN LAYER │ │
│ │ (Pure Business Logic - No Dependencies) │ │
│ │ │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │
│ │ │ User │ │ Anuncio │ │ Solicitud│ │ │
│ │ │ │ │ Servicio │ │ Contrato │ │ │
│ │ │ - saldo │ │ │ │ │ │ │
│ │ │ + restar │ │ + pausar │ │ + aceptar│ │ │
│ │ │ Saldo()│ │ Anuncio()│ │() │ │ │
│ │ └──────────┘ └──────────┘ └──────────┘ │ │
│ │ │ │
│ │ ┌─────────────────────────────────────────┐ │ │
│ │ │ PORTS (Interfaces) │ │ │
│ │ │ │ │ │
│ │ │ InPorts OutPorts │ │ │
│ │ │ (Driven by UI) (Drive Infrastructure)│ │ │
│ │ └─────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
└──────────────────────────┬─────────────────────────────────┘
│
↓
┌─────────────────┐
│ PostgreSQL │
│ Database │
└─────────────────┘
Estructura de Módulos
El backend está organizado en cuatro contextos acotados (módulos), cada uno siguiendo la arquitectura hexagonal:
backend/src/main/java/tfg/imirly/
├── auth/ # Autenticación de usuarios y gestión de billetera
├── catalog/ # Listados de servicios y categorías
├── contracting/ # Ciclo de vida de contratos y pagos
└── messages/ # Chat en tiempo real y propuestas
Anatomía del Módulo
Cada módulo sigue la misma estructura de tres capas:
module/
├── domain/ # CAPA INTERNA - Lógica pura de negocio
│ ├── model/ # Entidades de dominio (User, AnuncioServicio, etc.)
│ └── port/
│ └── out/ # OutPorts: Interfaces que necesita el dominio
├── application/ # CAPA MEDIA - Casos de uso
│ ├── port/
│ │ └── in/ # InPorts: Interfaces de casos de uso
│ └── service/ # Implementaciones de servicios (ejecutan lógica)
└── infrastructure/ # CAPA EXTERNA - Adaptadores
├── input/ # Adaptadores driving (REST controllers, filtros)
│ └── rest/
│ ├── Controller.java
│ └── dto/ # DTOs Request/Response
└── output/ # Adaptadores driven (JPA repos, APIs externas)
└── persistence/
├── entity/ # Entidades JPA
├── repository/ # Repositorios Spring Data
└── RepositoryAdapter.java # Implementa OutPorts del dominio
💡 Insight Clave: Las dependencias siempre apuntan hacia adentro. Infraestructura depende de Aplicación, Aplicación depende de Dominio. Dominio NO depende de nada.
Módulos del Sistema
Auth
Autenticación de usuarios, gestión de perfiles y billetera virtual
Catalog
Gestión de categorías, subcategorías y anuncios de servicios
Contracting
Ciclo de vida de contratos, propuestas de presupuesto y pagos
Messages
Chat en tiempo real con propuestas integradas y notificaciones
Capa de Dominio (Lógica de Negocio Core)
El corazón de la aplicación. Contiene:
Modelos de Dominio
Clases Java puras con lógica de negocio y cero dependencias de framework:
// auth/domain/model/User.java
package tfg.imirly.auth.domain.model;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
import java.util.UUID;
@Getter
@Setter
public class User {
private UUID id;
private String nombre;
private String email;
private String passwordHash;
private UserType tipo; // CLIENTE or PROFESIONAL
private String documentoIdentidad;
private boolean verificado;
private LocalDateTime fechaRegistro;
// Billetera virtual
private Double saldo;
private Double puntuacionTotal;
private Integer numeroValoraciones;
// Métodos de lógica de negocio
public void restarSaldo(Double cantidad) {
if (cantidad == null || cantidad <= 0) {
throw new IllegalArgumentException("La cantidad debe ser mayor que cero.");
}
this.saldo -= cantidad;
}
public void sumarSaldo(Double cantidad) {
if (cantidad == null || cantidad <= 0) {
throw new IllegalArgumentException("La cantidad debe ser mayor que cero.");
}
this.saldo += cantidad;
}
public Double getValoracionMedia() {
if (numeroValoraciones == null || numeroValoraciones == 0)
return 0.0;
return Math.round((puntuacionTotal / numeroValoraciones) * 10.0) / 10.0;
}
}
Nota: Sin @Entity, sin @Table, sin anotaciones Spring.
Esto es lógica pura de negocio.
OutPorts (Interfaces Definidas por el Dominio)
El dominio declara lo que necesita del mundo exterior:
// auth/domain/port/out/UserRepositoryPort.java
package tfg.imirly.auth.domain.port.out;
import tfg.imirly.auth.domain.model.User;
import java.util.Optional;
import java.util.UUID;
public interface UserRepositoryPort {
User save(User user);
Optional<User> findByEmail(String email);
Optional<User> findById(UUID id);
boolean existsByEmail(String email);
}
// auth/domain/port/out/PasswordEncoderPort.java
public interface PasswordEncoderPort {
String encode(String rawPassword);
boolean matches(String rawPassword, String encodedPassword);
}
Estas son interfaces, no implementaciones. El dominio no le importa si usas BCrypt, Argon2 o MD5 (por favor no). Solo necesita codificación de contraseñas.
Capa de Aplicación (Casos de Uso)
Orquesta la lógica de dominio para cumplir casos de uso específicos.
InPorts (Interfaces de Casos de Uso)
Define lo que la aplicación puede hacer:
// auth/application/port/in/RegisterUserUseCase.java
package tfg.imirly.auth.application.port.in;
import tfg.imirly.auth.domain.model.User;
public interface RegisterUserUseCase {
User registerUser(RegisterUserCommand command);
}
// auth/application/port/in/RegisterUserCommand.java
public class RegisterUserCommand {
private final String nombre;
private final String email;
private final String rawPassword;
private final UserType tipo;
private final String documentoIdentidad;
// Constructor, getters
}
Servicios (Implementaciones de Casos de Uso)
Ejecutan lógica de negocio coordinando objetos de dominio y puertos:
// auth/application/service/RegisterUserService.java
package tfg.imirly.auth.application.service;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import tfg.imirly.auth.application.port.in.RegisterUserCommand;
import tfg.imirly.auth.application.port.in.RegisterUserUseCase;
import tfg.imirly.auth.domain.model.User;
import tfg.imirly.auth.domain.port.out.PasswordEncoderPort;
import tfg.imirly.auth.domain.port.out.UserRepositoryPort;
@Service
@RequiredArgsConstructor
public class RegisterUserService implements RegisterUserUseCase {
private final UserRepositoryPort userRepository;
private final PasswordEncoderPort passwordEncoder;
@Override
public User registerUser(RegisterUserCommand command) {
// Regla de negocio: email debe ser único
if (userRepository.existsByEmail(command.getEmail())) {
throw new IllegalArgumentException("Email ya registrado");
}
// Hashear la contraseña
String hashedPassword = passwordEncoder.encode(command.getRawPassword());
// Crear objeto de dominio
User newUser = new User(
command.getNombre(),
command.getEmail(),
hashedPassword,
command.getTipo(),
command.getDocumentoIdentidad()
);
// Persistir vía puerto
return userRepository.save(newUser);
}
}
El servicio depende de puertos (UserRepositoryPort,
PasswordEncoderPort), no de implementaciones concretas. La inyección de
dependencias de Spring proporciona los adaptadores en runtime.
Capa de Infraestructura (Adaptadores)
Conecta el dominio con el mundo real.
Adaptadores de Entrada (Driving - Controladores REST)
Reciben peticiones del mundo exterior e invocan casos de uso:
// auth/infrastructure/input/rest/AuthController.java
package tfg.imirly.auth.infrastructure.input.rest;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import tfg.imirly.auth.application.port.in.*;
import tfg.imirly.auth.domain.model.User;
import tfg.imirly.auth.domain.model.UserType;
import tfg.imirly.auth.infrastructure.input.rest.dto.*;
@RestController
@RequestMapping("/api/v1/auth")
@RequiredArgsConstructor
public class AuthController {
private final RegisterUserUseCase registerUserUseCase;
private final LoginUserUseCase loginUserUseCase;
@PostMapping("/register")
public ResponseEntity<?> registerUser(@RequestBody RegisterUserRequest request) {
try {
// Mapear DTO a Command
RegisterUserCommand command = new RegisterUserCommand(
request.getNombre(),
request.getEmail(),
request.getRawPassword(),
UserType.valueOf(request.getTipoUsuario().toUpperCase()),
request.getDocumentoIdentidad()
);
// Ejecutar caso de uso
User newUser = registerUserUseCase.registerUser(command);
return ResponseEntity.status(HttpStatus.CREATED)
.body("Usuario registrado con ID: " + newUser.getId());
} catch (IllegalArgumentException e) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(e.getMessage());
}
}
}
Adaptadores de Salida (Driven - Repositorios JPA)
Implementan OutPorts del dominio usando Spring Data JPA:
// auth/infrastructure/output/persistence/UserRepositoryAdapter.java
package tfg.imirly.auth.infrastructure.output.persistence;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import tfg.imirly.auth.domain.model.User;
import tfg.imirly.auth.domain.port.out.UserRepositoryPort;
import tfg.imirly.auth.infrastructure.output.persistence.entity.UserEntity;
import tfg.imirly.auth.infrastructure.output.persistence.repository.SpringDataUserRepository;
import java.util.Optional;
import java.util.UUID;
@Component
@RequiredArgsConstructor
public class UserRepositoryAdapter implements UserRepositoryPort {
private final SpringDataUserRepository springDataRepo;
private final UserMapper mapper;
@Override
public User save(User user) {
UserEntity entity = mapper.toEntity(user);
UserEntity saved = springDataRepo.save(entity);
return mapper.toDomain(saved);
}
@Override
public Optional<User> findByEmail(String email) {
return springDataRepo.findByEmail(email)
.map(mapper::toDomain);
}
@Override
public boolean existsByEmail(String email) {
return springDataRepo.existsByEmail(email);
}
}
El adaptador traduce entre modelos de dominio y entidades JPA:
// auth/infrastructure/output/persistence/entity/UserEntity.java
@Entity
@Table(name = "usuarios")
@Getter
@Setter
public class UserEntity {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@Column(nullable = false)
private String nombre;
@Column(unique = true, nullable = false)
private String email;
@Column(nullable = false)
private String passwordHash;
@Enumerated(EnumType.STRING)
private UserType tipo;
private String documentoIdentidad;
private boolean verificado;
private LocalDateTime fechaRegistro;
private Double saldo;
private Double puntuacionTotal;
private Integer numeroValoraciones;
}
Ejemplo de Flujo de Datos: Registro de Usuario
App Android Envía Petición
La app construye un RegisterUserRequest y lo envía vía Retrofit
Controlador REST Recibe (Infraestructura Input)
AuthController mapea el DTO a RegisterUserCommand y llama a RegisterUserUseCase
Caso de Uso Ejecuta (Capa de Aplicación)
RegisterUserService valida unicidad del email vía UserRepositoryPort, hashea contraseña vía PasswordEncoderPort
Objeto de Dominio Creado
Se crea un User puro con reglas de negocio aplicadas (saldo = 0.0, verificado = false)
Persistencia vía Adaptador (Infraestructura Output)
UserRepositoryAdapter convierte User a UserEntity y guarda en PostgreSQL
Respuesta Fluye de Vuelta
El User guardado se devuelve a través de las capas, el controlador envía HTTP 201 con el ID del usuario
Beneficios de Esta Arquitectura
Testabilidad
Prueba la lógica de negocio sin bases de datos, frameworks o dependencias externas
Flexibilidad
Cambia PostgreSQL por MongoDB, Spring por Quarkus, REST por GraphQL - sin tocar las reglas de negocio
Mantenibilidad
Separación clara de responsabilidades. Sabes exactamente dónde añadir features o corregir bugs
Enfoque en Dominio
La lógica de negocio es explícita, no dispersa entre controladores y repositorios