NutriScore Analytics SL Servicio de Predicciones v2

Patrones de Arquitectura Spring Boot

Referencia técnica — Microservicio de análisis nutricional con IA · Java 21 + Spring Boot 3.3 + PostgreSQL + Redis

Java 21 Spring Boot 3.3 REST API Spring Data JPA @Cacheable / Redis @Async RFC 7807 Errors Rate Limiting Micrometer + OTel

Arquitectura por capas — Flujo de una predicción

🌐
Cliente
  • REST HTTP
  • API Key
  • JSON payload
🎛️
Controller
  • @RestController
  • @Validated
  • DTOs + Page<T>
  • RateLimitFilter
⚙️
Service
  • @Transactional
  • @Cacheable
  • @Async alerts
  • ML call + retry
🗄️
Repository
  • JpaRepository
  • @Query JPQL
  • readOnly=true
  • Pageable
🐘
PostgreSQL
  • HikariCP pool
  • analyses table
  • alerts table
  • Redis cache
🎛️

PredictionController — capa REST

REST
@RestController
@RequestMapping("/api/v1/predictions")
@Validated
class PredictionController {
  private final PredictionService predictionService;

  // Constructor injection — no field injection
  PredictionController(PredictionService svc) {
    this.predictionService = svc;
  }

  @PostMapping
  ResponseEntity<PredictionResponse> analyze(
      @Valid @RequestBody AnalysisRequest req) {
    Prediction result = predictionService.analyze(req);
    return ResponseEntity
      .status(HttpStatus.CREATED)
      .body(PredictionResponse.from(result));
  }

  @GetMapping
  ResponseEntity<Page<PredictionResponse>> history(
      @RequestParam(defaultValue="0") int page,
      @RequestParam(defaultValue="20") int size) {
    return ResponseEntity.ok(
      predictionService.history(
        PageRequest.of(page, size,
          Sort.by("createdAt").descending())
      ).map(PredictionResponse::from)
    );
  }
}
🗄️

PredictionRepository — Spring Data JPA

JPA
public interface PredictionRepository
    extends JpaRepository<PredictionEntity, Long> {

  // Historial paginado por tenant (cliente B2B)
  @Query("select p from PredictionEntity p " +
         "where p.tenantId = :tenantId " +
         "order by p.createdAt desc")
  Page<PredictionEntity> findByTenant(
      @Param("tenantId") String tenantId,
      Pageable pageable);

  // Búsqueda por hash de ingredientes (cache miss check)
  Optional<PredictionEntity> findByIngredientsHash(
      String hash);

  // Alertas pendientes de procesar
  @Query("select p from PredictionEntity p " +
         "where p.score >= :threshold " +
         "and p.alertSent = false")
  List<PredictionEntity> findPendingAlerts(
      @Param("threshold") double threshold);
}
⚙️

PredictionService — lógica de negocio

@Transactional
@Service
public class PredictionService {
  private static final Logger log =
    LoggerFactory.getLogger(PredictionService.class);

  private final PredictionRepository repo;
  private final MlClientService mlClient;
  private final AlertService alertService;

  @Transactional
  public Prediction analyze(AnalysisRequest req) {
    log.info("analyze tenant={} items={}",
      req.tenantId(), req.ingredients().size());

    String hash = IngredientHasher.hash(req.ingredients());

    // Score via ML model con retry exponencial
    double score = withRetry(
      () -> mlClient.predict(req.ingredients()), 3);

    PredictionEntity entity = new PredictionEntity(
      req.tenantId(), hash, score, req.ingredients());
    PredictionEntity saved = repo.save(entity);

    // Alerta async si score alto (riesgo nutricional)
    if (score >= 7.5) {
      alertService.sendAsync(saved);
    }
    return Prediction.from(saved);
  }

  @Transactional(readOnly = true)
  public Page<Prediction> history(Pageable pageable) {
    return repo.findAll(pageable).map(Prediction::from);
  }
}
📦

