1. Guía de Implementación Técnica: Gestión de Gastos Compartidos
Contexto: Documento técnico para desarrolladores. Acompaña al Diseño Funcional.
Stack: Java 21 (Spring Boot 3.2), Angular 17, PostgreSQL 16, Kafka, Docker.
1.1 1. Estructura de Proyectos (Microservicios)
Adoptamos Arquitectura Hexagonal (Ports & Adapters) para desacoplar el dominio de la infraestructura.
1.1.1 Árbol de Carpetas (ms-expenses)
ms-expenses/
├── src/
│ ├── main/
│ │ ├── java/com/fintech/expenses/
│ │ │ ├── domain/ # -- CORE --
│ │ │ │ ├── model/ # Entidades anémicas NO, Ricas SI
│ │ │ │ │ ├── Expense.java
│ │ │ │ │ ├── Split.java
│ │ │ │ │ └── Money.java (Value Obj)
│ │ │ │ ├── port/ # Interfaces (Puertos)
│ │ │ │ │ ├── inbound/ # Casos de Uso (API del Core)
│ │ │ │ │ │ └── ProcessReceiptUseCase.java
│ │ │ │ │ └── outbound/ # Repositorios/Clientes
│ │ │ │ │ ├── ExpenseRepository.java
│ │ │ │ │ └── OcrClient.java
│ │ │ │ ├── service/ # Lógica de Negocio
│ │ │ │ │ └── ExpenseService.java
│ │ │ ├── application/ # -- ORCHESTRATION --
│ │ │ │ └── dto/
│ │ │ ├── infrastructure/ # -- ADAPTERS --
│ │ │ │ ├── persistence/ # Database
│ │ │ │ │ ├── entity/ (JPA Entities)
│ │ │ │ │ └── JpaExpenseAdapter.java
│ │ │ │ ├── messaging/ # Kafka
│ │ │ │ │ └── KafkaReceiptConsumer.java
│ │ │ │ └── config/ # Spring Configuration
│ │ │ └── presentation/ # -- API --
│ │ │ ├── controller/
│ │ │ └── GlobalExceptionHandler.java
1.2 2. Definición de Contratos (Parsing)
Para manejar formatos heterogéneos (CSV, Excel), definimos una interfaz canónica interna.
// Contrato interno unificado para cualquier importador
public record BankTransactionDto(
LocalDate date,
String description,
BigDecimal amount,
String currency,
String referenceId, // ID único del banco para deduplicación
String rawCategory // Categoría original del banco (ej: "MCC 5411")
) {}
// Interface del Parser
public interface BankStatementParser {
boolean supports(String mimeType, String fileName);
List<BankTransactionDto> parse(InputStream content);
}
1.3 3. Ejemplos de Implementación Backend
1.3.1 3.1 Entidad Rica (Domain)
Encapsula lógica de negocio y validaciones.
public class Expense {
private ExpenseId id;
private Money amount;
private List<Split> splits;
private ExpenseStatus status;
// Factory method
public static Expense createShared(Money total, List<Split> splits) {
validateTotalMatchesSplits(total, splits);
return new Expense(ExpenseId.newIdentity(), total, splits, ExpenseStatus.PENDING);
}
private static void validateTotalMatchesSplits(Money total, List<Split> splits) {
Money sum = splits.stream().map(Split::amount).reduce(Money.ZERO, Money::add);
if (!total.equals(sum)) {
throw new DomainException("La suma de los splits no coincide con el total. Diferencia: " + total.minus(sum));
}
}
// Business Logic
public void approve(UserId approverId) {
if (this.status != ExpenseStatus.PENDING) {
throw new DomainException("Solo gastos pendientes pueden aprobarse");
}
// Logica adicional de permisos...
this.status = ExpenseStatus.APPROVED;
}
}
1.3.2 3.2 Puerto de Repositorio (Port)
Interfaz definida en el dominio, implementada en infraestructura.
public interface ExpenseRepository {
Expense save(Expense expense);
Optional<Expense> findById(ExpenseId id);
List<Expense> findByGroup(GroupId groupId, DateRange range);
}
1.3.3 3.3 Adaptador JPA (Adapter)
Implementación concreta usando Spring Data JPA.
@Component
@RequiredArgsConstructor
public class JpaExpenseAdapter implements ExpenseRepository {
private final SpringDataExpenseRepository jpaRepo; // Interface interna de Hibernate
private final ExpenseMapper mapper; // MapStruct
@Override
public Expense save(Expense domainExpense) {
ExpenseEntity entity = mapper.toEntity(domainExpense);
ExpenseEntity saved = jpaRepo.save(entity);
return mapper.toDomain(saved);
}
}
1.4 4. Testing (Pirámide de Pruebas)
1.4.1 4.1 Test Unitario (Dominio)
Rápido, sin Spring, prueba lógica pura.
class ExpenseTest {
@Test
void should_throw_error_when_splits_dont_sum_up() {
Money total = Money.of(100);
Split s1 = new Split(userA, Money.of(50));
Split s2 = new Split(userB, Money.of(49.99)); // Faltan 0.01
assertThrows(DomainException.class, () ->
Expense.createShared(total, List.of(s1, s2))
);
}
}
1.4.2 4.2 Test de Integración (Testcontainers)
Prueba el adapter real contra una BD Postgres real efímera.
@SpringBootTest
@Testcontainers
class JpaExpenseAdapterIT {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16");
@Autowired JpaExpenseAdapter adapter;
@DynamicPropertySource
static void setProps(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
@Test
void should_persist_and_retrieve_expense() {
Expense expense = ExpenseMother.random(); // Object Mother pattern
adapter.save(expense);
Optional<Expense> found = adapter.findById(expense.getId());
assertThat(found).isPresent();
assertThat(found.get().getAmount()).isEqualTo(expense.getAmount());
}
}
1.5 5. Frontend Angular (Moderno)
1.5.1 5.1 Service con Signals
Gestión de estado reactiva y granular.
@Injectable({ providedIn: 'root' })
export class ExpenseService {
private http = inject(HttpClient);
// State: Signal writable
private expensesSignal = signal<Expense[]>([]);
// Selector: Signal computed (Derivado)
public expenses = this.expensesSignal.asReadonly();
public totalAmount = computed(() =>
this.expenses().reduce((acc, curr) => acc + curr.amount, 0)
);
loadByGroup(groupId: string) {
this.http.get<Expense[]>(`/api/groups/${groupId}/expenses`)
.subscribe(data => this.expensesSignal.set(data));
}
processReceipt(file: File): Observable<ReceiptData> {
const formData = new FormData();
formData.append('file', file);
return this.http.post<ReceiptData>('/api/processor/upload', formData);
}
}
1.6 6. Configuración e Infra
1.6.1 6.1 Kafka Configuration (Reliability)
spring:
kafka:
consumer:
group-id: expenses-processor
auto-offset-reset: earliest
enable-auto-commit: false # Importante para 'At-least-once' processing
properties:
isolation.level: read_committed # Solo leer transacciones confirmadas
listener:
ack-mode: MANUAL_IMMEDIATE