From beb8387503466817efef7d2b5a1068607b8c9317 Mon Sep 17 00:00:00 2001 From: Leandro Moraes Date: Wed, 3 Jun 2026 12:30:57 -0300 Subject: [PATCH 01/11] build(pom): upgrade Spring Boot 3.5.3, fix groupId/deps, add kafka/otel/redis/testcontainers/spotless/checkstyle --- pom.xml | 167 ++++++++++++++++++++++++++++++++++++-------------------- 1 file changed, 108 insertions(+), 59 deletions(-) diff --git a/pom.xml b/pom.xml index 710886d..a954345 100644 --- a/pom.xml +++ b/pom.xml @@ -2,90 +2,114 @@ 4.0.0 + org.springframework.boot spring-boot-starter-parent - 4.0.6 - + 3.5.3 + - leandroDev + + com.lmoraesdev payment-api 0.0.1-SNAPSHOT - - - - - - - - - - - - - - - + payment-api + Payment API — Clean Architecture, Spring Boot 3.5, Kafka, Postgres, OTel + 21 + 2.43.0 + 3.5.0 + + org.springframework.boot - spring-boot-starter-actuator + spring-boot-starter-web org.springframework.boot - spring-boot-starter-data-jpa + spring-boot-starter-validation + + org.springframework.boot - spring-boot-starter-validation + spring-boot-starter-data-jpa + + org.postgresql + postgresql + runtime + + + org.springframework.boot - spring-boot-starter-webmvc + spring-boot-starter-data-redis + + - org.springdoc - springdoc-openapi-starter-webmvc-ui - 3.0.2 + org.springframework.kafka + spring-kafka + - org.postgresql - postgresql + org.springframework.boot + spring-boot-starter-actuator + + + io.micrometer + micrometer-registry-prometheus runtime + + + + io.micrometer + micrometer-tracing-bridge-otel + + + io.opentelemetry + opentelemetry-exporter-otlp + + + org.projectlombok lombok true + + org.springframework.boot - spring-boot-starter-actuator-test + spring-boot-starter-test test org.springframework.boot - spring-boot-starter-data-jpa-test + spring-boot-testcontainers test - org.springframework.boot - spring-boot-starter-validation-test + org.testcontainers + postgresql test - org.springframework.boot - spring-boot-starter-webmvc-test + org.testcontainers + junit-jupiter test + org.springframework.boot spring-boot-maven-plugin @@ -98,42 +122,67 @@ + + org.apache.maven.plugins maven-compiler-plugin + + + + org.projectlombok + lombok + + + + + + + + org.apache.maven.plugins + maven-failsafe-plugin - default-compile - compile - - compile - - - - - org.projectlombok - lombok - - - - - - default-testCompile - test-compile - testCompile + integration-test + verify - - - - org.projectlombok - lombok - - - + + + + + com.diffplug.spotless + spotless-maven-plugin + ${spotless.version} + + + + + + false + + + + + + + + + + + org.apache.maven.plugins + maven-checkstyle-plugin + ${checkstyle.version} + + config/checkstyle.xml + true + true + true + + From 06fed2f597c4b175807b25e5b1a7af7b463b94f1 Mon Sep 17 00:00:00 2001 From: Leandro Moraes Date: Wed, 3 Jun 2026 12:31:05 -0300 Subject: [PATCH 02/11] refactor(java): migrate to com.lmoraesdev.payment, drop ADIADO stubs, add traceId+spanId to Log5w1h --- .../payment}/PaymentApiApplication.java | 9 +++--- .../in/web}/GlobalExceptionHandler.java | 21 +++++-------- .../adapter/in/web/PingController.java | 23 ++++++++++++++ .../payment/adapter/out/messaging/.gitkeep | 0 .../payment/adapter/out/persistence/.gitkeep | 0 .../payment/application/port/in/.gitkeep | 0 .../payment/application/port/out/.gitkeep | 0 .../payment/application/usecase/.gitkeep | 0 .../payment/config/logging}/Log5w1h.java | 8 ++--- .../payment/config/logging}/Logger5w1h.java | 6 ++-- .../config/logging}/Logger5w1hBuilder.java | 18 +++++------ .../lmoraesdev/payment/domain/event/.gitkeep | 0 .../domain/exception}/DomainException.java | 2 +- .../lmoraesdev/payment/domain/model/.gitkeep | 0 .../adapters/in/web/HealthController.java | 30 ------------------- .../out/cache/RedisAccountCacheAdapter.java | 5 ---- .../infrastructure/config/OpenApiConfig.java | 5 ---- .../config/OpenTelemetryConfig.java | 5 ---- .../infrastructure/config/RedisConfig.java | 5 ---- .../payment/PaymentApiApplicationIT.java | 21 +++++++++++++ .../PaymentApiApplicationTests.java | 13 -------- 21 files changed, 71 insertions(+), 100 deletions(-) rename src/main/java/{dev/leandromoraes/paymentapi => com/lmoraesdev/payment}/PaymentApiApplication.java (55%) rename src/main/java/{dev/leandromoraes/paymentapi/presentation/handler => com/lmoraesdev/payment/adapter/in/web}/GlobalExceptionHandler.java (59%) create mode 100644 src/main/java/com/lmoraesdev/payment/adapter/in/web/PingController.java create mode 100644 src/main/java/com/lmoraesdev/payment/adapter/out/messaging/.gitkeep create mode 100644 src/main/java/com/lmoraesdev/payment/adapter/out/persistence/.gitkeep create mode 100644 src/main/java/com/lmoraesdev/payment/application/port/in/.gitkeep create mode 100644 src/main/java/com/lmoraesdev/payment/application/port/out/.gitkeep create mode 100644 src/main/java/com/lmoraesdev/payment/application/usecase/.gitkeep rename src/main/java/{dev/leandromoraes/paymentapi/infrastructure/logger => com/lmoraesdev/payment/config/logging}/Log5w1h.java (52%) rename src/main/java/{dev/leandromoraes/paymentapi/infrastructure/logger => com/lmoraesdev/payment/config/logging}/Logger5w1h.java (83%) rename src/main/java/{dev/leandromoraes/paymentapi/infrastructure/logger => com/lmoraesdev/payment/config/logging}/Logger5w1hBuilder.java (78%) create mode 100644 src/main/java/com/lmoraesdev/payment/domain/event/.gitkeep rename src/main/java/{dev/leandromoraes/paymentapi/domain/exceptions => com/lmoraesdev/payment/domain/exception}/DomainException.java (83%) create mode 100644 src/main/java/com/lmoraesdev/payment/domain/model/.gitkeep delete mode 100644 src/main/java/dev/leandromoraes/paymentapi/adapters/in/web/HealthController.java delete mode 100644 src/main/java/dev/leandromoraes/paymentapi/adapters/out/cache/RedisAccountCacheAdapter.java delete mode 100644 src/main/java/dev/leandromoraes/paymentapi/infrastructure/config/OpenApiConfig.java delete mode 100644 src/main/java/dev/leandromoraes/paymentapi/infrastructure/config/OpenTelemetryConfig.java delete mode 100644 src/main/java/dev/leandromoraes/paymentapi/infrastructure/config/RedisConfig.java create mode 100644 src/test/java/com/lmoraesdev/payment/PaymentApiApplicationIT.java delete mode 100644 src/test/java/dev/leandromoraes/paymentapi/PaymentApiApplicationTests.java diff --git a/src/main/java/dev/leandromoraes/paymentapi/PaymentApiApplication.java b/src/main/java/com/lmoraesdev/payment/PaymentApiApplication.java similarity index 55% rename from src/main/java/dev/leandromoraes/paymentapi/PaymentApiApplication.java rename to src/main/java/com/lmoraesdev/payment/PaymentApiApplication.java index 8f1f74a..946f3ab 100644 --- a/src/main/java/dev/leandromoraes/paymentapi/PaymentApiApplication.java +++ b/src/main/java/com/lmoraesdev/payment/PaymentApiApplication.java @@ -1,4 +1,4 @@ -package dev.leandromoraes.paymentapi; +package com.lmoraesdev.payment; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @@ -6,8 +6,7 @@ @SpringBootApplication public class PaymentApiApplication { - public static void main(String[] args) { - SpringApplication.run(PaymentApiApplication.class, args); - } - + public static void main(String[] args) { + SpringApplication.run(PaymentApiApplication.class, args); + } } diff --git a/src/main/java/dev/leandromoraes/paymentapi/presentation/handler/GlobalExceptionHandler.java b/src/main/java/com/lmoraesdev/payment/adapter/in/web/GlobalExceptionHandler.java similarity index 59% rename from src/main/java/dev/leandromoraes/paymentapi/presentation/handler/GlobalExceptionHandler.java rename to src/main/java/com/lmoraesdev/payment/adapter/in/web/GlobalExceptionHandler.java index c80777d..65cee46 100644 --- a/src/main/java/dev/leandromoraes/paymentapi/presentation/handler/GlobalExceptionHandler.java +++ b/src/main/java/com/lmoraesdev/payment/adapter/in/web/GlobalExceptionHandler.java @@ -1,19 +1,18 @@ -package dev.leandromoraes.paymentapi.presentation.handler; - -import dev.leandromoraes.paymentapi.infrastructure.logger.Logger5w1hBuilder; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; +package com.lmoraesdev.payment.adapter.in.web; +import com.lmoraesdev.payment.config.logging.Logger5w1hBuilder; import java.time.LocalDateTime; import java.util.Map; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; @RestControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(Exception.class) public ResponseEntity> handleException(Exception ex) { - Logger5w1hBuilder.create(GlobalExceptionHandler.class) .where("GlobalExceptionHandler") .what("unexpected_error") @@ -22,11 +21,7 @@ public ResponseEntity> handleException(Exception ex) { .how("exception handling") .error(ex); - return ResponseEntity - .status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(Map.of( - "message", "Unexpected error", - "timestamp", LocalDateTime.now() - )); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(Map.of("message", "Unexpected error", "timestamp", LocalDateTime.now())); } } diff --git a/src/main/java/com/lmoraesdev/payment/adapter/in/web/PingController.java b/src/main/java/com/lmoraesdev/payment/adapter/in/web/PingController.java new file mode 100644 index 0000000..cde18d7 --- /dev/null +++ b/src/main/java/com/lmoraesdev/payment/adapter/in/web/PingController.java @@ -0,0 +1,23 @@ +package com.lmoraesdev.payment.adapter.in.web; + +import com.lmoraesdev.payment.config.logging.Logger5w1hBuilder; +import java.util.Map; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class PingController { + + @GetMapping("/ping") + public Map ping() { + Logger5w1hBuilder.create(PingController.class) + .where("PingController") + .what("ping") + .why("smoke_test") + .who("system") + .how("GET /ping") + .info(); + + return Map.of("status", "pong"); + } +} diff --git a/src/main/java/com/lmoraesdev/payment/adapter/out/messaging/.gitkeep b/src/main/java/com/lmoraesdev/payment/adapter/out/messaging/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/main/java/com/lmoraesdev/payment/adapter/out/persistence/.gitkeep b/src/main/java/com/lmoraesdev/payment/adapter/out/persistence/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/main/java/com/lmoraesdev/payment/application/port/in/.gitkeep b/src/main/java/com/lmoraesdev/payment/application/port/in/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/main/java/com/lmoraesdev/payment/application/port/out/.gitkeep b/src/main/java/com/lmoraesdev/payment/application/port/out/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/main/java/com/lmoraesdev/payment/application/usecase/.gitkeep b/src/main/java/com/lmoraesdev/payment/application/usecase/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/main/java/dev/leandromoraes/paymentapi/infrastructure/logger/Log5w1h.java b/src/main/java/com/lmoraesdev/payment/config/logging/Log5w1h.java similarity index 52% rename from src/main/java/dev/leandromoraes/paymentapi/infrastructure/logger/Log5w1h.java rename to src/main/java/com/lmoraesdev/payment/config/logging/Log5w1h.java index 264b05c..cb4bbbf 100644 --- a/src/main/java/dev/leandromoraes/paymentapi/infrastructure/logger/Log5w1h.java +++ b/src/main/java/com/lmoraesdev/payment/config/logging/Log5w1h.java @@ -1,11 +1,11 @@ -package dev.leandromoraes.paymentapi.infrastructure.logger; +package com.lmoraesdev.payment.config.logging; public record Log5w1h( + String traceId, + String spanId, String where, String why, String when, Object who, Object what, - String how -) { -} + String how) {} diff --git a/src/main/java/dev/leandromoraes/paymentapi/infrastructure/logger/Logger5w1h.java b/src/main/java/com/lmoraesdev/payment/config/logging/Logger5w1h.java similarity index 83% rename from src/main/java/dev/leandromoraes/paymentapi/infrastructure/logger/Logger5w1h.java rename to src/main/java/com/lmoraesdev/payment/config/logging/Logger5w1h.java index 1a4fdd7..350f5f2 100644 --- a/src/main/java/dev/leandromoraes/paymentapi/infrastructure/logger/Logger5w1h.java +++ b/src/main/java/com/lmoraesdev/payment/config/logging/Logger5w1h.java @@ -1,4 +1,4 @@ -package dev.leandromoraes.paymentapi.infrastructure.logger; +package com.lmoraesdev.payment.config.logging; import com.fasterxml.jackson.databind.ObjectMapper; import org.slf4j.Logger; @@ -6,7 +6,7 @@ public class Logger5w1h { - private static final ObjectMapper mapper = new ObjectMapper(); + private static final ObjectMapper MAPPER = new ObjectMapper(); private final Logger logger; @@ -36,7 +36,7 @@ public void error(Log5w1h data, Throwable exception) { private String toJson(Log5w1h data) { try { - return mapper.writeValueAsString(data); + return MAPPER.writeValueAsString(data); } catch (Exception exception) { return data.toString(); } diff --git a/src/main/java/dev/leandromoraes/paymentapi/infrastructure/logger/Logger5w1hBuilder.java b/src/main/java/com/lmoraesdev/payment/config/logging/Logger5w1hBuilder.java similarity index 78% rename from src/main/java/dev/leandromoraes/paymentapi/infrastructure/logger/Logger5w1hBuilder.java rename to src/main/java/com/lmoraesdev/payment/config/logging/Logger5w1hBuilder.java index 38d7a27..2cd43c5 100644 --- a/src/main/java/dev/leandromoraes/paymentapi/infrastructure/logger/Logger5w1hBuilder.java +++ b/src/main/java/com/lmoraesdev/payment/config/logging/Logger5w1hBuilder.java @@ -1,6 +1,7 @@ -package dev.leandromoraes.paymentapi.infrastructure.logger; +package com.lmoraesdev.payment.config.logging; import java.time.LocalDateTime; +import org.slf4j.MDC; public class Logger5w1hBuilder { @@ -64,18 +65,13 @@ public void warn() { logger.warn(build()); } - public void error(Throwable exception) { - logger.error(build(), exception); + public void error(Throwable e) { + logger.error(build(), e); } private Log5w1h build() { - return new Log5w1h( - where, - why, - when, - who, - what, - how - ); + // traceId is injected automatically from Micrometer Tracing MDC — + // no caller needs to pass it explicitly. + return new Log5w1h(MDC.get("traceId"), MDC.get("spanId"), where, why, when, who, what, how); } } diff --git a/src/main/java/com/lmoraesdev/payment/domain/event/.gitkeep b/src/main/java/com/lmoraesdev/payment/domain/event/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/main/java/dev/leandromoraes/paymentapi/domain/exceptions/DomainException.java b/src/main/java/com/lmoraesdev/payment/domain/exception/DomainException.java similarity index 83% rename from src/main/java/dev/leandromoraes/paymentapi/domain/exceptions/DomainException.java rename to src/main/java/com/lmoraesdev/payment/domain/exception/DomainException.java index d220379..daf0c27 100644 --- a/src/main/java/dev/leandromoraes/paymentapi/domain/exceptions/DomainException.java +++ b/src/main/java/com/lmoraesdev/payment/domain/exception/DomainException.java @@ -1,4 +1,4 @@ -package dev.leandromoraes.paymentapi.domain.exceptions; +package com.lmoraesdev.payment.domain.exception; public abstract class DomainException extends RuntimeException { diff --git a/src/main/java/com/lmoraesdev/payment/domain/model/.gitkeep b/src/main/java/com/lmoraesdev/payment/domain/model/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/main/java/dev/leandromoraes/paymentapi/adapters/in/web/HealthController.java b/src/main/java/dev/leandromoraes/paymentapi/adapters/in/web/HealthController.java deleted file mode 100644 index 928a579..0000000 --- a/src/main/java/dev/leandromoraes/paymentapi/adapters/in/web/HealthController.java +++ /dev/null @@ -1,30 +0,0 @@ -package dev.leandromoraes.paymentapi.adapters.in.web; - -import dev.leandromoraes.paymentapi.infrastructure.logger.Logger5w1hBuilder; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RestController; - -import java.util.Map; - -@RestController -public class HealthController { - - @GetMapping("/health") - public Map health() { - Logger5w1hBuilder.create(HealthController.class) - .where("HealthController") - .what("health_check") - .why("system_monitoring") - .who("system") - .how("GET /health") - .info(); - - return Map.of("status", "UP"); - } - - @GetMapping("/error-test") - public void error() { - throw new RuntimeException("Teste erro"); - } - -} diff --git a/src/main/java/dev/leandromoraes/paymentapi/adapters/out/cache/RedisAccountCacheAdapter.java b/src/main/java/dev/leandromoraes/paymentapi/adapters/out/cache/RedisAccountCacheAdapter.java deleted file mode 100644 index d7fd1fa..0000000 --- a/src/main/java/dev/leandromoraes/paymentapi/adapters/out/cache/RedisAccountCacheAdapter.java +++ /dev/null @@ -1,5 +0,0 @@ -package dev.leandromoraes.paymentapi.adapters.out.cache; - -public class RedisAccountCacheAdapter { - -} diff --git a/src/main/java/dev/leandromoraes/paymentapi/infrastructure/config/OpenApiConfig.java b/src/main/java/dev/leandromoraes/paymentapi/infrastructure/config/OpenApiConfig.java deleted file mode 100644 index c899db8..0000000 --- a/src/main/java/dev/leandromoraes/paymentapi/infrastructure/config/OpenApiConfig.java +++ /dev/null @@ -1,5 +0,0 @@ -package dev.leandromoraes.paymentapi.infrastructure.config; - -public class OpenApiConfig { - -} diff --git a/src/main/java/dev/leandromoraes/paymentapi/infrastructure/config/OpenTelemetryConfig.java b/src/main/java/dev/leandromoraes/paymentapi/infrastructure/config/OpenTelemetryConfig.java deleted file mode 100644 index 953428c..0000000 --- a/src/main/java/dev/leandromoraes/paymentapi/infrastructure/config/OpenTelemetryConfig.java +++ /dev/null @@ -1,5 +0,0 @@ -package dev.leandromoraes.paymentapi.infrastructure.config; - -public class OpenTelemetryConfig { - -} diff --git a/src/main/java/dev/leandromoraes/paymentapi/infrastructure/config/RedisConfig.java b/src/main/java/dev/leandromoraes/paymentapi/infrastructure/config/RedisConfig.java deleted file mode 100644 index 0806762..0000000 --- a/src/main/java/dev/leandromoraes/paymentapi/infrastructure/config/RedisConfig.java +++ /dev/null @@ -1,5 +0,0 @@ -package dev.leandromoraes.paymentapi.infrastructure.config; - -public class RedisConfig { - -} diff --git a/src/test/java/com/lmoraesdev/payment/PaymentApiApplicationIT.java b/src/test/java/com/lmoraesdev/payment/PaymentApiApplicationIT.java new file mode 100644 index 0000000..5c30881 --- /dev/null +++ b/src/test/java/com/lmoraesdev/payment/PaymentApiApplicationIT.java @@ -0,0 +1,21 @@ +package com.lmoraesdev.payment; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.test.context.ActiveProfiles; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +@SpringBootTest +@Testcontainers +@ActiveProfiles("test") +class PaymentApiApplicationIT { + + @Container @ServiceConnection + static PostgreSQLContainer postgres = new PostgreSQLContainer<>("postgres:18-alpine"); + + @Test + void contextLoads() {} +} diff --git a/src/test/java/dev/leandromoraes/paymentapi/PaymentApiApplicationTests.java b/src/test/java/dev/leandromoraes/paymentapi/PaymentApiApplicationTests.java deleted file mode 100644 index 84f6fa6..0000000 --- a/src/test/java/dev/leandromoraes/paymentapi/PaymentApiApplicationTests.java +++ /dev/null @@ -1,13 +0,0 @@ -package dev.leandromoraes.paymentapi; - -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; - -@SpringBootTest -class PaymentApiApplicationTests { - - @Test - void contextLoads() { - } - -} From a88c4db15780820f3904dc7575eb3ef23d004f0d Mon Sep 17 00:00:00 2001 From: Leandro Moraes Date: Wed, 3 Jun 2026 12:31:11 -0300 Subject: [PATCH 03/11] feat(config): replace application.properties with 12-factor application.yml, add test profile with Testcontainers --- src/main/resources/application.properties | 21 --------- src/main/resources/application.yml | 52 +++++++++++++++++++++++ src/test/resources/application-test.yml | 21 +++++++++ 3 files changed, 73 insertions(+), 21 deletions(-) delete mode 100644 src/main/resources/application.properties create mode 100644 src/main/resources/application.yml create mode 100644 src/test/resources/application-test.yml diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties deleted file mode 100644 index 2212e20..0000000 --- a/src/main/resources/application.properties +++ /dev/null @@ -1,21 +0,0 @@ -# Configuração do Banco de Dados (usando o padrão do Docker que definimos) -spring.datasource.url=jdbc:postgresql://${DB_HOST:localhost}:5432/${DB_NAME:payment_db} -spring.datasource.username=${DB_USER:admin} -spring.datasource.password=${DB_PASSWORD:admin_pass} - -# Ativando o Swagger (OpenAPI) -springdoc.api-docs.path=/api-docs -springdoc.swagger-ui.path=/swagger-ui.html - -# Logging -logging.level.root=INFO -logging.level.dev.leandromoraes.paymentapi=DEBUG - -logging.pattern.console=%d{yyyy-MM-dd HH:mm:ss} %-5level [%thread] %logger{36} - %msg%n - -# JPA / Hibernate -spring.jpa.hibernate.ddl-auto=validate -spring.jpa.open-in-view=false -spring.jpa.properties.hibernate.format_sql=true - -spring.jpa.show-sql=false diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..b9b966e --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,52 @@ +spring: + datasource: + url: ${SPRING_DATASOURCE_URL:jdbc:postgresql://localhost:5432/payment_db} + username: ${SPRING_DATASOURCE_USERNAME:admin} + password: ${SPRING_DATASOURCE_PASSWORD:admin_pass} + jpa: + hibernate: + ddl-auto: none + open-in-view: false + properties: + hibernate: + format_sql: true + kafka: + bootstrap-servers: ${SPRING_KAFKA_BOOTSTRAP_SERVERS:localhost:9092} + consumer: + group-id: payment-api + auto-offset-reset: earliest + key-deserializer: org.apache.kafka.common.serialization.StringDeserializer + value-deserializer: org.apache.kafka.common.serialization.StringDeserializer + producer: + key-serializer: org.apache.kafka.common.serialization.StringSerializer + value-serializer: org.apache.kafka.common.serialization.StringSerializer + data: + redis: + host: ${SPRING_REDIS_HOST:localhost} + port: ${SPRING_REDIS_PORT:6379} + +management: + endpoints: + web: + exposure: + include: health,info,prometheus + endpoint: + health: + probes: + enabled: true + show-details: always + tracing: + sampling: + probability: 1.0 + opentelemetry: + tracing: + export: + otlp: + endpoint: ${OTEL_EXPORTER_OTLP_ENDPOINT:http://localhost:4318/v1/traces} + +logging: + level: + root: INFO + com.lmoraesdev.payment: DEBUG + pattern: + console: "%d{yyyy-MM-dd HH:mm:ss} %-5level [%thread] %X{traceId} %logger{36} - %msg%n" diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml new file mode 100644 index 0000000..1e0afb1 --- /dev/null +++ b/src/test/resources/application-test.yml @@ -0,0 +1,21 @@ +spring: + jpa: + hibernate: + ddl-auto: create-drop + show-sql: true + properties: + hibernate: + format_sql: true + kafka: + bootstrap-servers: localhost:19092 # non-existent port; no Kafka in IT tests + +management: + health: + kafka: + enabled: false + redis: + enabled: false + # Disable OTel exporter in tests — no Jaeger running + tracing: + export: + enabled: false From de69b24b24e05866adce7ddbbaaf8d0b1c5d1ca3 Mon Sep 17 00:00:00 2001 From: Leandro Moraes Date: Wed, 3 Jun 2026 12:31:16 -0300 Subject: [PATCH 04/11] feat(docker): multi-stage build maven->temurin-jre-alpine, non-root user, HEALTHCHECK on /actuator/health --- Dockerfile | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/Dockerfile b/Dockerfile index e69de29..3faa0b8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -0,0 +1,26 @@ +# ── Build stage ──────────────────────────────────────────────────────────────── +FROM maven:3.9-eclipse-temurin-21 AS builder +WORKDIR /app + +# Cache dependency layer: copy descriptor first, then source +COPY pom.xml . +RUN mvn dependency:go-offline -q + +COPY src ./src +RUN mvn package -DskipTests -q + +# ── Runtime stage ─────────────────────────────────────────────────────────────── +FROM eclipse-temurin:21-jre-alpine +RUN addgroup -S app && adduser -S app -G app +WORKDIR /app + +COPY --from=builder /app/target/*.jar app.jar +RUN chown app:app app.jar + +USER app +EXPOSE 8080 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \ + CMD wget -qO- http://localhost:8080/actuator/health || exit 1 + +ENTRYPOINT ["java", "-jar", "app.jar"] From 1a721b5ad6d85b6f83e7e2b2878fe1485623cdca Mon Sep 17 00:00:00 2001 From: Leandro Moraes Date: Wed, 3 Jun 2026 12:31:20 -0300 Subject: [PATCH 05/11] feat(compose): add Jaeger all-in-one OTLP, Redis under profile cache, wire OTEL_EXPORTER_OTLP_ENDPOINT --- docker-compose.yml | 172 +++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 159 insertions(+), 13 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index e412c13..d0b0f89 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,23 +1,169 @@ +# ============================================================= +# Subir TUDO com um comando: +# cp .env.example .env +# docker compose up -d --build +# +# Subir com Redis (profile cache): +# docker compose --profile cache up -d --build +# +# Ver status: docker compose ps +# Ver logs: docker compose logs -f app +# Derrubar: docker compose down (mantém dados) +# docker compose down -v (apaga dados) +# ============================================================= + services: + + # ----------------------------------------------------------- + # APP — Spring Boot, buildado pelo Dockerfile multi-stage. + # ----------------------------------------------------------- + app: + build: + context: . + dockerfile: Dockerfile + container_name: pay-app + depends_on: + postgres: + condition: service_healthy + kafka: + condition: service_started + jaeger: + condition: service_started + ports: + - "8080:8080" + environment: + SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/${POSTGRES_DB} + SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER} + SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD} + SPRING_KAFKA_BOOTSTRAP_SERVERS: kafka:29092 + # Traces vão para o Jaeger via OTLP HTTP + OTEL_EXPORTER_OTLP_ENDPOINT: http://jaeger:4318/v1/traces + + # ----------------------------------------------------------- + # POSTGRES + # ----------------------------------------------------------- postgres: - image: postgres:15 - container_name: payment_db + image: postgres:18-alpine + container_name: pay-postgres environment: - POSTGRES_DB: ${DB_NAME:-payment_db} - POSTGRES_USER: ${DB_USER:-admin} - POSTGRES_PASSWORD: ${DB_PASSWORD:-admin_pass} + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_DB: ${POSTGRES_DB} ports: - "5432:5432" + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] + interval: 5s + timeout: 3s + retries: 5 + + # ----------------------------------------------------------- + # KAFKA — KRaft (sem Zookeeper). Listener duplo: + # INTERNAL kafka:29092 (rede docker) / EXTERNAL localhost:9092 (host) + # ----------------------------------------------------------- + kafka: + image: apache/kafka:3.9.0 + container_name: pay-kafka + ports: + - "9092:9092" + environment: + KAFKA_NODE_ID: 1 + KAFKA_PROCESS_ROLES: broker,controller + KAFKA_CONTROLLER_QUORUM_VOTERS: 1@kafka:9093 + KAFKA_LISTENERS: INTERNAL://0.0.0.0:29092,EXTERNAL://0.0.0.0:9092,CONTROLLER://0.0.0.0:9093 + KAFKA_ADVERTISED_LISTENERS: INTERNAL://kafka:29092,EXTERNAL://localhost:9092 + KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: CONTROLLER:PLAINTEXT,INTERNAL:PLAINTEXT,EXTERNAL:PLAINTEXT + KAFKA_INTER_BROKER_LISTENER_NAME: INTERNAL + KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER + KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 + KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1 + KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1 + KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS: 0 + volumes: + - kafkadata:/var/lib/kafka/data + + # ----------------------------------------------------------- + # KAFKA-UI + # ----------------------------------------------------------- + kafka-ui: + image: provectuslabs/kafka-ui:latest + container_name: pay-kafka-ui + depends_on: + - kafka + ports: + - "8090:8080" + environment: + KAFKA_CLUSTERS_0_NAME: local + KAFKA_CLUSTERS_0_BOOTSTRAPSERVERS: kafka:29092 + DYNAMIC_CONFIG_ENABLED: "true" + + # ----------------------------------------------------------- + # PROMETHEUS — raspa /actuator/prometheus do app. + # ----------------------------------------------------------- + prometheus: + image: prom/prometheus:latest + container_name: pay-prometheus + depends_on: + - app + ports: + - "9090:9090" + volumes: + - ./infra/prometheus.yml:/etc/prometheus/prometheus.yml:ro + + # ----------------------------------------------------------- + # GRAFANA — datasource Prometheus já provisionado. + # ----------------------------------------------------------- + grafana: + image: grafana/grafana:latest + container_name: pay-grafana + depends_on: + - prometheus + ports: + - "3000:3000" + environment: + GF_SECURITY_ADMIN_USER: ${GRAFANA_ADMIN_USER} + GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD} + GF_USERS_ALLOW_SIGN_UP: "false" + volumes: + - ./infra/grafana/provisioning:/etc/grafana/provisioning:ro + - grafanadata:/var/lib/grafana + # ----------------------------------------------------------- + # JAEGER — all-in-one. Recebe OTLP HTTP (:4318) e expõe UI (:16686). + # ----------------------------------------------------------- + jaeger: + image: jaegertracing/all-in-one:latest + container_name: pay-jaeger + ports: + - "16686:16686" # UI + - "4317:4317" # OTLP gRPC + - "4318:4318" # OTLP HTTP + environment: + COLLECTOR_OTLP_ENABLED: "true" + + # ----------------------------------------------------------- + # REDIS — só sobe com: docker compose --profile cache up -d + # Pré-instalado para idempotência/cache/lock futuro. Não usado ainda. + # ----------------------------------------------------------- redis: - image: redis:alpine - container_name: payment_cache + image: redis:7-alpine + container_name: pay-redis + profiles: + - cache ports: - "6379:6379" + volumes: + - redisdata:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 5 - # Opcional: OpenTelemetry Collector (para centralizar os traces) - otel-collector: - image: otel/opentelemetry-collector:latest - command: ["--config=/etc/otel-collector-config.yaml"] - ports: - - "4317:4317" # OTLP gRPC +volumes: + pgdata: + kafkadata: + grafanadata: + redisdata: From d42b22897ddda90ea581b47e1f0ebf57a653ba4c Mon Sep 17 00:00:00 2001 From: Leandro Moraes Date: Wed, 3 Jun 2026 12:31:25 -0300 Subject: [PATCH 06/11] feat(infra): prometheus scrape config, Grafana datasource provisioning and payment-overview dashboard --- .../dashboards/payment-overview.json | 65 +++++++++++++++++++ .../provisioning/dashboards/provider.yml | 10 +++ .../provisioning/datasources/prometheus.yml | 11 ++++ infra/prometheus.yml | 9 +++ 4 files changed, 95 insertions(+) create mode 100644 infra/grafana/provisioning/dashboards/payment-overview.json create mode 100644 infra/grafana/provisioning/dashboards/provider.yml create mode 100644 infra/grafana/provisioning/datasources/prometheus.yml create mode 100644 infra/prometheus.yml diff --git a/infra/grafana/provisioning/dashboards/payment-overview.json b/infra/grafana/provisioning/dashboards/payment-overview.json new file mode 100644 index 0000000..c803f98 --- /dev/null +++ b/infra/grafana/provisioning/dashboards/payment-overview.json @@ -0,0 +1,65 @@ +{ + "title": "Payment — Overview", + "uid": "payment-overview", + "schemaVersion": 38, + "version": 1, + "refresh": "30s", + "time": { "from": "now-1h", "to": "now" }, + "panels": [ + { + "id": 1, + "title": "Ping Rate (req/s)", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 }, + "datasource": { "uid": "prometheus", "type": "prometheus" }, + "targets": [ + { + "expr": "rate(http_server_requests_seconds_count{uri=\"/ping\"}[1m])", + "legendFormat": "ping req/s" + } + ] + }, + { + "id": 2, + "title": "HTTP Request Rate (all endpoints)", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 }, + "datasource": { "uid": "prometheus", "type": "prometheus" }, + "targets": [ + { + "expr": "sum(rate(http_server_requests_seconds_count[1m])) by (uri, status)", + "legendFormat": "{{uri}} {{status}}" + } + ] + }, + { + "id": 3, + "title": "JVM Heap Used (bytes)", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 8 }, + "datasource": { "uid": "prometheus", "type": "prometheus" }, + "targets": [ + { + "expr": "jvm_memory_used_bytes{area=\"heap\"}", + "legendFormat": "{{id}}" + } + ], + "fieldConfig": { + "defaults": { "unit": "bytes" } + } + }, + { + "id": 4, + "title": "HTTP 5xx Error Rate", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 8 }, + "datasource": { "uid": "prometheus", "type": "prometheus" }, + "targets": [ + { + "expr": "sum(rate(http_server_requests_seconds_count{status=~\"5..\"}[1m]))", + "legendFormat": "5xx/s" + } + ] + } + ] +} diff --git a/infra/grafana/provisioning/dashboards/provider.yml b/infra/grafana/provisioning/dashboards/provider.yml new file mode 100644 index 0000000..a8e95b9 --- /dev/null +++ b/infra/grafana/provisioning/dashboards/provider.yml @@ -0,0 +1,10 @@ +apiVersion: 1 + +providers: + - name: Payment Dashboards + folder: Payment + type: file + disableDeletion: false + updateIntervalSeconds: 30 + options: + path: /etc/grafana/provisioning/dashboards diff --git a/infra/grafana/provisioning/datasources/prometheus.yml b/infra/grafana/provisioning/datasources/prometheus.yml new file mode 100644 index 0000000..04897dc --- /dev/null +++ b/infra/grafana/provisioning/datasources/prometheus.yml @@ -0,0 +1,11 @@ +apiVersion: 1 + +datasources: + - name: Prometheus + type: prometheus + uid: prometheus + url: http://prometheus:9090 + isDefault: true + editable: false + jsonData: + timeInterval: 15s diff --git a/infra/prometheus.yml b/infra/prometheus.yml new file mode 100644 index 0000000..fce22c0 --- /dev/null +++ b/infra/prometheus.yml @@ -0,0 +1,9 @@ +global: + scrape_interval: 15s + evaluation_interval: 15s + +scrape_configs: + - job_name: payment-app + metrics_path: /actuator/prometheus + static_configs: + - targets: ['app:8080'] From 0ffef099caad449fecda009f1f6df8218581c4c1 Mon Sep 17 00:00:00 2001 From: Leandro Moraes Date: Wed, 3 Jun 2026 12:31:31 -0300 Subject: [PATCH 07/11] feat(quality): add checkstyle.xml with import, naming, braces and code-quality rules --- config/checkstyle.xml | 64 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 config/checkstyle.xml diff --git a/config/checkstyle.xml b/config/checkstyle.xml new file mode 100644 index 0000000..f33f16b --- /dev/null +++ b/config/checkstyle.xml @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From ebecd484273c68d1c676ed80d522bd8038671715 Mon Sep 17 00:00:00 2001 From: Leandro Moraes Date: Wed, 3 Jun 2026 12:31:35 -0300 Subject: [PATCH 08/11] feat(ci): split into unit-tests (spotless+checkstyle+test, always) and full-verify (main only), add concurrency --- .github/workflows/ci.yml | 82 ++++++++++++++++++++-------------------- 1 file changed, 41 insertions(+), 41 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c10093e..5b143a7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,60 +1,60 @@ name: CI on: - pull_request: - branches: ["main"] push: - branches: ["main"] + branches: [develop, main] + pull_request: + branches: [main] permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: - build: - name: Build and Test + unit-tests: + name: Lint + Unit Tests runs-on: ubuntu-latest - - services: - postgres: - image: postgres:15 - env: - POSTGRES_DB: payment_db - POSTGRES_USER: admin - POSTGRES_PASSWORD: admin_pass - ports: - - 5432:5432 - options: >- - --health-cmd="pg_isready -U admin -d payment_db" - --health-interval=10s - --health-timeout=5s - --health-retries=5 - - redis: - image: redis:alpine - ports: - - 6379:6379 - options: >- - --health-cmd="redis-cli ping" - --health-interval=10s - --health-timeout=5s - --health-retries=5 - - env: - DB_HOST: localhost - DB_NAME: payment_db - DB_USER: admin - DB_PASSWORD: admin_pass + timeout-minutes: 15 steps: - - name: Checkout repository - uses: actions/checkout@v4 + - uses: actions/checkout@v4 - - name: Setup Java - uses: actions/setup-java@v4 + - uses: actions/setup-java@v4 with: distribution: temurin java-version: 21 cache: maven - - name: Run tests + - name: Check formatting (Spotless) + run: ./mvnw spotless:check + + - name: Check style (Checkstyle) + run: ./mvnw checkstyle:check + + - name: Run unit tests run: ./mvnw test + + full-verify: + name: Full Verify + Docker Build + # Runs on pushes to main and on PRs targeting main + if: github.ref == 'refs/heads/main' || github.base_ref == 'main' + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 21 + cache: maven + + - name: Run full verify (unit + integration tests) + run: ./mvnw verify + + - name: Build Docker image + run: docker build -t payment-api:ci . From 5b1c15fde52d6bcafae703c07a2560e5e9199eaa Mon Sep 17 00:00:00 2001 From: Leandro Moraes Date: Wed, 3 Jun 2026 12:31:38 -0300 Subject: [PATCH 09/11] feat(hooks): pre-push runs mvnw verify; commit-msg validates Conventional Commits format --- .githooks/commit-msg | 31 +++++++++++++++++++++++++++++++ .githooks/pre-push | 8 ++++++++ 2 files changed, 39 insertions(+) create mode 100755 .githooks/commit-msg create mode 100755 .githooks/pre-push diff --git a/.githooks/commit-msg b/.githooks/commit-msg new file mode 100755 index 0000000..cb3aa72 --- /dev/null +++ b/.githooks/commit-msg @@ -0,0 +1,31 @@ +#!/bin/sh +# Validates that the commit message follows Conventional Commits. +# Enable with: git config core.hooksPath .githooks +# +# Format: tipo(escopo opcional): descrição +# Tipos permitidos: feat, fix, docs, style, refactor, test, chore, build, ci, perf, revert + +COMMIT_MSG_FILE="$1" +COMMIT_MSG=$(cat "$COMMIT_MSG_FILE") + +# Allow merge commits and fixup commits +case "$COMMIT_MSG" in + Merge*|Revert*|fixup!*|squash!*) exit 0 ;; +esac + +PATTERN="^(feat|fix|docs|style|refactor|test|chore|build|ci|perf|revert)(\([a-zA-Z0-9/_-]+\))?: .{1,100}$" + +if ! echo "$COMMIT_MSG" | grep -qE "$PATTERN"; then + echo "" + echo " ✗ Commit message does not follow Conventional Commits." + echo "" + echo " Format : tipo(escopo): descrição" + echo " Example: feat(payment): add charge creation use case" + echo " Types : feat fix docs style refactor test chore build ci perf revert" + echo "" + echo " Your message: $COMMIT_MSG" + echo "" + exit 1 +fi + +exit 0 diff --git a/.githooks/pre-push b/.githooks/pre-push new file mode 100755 index 0000000..b0a9fc2 --- /dev/null +++ b/.githooks/pre-push @@ -0,0 +1,8 @@ +#!/bin/sh +# Run full Maven verify (unit + integration tests) before every push. +# Enable with: git config core.hooksPath .githooks +set -e + +echo ">>> pre-push: running ./mvnw verify ..." +./mvnw verify +echo ">>> pre-push: OK" From f5f387a4820576d5c8ff37523ebb973ede6bd0eb Mon Sep 17 00:00:00 2001 From: Leandro Moraes Date: Wed, 3 Jun 2026 12:31:42 -0300 Subject: [PATCH 10/11] chore(dx): add Makefile (up/down/clean/logs/test/verify/format/db), .env.example, update .gitignore --- .editorconfig | 18 ++++++++++++++++++ .env.example | 27 +++++++++++++++++++++++++++ .gitignore | 4 ++++ Makefile | 37 +++++++++++++++++++++++++++++++++++++ 4 files changed, 86 insertions(+) create mode 100644 .editorconfig create mode 100644 .env.example create mode 100644 Makefile diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..621b693 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,18 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 4 + +[*.{yml,yaml,json}] +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false + +[Makefile] +indent_style = tab diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..4956686 --- /dev/null +++ b/.env.example @@ -0,0 +1,27 @@ +# Copy this file to .env and fill in real values before running docker compose. +# cp .env.example .env + +# ── PostgreSQL ───────────────────────────────────────────────────────────────── +POSTGRES_USER=admin +POSTGRES_PASSWORD=admin_pass +POSTGRES_DB=payment_db + +# ── Grafana ──────────────────────────────────────────────────────────────────── +GRAFANA_ADMIN_USER=admin +GRAFANA_ADMIN_PASSWORD=admin + +# ── OpenTelemetry / Jaeger ───────────────────────────────────────────────────── +# Automatically set to http://jaeger:4318/v1/traces by docker-compose.yml. +# Override here only if pointing to an external collector. +# OTEL_EXPORTER_OTLP_ENDPOINT=http://jaeger:4318/v1/traces + +# ── Redis (profile: cache) ───────────────────────────────────────────────────── +# Redis only starts with: docker compose --profile cache up -d +# SPRING_REDIS_HOST=redis +# SPRING_REDIS_PORT=6379 + +# ── Spring overrides (set automatically by docker-compose.yml) ───────────────── +# SPRING_DATASOURCE_URL=jdbc:postgresql://postgres:5432/payment_db +# SPRING_DATASOURCE_USERNAME=admin +# SPRING_DATASOURCE_PASSWORD=admin_pass +# SPRING_KAFKA_BOOTSTRAP_SERVERS=kafka:29092 diff --git a/.gitignore b/.gitignore index 667aaef..8f3304e 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,10 @@ target/ !**/src/main/**/target/ !**/src/test/**/target/ +# Secrets — never commit real env file +.env +!.env.example + ### STS ### .apt_generated .classpath diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..6ab1acc --- /dev/null +++ b/Makefile @@ -0,0 +1,37 @@ +.PHONY: up down clean logs test verify format db help + +help: + @echo "Available targets:" + @echo " up - Build and start all services (docker compose up -d --build)" + @echo " down - Stop services, keep data volumes" + @echo " clean - Stop services and remove data volumes" + @echo " logs - Tail app service logs" + @echo " test - Run unit tests (no Docker required)" + @echo " verify - Run unit + integration tests (requires Docker)" + @echo " format - Apply Spotless formatter to all Java files" + @echo " db - Open psql shell in the running postgres container" + +up: + docker compose up -d --build + +down: + docker compose down + +clean: + docker compose down -v + +logs: + docker compose logs -f app + +test: + ./mvnw test + +verify: + ./mvnw verify + +format: + ./mvnw spotless:apply + +db: + @source .env 2>/dev/null || true; \ + docker compose exec postgres psql -U "$${POSTGRES_USER}" "$${POSTGRES_DB}" From a6551655158b9111c3baf29ec517fd0b1cf40f88 Mon Sep 17 00:00:00 2001 From: Leandro Moraes Date: Wed, 3 Jun 2026 12:31:46 -0300 Subject: [PATCH 11/11] docs: architecture flow (domain/usecase/api/kafka), devops pipeline diagram, update README --- README.md | 182 ++++++------ docs/architecture.md | 278 ++++++++++++++++++ docs/devops-flow.md | 85 ++++++ docs/{devops-flow.png.png => devops-flow.png} | Bin 4 files changed, 461 insertions(+), 84 deletions(-) create mode 100644 docs/architecture.md create mode 100644 docs/devops-flow.md rename docs/{devops-flow.png.png => devops-flow.png} (100%) diff --git a/README.md b/README.md index 44fe401..1f77926 100644 --- a/README.md +++ b/README.md @@ -1,119 +1,133 @@ -# 💳 Payment API +# Payment API -API de pagamentos desenvolvida em Java + Spring Boot, com foco em arquitetura limpa, logging estruturado e boas práticas de backend. +API de pagamentos Pix em Java + Spring Boot com Clean Architecture, Kafka (KRaft), Postgres, Prometheus, Grafana e Jaeger. ---- +**Documentação de arquitetura:** +- [docs/architecture.md](docs/architecture.md) — domínio, fluxos, API, pacotes, idempotência +- [docs/devops-flow.md](docs/devops-flow.md) — pipeline CI/CD + observabilidade (Mermaid) -## 🚀 Objetivo +## Stack -Simular um sistema real de pagamentos aplicando: +| Camada | Tecnologia | +|---|---| +| Runtime | Java 21 LTS | +| Framework | Spring Boot 3.5.3 | +| Messaging | Kafka 3.9 (KRaft — sem Zookeeper) | +| Persistence | PostgreSQL 18 | +| Métricas | Actuator + Micrometer + Prometheus + Grafana | +| Tracing | Micrometer Tracing + OpenTelemetry → Jaeger | +| Cache | Redis 7 (pré-instalado, profile `cache`) | +| Testes | JUnit 5 + Testcontainers | +| Qualidade | Spotless (GJF AOSP) + Checkstyle | -* Arquitetura em camadas -* Logs estruturados (5W1H) -* Tratamento global de erros -* Boas práticas de versionamento +## Quickstart ---- - -## 🏗️ Arquitetura - -O sistema segue um fluxo baseado em CI/CD com separação clara entre aplicação, persistência e observabilidade. - -![DevOps Flow](./docs/devops-flow.png) +```bash +# 1. Variáveis de ambiente +cp .env.example .env +# 2. Subir toda a infra + app +make up +# 3. Status +docker compose ps ``` -src/main/java/dev/leandromoraes/paymentapi -├── adapters -│ └── in -│ └── web -├── application -├── domain -├── infrastructure -│ └── logger -└── presentation -└── handler -``` ---- -## ⚙️ Tecnologias +| URL | Descrição | +|---|---| +| `GET /ping` | Smoke test | +| `GET /actuator/health` | Health probe | +| `GET /actuator/prometheus` | Métricas Prometheus | +| http://localhost:8090 | Kafka UI | +| http://localhost:9090 | Prometheus | +| http://localhost:3000 | Grafana — Payment Overview | +| http://localhost:16686 | Jaeger — traces | -* Java 21+ -* Spring Boot -* Maven -* PostgreSQL (Docker) -* Redis (Docker) +## Subir Redis (opcional) ---- - -## 🐳 Infra local +Redis está pré-instalado mas **não sobe por padrão**: ```bash -docker-compose up -d +docker compose --profile cache up -d ``` ---- - -## 📡 Endpoints - -### Health Check +## Makefile -```http -GET /health ``` - -Response: - -```json -{ - "status": "UP" -} +make up # docker compose up -d --build +make down # docker compose down (mantém volumes) +make clean # docker compose down -v (apaga volumes) +make logs # docker compose logs -f app +make test # ./mvnw test (sem Docker) +make verify # ./mvnw verify (com Testcontainers — requer Docker) +make format # ./mvnw spotless:apply +make db # psql no container postgres ``` ---- +## Qualidade de código -## 📊 Logging (5W1H) - -O projeto utiliza logs estruturados no padrão: +```bash +# Formatar +./mvnw spotless:apply # ou: make format -* where → onde aconteceu -* why → por que aconteceu -* when → quando aconteceu -* who → quem causou -* what → o que aconteceu -* how → como aconteceu +# Verificar formatação (GJF AOSP, 4-space) +./mvnw spotless:check -Exemplo: +# Verificar estilo (Checkstyle) +./mvnw checkstyle:check -```json -{ - "where": "HealthController", - "what": "health_check", - "why": "system_monitoring", - "who": "system", - "how": "GET /health", - "when": "2026-05-04T16:00:00" -} +# CI roda os dois antes dos testes: +./mvnw spotless:check && ./mvnw checkstyle:check && ./mvnw test ``` ---- +## Pacotes (Clean Architecture) + +``` +com.lmoraesdev.payment +├── adapter +│ ├── in.web ← controllers, exception handler +│ └── out +│ ├── messaging ← Kafka producers (futuro) +│ └── persistence ← JPA repositories (futuro) +├── application +│ ├── port.in ← interfaces de entrada (futuro) +│ ├── port.out ← interfaces de saída (futuro) +│ └── usecase ← casos de uso (futuro) +├── config +│ └── logging ← Logger5w1h estruturado +└── domain + ├── event ← domain events (futuro) + ├── exception ← DomainException base + └── model ← entidades / value objects (futuro) +``` -## ⚠️ Tratamento de Erros +## Testes -Implementado com `@RestControllerAdvice`, garantindo: +```bash +# Unitários (sem Docker) +./mvnw test -* respostas padronizadas -* logging estruturado -* desacoplamento da lógica de negócio +# Integração — sobe Postgres 18 via Testcontainers +./mvnw verify +``` ---- +## Git hooks -## 🧪 Status +```bash +git config core.hooksPath .githooks +``` -Em desenvolvimento +| Hook | O que faz | +|---|---| +| `pre-push` | Executa `./mvnw verify` antes de cada push | +| `commit-msg` | Valida formato Conventional Commits | ---- +Formato de commit: `tipo(escopo): descrição` +Tipos: `feat fix docs style refactor test chore build ci perf revert` -## 👨‍💻 Autor +## CI -![Leandro-Moraes](https://www.linkedin.com/in/lmoraesdev/) +| Branch / PR | Jobs | +|---|---| +| `develop` push | Lint (Spotless + Checkstyle) + unit tests | +| `main` push / PR → main | Lint + unit tests + full-verify + docker build | diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..acb128f --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,278 @@ +# Arquitetura — payment-api + +> Espelho do [pix-payment-core](https://github.com/lmoraesdev/pix-payment-core) em Java com +> Clean Architecture pragmática. A lógica de negócio fica isolada no domínio; infraestrutura +> e framework ficam nas bordas. + +--- + +## Domínio: Charge (Cobrança Pix) + +### Entidade principal + +``` +Charge +├── id : UUID (gerado na criação) +├── idempotencyKey: String (único — cliente envia no header) +├── amount : Money (value object — BigDecimal + "BRL") +├── pixKey : String (chave Pix do recebedor) +├── qrCode : String (gerado pelo provedor, retornado ao cliente) +├── status : ChargeStatus (máquina de estados — ver abaixo) +├── createdAt : Instant +├── updatedAt : Instant +└── expiresAt : Instant (TTL configurável, padrão 30 min) +``` + +### Value Object: Money + +``` +Money +├── amount : BigDecimal (escala 2, arredondamento HALF_UP) +└── currency: String (fixo "BRL" por ora) +``` + +### Máquina de estados: ChargeStatus + +```mermaid +stateDiagram-v2 + [*] --> CREATED : POST /charges + + CREATED --> AWAITING_PAYMENT : enviado ao provedor Pix + + AWAITING_PAYMENT --> PAID : webhook PAYMENT_CONFIRMED + AWAITING_PAYMENT --> EXPIRED : webhook PAYMENT_EXPIRED\nou TTL atingido + + PAID --> [*] + EXPIRED --> [*] +``` + +> **Regra:** Não existe `setStatus()`. Apenas `transitionTo(newStatus)` com validação. +> Qualquer transição inválida lança `InvalidStateTransitionException` (subtipo de `DomainException`). + +--- + +## Fluxo de uma cobrança (sequência) + +```mermaid +sequenceDiagram + participant Client + participant API as adapter.in.web
ChargeController + participant UC as application.usecase
CreateChargeUseCase + participant DOM as domain
Charge + participant REPO as adapter.out.persistence
ChargeRepository + participant PIX as adapter.out.provider
PixProviderPort + participant KFK as adapter.out.messaging
EventPublisher + + Client->>+API: POST /charges
Idempotency-Key: + API->>+UC: CreateChargeCommand(key, amount, pixKey) + + UC->>REPO: findByIdempotencyKey(key) + alt Já existe + REPO-->>UC: Charge existente + UC-->>API: ChargeResponse (idempotente) + API-->>Client: 200 OK (mesma resposta) + else Novo + UC->>DOM: Charge.create(id, key, amount, pixKey) + DOM-->>UC: Charge(status=CREATED) + UC->>PIX: generateQrCode(charge) + PIX-->>UC: qrCode + UC->>DOM: charge.transitionTo(AWAITING_PAYMENT) + UC->>REPO: save(charge) + UC-->>-API: ChargeResponse + API-->>-Client: 201 Created + qrCode + end +``` + +### Webhook do provedor (deduplicação) + +```mermaid +sequenceDiagram + participant PIX as Provedor Pix + participant WH as adapter.in.web
WebhookController + participant UC as ProcessWebhookUseCase + participant REPO as ChargeRepository + participant KFK as EventPublisher + + PIX->>+WH: POST /webhooks/provider
event_id, chargeId, status + WH->>+UC: ProcessWebhookCommand(eventId, chargeId, newStatus) + + UC->>REPO: findByEventId(eventId) + alt Evento já processado + UC-->>WH: ignorado (idempotente) + else Novo evento + UC->>REPO: findById(chargeId) + REPO-->>UC: Charge(status=AWAITING_PAYMENT) + UC->>REPO: Domain: charge.transitionTo(PAID | EXPIRED) + UC->>REPO: save(charge) + markEventProcessed(eventId) + alt Status = PAID + UC->>KFK: publish(ChargePayedEvent) + end + end + WH-->>-PIX: 200 OK +``` + +--- + +## Estrutura de pacotes + +``` +com.lmoraesdev.payment +│ +├── domain/ +│ ├── model/ +│ │ ├── Charge.java ← entidade + transitionTo() +│ │ ├── ChargeStatus.java ← enum com lógica de transição válida +│ │ └── Money.java ← value object +│ ├── event/ +│ │ └── ChargePayedEvent.java ← domain event publicado no Kafka +│ └── exception/ +│ ├── DomainException.java ← base abstrata (já existe) +│ ├── InvalidStateTransitionException.java +│ └── ChargeNotFoundException.java +│ +├── application/ +│ ├── usecase/ +│ │ ├── CreateChargeUseCase.java +│ │ ├── ProcessWebhookUseCase.java +│ │ └── GetChargeUseCase.java +│ ├── port/ +│ │ ├── in/ ← interfaces que os controllers chamam +│ │ │ ├── CreateChargePort.java +│ │ │ ├── ProcessWebhookPort.java +│ │ │ └── GetChargePort.java +│ │ └── out/ ← interfaces que os use cases dependem +│ │ ├── ChargeRepositoryPort.java +│ │ ├── PixProviderPort.java +│ │ └── EventPublisherPort.java +│ +├── adapter/ +│ ├── in/ +│ │ └── web/ +│ │ ├── ChargeController.java ← POST /charges, GET /charges/{id} +│ │ ├── WebhookController.java ← POST /webhooks/provider +│ │ ├── GlobalExceptionHandler.java ← já existe +│ │ ├── PingController.java ← smoke test, já existe +│ │ └── dto/ +│ │ ├── CreateChargeRequest.java +│ │ ├── ChargeResponse.java +│ │ └── WebhookProviderPayload.java +│ └── out/ +│ ├── persistence/ +│ │ ├── ChargeJpaRepository.java ← interface Spring Data JPA +│ │ ├── ChargeRepositoryAdapter.java ← implementa ChargeRepositoryPort +│ │ └── entity/ +│ │ └── ChargeEntity.java ← @Entity JPA, separado do domain +│ └── messaging/ +│ └── KafkaEventPublisher.java ← implementa EventPublisherPort +│ +└── config/ + ├── logging/ ← Logger5w1h (já existe) + └── KafkaConfig.java ← topics, serializers (futuro) +``` + +--- + +## Contratos de API + +### POST /charges +``` +Headers: + Idempotency-Key: (obrigatório) + Content-Type: application/json + +Body: +{ + "amount": 150.00, + "pixKey": "leandro@email.com" +} + +Response 201: +{ + "id": "uuid", + "status": "AWAITING_PAYMENT", + "qrCode": "00020126...", + "expiresAt": "2026-06-03T15:30:00Z" +} + +Response 200: (mesma resposta se Idempotency-Key já foi usada) +Response 422: transição de estado inválida ou valor inválido +``` + +### GET /charges/{id} +``` +Response 200: +{ + "id": "uuid", + "status": "PAID | AWAITING_PAYMENT | EXPIRED | CREATED", + "amount": 150.00, + "pixKey": "leandro@email.com", + "createdAt": "...", + "updatedAt": "..." +} + +Response 404: charge não encontrada +``` + +### POST /webhooks/provider +``` +Body: +{ + "eventId": "uuid", (deduplicação) + "chargeId": "uuid", + "status": "PAYMENT_CONFIRMED | PAYMENT_EXPIRED" +} + +Response 200: sempre, mesmo para eventos duplicados (idempotente) +``` + +--- + +## Kafka — tópicos e eventos + +| Tópico | Evento | Publicado quando | +|---|---|---| +| `payments.charged` | `ChargePayedEvent` | Charge transiciona para `PAID` | + +```json +// ChargePayedEvent +{ + "eventId": "uuid", + "chargeId": "uuid", + "amount": 150.00, + "pixKey": "leandro@email.com", + "occurredAt": "2026-06-03T15:00:00Z" +} +``` + +--- + +## Idempotência e Redis (futuro) + +Redis está pré-instalado (`docker compose --profile cache up -d`). +Quando o domínio precisar: + +| Uso | Chave Redis | TTL | +|---|---|---| +| Lock de criação | `idempotency:lock:{key}` | 30s | +| Cache de resposta | `idempotency:response:{key}` | 24h | +| Deduplicação de webhook | `webhook:seen:{eventId}` | 7d | + +> Por enquanto a deduplicação de webhook usa constraint `UNIQUE(event_id)` no Postgres. +> Redis entra quando precisar de lock distribuído (múltiplas instâncias do app). + +--- + +## Observabilidade + +``` +Requisição HTTP + │ + ├── Log estruturado (5W1H + traceId) ──────────────► stdout / futuramente Loki + │ + ├── Métricas Micrometer ────────────────────────────► Prometheus ──► Grafana + │ + └── Trace OpenTelemetry (OTLP HTTP) ───────────────► Jaeger :16686 +``` + +Todo log já carrega o `traceId` via MDC (`%X{traceId}` no pattern). +Com isso, é possível correlacionar log ↔ trace no Jaeger clicando num traceId. diff --git a/docs/devops-flow.md b/docs/devops-flow.md new file mode 100644 index 0000000..d64e038 --- /dev/null +++ b/docs/devops-flow.md @@ -0,0 +1,85 @@ +# DevOps Flow — payment-api + +> Fluxo de desenvolvimento, CI/CD e observabilidade do projeto. + +```mermaid +flowchart LR + subgraph DEV["💻 Dev Local"] + SB["Spring Boot\n:8080"] + end + + subgraph VCS["🔀 Git"] + BR["develop / main"] + end + + subgraph CI["⚙️ CI — GitHub Actions"] + direction TB + LINT["Spotless + Checkstyle"] + UNIT["./mvnw test"] + IT["./mvnw verify\n(Testcontainers)"] + DOCKER["docker build"] + LINT --> UNIT --> IT --> DOCKER + end + + subgraph REG["📦 Registry"] + IMG["Docker Image\nGHCR / DockerHub"] + end + + subgraph CLOUD["☁️ Cloud Deploy"] + direction TB + DENV["Dev"] + STAGE["Staging"] + PROD["Prod"] + end + + subgraph APP["🚀 API — Spring Boot"] + direction TB + CTRL["Controllers\nadapter.in.web"] + UC["Use Cases\napplication.usecase"] + DOM["Domain\nCharge + StateMachine"] + CTRL --> UC --> DOM + end + + subgraph INFRA["🗄️ Infraestrutura"] + direction TB + PG["PostgreSQL 18\n(JPA / Hibernate)"] + KFK["Kafka KRaft\nChargePayedEvent"] + RDS["Redis 7\n(idempotência — futuro)"] + end + + subgraph OBS["🔭 Observabilidade"] + direction TB + PROM["Prometheus\nmétrics"] + GRF["Grafana\ndashboard"] + JAEG["Jaeger\ntraces OTLP"] + LOG["Logs estruturados\n5W1H + traceId"] + PROM --> GRF + end + + DEV -->|push| VCS + VCS -->|trigger| CI + CI -->|push image| REG + REG -->|pull & deploy| CLOUD + CLOUD --> APP + APP --> INFRA + APP --> OBS +``` + +--- + +## Environments + +| Env | Branch | Gatilho | +|---|---|---| +| Dev | `develop` | push → unit tests + lint | +| Staging / Prod | `main` | PR aprovado → full-verify + docker build | + +## Observabilidade: Logs + Métricas + Traces + +Toda requisição nasce com um **traceId** gerado pelo OpenTelemetry e propagado automaticamente: + +- **Logs** — `application.yml` injeta `%X{traceId}` em cada linha via MDC +- **Métricas** — Micrometer → Prometheus → Grafana (dashboard `Payment — Overview`) +- **Traces** — Micrometer Tracing → OTLP HTTP → Jaeger (`:16686`) + +> **Futuro:** Loki para log aggregation (completa o trio Logs + Métricas + Traces no Grafana). diff --git a/docs/devops-flow.png.png b/docs/devops-flow.png similarity index 100% rename from docs/devops-flow.png.png rename to docs/devops-flow.png