Arquitectura de referencia para microservicios Java cloud-native y event-driven. Cubre REST API, Panache, Camel/RabbitMQ, CDI y configuración multi-entorno.
@Path("/api/invoices") @Produces(MediaType.APPLICATION_JSON) @Consumes(MediaType.APPLICATION_JSON) @RequiredArgsConstructor public class InvoiceResource { private final InvoiceService invoiceService; @GET public Response list( @QueryParam("page") @DefaultValue("0") int page, @QueryParam("size") @DefaultValue("20") int size) { List<Invoice> invoices = invoiceService.list(page, size); return Response.ok(invoices).build(); } @POST public Response create( @Valid CreateInvoiceRequest request, @Context UriInfo uriInfo) { Invoice invoice = invoiceService.create(request); URI location = uriInfo.getAbsolutePathBuilder() .path(String.valueOf(invoice.id)).build(); return Response.created(location) .entity(InvoiceResponse.from(invoice)).build(); } @GET @Path("/{id}") public Response getById(@PathParam("id") Long id) { return invoiceService.findById(id) .map(InvoiceResponse::from) .map(Response::ok) .orElse(Response.status(NOT_FOUND)) .build(); } }
@Slf4j @ApplicationScoped @RequiredArgsConstructor public class InvoiceService { private final InvoiceRepository repo; private final EventService eventService; private final InvoiceValidator validator; private final ProcessingPublisher publisher; @Transactional public Invoice create(CreateInvoiceRequest request) { ValidationResult v = validator.validate(request); if (!v.valid()) { eventService.createErrorEvent( request, "INVOICE_REJECTED", v.message()); throw new WebApplicationException( v.message(), BAD_REQUEST); } Invoice inv = Invoice.from(request); repo.persist(inv); InvoiceReceipt receipt = InvoiceReceipt.from(inv); publisher.publishAsync(receipt); eventService.createSuccessEvent( receipt, "INVOICE_CREATED"); log.info("Invoice {} created", inv.id); return inv; } public Optional<Invoice> findById(Long id) { return repo.findByIdOptional(id); } public List<Invoice> list(int page, int size) { return repo.findAll() .page(page, size).list(); } }
@ApplicationScoped public class InvoiceRepository implements PanacheRepository<Invoice> { // Buscar por estado con paginación public List<Invoice> findByStatus( InvoiceStatus status, int page, int size) { return find("status = ?1 order by createdAt desc", status).page(page, size).list(); } // Buscar por número de referencia único public Optional<Invoice> findByReference( String ref) { return find("referenceNumber", ref) .firstResultOptional(); } // Contar facturas pendientes del día public long countPendingToday(LocalDate date) { return count( "status = ?1 and createdAt >= ?2", InvoiceStatus.PENDING, date.atStartOfDay()); } // Facturas por cliente con importe > umbral public List<Invoice> findHighValue( String clientId, BigDecimal minAmount) { return list( "clientId = ?1 and amount >= ?2 " + "order by amount desc", clientId, minAmount); } }
// DTO de entrada con validaciones public record CreateInvoiceRequest( @NotBlank @Size(max = 50) String referenceNumber, @NotNull String clientId, @NotNull @Positive BigDecimal amount, @NotNull @FutureOrPresent Instant dueDate, @NotEmpty List<@NotBlank String> lines) {} // DTO de respuesta con factory method public record InvoiceResponse( Long id, String referenceNumber, BigDecimal amount, InvoiceStatus status) { public static InvoiceResponse from(Invoice inv) { return new InvoiceResponse( inv.getId(), inv.getReferenceNumber(), inv.getAmount(), inv.getStatus()); } } // Mapper de violaciones de validación → 400 @Provider public class ValidationExceptionMapper implements ExceptionMapper<ConstraintViolationException> { @Override public Response toResponse(ConstraintViolationException ex) { String msg = ex.getConstraintViolations().stream() .map(cv -> cv.getPropertyPath() + ": " + cv.getMessage()) .collect(Collectors.joining(", ")); return Response.status(BAD_REQUEST) .entity(Map.of("error", "validation_error", "message", msg)).build(); } }
@ApplicationScoped public class InvoiceProcessingRoute extends RouteBuilder { @Override public void configure() { // Manejo de errores de validación onException(ValidationException.class) .handled(true) .to("direct:validation-error") .log("Error: ${exception.message}"); // Ruta principal de procesamiento from("direct:process-invoice") .routeId("invoice-processing") .log("Invoice: ${header.invoiceId}") .bean(InvoiceValidator.class, "validate") .bean(InvoiceTransformer.class, "enrich") // Routing condicional por tipo .choice() .when(header("type") .isEqualTo("CREDIT_NOTE")) .to("direct:process-credit") .when(header("type") .isEqualTo("PROFORMA")) .to("direct:process-proforma") .otherwise() .to("direct:process-standard") .end(); } }
// Publisher asíncrono a RabbitMQ @ApplicationScoped public class ProcessingPublisher { private final ProducerTemplate producer; public void publishAsync(InvoiceReceipt receipt) { producer.asyncSendBody( "direct:invoice-publisher", receipt); } } // Ruta de publicación con marshal JSON @ApplicationScoped public class InvoicePublisherRoute extends RouteBuilder { @ConfigProperty(name = "camel.rabbitmq.queue.invoices") String invoiceQueue; @ConfigProperty(name = "rabbitmq.host") String rabbitHost; @Override public void configure() { from("direct:invoice-publisher") .routeId("invoice-publisher") .log("Publishing to RabbitMQ: ${body}") .marshal().json(JsonLibrary.Jackson) .toF("spring-rabbitmq:%s?hostname=%s", invoiceQueue, rabbitHost); } }
| Área | Patrón | Implementación | Estado |
|---|---|---|---|
| Arquitectura | Constructor injection via Lombok | @RequiredArgsConstructor en todas las clases CDI |
Adoptado |
| Arquitectura | Resource → Service → Repository | 3 capas estrictas, sin saltar niveles ni acceder al repo desde resource | Adoptado |
| Event-Driven | EventService tracking | Toda operación registra evento SUCCESS o ERROR en tabla events |
Adoptado |
| Event-Driven | Publicación async fuera de TX | publishAsync() se llama tras repo.persist() y commit |
Adoptado |
| Logging | Logback + Logstash encoder | JSON estructurado, MDC context propagation para tracing de request | Configurar |
| Logging | LogContext en operaciones async | SafeAutoCloseable scope en CompletableFuture para tracing |
Pendiente |
| Async | CompletableFuture para S3/storage | Upload a S3/MinIO en pool separado, no bloquea hilo HTTP | Pendiente |
| Config | YAML multi-profile | %dev/%test/%prod con env vars para secretos, nunca hardcoded | Adoptado |
| Transacciones | @Transactional scope mínimo | TX cortas en service, nunca llamar async dentro de la TX | Adoptado |
| Testing | camel-quarkus-junit5 | Tests de rutas con mock de beans, AssertJ para assertions fluidas | En progreso |
# application.yml — InvoiceFlow SaaS "%dev": quarkus: datasource: jdbc: url: jdbc:postgresql://localhost:5432/invoiceflow_dev username: dev_user password: ${DB_PASSWORD} hibernate-orm: database: generation: drop-and-create log: sql: true rabbitmq: host: localhost port: 5672 username: ${RABBITMQ_USER} password: ${RABBITMQ_PASSWORD} "%test": quarkus: datasource: jdbc: url: jdbc:h2:mem:invoiceflow_test;DB_CLOSE_DELAY=-1 hibernate-orm: database: generation: drop-and-create "%prod": quarkus: datasource: jdbc: url: ${DATABASE_URL} username: ${DB_USER} password: ${DB_PASSWORD} hibernate-orm: database: generation: validate # nunca drop en prod rabbitmq: host: ${RABBITMQ_HOST} port: ${RABBITMQ_PORT} # Colas Camel camel: rabbitmq: queue: invoices: invoiceflow-processing-queue notifications: invoiceflow-notifications-queue # Health checks activos quarkus: smallrye-health: ui: enable: true