DTOs + Validación — Records Java 21

Validation
// REQUEST — validado con Bean Validation
public record AnalysisRequest(
  @NotBlank
  String tenantId,

  @NotEmpty @Size(max = 50)
  List<@Valid IngredientItem> ingredients,

  @Size(max = 200)
  String productName
) {}

public record IngredientItem(
  @NotBlank String name,
  @Positive double grams,
  @NotBlank String unit
) {}

// RESPONSE — projection sin datos internos
public record PredictionResponse(
  Long id,
  double nutriScore,
  String grade,      // A/B/C/D/E
  Instant analyzedAt,
  List<String> warnings
) {
  static PredictionResponse from(Prediction p) {
    return new PredictionResponse(
      p.id(), p.score(),
      NutriGrade.from(p.score()),
      p.createdAt(), p.warnings()
    );
  }
}

Cache Redis

@Cacheable
@Service
public class PredictionCacheService {

  // Cache por hash de ingredientes
  // TTL 24h — recetas no cambian
  @Cacheable(
    value = "predictions",
    key = "#hash",
    unless = "#result == null"
  )
  public Optional<Prediction>
      findByHash(String hash) {
    return repo.findByIngredientsHash(hash)
               .map(Prediction::from);
  }

  @CacheEvict(
    value = "predictions",
    key = "#hash"
  )
  public void evict(String hash) {}
}
📨

Alertas Async

@Async
@Service
public class AlertService {
  private static final Logger log =
    LoggerFactory.getLogger(AlertService.class);

  // No bloquea el hilo del request
  @Async("alertExecutor")
  public CompletableFuture<Void>
      sendAsync(PredictionEntity p) {
    log.info("alert_send id={} score={}",
      p.getId(), p.getScore());
    try {
      emailClient.send(
        Alert.highRisk(p));
      p.setAlertSent(true);
      repo.save(p);
    } catch (Exception ex) {
      log.error("alert_failed id={}",
        p.getId(), ex);
    }
    return CompletableFuture
      .completedFuture(null);
  }
}
🛡️

Exception Handler

RFC 7807
@ControllerAdvice
class GlobalExceptionHandler {

  // 400 — validación DTO
  @ExceptionHandler
  ResponseEntity<ProblemDetail>
      handleValidation(
      MethodArgumentNotValidException ex) {
    var pd = ProblemDetail.forStatus(400);
    pd.setTitle("Validation failed");
    pd.setProperty("errors",
      ex.getFieldErrors()
        .stream()
        .map(e -> e.getField() +
               ": " + e.getDefaultMessage())
        .toList());
    return ResponseEntity
      .badRequest().body(pd);
  }

  // 429 — rate limit
  @ExceptionHandler(RateLimitException.class)
  ResponseEntity<ProblemDetail>
      handleRateLimit() {
    return ResponseEntity
      .status(429)
      .body(ProblemDetail
        .forStatus(429));
  }
}
🔒

Rate Limiting por API Key — Filter + Bucket4j

Security
@Component
public class ApiKeyRateLimitFilter
    extends OncePerRequestFilter {

  private final Map<String, Bucket> buckets =
    new ConcurrentHashMap<>();

  /*
   * SECURITY: Usamos request.getRemoteAddr()
   * porque el ALB de AWS está configurado con
   * server.forward-headers-strategy=NATIVE
   * → getRemoteAddr() devuelve la IP real del
   * cliente, no la del proxy.
   * NUNCA leer X-Forwarded-For directamente.
   */
  @Override
  protected void doFilterInternal(
      HttpServletRequest req,
      HttpServletResponse res,
      FilterChain chain) {

    String apiKey = req.getHeader("X-Api-Key");
    if (apiKey == null) {
      res.setStatus(401); return;
    }

    Bucket bucket = buckets
      .computeIfAbsent(apiKey, k ->
        Bucket.builder()
          .addLimit(Bandwidth.classic(
            500,
            Refill.greedy(500,
              Duration.ofMinutes(1))
          )).build());

    if (bucket.tryConsume(1)) {
      chain.doFilter(req, res);
    } else {
      res.setStatus(HttpStatus.TOO_MANY_REQUESTS
        .value());
    }
  }
}

