Skip to content

Product API

A Product API implementa o CRUD de produtos do domínio store, seguindo o mesmo padrão adotado no projeto: interface (product) e service (product-service) por trás do gateway e protegido por JWT.

Trusted layer e segurança

Toda requisição externa entra pelo gateway.
As rotas /product/** são protegidas: é obrigatório enviar Authorization: Bearer <jwt>.


Visão geral

  • Interface (product): define o contrato (DTOs e Feign) consumido por outros módulos/fronts.
  • Service (product-service): implementação REST, regras de negócio, persistência (JPA), e migrações (Flyway).
classDiagram
    namespace product {
        class ProductController {
            +create(ProductIn ProductIn): ProductOut
            +delete(String id): void
            +findAll(): List<ProductOut>
            +findById(String id): ProductOut
        }
        class ProductIn {
            -String name
            -Double price
            -String unit
        }
        class ProductOut {
            -String id
            -String name
            -Double price
            -String unit
        }
    }
    namespace product-service {
        class ProductResource {
            +create(ProductIn ProductIn): ProductOut
            +delete(String id): void
            +findAll(): List<ProductOut>
            +findById(String id): ProductOut
        }
        class ProductService {
            +create(ProductIn ProductIn): ProductOut
            +delete(String id): void
            +findAll(): List<ProductOut>
            +findById(String id): ProductOut
        }
        class ProductRepository {
            +create(ProductIn ProductIn): ProductOut
            +delete(String id): void
            +findAll(): List<ProductOut>
            +findById(String id): ProductOut
        }
        class Product {
            -String id
            -String name
            -Double price
            -String unit
        }
        class ProductModel {
            +create(ProductIn ProductIn): ProductOut
            +delete(String id): void
            +findAll(): List<ProductOut>
            +findById(String id): ProductOut
        }
    }
    <<Interface>> ProductController
    ProductController ..> ProductIn
    ProductController ..> ProductOut

    <<Interface>> ProductRepository
    ProductController <|-- ProductResource
    ProductResource *-- ProductService
    ProductService *-- ProductRepository
    ProductService ..> Product
    ProductService ..> ProductModel
    ProductRepository ..> ProductModel

Estrutura da requisição

flowchart LR
    subgraph api [Trusted Layer]
        direction TB
        gateway --> account
        gateway --> auth
        account --> db@{ shape: cyl, label: "Database" }
        auth --> account
        gateway e5@==> product:::red
        product e2@==> db
    end
    internet e1@==>|request| gateway
    e1@{ animate: true }
    e2@{ animate: true }
    e5@{ animate: true }
    classDef red fill:#fcc
    click product "#product-api" "Product API"

Product

📁 api/
└── 📁 product/
    ├── 📁 src/
       └── 📁 main/
           └── 📁 java/
               └── 📁 store/
                   └── 📁 product/
                       ├── 📄 ProductController.java
                       ├── 📄 ProductIn.java
                       └── 📄 ProductOut.java
    ├── 📄 pom.xml
    └── 📄 Jenkinsfile
Source
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.5.5</version>
        <relativePath/>
    </parent>

    <groupId>store</groupId>
    <artifactId>product</artifactId>
    <version>1.0.0</version>

    <properties>
        <java.version>21</java.version>
        <spring-cloud.version>2025.0.0</spring-cloud.version>
        <maven.compiler.proc>full</maven.compiler.proc>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-openfeign</artifactId>
        </dependency>

        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
    </dependencies>

    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.springframework.cloud</groupId>
                <artifactId>spring-cloud-dependencies</artifactId>
                <version>${spring-cloud.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>

</project>
pipeline {
    agent any

    stages {
        stage('Build') {
            steps {
                sh 'mvn -B -DskipTests clean install'
            }
        }
    }

}
ProductController.java
package store.product;

import java.util.List;

import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;

@FeignClient(name = "product", url = "http://product:8080")
public interface ProductController {

    @PostMapping("/product")
    public ResponseEntity<ProductOut> create(
        @RequestBody ProductIn in
    );

    @GetMapping("/product/{id}")
    public ResponseEntity<ProductOut> findById(
        @PathVariable("id") String id
    );

    @GetMapping("/product")
    public ResponseEntity<List<ProductOut>> findAll();

    @DeleteMapping("/product/{id}")
    public ResponseEntity<Void> delete(
        @PathVariable("id") String id
    );

}
ProductIn.java
package store.product;

import lombok.Builder;

@Builder
public record ProductIn(
    String name,
    Double price,
    String unit
) {

}
ProductOut.java
package store.product;

import lombok.Builder;

@Builder
public record ProductOut(
    String id,
    String name,
    Double price,
    String unit
) {

}
mvn clean install

Product-Service

📁 api/
└── 📁 product-service/
    ├── 📁 k8s/
       └── 📄 k8s.yaml
    ├── 📁 src/
       └── 📁 main/
           ├── 📁 java/
              └── 📁 store/
                  └── 📁 product/
                      ├── 📄 Product.java
                      ├── 📄 ProductApplication.java
                      ├── 📄 ProductModel.java
                      ├── 📄 ProductParser.java
                      ├── 📄 ProductRepository.java
                      ├── 📄 ProductResource.java
                      ├── 📄 ProductService.java
                      └── 📄 RedisCacheConfig.java
           └── 📁 resources/
               ├── 📄 application.yaml
               └── 📁 db/
                   └── 📁 migration/
                       ├── 📄 V2025.08.29.001__create_schema.sql
                       └── 📄 V2025.08.29.002__create_table_product.sql
    ├── 📄 pom.xml
    ├── 📄 Dockerfile
    └── 📄 Jenkinsfile
Source
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.5.5</version>
        <relativePath/>
    </parent>

    <groupId>store</groupId>
    <artifactId>product-service</artifactId>
    <version>1.0.0</version>

    <properties>
        <java.version>21</java.version>
        <spring-cloud.version>2025.0.0</spring-cloud.version>
        <maven.compiler.proc>full</maven.compiler.proc>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-cache</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>

        <dependency>
            <groupId>${project.groupId}</groupId>
            <artifactId>product</artifactId>
            <version>${project.version}</version>
        </dependency>

        <!-- https://mvnrepository.com/artifact/org.projectlombok/lombok -->
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>

        <dependency>
            <groupId>org.postgresql</groupId>
            <artifactId>postgresql</artifactId>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>org.flywaydb</groupId>
            <artifactId>flyway-core</artifactId>
        </dependency>
        <dependency>
            <groupId>org.flywaydb</groupId>
            <artifactId>flyway-database-postgresql</artifactId>
        </dependency>


    </dependencies>

    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.springframework.cloud</groupId>
                <artifactId>spring-cloud-dependencies</artifactId>
                <version>${spring-cloud.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <configuration>
                    <excludes>
                        <exclude>
                            <groupId>org.projectlombok</groupId>
                            <artifactId>lombok</artifactId>
                        </exclude>
                    </excludes>
                </configuration>
            </plugin>
        </plugins>
    </build>

</project>
1
2
3
4
FROM openjdk:23-slim
VOLUME /tmp
COPY target/*.jar /app.jar
ENTRYPOINT ["java","-jar","/app.jar"]
pipeline {
    agent any
    environment {
        SERVICE = 'product'
        NAME = "jpqv/${env.SERVICE}"
        AWS_REGION  = "us-east-2"
        EKS_CLUSTER = "eks-store"
    }
    stages {
        stage('Dependecies') {
            steps {
                build job: 'product', wait: true
            }
        }
        stage('Build') { 
            steps {
                sh 'mvn -B -DskipTests clean package'
            }
        }      
        stage('Build & Push Image') {
            steps {
                withCredentials([usernamePassword(
                    credentialsId: 'dockerhub-credential',
                    usernameVariable: 'USERNAME',
                    passwordVariable: 'TOKEN')])
                {
                    sh "docker login -u $USERNAME -p $TOKEN"
                    sh "docker buildx create --use --platform=linux/arm64,linux/amd64 --node multi-platform-builder-${env.SERVICE} --name multi-platform-builder-${env.SERVICE}"
                    sh "docker buildx build --platform=linux/arm64,linux/amd64 --push --tag ${env.NAME}:latest --tag ${env.NAME}:${env.BUILD_ID} -f DockerFile ."
                    sh "docker buildx rm --force multi-platform-builder-${env.SERVICE}"
                }
            }
        }
        stage('Deploy to EKS') {
                steps {
                    withCredentials([[$class: 'AmazonWebServicesCredentialsBinding',
                    credentialsId: 'aws-credential',
                    accessKeyVariable: 'AWS_ACCESS_KEY_ID',
                    secretKeyVariable: 'AWS_SECRET_ACCESS_KEY']]) 
                {
                    sh """
                        # garante diretório padrão do kubeconfig
                        mkdir -p ~/.kube

                        # configura contexto do cluster no caminho padrão (~/.kube/config)
                        aws eks update-kubeconfig --region ${AWS_REGION} --name ${EKS_CLUSTER}

                        kubectl config current-context

                        # aplica manifest inicial se ainda não existir
                        if ! kubectl get deploy ${SERVICE} >/dev/null 2>&1; then
                        kubectl apply -f ./k8s/k8s.yaml
                        fi

                        # atualiza a imagem do Deployment
                        kubectl set image deploy/${SERVICE} ${SERVICE}=${NAME}:${BUILD_ID} --record

                        # espera o rollout
                        kubectl rollout status deployment/${SERVICE} --timeout=180s
                    """
                }
            }
        }
    }
}
apiVersion: apps/v1
kind: Deployment
metadata:
  name: product
spec:
  replicas: 1
  selector:
    matchLabels:
      app: product
  template:
    metadata:
      labels:
        app: product
    spec:
      containers:
        - name: product
          image: jpqv/product:latest
          imagePullPolicy: Always
          ports:
            - containerPort: 8080
          env:
            - name: POSTGRES_DB
              valueFrom:
                configMapKeyRef:
                  name: postgres-configmap
                  key: POSTGRES_DB
            - name: DATABASE_USERNAME
              valueFrom:
                secretKeyRef:
                  name: postgres-secrets
                  key: POSTGRES_USER
            - name: DATABASE_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: postgres-secrets
                  key: POSTGRES_PASSWORD
            - name: DATABASE_URL
              value: "jdbc:postgresql://postgres:5432/$(POSTGRES_DB)"
            - name: SPRING_CACHE_TYPE
              value: redis
            - name: SPRING_DATA_REDIS_HOST
              value: redis
            - name: SPRING_DATA_REDIS_PORT
              value: "6379"
          resources:
            requests:
              memory: "200Mi"
              cpu: "50m"
            limits:
              memory: "300Mi"
              cpu: "200m"

---

apiVersion: v1
kind: Service
metadata:
  name: product
  labels:
    app: product
spec:
  type: ClusterIP
  ports:
    - port: 80
      protocol: TCP
      targetPort: 8080

  selector:
    app: product
server:
  port: 8080

spring:
  application:
    name: product

  mvc:
    problemdetails:
      enabled: true

  datasource:
    url: ${DATABASE_URL}
    username: ${DATABASE_USERNAME}
    password: ${DATABASE_PASSWORD}
    driver-class-name: org.postgresql.Driver

  flyway:
    baseline-on-migrate: true
    schemas: product
  jpa:
    properties:
      hibernate:
        default_schema: product

  cache:
    type: redis
  data:
    redis:
      host: redis
      port: 6379

logging:
  level:
    store: debug
package store.product;

import lombok.Builder;
import lombok.Data;
import lombok.experimental.Accessors;

@Builder @Data @Accessors(fluent = true, chain = true)
public class Product {
    String id;
    String name;
    Double price;
    String unit;
}
package store.product;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.cloud.openfeign.EnableFeignClients;

@EnableFeignClients(basePackages = {
})
@EnableCaching
@SpringBootApplication
public class ProductApplication {

    public static void main(String[] args) {
        SpringApplication.run(ProductApplication.class, args);
    }

}
package store.product;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.experimental.Accessors;

@Entity
@Table(name = "product")
@Setter @Accessors(chain = true, fluent = true)
@NoArgsConstructor @AllArgsConstructor
public class ProductModel {

    @Id
    @Column(name = "id")
    @GeneratedValue(strategy = GenerationType.UUID)
    private String id;

    @Column(name = "name")
    private String name;

    @Column(name = "price")
    private Double price;

    @Column(name = "unit")
    private String unit;

    public ProductModel(Product p) {
        this.id = p.id();
        this.name = p.name();
        this.price = p.price();
        this.unit = p.unit();
    }

    public Product to() {
        return Product.builder()
            .id(this.id)
            .name(this.name)
            .price(this.price)
            .unit(this.unit)
            .build();
    }   
}
package store.product;

import java.util.List;

public class ProductParser {

    public static Product to(ProductIn in) {
        return in == null ? null :
            Product.builder()
                .name(in.name())
                .price(in.price())
                .unit(in.unit())
                .build();
    }

    public static ProductOut to(Product a) {
        return a == null ? null :
            ProductOut.builder()
                .id(a.id())
                .name(a.name())
                .price(a.price())
                .unit(a.unit())
                .build();
    }

    public static List<ProductOut> to(List<Product> as) {
        return as == null ? null :
            as.stream().map(ProductParser::to).toList();
    }

}
package store.product;

import org.springframework.data.repository.CrudRepository;
import org.springframework.stereotype.Repository;

@Repository
public interface ProductRepository extends CrudRepository<ProductModel, String> {

    ProductModel findByName(String name);

}
package store.product;

import java.util.List;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;

@RestController
public class ProductResource implements ProductController {

    @Autowired
    private ProductService productService;

    @Override
    public ResponseEntity<ProductOut> create(ProductIn in) {
        Product product = ProductParser.to(in);

        Product saved = productService.create(product);

        return ResponseEntity.created(
            ServletUriComponentsBuilder.fromCurrentRequest()
                .path("/{id}")
                .buildAndExpand(saved.id())
                .toUri()
        ).body(ProductParser.to(saved));
    }

    @Override
    public ResponseEntity<ProductOut> findById(String id) {
        Product product = productService.findById(id);
        return ResponseEntity
        .ok()
        .body(ProductParser.to(product));
    }

    @Override
    public ResponseEntity<List<ProductOut>> findAll() {
        return ResponseEntity
            .ok()
            .body(ProductParser.to(productService.findAll()));
    }

    @Override
    public ResponseEntity<Void> delete(String id) {
        productService.delete(id);
        return ResponseEntity.noContent().build(); // 204
    }

}
package store.product;

import java.util.List;
import java.util.stream.StreamSupport;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.CachePut;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.cache.annotation.Caching;

@Service
public class ProductService {

    @Autowired
    private ProductRepository productRepository;

    @Caching(
        evict = {
            @CacheEvict(cacheNames = "products-list", allEntries = true)
        },
        put = {
            @CachePut(cacheNames = "product-by-id", key = "#result.id", unless = "#result == null")
        }
    )
    public Product create(Product product) {
        if (null == product.name()) {
            throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
                "Name is mandatory!"
            );
        }
        if (null == product.price()) {
            throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
                "Price is mandatory!"
            );
        }

        if (productRepository.findByName(product.name()) != null)
            throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
                "Name already have been registered!"
            );
        return productRepository.save(
            new ProductModel(product)
        ).to();
    }

    @Cacheable(cacheNames = "products-list", unless = "#result == null || #result.isEmpty()")
    public List<Product> findAll() {
        return StreamSupport.stream(
            productRepository.findAll().spliterator(), false)
            .map(ProductModel::to)
            .toList();
    }    

    @Cacheable(cacheNames = "product-by-id", key = "#id", unless = "#result == null")
    public Product findById(String id) {
        return productRepository.findById(id)
            .map(ProductModel::to)
            .orElseThrow(() -> new ResponseStatusException(
                HttpStatus.NOT_FOUND, "Product not found"
            ));
    }

    @Caching(
        evict = {
            @CacheEvict(cacheNames = "product-by-id", key = "#id"),
            @CacheEvict(cacheNames = "products-list", allEntries = true)
        }
    )
    public void delete(String id) {
        if (!productRepository.existsById(id)) {
            throw new ResponseStatusException(
                HttpStatus.NOT_FOUND, "Product not found"
            );
        }
        productRepository.deleteById(id);
    }
}
package store.product;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.*;
import org.springframework.data.redis.connection.RedisConnectionFactory;

import java.time.Duration;
import java.util.Map;

@Configuration
public class RedisCacheConfig {

  @Bean
  public RedisCacheManager cacheManager(RedisConnectionFactory cf) {
    var defaults = RedisCacheConfiguration.defaultCacheConfig()
        .entryTtl(Duration.ofMinutes(5));

    var perCache = Map.<String, RedisCacheConfiguration>of(
        "product-by-id", defaults.entryTtl(Duration.ofMinutes(10)),
        "products-list", defaults.entryTtl(Duration.ofMinutes(2))
    );

    return RedisCacheManager.builder(cf)
        .cacheDefaults(defaults)
        .withInitialCacheConfigurations(perCache)
        .build();
  }
}
CREATE SCHEMA IF NOT EXISTS product;
1
2
3
4
5
6
7
8
CREATE TABLE product
(
    id varchar(36) NOT NULL,
    name varchar(255) NOT NULL,
    price decimal(10,2) NOT NULL,
    unit varchar(50) NOT NULL,
    CONSTRAINT product_pkey PRIMARY KEY (id)
)
mvn clean package spring-boot:run