Configuración requerida en application.yaml:

# application-prod.yaml
server:
  # OBLIGATORIO cuando hay ALB/nginx delante
  forward-headers-strategy: NATIVE

spring:
  cache:
    type: redis
  data:
    redis:
      host: ${REDIS_HOST}
      port: 6379
  mvc:
    # Errores RFC 7807 (Spring Boot 3+)
    problemdetails:
      enabled: true

  datasource:
    hikari:
      maximum-pool-size: 20
      minimum-idle: 5
      connection-timeout: 30000
      idle-timeout: 600000

management:
  tracing:
    sampling:
      probability: 1.0
  metrics:
    export:
      prometheus:
        enabled: true
📊

Observabilidad — Logging + Métricas

OTel
@Component
public class RequestLoggingFilter
    extends OncePerRequestFilter {
  private static final Logger log =
    LoggerFactory.getLogger(RequestLoggingFilter.class);

  @Override
  protected void doFilterInternal(...) {
    long start = System.currentTimeMillis();
    try {
      chain.doFilter(req, res);
    } finally {
      long ms = System.currentTimeMillis()-start;
      log.info(
        "req method={} uri={} status={} ms={}",
        req.getMethod(),
        req.getRequestURI(),
        res.getStatus(), ms);
    }
  }
}

Stack de observabilidad NutriScore:

CapaHerramientaEstado
LogsSLF4J + Logback JSON✓ activo
MétricasMicrometer + Prometheus✓ activo
TrazasMicrometer Tracing + OTel→ pendiente
AlertasGrafana Alerting⚠ config

Checklist de producción

Prod-Ready
  • Constructor injection Sin @Autowired en campos — testabilidad garantizada
  • @Transactional(readOnly=true) En todas las queries — HikariCP optimiza el pool
  • RFC 7807 Problem Details spring.mvc.problemdetails.enabled=true
  • ForwardedHeaderFilter forward-headers-strategy=NATIVE en prod (AWS ALB)
  • HikariCP tuning max-pool-size=20, timeouts configurados
  • @Async + thread pool dedicado alertExecutor separado del request thread
  • ⚠️
    Retry exponencial en ML call withRetry(supplier, 3) — pendiente Resilience4j
  • ⚠️
    Null-safety con @NonNull Pendiente activar en módulo principal
🔄

Flujo completo — POST /api/v1/predictions

1
Cliente envía POST con JSON + X-Api-Key { "tenantId": "nutriscore-001", "ingredients": [...] }
2
ApiKeyRateLimitFilter — verifica y consume token 500 req/min por API key · 429 si superado
3
PredictionController — @Valid valida AnalysisRequest Bean Validation · 400 + ProblemDetail si falla
4
PredictionCacheService — busca en Redis por hash Cache hit → responde en ~2ms · miss → llama ML
5
PredictionService — @Transactional · llama ML · persiste withRetry(3) · salva en PostgreSQL · async alert si score >= 7.5

Ejemplo de respuesta — NutriScore Grade C

// POST /api/v1/predictions → 201 Created
{
  "id": 4821,
  "nutriScore": 5.8,
  "grade": "C",
  "analyzedAt": "2026-06-18T10:24:33Z",
  "warnings": [
    "Alto contenido en sodio (1.2g/100g)",
    "Azúcares añadidos detectados"
  ]
}

// Error 400 — RFC 7807 Problem Details
{
  "type": "about:blank",
  "title": "Validation failed",
  "status": 400,
  "errors": [
    "ingredients[0].grams: must be positive",
    "tenantId: must not be blank"
  ]
}

// GET /api/v1/predictions?page=0&size=5
{
  "content": [...],
  "totalElements": 847,
  "totalPages": 170,
  "size": 5
}