├── LICENSE ├── README.md ├── account-service ├── pom.xml └── src │ └── main │ ├── java │ └── io │ │ └── learning │ │ └── account │ │ ├── StartAccountApplication.java │ │ ├── config │ │ ├── OpenApiConfig.java │ │ └── RestConfig.java │ │ ├── controller │ │ └── AccountController.java │ │ ├── devil │ │ ├── AccountNotFoundException.java │ │ └── AccountProcessingException.java │ │ ├── domain │ │ └── Account.java │ │ ├── event │ │ └── AccountTransactionEvent.java │ │ ├── listener │ │ ├── AccountTransactionListener.java │ │ └── DistributedTransactionEventListener.java │ │ ├── repository │ │ └── AccountRepository.java │ │ └── service │ │ ├── AccountService.java │ │ └── EventBus.java │ └── resources │ ├── application.yml │ └── schema.sql ├── discovery-server ├── pom.xml ├── src │ └── main │ │ └── java │ │ └── io │ │ └── learning │ │ └── discovery │ │ └── StartDiscoveryServer.java └── srs │ └── main │ └── resources │ └── application.yml ├── order-service ├── pom.xml └── src │ └── main │ ├── java │ └── io │ │ └── learning │ │ └── order │ │ ├── StartOrderService.java │ │ ├── config │ │ ├── OpenApiConfig.java │ │ └── RestConfig.java │ │ ├── controller │ │ └── OrderController.java │ │ ├── devil │ │ ├── InSufficientFundException.java │ │ └── OrderProcessingException.java │ │ ├── domain │ │ └── Order.java │ │ ├── event │ │ ├── OrderTransactionEvent.java │ │ └── listener │ │ │ ├── DistributedTransactionEventListener.java │ │ │ └── OrderTransactionListener.java │ │ ├── repository │ │ └── OrderRepository.java │ │ └── service │ │ ├── EventBus.java │ │ └── OrderService.java │ └── resources │ ├── application.yml │ └── schema.sql ├── pom.xml ├── product-service ├── pom.xml └── src │ └── main │ ├── java │ └── io │ │ └── learning │ │ └── product │ │ ├── StartProductApplication.java │ │ ├── config │ │ ├── OpenApiConfig.java │ │ └── RestConfig.java │ │ ├── controller │ │ └── ProductController.java │ │ ├── devil │ │ ├── ProductNotFoundException.java │ │ └── ProductProcessingException.java │ │ ├── domain │ │ └── ProductEntity.java │ │ ├── event │ │ ├── ProductTransactionEvent.java │ │ └── listener │ │ │ ├── DistributedTransactionEventListener.java │ │ │ └── ProductTransactionListener.java │ │ ├── mapper │ │ └── ProductMapper.java │ │ ├── repository │ │ └── ProductRepository.java │ │ └── service │ │ ├── EventBus.java │ │ └── ProductService.java │ └── resources │ ├── application.yml │ └── schema.sql ├── resources ├── distributed-txn-architecture.drawio ├── distributed-txn-architecture.png └── distributed-txn-flow.png ├── transaction-server ├── pom.xml └── src │ └── main │ ├── java │ └── io │ │ └── learning │ │ └── transaction │ │ ├── StartTransactionServerApplication.java │ │ ├── config │ │ └── OpenApiConfig.java │ │ ├── controller │ │ └── TransactionServerController.java │ │ └── repo │ │ └── DistributedTransactionRepo.java │ └── resources │ └── application.yml └── transactional-core ├── pom.xml └── src └── main └── java └── io └── learning └── core ├── domain ├── Account.java ├── DistributedTransaction.java ├── DistributedTransactionParticipant.java ├── DistributedTransactionStatus.java └── Product.java └── eventlistener └── TransactionListener.java /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Anil Jaglan 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Distributed Transactions in Microservices Using 2-Phase Commit (2PC) 2 | 3 | The services in this project are designed with microservice architecture for performing distributed transaction using 2-Phase Commit (2PC). 4 | 5 | Each microservice exposes REST API interfaces that can be accessed through OpenAPI endpoint (/swagger-ui.html) 6 | 7 | ## Tech Stack 8 | 1. SpringBoot 9 | 2. Spring Data JPA 10 | 3. MySQL Database 11 | 4. RabbitMQ 12 | 13 | 14 | ## Pre-Requisites 15 | 1. RabbitMQ 16 | - Start RabbitMQ in Docker with command `docker run -d --hostname my-rabbit --name some-rabbit -p 8080:15672 rabbitmq:3-management` [Learn more](https://hub.docker.com/_/rabbitmq) 17 | 2. MySQL Database 18 | - Start MySQL in Docker with command `docker run --name some-mysql -e MYSQL_ROOT_PASSWORD=my-secret-pw -d mysql:tag` [Learn more](https://hub.docker.com/_/mysql) 19 | 20 | ## Usage 21 | 1. Start `discovery-server`. Default port is 8761. 22 | 2. Start all microservices: `transaction-server`, `account-service`, `order-service`, `product-service` 23 | 3. Add some test data to `account-service` and `product-service` 24 | 4. Send order creation request to `order-service` for testing the flow. 25 | 26 | ## Architecture 27 | 28 | In this microservice-based architecture design, `discovery-server` plays an important role for registering and retrieving the service instances from a centralize location. 29 | 30 | The `transaction-server` is responsible for maintaining transaction status for multiple services for a given transactionId. 31 | 32 | There are three applications: `order-service`, `account-service` and `product-service`. 33 | 34 | The application `order-service` is communicating with `account-service` and `product-service`. All these applications are using MySQL database as a backend store. 35 | 36 | ![Architecutre](./resources/distributed-txn-architecture.png) 37 | 38 | 39 | ## Distributed Transaction Flow 40 | 41 | 42 | 43 | ![Application Flow](./resources/distributed-txn-flow.png) -------------------------------------------------------------------------------- /account-service/pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 4.0.0 7 | 8 | io.learning 9 | spring-boot-distributed-transaction 10 | 0.0.1-SNAPSHOT 11 | 12 | 13 | account-service 14 | account-service 15 | 16 | 17 | 18 | 19 | io.learning 20 | transactional-core 21 | 22 | 23 | 24 | 25 | org.springframework.boot 26 | spring-boot-starter-web 27 | 28 | 29 | org.springframework.boot 30 | spring-boot-starter-actuator 31 | 32 | 33 | org.springframework.boot 34 | spring-boot-starter-data-jpa 35 | 36 | 37 | org.springframework.boot 38 | spring-boot-starter-amqp 39 | 40 | 41 | 42 | 43 | org.springframework.cloud 44 | spring-cloud-starter-netflix-eureka-client 45 | 46 | 47 | org.springframework.cloud 48 | spring-cloud-starter-sleuth 49 | 50 | 51 | 52 | 53 | org.springdoc 54 | springdoc-openapi-ui 55 | 56 | 57 | 58 | 59 | mysql 60 | mysql-connector-java 61 | 62 | 63 | 64 | 65 | org.projectlombok 66 | lombok 67 | 68 | 69 | 70 | -------------------------------------------------------------------------------- /account-service/src/main/java/io/learning/account/StartAccountApplication.java: -------------------------------------------------------------------------------- 1 | package io.learning.account; 2 | 3 | import org.springframework.amqp.rabbit.annotation.EnableRabbit; 4 | import org.springframework.boot.SpringApplication; 5 | import org.springframework.boot.autoconfigure.SpringBootApplication; 6 | import org.springframework.cloud.client.discovery.EnableDiscoveryClient; 7 | import org.springframework.scheduling.annotation.EnableAsync; 8 | 9 | @EnableDiscoveryClient 10 | @SpringBootApplication 11 | @EnableAsync 12 | @EnableRabbit 13 | public class StartAccountApplication { 14 | 15 | public static void main(String[] args) { 16 | SpringApplication.run(StartAccountApplication.class, args); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /account-service/src/main/java/io/learning/account/config/OpenApiConfig.java: -------------------------------------------------------------------------------- 1 | package io.learning.account.config; 2 | 3 | import org.springframework.context.annotation.Configuration; 4 | 5 | import io.swagger.v3.oas.annotations.OpenAPIDefinition; 6 | import io.swagger.v3.oas.annotations.info.Info; 7 | 8 | @Configuration 9 | @OpenAPIDefinition(info = @Info(title = "Account Service", description = "REST API for CRUD operation", version = "1.0")) 10 | public class OpenApiConfig { 11 | 12 | } 13 | -------------------------------------------------------------------------------- /account-service/src/main/java/io/learning/account/config/RestConfig.java: -------------------------------------------------------------------------------- 1 | package io.learning.account.config; 2 | 3 | import org.springframework.cloud.client.loadbalancer.LoadBalanced; 4 | import org.springframework.context.annotation.Bean; 5 | import org.springframework.context.annotation.Configuration; 6 | import org.springframework.web.client.RestTemplate; 7 | 8 | @Configuration 9 | public class RestConfig { 10 | 11 | @Bean 12 | @LoadBalanced 13 | public RestTemplate restTemplate() { 14 | return new RestTemplate(); 15 | } 16 | 17 | } 18 | -------------------------------------------------------------------------------- /account-service/src/main/java/io/learning/account/controller/AccountController.java: -------------------------------------------------------------------------------- 1 | package io.learning.account.controller; 2 | 3 | import org.springframework.beans.factory.annotation.Autowired; 4 | import org.springframework.web.bind.annotation.GetMapping; 5 | import org.springframework.web.bind.annotation.PathVariable; 6 | import org.springframework.web.bind.annotation.PostMapping; 7 | import org.springframework.web.bind.annotation.PutMapping; 8 | import org.springframework.web.bind.annotation.RequestBody; 9 | import org.springframework.web.bind.annotation.RequestHeader; 10 | import org.springframework.web.bind.annotation.RequestMapping; 11 | import org.springframework.web.bind.annotation.RestController; 12 | 13 | import io.learning.account.domain.Account; 14 | import io.learning.account.service.AccountService; 15 | import io.learning.account.service.EventBus; 16 | import io.swagger.v3.oas.annotations.Operation; 17 | import io.swagger.v3.oas.annotations.tags.Tag; 18 | 19 | /** 20 | * 21 | * Exposes REST API Interface for interacting with AccountService. 22 | * 23 | * @author Anil Jaglan 24 | * @version 1.0 25 | */ 26 | @RestController 27 | @RequestMapping("/accounts") 28 | @Tag(name = "Accounts") 29 | public class AccountController { 30 | 31 | @Autowired 32 | private AccountService accountService; 33 | 34 | @Autowired 35 | private EventBus eventBus; 36 | 37 | @PostMapping 38 | @Operation(summary = "Create a new account") 39 | public Account createAccount(@RequestBody Account account) { 40 | return accountService.createAccount(account); 41 | } 42 | 43 | @GetMapping("/customer/{customerId}") 44 | @Operation(summary = "Get account by customer ID") 45 | public Account findByCustomerId(@PathVariable("customerId") Long customerId) { 46 | return accountService.findByCustomerId(customerId); 47 | } 48 | 49 | @PutMapping("/{id}/deposit/{amount}") 50 | @Operation(summary = "Deposit money in customer account") 51 | public Account deposit(@PathVariable("id") Long accountId, @PathVariable("amount") int amount, @RequestHeader("X-Txn-ID") String transactionId) { 52 | accountService.deposit(accountId, amount, transactionId); 53 | return eventBus.receiveEvent(transactionId).getAccount(); 54 | } 55 | 56 | @PutMapping("/{id}/withdrawl/{amount}") 57 | @Operation(summary = "Withdraw money from customer account") 58 | public Account withdrawl(@PathVariable("id") Long accountId, @PathVariable("amount") int amount, @RequestHeader("X-Txn-ID") String transactionId) { 59 | accountService.withdrawl(accountId, amount, transactionId); 60 | return eventBus.receiveEvent(transactionId).getAccount(); 61 | } 62 | 63 | } 64 | -------------------------------------------------------------------------------- /account-service/src/main/java/io/learning/account/devil/AccountNotFoundException.java: -------------------------------------------------------------------------------- 1 | package io.learning.account.devil; 2 | 3 | public class AccountNotFoundException extends RuntimeException { 4 | 5 | private static final long serialVersionUID = -1219518716563440469L; 6 | 7 | public AccountNotFoundException(String message) { 8 | super(message); 9 | } 10 | 11 | public AccountNotFoundException(String message, Throwable th) { 12 | super(message, th); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /account-service/src/main/java/io/learning/account/devil/AccountProcessingException.java: -------------------------------------------------------------------------------- 1 | package io.learning.account.devil; 2 | 3 | public class AccountProcessingException extends RuntimeException { 4 | 5 | private static final long serialVersionUID = 8337796345425999746L; 6 | 7 | public AccountProcessingException(String message) { 8 | super(message); 9 | } 10 | 11 | public AccountProcessingException(String message, Throwable th) { 12 | super(message, th); 13 | } 14 | 15 | } 16 | -------------------------------------------------------------------------------- /account-service/src/main/java/io/learning/account/domain/Account.java: -------------------------------------------------------------------------------- 1 | package io.learning.account.domain; 2 | 3 | import javax.persistence.Column; 4 | import javax.persistence.Entity; 5 | import javax.persistence.GeneratedValue; 6 | import javax.persistence.GenerationType; 7 | import javax.persistence.Id; 8 | import javax.persistence.Table; 9 | 10 | import lombok.Data; 11 | 12 | @Data 13 | @Entity 14 | @Table(name = "ACCOUNTS") 15 | public class Account { 16 | 17 | @Id 18 | @GeneratedValue(strategy = GenerationType.AUTO) 19 | @Column(name = "ID") 20 | private Long id; 21 | 22 | @Column(name = "CUSTOMER_ID") 23 | private Long customerId; 24 | 25 | @Column(name = "BALANCE") 26 | private Integer balance; 27 | 28 | } 29 | -------------------------------------------------------------------------------- /account-service/src/main/java/io/learning/account/event/AccountTransactionEvent.java: -------------------------------------------------------------------------------- 1 | package io.learning.account.event; 2 | 3 | import io.learning.account.domain.Account; 4 | import lombok.AllArgsConstructor; 5 | import lombok.Data; 6 | 7 | @Data 8 | @AllArgsConstructor 9 | public class AccountTransactionEvent { 10 | 11 | private String transactionId; 12 | 13 | private Account account; 14 | 15 | } 16 | -------------------------------------------------------------------------------- /account-service/src/main/java/io/learning/account/listener/AccountTransactionListener.java: -------------------------------------------------------------------------------- 1 | package io.learning.account.listener; 2 | 3 | import static io.learning.core.domain.DistributedTransactionStatus.CONFIRMED; 4 | import static io.learning.core.domain.DistributedTransactionStatus.TO_ROLLBACK; 5 | 6 | import java.util.concurrent.TimeUnit; 7 | 8 | import org.springframework.beans.factory.annotation.Autowired; 9 | import org.springframework.stereotype.Component; 10 | import org.springframework.transaction.event.TransactionPhase; 11 | import org.springframework.transaction.event.TransactionalEventListener; 12 | import org.springframework.web.client.RestTemplate; 13 | 14 | import io.learning.account.devil.AccountProcessingException; 15 | import io.learning.account.event.AccountTransactionEvent; 16 | import io.learning.account.service.EventBus; 17 | import io.learning.core.domain.DistributedTransaction; 18 | import io.learning.core.eventlistener.TransactionListener; 19 | import lombok.extern.slf4j.Slf4j; 20 | 21 | /** 22 | * Class to handle transactional events for {@link AccountTransactionEvent} 23 | * type. 24 | * 25 | * @author Anil Jaglan 26 | * @version 1.0 27 | */ 28 | @Component 29 | @Slf4j 30 | public class AccountTransactionListener implements TransactionListener { 31 | 32 | @Autowired 33 | private RestTemplate restTemplate; 34 | 35 | @Autowired 36 | private EventBus eventBus; 37 | 38 | @Override 39 | @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT) 40 | public void handleEvent(AccountTransactionEvent event) throws AccountProcessingException { 41 | log.debug("Handling event before commit: {}", event); 42 | eventBus.sendEvent(event); 43 | 44 | DistributedTransaction transaction = null; 45 | int count = 3000; 46 | log.info("Waiting for receiving transaction."); 47 | while (count > 0) { 48 | transaction = eventBus.receiveTransaction(event.getTransactionId()); 49 | if (transaction == null) { 50 | try { 51 | TimeUnit.MILLISECONDS.sleep(10); 52 | } catch (InterruptedException ex) { 53 | log.error("Error while receiving transaction for: {}. Cause: {}", event.getTransactionId(), ex); 54 | } 55 | --count; 56 | } else { 57 | break; 58 | } 59 | } 60 | if (transaction == null || transaction.getStatus() != CONFIRMED) { 61 | log.info("Transaction received after waiting: {}", transaction); 62 | throw new AccountProcessingException("Distributed transaction wasn't confirmed for txnId: " + event.getTransactionId()); 63 | } 64 | } 65 | 66 | @Override 67 | @TransactionalEventListener(phase = TransactionPhase.AFTER_ROLLBACK) 68 | public void handleAfterRollback(AccountTransactionEvent event) { 69 | log.debug("Updating transaction[{}] status to : {} for account-service", event.getTransactionId(), TO_ROLLBACK); 70 | restTemplate.put( 71 | "http://transaction-server/transactions/{transactionId}/participants/{serviceId}/status/{status}", 72 | null, 73 | event.getTransactionId(), 74 | "account-service", 75 | TO_ROLLBACK); 76 | } 77 | 78 | @Override 79 | @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) 80 | public void handleAfterCompletion(AccountTransactionEvent event) { 81 | log.debug("Updating transaction[{}] status to: {} for account-service.", event.getTransactionId(), CONFIRMED); 82 | restTemplate.put( 83 | "http://transaction-server/transactions/{transactionId}/participants/{serviceId}/status/{status}", 84 | null, 85 | event.getTransactionId(), 86 | "account-service", 87 | CONFIRMED); 88 | } 89 | 90 | } 91 | -------------------------------------------------------------------------------- /account-service/src/main/java/io/learning/account/listener/DistributedTransactionEventListener.java: -------------------------------------------------------------------------------- 1 | package io.learning.account.listener; 2 | 3 | import org.springframework.amqp.core.ExchangeTypes; 4 | import org.springframework.amqp.rabbit.annotation.Exchange; 5 | import org.springframework.amqp.rabbit.annotation.Queue; 6 | import org.springframework.amqp.rabbit.annotation.QueueBinding; 7 | import org.springframework.amqp.rabbit.annotation.RabbitListener; 8 | import org.springframework.beans.factory.annotation.Autowired; 9 | import org.springframework.stereotype.Component; 10 | 11 | import io.learning.account.service.EventBus; 12 | import io.learning.core.domain.DistributedTransaction; 13 | import lombok.extern.slf4j.Slf4j; 14 | 15 | @Component 16 | @Slf4j 17 | public class DistributedTransactionEventListener { 18 | 19 | @Autowired 20 | private EventBus eventBus; 21 | 22 | @RabbitListener(bindings = { 23 | @QueueBinding(value = @Queue("txn-events-account"), exchange = @Exchange(type = ExchangeTypes.TOPIC, name = "txn-events")) 24 | }) 25 | public void onMessage(DistributedTransaction transaction) { 26 | debug.info("Transaction message received: {}", transaction); 27 | eventBus.sendTransaction(transaction); 28 | } 29 | 30 | } 31 | -------------------------------------------------------------------------------- /account-service/src/main/java/io/learning/account/repository/AccountRepository.java: -------------------------------------------------------------------------------- 1 | package io.learning.account.repository; 2 | 3 | import java.util.Optional; 4 | 5 | import org.springframework.data.jpa.repository.JpaRepository; 6 | import org.springframework.stereotype.Repository; 7 | 8 | import io.learning.account.domain.Account; 9 | 10 | @Repository 11 | public interface AccountRepository extends JpaRepository { 12 | 13 | Optional findByCustomerId(Long customerId); 14 | 15 | } 16 | -------------------------------------------------------------------------------- /account-service/src/main/java/io/learning/account/service/AccountService.java: -------------------------------------------------------------------------------- 1 | package io.learning.account.service; 2 | 3 | import java.util.Optional; 4 | 5 | import org.springframework.beans.factory.annotation.Autowired; 6 | import org.springframework.context.ApplicationEventPublisher; 7 | import org.springframework.scheduling.annotation.Async; 8 | import org.springframework.stereotype.Service; 9 | import org.springframework.transaction.annotation.Transactional; 10 | 11 | import io.learning.account.devil.AccountNotFoundException; 12 | import io.learning.account.domain.Account; 13 | import io.learning.account.event.AccountTransactionEvent; 14 | import io.learning.account.repository.AccountRepository; 15 | import lombok.extern.slf4j.Slf4j; 16 | 17 | @Service 18 | @Slf4j 19 | public class AccountService { 20 | 21 | @Autowired 22 | private AccountRepository accountRepository; 23 | 24 | @Autowired 25 | private ApplicationEventPublisher eventPublisher; 26 | 27 | @Transactional 28 | public Account createAccount(Account account) { 29 | return accountRepository.save(account); 30 | } 31 | 32 | public Account findByCustomerId(Long customerId) { 33 | return accountRepository.findByCustomerId(customerId).orElseThrow(() -> new AccountNotFoundException("Account not exists for customer ID: " + customerId)); 34 | } 35 | 36 | @Async 37 | @Transactional 38 | public void deposit(Long accountId, int amount, String transactionId) { 39 | transfer(accountId, amount, transactionId); 40 | } 41 | 42 | @Async 43 | @Transactional 44 | public void withdrawl(Long accountId, int amount, String transactionId) { 45 | transfer(accountId, amount * (-1), transactionId); 46 | } 47 | 48 | protected void transfer(Long accountId, int amount, String transactionId) { 49 | log.info("Transferring money to account={}, amount={}, txnId: {}", accountId, amount, transactionId); 50 | Optional accountOpt = accountRepository.findById(accountId); 51 | accountOpt.ifPresent(account -> { 52 | account.setBalance(account.getBalance() + amount); 53 | eventPublisher.publishEvent(new AccountTransactionEvent(transactionId, account)); 54 | accountRepository.save(account); 55 | }); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /account-service/src/main/java/io/learning/account/service/EventBus.java: -------------------------------------------------------------------------------- 1 | package io.learning.account.service; 2 | 3 | import java.util.ArrayList; 4 | import java.util.List; 5 | import java.util.concurrent.TimeUnit; 6 | 7 | import org.springframework.stereotype.Component; 8 | 9 | import io.learning.account.event.AccountTransactionEvent; 10 | import io.learning.core.domain.DistributedTransaction; 11 | import lombok.extern.slf4j.Slf4j; 12 | 13 | @Component 14 | @Slf4j 15 | public class EventBus { 16 | 17 | private List transactions; 18 | 19 | private List events; 20 | 21 | public EventBus() { 22 | this.transactions = new ArrayList<>(); 23 | this.events = new ArrayList<>(); 24 | } 25 | 26 | public void sendTransaction(DistributedTransaction transaction) { 27 | transactions.add(transaction); 28 | } 29 | 30 | public DistributedTransaction receiveTransaction(String eventId) { 31 | DistributedTransaction transaction = transactions.stream().filter(tx -> tx.getId().equals(eventId)).findAny().orElse(null); 32 | if (transaction != null) { 33 | transactions.remove(transaction); 34 | } 35 | return transaction; 36 | } 37 | 38 | public void sendEvent(AccountTransactionEvent event) { 39 | events.add(event); 40 | } 41 | 42 | public AccountTransactionEvent receiveEvent(String eventId) { 43 | AccountTransactionEvent event = null; 44 | while (event == null) { 45 | event = events.stream().filter(evnt -> evnt.getTransactionId().equals(eventId)).findAny().orElse(null); 46 | events.remove(event); 47 | if (event != null) { 48 | return event; 49 | } 50 | try { 51 | TimeUnit.MILLISECONDS.sleep(10); 52 | } catch (InterruptedException ex) { 53 | log.error("Error while received event for: {}, Cause:{}", eventId, ex); 54 | } 55 | } 56 | return event; 57 | } 58 | 59 | } 60 | -------------------------------------------------------------------------------- /account-service/src/main/resources/application.yml: -------------------------------------------------------------------------------- 1 | spring: 2 | application: 3 | name: account-service 4 | datasource: 5 | url: jdbc:mysql://localhost:3306/distibuted_txn?useSSL=false 6 | username: root 7 | password: root 8 | jpa: 9 | database-platform: org.hibernate.dialect.MySQLDialect 10 | hibernate.ddl-auto: update 11 | rabbitmq: 12 | host: localhost 13 | port: 5672 14 | username: guest 15 | password: guest 16 | server: 17 | port: 8080 18 | 19 | eureka: 20 | client: 21 | service-url: 22 | defaultZone: http://localhost:8761/eureka/ 23 | instance: 24 | appname: ${spring.application.name} 25 | prefer-ip-address: true 26 | 27 | springdoc: 28 | swagger-ui.path: /swagger-ui.html 29 | 30 | logging: 31 | level: 32 | org.springframework.data: ERROR 33 | com.netflix: ERROR 34 | pattern: 35 | console: '%d{yyyy-MM-dd HH:mm:ss:SSS} [S=${APP_NAME:-}] [T=%X{traceId}] %-5level %logger{36}.%L %msg%n' -------------------------------------------------------------------------------- /account-service/src/main/resources/schema.sql: -------------------------------------------------------------------------------- 1 | create table accounts (id bigint not null, balance integer, customer_id bigint, primary key (id)); -------------------------------------------------------------------------------- /discovery-server/pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 4.0.0 7 | 8 | io.learning 9 | spring-boot-distributed-transaction 10 | 0.0.1-SNAPSHOT 11 | 12 | 13 | discovery-server 14 | discovery-server 15 | 16 | 17 | 18 | org.springframework.cloud 19 | spring-cloud-starter-netflix-eureka-server 20 | 21 | 22 | 23 | 24 | 25 | 26 | org.springframework.boot 27 | spring-boot-maven-plugin 28 | ${springboot.version} 29 | 30 | 31 | 32 | repackage 33 | 34 | 35 | 36 | 37 | 38 | exec 39 | 40 | 41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /discovery-server/src/main/java/io/learning/discovery/StartDiscoveryServer.java: -------------------------------------------------------------------------------- 1 | package io.learning.discovery; 2 | 3 | import org.springframework.boot.SpringApplication; 4 | import org.springframework.boot.autoconfigure.SpringBootApplication; 5 | import org.springframework.cloud.netflix.eureka.server.EnableEurekaServer; 6 | 7 | @SpringBootApplication 8 | @EnableEurekaServer 9 | public class StartDiscoveryServer { 10 | 11 | public static void main(String[] args) { 12 | SpringApplication.run(StartDiscoveryServer.class, args); 13 | } 14 | 15 | } 16 | -------------------------------------------------------------------------------- /discovery-server/srs/main/resources/application.yml: -------------------------------------------------------------------------------- 1 | spring: 2 | application: 3 | name: discovery-server 4 | server: 5 | port: 8761 6 | eureka: 7 | dashboard: 8 | enabled: true 9 | instance: 10 | hostname: localhost 11 | appname: ${spring.application.name} 12 | instanceId: ${spring.application.name}:${vcap.application.instance_id:${spring.application.instance_id:${random.value}}} 13 | client: 14 | registerWithEureka: false 15 | fetchRegistry: false 16 | server: 17 | eviction-interval-timer-in-ms: 30000 -------------------------------------------------------------------------------- /order-service/pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 4.0.0 7 | 8 | io.learning 9 | spring-boot-distributed-transaction 10 | 0.0.1-SNAPSHOT 11 | 12 | 13 | order-service 14 | order-service 15 | 16 | 17 | io.learning.order.StartOrderService 18 | 19 | 20 | 21 | 22 | 23 | io.learning 24 | transactional-core 25 | 26 | 27 | 28 | 29 | org.springframework.boot 30 | spring-boot-starter-web 31 | 32 | 33 | org.springframework.boot 34 | spring-boot-starter-actuator 35 | 36 | 37 | org.springframework.boot 38 | spring-boot-starter-data-jpa 39 | 40 | 41 | org.springframework.boot 42 | spring-boot-starter-amqp 43 | 44 | 45 | 46 | 47 | org.springframework.cloud 48 | spring-cloud-starter-netflix-eureka-client 49 | 50 | 51 | org.springframework.cloud 52 | spring-cloud-starter-sleuth 53 | 54 | 55 | 56 | 57 | org.springdoc 58 | springdoc-openapi-ui 59 | 60 | 61 | 62 | 63 | mysql 64 | mysql-connector-java 65 | 66 | 67 | 68 | 69 | org.projectlombok 70 | lombok 71 | 72 | 73 | 74 | 75 | ${project.artifactId} 76 | 77 | 78 | org.springframework.boot 79 | spring-boot-maven-plugin 80 | 81 | ${start-class} 82 | 83 | true 84 | 85 | 86 | 87 | 88 | 89 | repackage 90 | build-info 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | -------------------------------------------------------------------------------- /order-service/src/main/java/io/learning/order/StartOrderService.java: -------------------------------------------------------------------------------- 1 | package io.learning.order; 2 | 3 | import org.springframework.amqp.rabbit.annotation.EnableRabbit; 4 | import org.springframework.boot.SpringApplication; 5 | import org.springframework.boot.autoconfigure.SpringBootApplication; 6 | import org.springframework.cloud.client.discovery.EnableDiscoveryClient; 7 | 8 | @SpringBootApplication 9 | @EnableDiscoveryClient 10 | @EnableRabbit 11 | public class StartOrderService { 12 | 13 | public static void main(String[] args) { 14 | SpringApplication.run(StartOrderService.class, args); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /order-service/src/main/java/io/learning/order/config/OpenApiConfig.java: -------------------------------------------------------------------------------- 1 | package io.learning.order.config; 2 | 3 | import org.springframework.context.annotation.Configuration; 4 | 5 | import io.swagger.v3.oas.annotations.OpenAPIDefinition; 6 | import io.swagger.v3.oas.annotations.info.Info; 7 | 8 | @Configuration 9 | @OpenAPIDefinition(info = @Info(title = "Order Service", description = "REST API for CRUD operation", version = "1.0")) 10 | public class OpenApiConfig { 11 | 12 | } 13 | -------------------------------------------------------------------------------- /order-service/src/main/java/io/learning/order/config/RestConfig.java: -------------------------------------------------------------------------------- 1 | package io.learning.order.config; 2 | 3 | import org.springframework.boot.web.client.RestTemplateBuilder; 4 | import org.springframework.cloud.client.loadbalancer.LoadBalanced; 5 | import org.springframework.context.annotation.Bean; 6 | import org.springframework.context.annotation.Configuration; 7 | import org.springframework.web.client.RestTemplate; 8 | 9 | @Configuration 10 | public class RestConfig { 11 | 12 | @LoadBalanced 13 | @Bean 14 | public RestTemplate restTemplate(RestTemplateBuilder builder) { 15 | return builder.build(); 16 | } 17 | 18 | } 19 | -------------------------------------------------------------------------------- /order-service/src/main/java/io/learning/order/controller/OrderController.java: -------------------------------------------------------------------------------- 1 | package io.learning.order.controller; 2 | 3 | import org.springframework.beans.factory.annotation.Autowired; 4 | import org.springframework.http.ResponseEntity; 5 | import org.springframework.web.bind.annotation.GetMapping; 6 | import org.springframework.web.bind.annotation.PathVariable; 7 | import org.springframework.web.bind.annotation.PostMapping; 8 | import org.springframework.web.bind.annotation.RequestBody; 9 | import org.springframework.web.bind.annotation.RestController; 10 | 11 | import io.learning.order.domain.Order; 12 | import io.learning.order.service.OrderService; 13 | import io.swagger.v3.oas.annotations.Operation; 14 | import io.swagger.v3.oas.annotations.tags.Tag; 15 | 16 | /** 17 | * 18 | * Exposes REST API Interface for interacting with OrderService. 19 | * 20 | * @author Anil Jaglan 21 | * @version 1.0 22 | */ 23 | @RestController 24 | @Tag(name = "Orders") 25 | public class OrderController { 26 | 27 | @Autowired 28 | private OrderService orderService; 29 | 30 | @PostMapping("/orders") 31 | @Operation(summary = "Create a new order") 32 | public Order createOrder(@RequestBody Order order) { 33 | return orderService.createOrder(order); 34 | } 35 | 36 | @GetMapping("/orders/{id}") 37 | @Operation(summary = "Get an order") 38 | public ResponseEntity getOrder(@PathVariable("id") Long orderId) { 39 | return orderService.getOrderById(orderId).map(ResponseEntity::ok).orElseGet(() -> ResponseEntity.badRequest().build()); 40 | } 41 | 42 | } 43 | -------------------------------------------------------------------------------- /order-service/src/main/java/io/learning/order/devil/InSufficientFundException.java: -------------------------------------------------------------------------------- 1 | package io.learning.order.devil; 2 | 3 | public class InSufficientFundException extends RuntimeException { 4 | 5 | private static final long serialVersionUID = 5029135185567404773L; 6 | 7 | public InSufficientFundException(String message) { 8 | super(message); 9 | } 10 | 11 | public InSufficientFundException(String message, Throwable th) { 12 | super(message, th); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /order-service/src/main/java/io/learning/order/devil/OrderProcessingException.java: -------------------------------------------------------------------------------- 1 | package io.learning.order.devil; 2 | 3 | public class OrderProcessingException extends RuntimeException { 4 | 5 | private static final long serialVersionUID = -4310868473761742309L; 6 | 7 | public OrderProcessingException(String message) { 8 | super(message); 9 | } 10 | 11 | public OrderProcessingException(String message, Throwable th) { 12 | super(message, th); 13 | } 14 | 15 | } 16 | -------------------------------------------------------------------------------- /order-service/src/main/java/io/learning/order/domain/Order.java: -------------------------------------------------------------------------------- 1 | package io.learning.order.domain; 2 | 3 | import javax.persistence.Column; 4 | import javax.persistence.Entity; 5 | import javax.persistence.GeneratedValue; 6 | import javax.persistence.GenerationType; 7 | import javax.persistence.Id; 8 | import javax.persistence.Table; 9 | 10 | import lombok.Data; 11 | 12 | @Entity 13 | @Table(name = "ORDERS") 14 | @Data 15 | public class Order { 16 | 17 | @Id 18 | @GeneratedValue(strategy = GenerationType.AUTO) 19 | @Column(name = "ID") 20 | private Long id; 21 | 22 | @Column(name = "PRODUCT_ID") 23 | private Long productId; 24 | 25 | @Column(name = "QUANTITY") 26 | private Integer quantity; 27 | 28 | @Column(name = "CUSTOMER_ID") 29 | private Long customerId; 30 | 31 | } 32 | -------------------------------------------------------------------------------- /order-service/src/main/java/io/learning/order/event/OrderTransactionEvent.java: -------------------------------------------------------------------------------- 1 | package io.learning.order.event; 2 | 3 | import lombok.AllArgsConstructor; 4 | import lombok.Data; 5 | 6 | @Data 7 | @AllArgsConstructor 8 | public class OrderTransactionEvent { 9 | 10 | private String transactionId; 11 | 12 | } 13 | -------------------------------------------------------------------------------- /order-service/src/main/java/io/learning/order/event/listener/DistributedTransactionEventListener.java: -------------------------------------------------------------------------------- 1 | package io.learning.order.event.listener; 2 | 3 | import org.springframework.amqp.core.ExchangeTypes; 4 | import org.springframework.amqp.rabbit.annotation.Exchange; 5 | import org.springframework.amqp.rabbit.annotation.Queue; 6 | import org.springframework.amqp.rabbit.annotation.QueueBinding; 7 | import org.springframework.amqp.rabbit.annotation.RabbitListener; 8 | import org.springframework.beans.factory.annotation.Autowired; 9 | import org.springframework.stereotype.Component; 10 | 11 | import io.learning.core.domain.DistributedTransaction; 12 | import io.learning.order.service.EventBus; 13 | import lombok.extern.slf4j.Slf4j; 14 | 15 | @Component 16 | @Slf4j 17 | public class DistributedTransactionEventListener { 18 | 19 | @Autowired 20 | private EventBus eventBus; 21 | 22 | @RabbitListener(bindings = { 23 | @QueueBinding(value = @Queue("txn-events-order"), exchange = @Exchange(type = ExchangeTypes.TOPIC, name = "txn-events")) 24 | }) 25 | public void onMessage(DistributedTransaction transaction) { 26 | log.debug("Transaction message received: {}", transaction); 27 | eventBus.sendTransaction(transaction); 28 | } 29 | 30 | } 31 | -------------------------------------------------------------------------------- /order-service/src/main/java/io/learning/order/event/listener/OrderTransactionListener.java: -------------------------------------------------------------------------------- 1 | package io.learning.order.event.listener; 2 | 3 | import org.springframework.beans.factory.annotation.Autowired; 4 | import org.springframework.stereotype.Component; 5 | import org.springframework.transaction.event.TransactionPhase; 6 | import org.springframework.transaction.event.TransactionalEventListener; 7 | import org.springframework.web.client.RestTemplate; 8 | 9 | import io.learning.core.domain.DistributedTransactionStatus; 10 | import io.learning.core.eventlistener.TransactionListener; 11 | import io.learning.order.devil.OrderProcessingException; 12 | import io.learning.order.event.OrderTransactionEvent; 13 | import io.learning.order.service.EventBus; 14 | import lombok.extern.slf4j.Slf4j; 15 | 16 | @Component 17 | @Slf4j 18 | public class OrderTransactionListener implements TransactionListener { 19 | 20 | @Autowired 21 | private RestTemplate restTemplate; 22 | 23 | @Autowired 24 | private EventBus eventBus; 25 | 26 | @Override 27 | @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT) 28 | public void handleEvent(OrderTransactionEvent event) throws OrderProcessingException { 29 | log.debug("Handling event before commit: {}", event); 30 | eventBus.sendEvent(event); 31 | } 32 | 33 | @Override 34 | @TransactionalEventListener(phase = TransactionPhase.AFTER_ROLLBACK) 35 | public void handleAfterRollback(OrderTransactionEvent event) { 36 | log.debug("Handling event after rollback : {}", event); 37 | restTemplate.put( 38 | "http://transaction-server/transactions/{id}/finish/{status}", 39 | null, 40 | event.getTransactionId(), 41 | DistributedTransactionStatus.ROLLBACK); 42 | } 43 | 44 | @Override 45 | @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) 46 | public void handleAfterCompletion(OrderTransactionEvent event) { 47 | log.debug("Handling event after completion : {}", event); 48 | restTemplate.put( 49 | "http://transaction-server/transactions/{id}/finish/{status}", 50 | null, 51 | event.getTransactionId(), 52 | DistributedTransactionStatus.CONFIRMED); 53 | } 54 | 55 | } 56 | -------------------------------------------------------------------------------- /order-service/src/main/java/io/learning/order/repository/OrderRepository.java: -------------------------------------------------------------------------------- 1 | package io.learning.order.repository; 2 | 3 | import org.springframework.data.jpa.repository.JpaRepository; 4 | import org.springframework.stereotype.Repository; 5 | 6 | import io.learning.order.domain.Order; 7 | 8 | @Repository 9 | public interface OrderRepository extends JpaRepository { 10 | 11 | } 12 | -------------------------------------------------------------------------------- /order-service/src/main/java/io/learning/order/service/EventBus.java: -------------------------------------------------------------------------------- 1 | package io.learning.order.service; 2 | 3 | import java.util.ArrayList; 4 | import java.util.List; 5 | import java.util.concurrent.TimeUnit; 6 | 7 | import org.springframework.stereotype.Component; 8 | 9 | import io.learning.core.domain.DistributedTransaction; 10 | import io.learning.order.event.OrderTransactionEvent; 11 | import lombok.extern.slf4j.Slf4j; 12 | 13 | @Component 14 | @Slf4j 15 | public class EventBus { 16 | 17 | private List transactions; 18 | 19 | private List events; 20 | 21 | public EventBus() { 22 | this.transactions = new ArrayList(); 23 | this.events = new ArrayList(); 24 | } 25 | 26 | public void sendTransaction(DistributedTransaction transaction) { 27 | transactions.add(transaction); 28 | } 29 | 30 | public DistributedTransaction receiveTransaction(String eventId) { 31 | DistributedTransaction transaction = null; 32 | while (transaction == null) { 33 | transaction = transactions.stream().filter(tx -> tx.getId().equals(eventId)).findAny().orElse(null); 34 | transactions.remove(transaction); 35 | if (transaction != null) { 36 | return transaction; 37 | } 38 | try { 39 | TimeUnit.MILLISECONDS.sleep(10); 40 | } catch (InterruptedException ex) { 41 | log.error("Error while received event for: {}, Cause:{}", eventId, ex); 42 | } 43 | } 44 | return transaction; 45 | } 46 | 47 | public void sendEvent(OrderTransactionEvent event) { 48 | events.add(event); 49 | } 50 | 51 | public OrderTransactionEvent receiveEvent(String eventId) { 52 | OrderTransactionEvent event = null; 53 | while (event == null) { 54 | event = events.stream().filter(evnt -> evnt.getTransactionId().equals(eventId)).findAny().orElse(null); 55 | events.remove(event); 56 | if (event != null) { 57 | return event; 58 | } 59 | try { 60 | TimeUnit.MILLISECONDS.sleep(10); 61 | } catch (InterruptedException ex) { 62 | log.error("Error while received event for: {}, Cause:{}", eventId, ex); 63 | } 64 | } 65 | return event; 66 | } 67 | 68 | } 69 | -------------------------------------------------------------------------------- /order-service/src/main/java/io/learning/order/service/OrderService.java: -------------------------------------------------------------------------------- 1 | package io.learning.order.service; 2 | 3 | import java.util.Optional; 4 | import java.util.Random; 5 | 6 | import org.springframework.beans.factory.annotation.Autowired; 7 | import org.springframework.context.ApplicationEventPublisher; 8 | import org.springframework.http.HttpEntity; 9 | import org.springframework.http.HttpHeaders; 10 | import org.springframework.http.HttpMethod; 11 | import org.springframework.stereotype.Service; 12 | import org.springframework.transaction.annotation.Transactional; 13 | import org.springframework.web.client.RestTemplate; 14 | 15 | import io.learning.core.domain.Account; 16 | import io.learning.core.domain.DistributedTransaction; 17 | import io.learning.core.domain.DistributedTransactionParticipant; 18 | import io.learning.core.domain.DistributedTransactionStatus; 19 | import io.learning.core.domain.Product; 20 | import io.learning.order.devil.InSufficientFundException; 21 | import io.learning.order.devil.OrderProcessingException; 22 | import io.learning.order.domain.Order; 23 | import io.learning.order.event.OrderTransactionEvent; 24 | import io.learning.order.repository.OrderRepository; 25 | import lombok.extern.slf4j.Slf4j; 26 | 27 | @Service 28 | @Slf4j 29 | public class OrderService { 30 | 31 | private static final String TXN_ID_HEADER = "X-Txn-ID"; 32 | 33 | private final Random random = new Random(); 34 | 35 | @Autowired 36 | private RestTemplate restTemplate; 37 | 38 | @Autowired 39 | private OrderRepository orderRepository; 40 | 41 | @Autowired 42 | private ApplicationEventPublisher eventPublisher; 43 | 44 | public Optional getOrderById(Long orderId) { 45 | return orderRepository.findById(orderId); 46 | } 47 | 48 | @Transactional 49 | public Order createOrder(Order order) { 50 | DistributedTransaction transaction = restTemplate.postForObject("http://transaction-server/transactions", new DistributedTransaction(), DistributedTransaction.class); 51 | log.info("Trasaction created: {}", transaction); 52 | Order savedOrder = orderRepository.save(order); 53 | Product product = updateProduct(transaction.getId(), savedOrder); 54 | log.info("Product updated: {}", product); 55 | int totalAmount = product.getPrice() * order.getQuantity(); 56 | Account account = restTemplate.getForObject("http://account-service/accounts/customer/{customerId}", Account.class, order.getCustomerId()); 57 | log.info("Account :{}", account); 58 | if (account.getBalance() >= totalAmount) { 59 | log.info("Withdrawing money: {}", totalAmount); 60 | withdraw(transaction.getId(), account.getId(), totalAmount); 61 | } else { 62 | throw new InSufficientFundException("Insufficient funds. Balance: " + account.getBalance() + ", orderAmount: " + totalAmount); 63 | } 64 | eventPublisher.publishEvent(new OrderTransactionEvent(transaction.getId())); 65 | int number = random.nextInt(); 66 | if (number % 2 == 0) { 67 | throw new OrderProcessingException("Error while processing your order"); 68 | } 69 | return savedOrder; 70 | } 71 | 72 | protected Product updateProduct(String transactionId, Order order) { 73 | addTransactionParticipant(transactionId, "product-service", DistributedTransactionStatus.NEW); 74 | HttpEntity requestEntity = new HttpEntity<>(prepareHeaders(transactionId)); 75 | return restTemplate.exchange( 76 | "http://product-service/products/{id}/quantity/{quantity}", 77 | HttpMethod.PUT, 78 | requestEntity, 79 | Product.class, 80 | order.getProductId(), 81 | order.getQuantity()).getBody(); 82 | } 83 | 84 | protected Account withdraw(String transactionId, Long accountId, int amount) { 85 | addTransactionParticipant(transactionId, "account-service", DistributedTransactionStatus.NEW); 86 | HttpEntity requestEntity = new HttpEntity<>(prepareHeaders(transactionId)); 87 | return restTemplate.exchange("http://account-service/accounts/{id}/withdrawl/{amount}", HttpMethod.PUT, requestEntity, Account.class, accountId, amount).getBody(); 88 | } 89 | 90 | protected void addTransactionParticipant(String transactionId, String serviceId, DistributedTransactionStatus status) { 91 | HttpEntity requestEntity = new HttpEntity<>(new DistributedTransactionParticipant(serviceId, status)); 92 | restTemplate.exchange("http://transaction-server/transactions/{id}/participants", HttpMethod.PUT, requestEntity, Object.class, transactionId); 93 | } 94 | 95 | private HttpHeaders prepareHeaders(String transactionId) { 96 | HttpHeaders headers = new HttpHeaders(); 97 | headers.set(TXN_ID_HEADER, transactionId); 98 | return headers; 99 | } 100 | 101 | } 102 | -------------------------------------------------------------------------------- /order-service/src/main/resources/application.yml: -------------------------------------------------------------------------------- 1 | spring: 2 | application: 3 | name: order-service 4 | datasource: 5 | url: jdbc:mysql://localhost:3306/distibuted_txn?useSSL=false 6 | username: root 7 | password: root 8 | jpa: 9 | database-platform: org.hibernate.dialect.MySQLDialect 10 | hibernate.ddl-auto: update 11 | rabbitmq: 12 | host: localhost 13 | port: 5672 14 | username: guest 15 | password: guest 16 | 17 | server: 18 | port: 8081 19 | 20 | eureka: 21 | client: 22 | service-url: 23 | defaultZone: http://localhost:8761/eureka/ 24 | instance: 25 | appname: ${spring.application.name} 26 | prefer-ip-address: true 27 | 28 | springdoc: 29 | swagger-ui.path: /swagger-ui.html 30 | 31 | logging: 32 | level: 33 | org.springframework.data: ERROR 34 | com.netflix: ERROR 35 | pattern: 36 | console: '%d{yyyy-MM-dd HH:mm:ss:SSS} [S=${APP_NAME:-}] [T=%X{traceId}] %-5level %logger{36}.%L %msg%n' 37 | -------------------------------------------------------------------------------- /order-service/src/main/resources/schema.sql: -------------------------------------------------------------------------------- 1 | create table orders (id bigint not null, customer_id bigint, product_id bigint, quantity integer, primary key (id)); -------------------------------------------------------------------------------- /pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 4.0.0 6 | 7 | io.learning 8 | spring-boot-distributed-transaction 9 | 0.0.1-SNAPSHOT 10 | pom 11 | 12 | spring-boot-distributed-transaction 13 | 14 | 15 | 16 | anil-jaglan 17 | Anil Jaglan 18 | aniljgln@gmail.com 19 | Asia/Kolkata 20 | 21 | 22 | 23 | 24 | Anil Jaglan 25 | 26 | 27 | 28 | 29 | 1.8 30 | 1.8 31 | 1.8 32 | UTF-8 33 | 34 | 2.3.1.RELEASE 35 | Hoxton.SR5 36 | 1.4.3 37 | 38 | 39 | 40 | transactional-core 41 | discovery-server 42 | order-service 43 | account-service 44 | product-service 45 | transaction-server 46 | 47 | 48 | 49 | 50 | 51 | 52 | io.learning 53 | transactional-core 54 | ${project.version} 55 | 56 | 57 | org.springframework.boot 58 | spring-boot-dependencies 59 | ${springboot.version} 60 | pom 61 | import 62 | 63 | 64 | org.springframework.cloud 65 | spring-cloud-dependencies 66 | ${springcloud.version} 67 | pom 68 | import 69 | 70 | 71 | org.springdoc 72 | springdoc-openapi-ui 73 | ${springdoc.openapi.version} 74 | 75 | 76 | 77 | -------------------------------------------------------------------------------- /product-service/pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 4.0.0 7 | 8 | io.learning 9 | spring-boot-distributed-transaction 10 | 0.0.1-SNAPSHOT 11 | 12 | 13 | product-service 14 | product-service 15 | 16 | 17 | 18 | 19 | io.learning 20 | transactional-core 21 | 22 | 23 | 24 | 25 | org.springframework.boot 26 | spring-boot-starter-web 27 | 28 | 29 | org.springframework.boot 30 | spring-boot-starter-actuator 31 | 32 | 33 | org.springframework.boot 34 | spring-boot-starter-data-jpa 35 | 36 | 37 | org.springframework.boot 38 | spring-boot-starter-amqp 39 | 40 | 41 | 42 | 43 | org.springframework.cloud 44 | spring-cloud-starter-netflix-eureka-client 45 | 46 | 47 | org.springframework.cloud 48 | spring-cloud-starter-sleuth 49 | 50 | 51 | 52 | 53 | org.springdoc 54 | springdoc-openapi-ui 55 | 56 | 57 | 58 | 59 | mysql 60 | mysql-connector-java 61 | 62 | 63 | 64 | 65 | org.projectlombok 66 | lombok 67 | 68 | 69 | 70 | -------------------------------------------------------------------------------- /product-service/src/main/java/io/learning/product/StartProductApplication.java: -------------------------------------------------------------------------------- 1 | package io.learning.product; 2 | 3 | import org.springframework.amqp.rabbit.annotation.EnableRabbit; 4 | import org.springframework.boot.SpringApplication; 5 | import org.springframework.boot.autoconfigure.SpringBootApplication; 6 | import org.springframework.cloud.client.discovery.EnableDiscoveryClient; 7 | import org.springframework.scheduling.annotation.EnableAsync; 8 | 9 | @EnableDiscoveryClient 10 | @SpringBootApplication 11 | @EnableAsync 12 | @EnableRabbit 13 | public class StartProductApplication { 14 | 15 | public static void main(String[] args) { 16 | SpringApplication.run(StartProductApplication.class, args); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /product-service/src/main/java/io/learning/product/config/OpenApiConfig.java: -------------------------------------------------------------------------------- 1 | package io.learning.product.config; 2 | 3 | import org.springframework.context.annotation.Configuration; 4 | 5 | import io.swagger.v3.oas.annotations.OpenAPIDefinition; 6 | import io.swagger.v3.oas.annotations.info.Info; 7 | 8 | @Configuration 9 | @OpenAPIDefinition(info = @Info(title = "Product Service", description = "REST API for CRUD operation", version = "1.0")) 10 | public class OpenApiConfig { 11 | 12 | } 13 | -------------------------------------------------------------------------------- /product-service/src/main/java/io/learning/product/config/RestConfig.java: -------------------------------------------------------------------------------- 1 | package io.learning.product.config; 2 | 3 | import org.springframework.cloud.client.loadbalancer.LoadBalanced; 4 | import org.springframework.context.annotation.Bean; 5 | import org.springframework.context.annotation.Configuration; 6 | import org.springframework.web.client.RestTemplate; 7 | 8 | @Configuration 9 | public class RestConfig { 10 | 11 | @Bean 12 | @LoadBalanced 13 | public RestTemplate restTemplate() { 14 | return new RestTemplate(); 15 | } 16 | 17 | } 18 | -------------------------------------------------------------------------------- /product-service/src/main/java/io/learning/product/controller/ProductController.java: -------------------------------------------------------------------------------- 1 | package io.learning.product.controller; 2 | 3 | import org.springframework.beans.factory.annotation.Autowired; 4 | import org.springframework.web.bind.annotation.GetMapping; 5 | import org.springframework.web.bind.annotation.PathVariable; 6 | import org.springframework.web.bind.annotation.PostMapping; 7 | import org.springframework.web.bind.annotation.PutMapping; 8 | import org.springframework.web.bind.annotation.RequestBody; 9 | import org.springframework.web.bind.annotation.RequestHeader; 10 | import org.springframework.web.bind.annotation.RequestMapping; 11 | import org.springframework.web.bind.annotation.RestController; 12 | 13 | import io.learning.core.domain.Product; 14 | import io.learning.product.devil.ProductNotFoundException; 15 | import io.learning.product.service.EventBus; 16 | import io.learning.product.service.ProductService; 17 | import io.swagger.v3.oas.annotations.Operation; 18 | import io.swagger.v3.oas.annotations.tags.Tag; 19 | 20 | /** 21 | * 22 | * Exposes REST API Interface for interacting with ProductService. 23 | * 24 | * @author Anil Jaglan 25 | * @version 1.0 26 | */ 27 | @RestController 28 | @RequestMapping("/products") 29 | @Tag(name = "Products") 30 | public class ProductController { 31 | 32 | @Autowired 33 | private ProductService productService; 34 | 35 | @Autowired 36 | private EventBus eventBus; 37 | 38 | @PostMapping 39 | @Operation(summary = "Create a new product") 40 | public Product createProduct(@RequestBody Product product) { 41 | return productService.createProduct(product); 42 | } 43 | 44 | @GetMapping("/{id}") 45 | @Operation(summary = "Find product by ID") 46 | public Product findById(@PathVariable("id") Long productId) { 47 | return productService.findById(productId).orElseThrow(() -> new ProductNotFoundException("Product not found for id: " + productId)); 48 | } 49 | 50 | @PutMapping("/{id}/quantity/{quantity}") 51 | @Operation(summary = "Update product quantity") 52 | public Product updateQuantity(@RequestHeader("X-Txn-ID") String transactionId, @PathVariable("id") Long productId, @PathVariable("quantity") int quantity) { 53 | productService.updateQuantity(transactionId, productId, quantity); 54 | return eventBus.receiveEvent(transactionId).getProduct(); 55 | } 56 | 57 | } 58 | -------------------------------------------------------------------------------- /product-service/src/main/java/io/learning/product/devil/ProductNotFoundException.java: -------------------------------------------------------------------------------- 1 | package io.learning.product.devil; 2 | 3 | public class ProductNotFoundException extends RuntimeException { 4 | 5 | private static final long serialVersionUID = 1L; 6 | 7 | public ProductNotFoundException(String message) { 8 | super(message); 9 | } 10 | 11 | public ProductNotFoundException(String message, Throwable th) { 12 | super(message, th); 13 | } 14 | 15 | } 16 | -------------------------------------------------------------------------------- /product-service/src/main/java/io/learning/product/devil/ProductProcessingException.java: -------------------------------------------------------------------------------- 1 | package io.learning.product.devil; 2 | 3 | public class ProductProcessingException extends RuntimeException { 4 | 5 | private static final long serialVersionUID = -191255860130453309L; 6 | 7 | public ProductProcessingException(String message) { 8 | super(message); 9 | } 10 | 11 | public ProductProcessingException(String message, Throwable th) { 12 | super(message, th); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /product-service/src/main/java/io/learning/product/domain/ProductEntity.java: -------------------------------------------------------------------------------- 1 | package io.learning.product.domain; 2 | 3 | import javax.persistence.Column; 4 | import javax.persistence.Entity; 5 | import javax.persistence.GeneratedValue; 6 | import javax.persistence.GenerationType; 7 | import javax.persistence.Id; 8 | import javax.persistence.Table; 9 | 10 | import lombok.Data; 11 | 12 | @Entity 13 | @Table(name = "PRODUCTS") 14 | @Data 15 | public class ProductEntity { 16 | 17 | @Id 18 | @GeneratedValue(strategy = GenerationType.AUTO) 19 | @Column(name = "ID") 20 | private Long id; 21 | 22 | @Column(name = "NAME") 23 | private String name; 24 | 25 | @Column(name = "QUANTITY") 26 | private Integer quantity; 27 | 28 | @Column(name = "PRICE") 29 | private Integer price; 30 | 31 | } 32 | -------------------------------------------------------------------------------- /product-service/src/main/java/io/learning/product/event/ProductTransactionEvent.java: -------------------------------------------------------------------------------- 1 | package io.learning.product.event; 2 | 3 | import io.learning.core.domain.Product; 4 | import lombok.AllArgsConstructor; 5 | import lombok.Data; 6 | 7 | @Data 8 | @AllArgsConstructor 9 | public class ProductTransactionEvent { 10 | 11 | private String transactionId; 12 | 13 | private Product product; 14 | 15 | } 16 | -------------------------------------------------------------------------------- /product-service/src/main/java/io/learning/product/event/listener/DistributedTransactionEventListener.java: -------------------------------------------------------------------------------- 1 | package io.learning.product.event.listener; 2 | 3 | import org.springframework.amqp.core.ExchangeTypes; 4 | import org.springframework.amqp.rabbit.annotation.Exchange; 5 | import org.springframework.amqp.rabbit.annotation.Queue; 6 | import org.springframework.amqp.rabbit.annotation.QueueBinding; 7 | import org.springframework.amqp.rabbit.annotation.RabbitListener; 8 | import org.springframework.beans.factory.annotation.Autowired; 9 | import org.springframework.stereotype.Component; 10 | 11 | import io.learning.core.domain.DistributedTransaction; 12 | import io.learning.product.service.EventBus; 13 | import lombok.extern.slf4j.Slf4j; 14 | 15 | @Component 16 | @Slf4j 17 | public class DistributedTransactionEventListener { 18 | 19 | @Autowired 20 | private EventBus eventBus; 21 | 22 | @RabbitListener(bindings = { 23 | @QueueBinding(value = @Queue("txn-events-product"), exchange = @Exchange(type = ExchangeTypes.TOPIC, name = "txn-events")) 24 | }) 25 | public void onMessage(DistributedTransaction transaction) { 26 | log.debug("Transaction message received: {}", transaction); 27 | eventBus.sendTransaction(transaction); 28 | } 29 | 30 | } 31 | -------------------------------------------------------------------------------- /product-service/src/main/java/io/learning/product/event/listener/ProductTransactionListener.java: -------------------------------------------------------------------------------- 1 | package io.learning.product.event.listener; 2 | 3 | import java.util.concurrent.TimeUnit; 4 | 5 | import org.springframework.beans.factory.annotation.Autowired; 6 | import org.springframework.stereotype.Component; 7 | import org.springframework.transaction.event.TransactionPhase; 8 | import org.springframework.transaction.event.TransactionalEventListener; 9 | import org.springframework.web.client.RestTemplate; 10 | 11 | import io.learning.core.domain.DistributedTransaction; 12 | import io.learning.core.domain.DistributedTransactionStatus; 13 | import io.learning.core.eventlistener.TransactionListener; 14 | import io.learning.product.devil.ProductProcessingException; 15 | import io.learning.product.event.ProductTransactionEvent; 16 | import io.learning.product.service.EventBus; 17 | import lombok.extern.slf4j.Slf4j; 18 | 19 | @Component 20 | @Slf4j 21 | public class ProductTransactionListener implements TransactionListener { 22 | 23 | @Autowired 24 | private RestTemplate restTemplate; 25 | 26 | @Autowired 27 | private EventBus eventBus; 28 | 29 | @Override 30 | @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT) 31 | public void handleEvent(ProductTransactionEvent event) throws ProductProcessingException { 32 | log.debug("Handling event before commit: {}", event); 33 | eventBus.sendEvent(event); 34 | 35 | DistributedTransaction transaction = null; 36 | int count = 3000; 37 | while (count > 0) { 38 | transaction = eventBus.receiveTransaction(event.getTransactionId()); 39 | if (transaction == null) { 40 | try { 41 | TimeUnit.MILLISECONDS.sleep(10); 42 | } catch (InterruptedException ex) { 43 | log.error("Error while receiving transaction for: {}. Cause: {}", event.getTransactionId(), ex); 44 | } 45 | --count; 46 | } else { 47 | break; 48 | } 49 | } 50 | if (transaction == null || transaction.getStatus() != DistributedTransactionStatus.CONFIRMED) { 51 | log.info("Transction received after waiting: {}", transaction); 52 | throw new ProductProcessingException("Distributed transaction wasn't confirmed for txnId: " + event.getTransactionId()); 53 | } 54 | } 55 | 56 | @Override 57 | @TransactionalEventListener(phase = TransactionPhase.AFTER_ROLLBACK) 58 | public void handleAfterRollback(ProductTransactionEvent event) { 59 | log.debug("Handling event after rollback : {}", event); 60 | restTemplate.put( 61 | "http://transaction-server/transactions/{transactionId}/participants/{serviceId}/status/{status}", 62 | null, 63 | event.getTransactionId(), 64 | "product-service", 65 | DistributedTransactionStatus.TO_ROLLBACK); 66 | } 67 | 68 | @Override 69 | @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) 70 | public void handleAfterCompletion(ProductTransactionEvent event) { 71 | log.debug("Handling event after completion : {}", event); 72 | restTemplate.put( 73 | "http://transaction-server/transactions/{transactionId}/participants/{serviceId}/status/{status}", 74 | null, 75 | event.getTransactionId(), 76 | "product-service", 77 | DistributedTransactionStatus.CONFIRMED); 78 | } 79 | 80 | } 81 | -------------------------------------------------------------------------------- /product-service/src/main/java/io/learning/product/mapper/ProductMapper.java: -------------------------------------------------------------------------------- 1 | package io.learning.product.mapper; 2 | 3 | import org.springframework.beans.BeanUtils; 4 | 5 | import io.learning.core.domain.Product; 6 | import io.learning.product.domain.ProductEntity; 7 | 8 | public class ProductMapper { 9 | 10 | public static Product map(ProductEntity product) { 11 | Product prod = new Product(); 12 | BeanUtils.copyProperties(product, prod); 13 | return prod; 14 | } 15 | 16 | public static ProductEntity map(Product product) { 17 | ProductEntity prod = new ProductEntity(); 18 | BeanUtils.copyProperties(product, prod); 19 | return prod; 20 | } 21 | 22 | } 23 | -------------------------------------------------------------------------------- /product-service/src/main/java/io/learning/product/repository/ProductRepository.java: -------------------------------------------------------------------------------- 1 | package io.learning.product.repository; 2 | 3 | import org.springframework.data.jpa.repository.JpaRepository; 4 | import org.springframework.stereotype.Repository; 5 | 6 | import io.learning.product.domain.ProductEntity; 7 | 8 | @Repository 9 | public interface ProductRepository extends JpaRepository { 10 | 11 | } 12 | -------------------------------------------------------------------------------- /product-service/src/main/java/io/learning/product/service/EventBus.java: -------------------------------------------------------------------------------- 1 | package io.learning.product.service; 2 | 3 | import java.util.ArrayList; 4 | import java.util.List; 5 | import java.util.concurrent.TimeUnit; 6 | 7 | import org.springframework.stereotype.Component; 8 | 9 | import io.learning.core.domain.DistributedTransaction; 10 | import io.learning.product.event.ProductTransactionEvent; 11 | import lombok.extern.slf4j.Slf4j; 12 | 13 | @Component 14 | @Slf4j 15 | public class EventBus { 16 | 17 | private List transactions; 18 | 19 | private List events; 20 | 21 | public EventBus() { 22 | this.transactions = new ArrayList<>(); 23 | this.events = new ArrayList<>(); 24 | } 25 | 26 | public void sendTransaction(DistributedTransaction transaction) { 27 | transactions.add(transaction); 28 | } 29 | 30 | public DistributedTransaction receiveTransaction(String eventId) { 31 | DistributedTransaction transaction = transactions.stream().filter(tx -> tx.getId().equals(eventId)).findAny().orElse(null); 32 | if (transaction != null) { 33 | transactions.remove(transaction); 34 | } 35 | return transaction; 36 | } 37 | 38 | public void sendEvent(ProductTransactionEvent event) { 39 | events.add(event); 40 | } 41 | 42 | public ProductTransactionEvent receiveEvent(String eventId) { 43 | ProductTransactionEvent event = null; 44 | while (event == null) { 45 | event = events.stream().filter(evnt -> evnt.getTransactionId().equals(eventId)).findAny().orElse(null); 46 | events.remove(event); 47 | if (event != null) { 48 | return event; 49 | } 50 | try { 51 | TimeUnit.MILLISECONDS.sleep(10); 52 | } catch (InterruptedException ex) { 53 | log.error("Error while received event for: {}, Cause:{}", eventId, ex); 54 | } 55 | } 56 | return event; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /product-service/src/main/java/io/learning/product/service/ProductService.java: -------------------------------------------------------------------------------- 1 | package io.learning.product.service; 2 | 3 | import java.util.Optional; 4 | 5 | import org.springframework.beans.factory.annotation.Autowired; 6 | import org.springframework.context.ApplicationEventPublisher; 7 | import org.springframework.scheduling.annotation.Async; 8 | import org.springframework.stereotype.Service; 9 | import org.springframework.transaction.annotation.Isolation; 10 | import org.springframework.transaction.annotation.Transactional; 11 | 12 | import io.learning.core.domain.Product; 13 | import io.learning.product.devil.ProductProcessingException; 14 | import io.learning.product.event.ProductTransactionEvent; 15 | import io.learning.product.mapper.ProductMapper; 16 | import io.learning.product.repository.ProductRepository; 17 | import lombok.extern.slf4j.Slf4j; 18 | 19 | @Service 20 | @Slf4j 21 | public class ProductService { 22 | 23 | @Autowired 24 | private ProductRepository productRepository; 25 | 26 | @Autowired 27 | private ApplicationEventPublisher eventPublisher; 28 | 29 | @Transactional 30 | public Product createProduct(Product product) { 31 | return ProductMapper.map(productRepository.save(ProductMapper.map(product))); 32 | } 33 | 34 | public Optional findById(Long productId) { 35 | return productRepository.findById(productId).map(ProductMapper::map); 36 | } 37 | 38 | @Async 39 | @Transactional(isolation = Isolation.SERIALIZABLE) 40 | public void updateQuantity(String transactionId, Long productId, int quantity) { 41 | log.info("Updating product quantity with id: {}, quantity: {}", productId, quantity); 42 | findById(productId).ifPresent(prod -> { 43 | if (prod.getQuantity() < quantity) { 44 | throw new ProductProcessingException("Insufficient product quantity. Available: " + prod.getQuantity() + ", Demand: " + quantity); 45 | } 46 | prod.setQuantity(prod.getQuantity() - quantity); 47 | eventPublisher.publishEvent(new ProductTransactionEvent(transactionId, prod)); 48 | productRepository.save(ProductMapper.map(prod)); 49 | }); 50 | } 51 | 52 | } 53 | -------------------------------------------------------------------------------- /product-service/src/main/resources/application.yml: -------------------------------------------------------------------------------- 1 | spring: 2 | application: 3 | name: product-service 4 | datasource: 5 | url: jdbc:mysql://localhost:3306/distibuted_txn?useSSL=false 6 | username: root 7 | password: root 8 | jpa: 9 | database-platform: org.hibernate.dialect.MySQLDialect 10 | hibernate.ddl-auto: update 11 | rabbitmq: 12 | host: localhost 13 | port: 5672 14 | username: guest 15 | password: guest 16 | 17 | server: 18 | port: 8082 19 | 20 | eureka: 21 | client: 22 | service-url: 23 | defaultZone: http://localhost:8761/eureka/ 24 | instance: 25 | appname: ${spring.application.name} 26 | prefer-ip-address: true 27 | 28 | springdoc: 29 | swagger-ui.path: /swagger-ui.html 30 | 31 | logging: 32 | level: 33 | org.springframework.data: ERROR 34 | com.netflix: ERROR 35 | pattern: 36 | console: '%d{yyyy-MM-dd HH:mm:ss:SSS} [S=${APP_NAME:-}] [T=%X{traceId}] %-5level %logger{36}.%L %msg%n' -------------------------------------------------------------------------------- /product-service/src/main/resources/schema.sql: -------------------------------------------------------------------------------- 1 | create table products (id bigint not null, name varchar(255), price integer, quantity integer, primary key (id)); -------------------------------------------------------------------------------- /resources/distributed-txn-architecture.drawio: -------------------------------------------------------------------------------- 1 | 7V1bl5s4Ev41frQP98tjX9K7D8lOz3b2JJmXORhoW9MYcYScdufXr2SQQQgMtrmoO+6HBITApr6qT1WlkjzT7za7fyEvWX+BQRjNNCXYzfT7maapjuOQ/2jLW9ZiOkrWsEIgyDsVDU/gV5g3sm5bEIQp1xFDGGGQ8I0+jOPQx1ybhxB85bs9w4j/1MRbhULDk+9FYus3EOB11uqYStH+7xCs1uyTVSW/svFY57whXXsBfC016Z9m+h2CEGdHm91dGFHhMblk9z00XD18MRTGuMsNf/z4C36zvobb7/f/uf0F5i/B9ttcVfLn/PSibf7K+dfFb0wGCG7jIKSPUWf67esa4PAp8Xx69ZWgTtrWeBPll59BFN3BCCJyHsM4pE0wxjmyqkPOAy9dHx5HTx49jEMU71s0xSCt+ZcKEQ53je+rHqRI1C+EmxCjN9Ilv2GuMpDeeJV6LXB0mTauyxjqRt7q5cqzOjy8kC85yEV8griddmGviLSTxtfP1dxbsu7KqWIhJshJRddEsah1YjGHEkrfGthJd5rhESU3mWT0dskQVknoIdjsiex2//9NmmRcqJAWj508gx0V4S2VDCAM99lbhtEjTAEGkNreEmIMN6UONxFY0QsYJuzJ5GyNMeXeG/pq2kOCvBWhOuCDGIcr5GGIFoQCwnTxClGQoDBNFz55qvagKapN/lPISz2kCQLxau5HcBss/klWQ6CWP0XntN1YqFb5zxZ031AXdT3KeDd06R1+W4D/HqQ+JDKi7/cUInIkKAQRH+btIcUIvoRVThZp2svh9om4yYNFPdiAIKAfU2uAhYkqVYrf83/+DdXKeNCZ7M+Avsr/Fg+b5YjEp9WNBw339U+EmgTDg14RmlszPNhjkqDb8/AgqmYvqpiBd3zwGFVuhvyDR/CLME8+PKRk5KB9tAccboiGP6iaoRuq6szzwWJJPOV5BFdwkcSXDRhNULHHVCxAFQxA1xeGKkKpD+YhiS7Sje8Tpcf5OACIrv/WA0ELpAbvBahWN+bXBgO0g2c3NNPPDV7PawOBURlL7UBZUlC9LhnVq+aV60/ESl6utwQs/0ABdfSvTN8BUOmYXgziRmf6qk9v2JMzfYdEmBRMb8vG9B2iod+V6RuwkpbpmSGXsHxEMNj6V6++G6Sycb3mCHCFwSpksoEIr4mix170qWitCLbo8xlSE9sL9J8Q47dcot4WQx7/cAfw9/x2evyDHi/M/Ox+V7p0/8ZOYvK+39kD6EnpLnpa3LY/e2vEXcCZvnEjynlTCrfID49JMmc17KFVeFRD6hUEhZGHwU/+i/SPtz0p3geMf5SutOBdgvgHh3A93lWIOfx7xNt9F3C78sCtnAW3Kol5M/dPbrzZwD8N3ursDDpXZlLSOUvsSI636Eq34z26RZ+GcBVUf4t+DkHgnRF2J0VYPwNhTmSjj9eXwT2JDTdMyo4EcYdU6ND5FlPpkFkftZCECUH2fEuGnkQVOJo4JfEVeXHq+fsMybUK4xhq3cN0vabKZrgwXcywC/gJGbIkRIB8PEXknnwGSNLwsWgqAy0AeCj85CBhdM3gn2m6odn27S1pj2ga7tbzX1Z7PEtdnvd/FfQ0+iFED0C8+roflPQiN3fI0YHNikgrAkvyL1jS3Nwygv6Lv/ZATNNzhVb/vfFicg9apD9XnEpdVlzXoiUupySuoCO6IaoIa+t/FDknsu/LUTjP83/fgX1mgpM5Deck7mSCuy2wHwvuzoH9tHgb58QB50BcwKVdhBfPwRx6RXhhd48vpogLLoY8v/URgn3dExt+KpObulkpz8zeIL+rUJwbhLy3UreEdkibP8c0+Lp5g19uQQ6yJxZaeXjHCxRVgmhGV/kXd2vWUYxc2djBgTttzUpvrrDRIYAZV1Yd6g9+0wnjJqyknTA2xKqJaxnoKZDKNmFsdKjmyOidB6gcCA7J/DzxO/rUxM8eLCPxNyS2p5NVh2V1vyvxN2AlLfGbYvr2WhPaHVDZaN+UoPrfqIRNEpB739X//emXKVvBv3kt+D8VK3nJXYxmr2Wgp0AqG71bpgCXpGWgVYhS7CF8Q3dNoQoSeWkKfNb8AKIirRmwTrlSkZb8es/ZRVOph/7CpOG8mjV0GKOyZ2TpTCFr2FdizxKN/g6FHiaWruSuXY0K7Xl5dny2sdGcUZiCX7nHQFHKE57k4ebtzLyvqINyzGK7uB9HTKPRkpWFrjgWb83Z2bl4vzG8K8YOn5/TcBhkxYSbKoKZzSC3+zDCEM17QJ0RO2FZKCuDY5ZhGCKfuiKdsrb+d8EQXZtRy261s+puFRKTlCd8FormtEz6cOybE2uZehWeegt+Lti3Tif2H1KqVRiGojtMAJmTFobZost8YNyvu/hj8m1mO822TvTUNfnKiwNVXjzEmpXn6mNRsC3G1zUcIi8FWzwitquJDOzUMPBQ+9LY1pQMfFZ9RGVdk91WODsQ9/ZJs11LK6wGmz/Nnk+dHrcrNa+2Ypb1rrW/qqojzKfbonP2uF1GIF2Txk8/w/wVP+BAYLUOBKbKe95z7bJxYASe7zx9dok8W+bItIoeK05NItWqyT8MtsmkpbWLZaJEqt1hlmxUWdkdNiIYL5F63LOoT7Nukwh6weIVvBAHPwDeAiJa6EvPE3pOjn242cA4pdnX9XZDC4DpRow2Ae3hv95yCfCXP//e515pwS/NzTpKspsLly5OzTahz7apqFrSQjXc4s8W92pU2V6mfCC6sIcKIphtl9TlM0hxWB8+fKCVoT1lBqsqPVQdKqPAVmfJaBgXx4lJnZrMkOSO9YkL0t6BX905fWFMqipiTPu/JMjSF39uvRgD/PYxXVfnuANGRglT5WeA2I7S7ydp7IguW02JurwZC83iYweHzcmVBmv2VG4ObjBzEYsJDuayz/YpT9jD2/poT8Jxe8jccPZdHINfQLRwXauF3AfPLHddWtCUWS6iW40nCPMyghiBECZNuY2xVH0gjR7Ko+zsJlycfruM98QE15EA5f37Bq1pLV1x5Dd2MQnxDeB1gLxX0vqF2Ea9azcaG5ywskwyOpiCAZpiyqLIgeXyWabjQg3d1d4woL6KeVfjPXmrqkP1sYyAq4lFDqP6q+xXFqSw8M4ppOy3mkpFDqra5jNeixyGHP7ZTHHJMh9AnE1vfdgiB7ehRrCUIGALKHJz12T3CFxx8U5NUanEDMtWH7HZYDZkTMav56z4n9iDqtQwaB8touoa3U8bUbliJukjR1Tu8Vp7ElGpLgsy+ynRHYFNJUqmdPatLN2acb6Vop2TjpM/rmK7OcjuWomZle4Z5fdPC20xrWaaQxTujxbTuhL8NGvl9wqtmkoi06ypjhlumWHfP9PRXyWR2/5brSPLSkyKXHeK7Ioam0a0Jdsq0hLrfQQAr1tF9r5VZJua6FJtFumKlv+0X9V8S3/bXVPuSbiAwHKLiWSIr8CRwg3yqW36eItkXeRb/fV2nh16yBXolWx4zf5ihqGJgJ5RFEpOEQWl5DqQV15/gQF1zz79Hw== -------------------------------------------------------------------------------- /resources/distributed-txn-architecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/anil-jaglan/springboot-microservices-distributed-transaction-2pc/b9e8c6cf1959b31669657c4128c45d13faba206b/resources/distributed-txn-architecture.png -------------------------------------------------------------------------------- /resources/distributed-txn-flow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/anil-jaglan/springboot-microservices-distributed-transaction-2pc/b9e8c6cf1959b31669657c4128c45d13faba206b/resources/distributed-txn-flow.png -------------------------------------------------------------------------------- /transaction-server/pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 4.0.0 7 | 8 | io.learning 9 | spring-boot-distributed-transaction 10 | 0.0.1-SNAPSHOT 11 | 12 | 13 | transaction-server 14 | transaction-server 15 | 16 | 17 | 18 | 19 | io.learning 20 | transactional-core 21 | 22 | 23 | 24 | 25 | org.springframework.boot 26 | spring-boot-starter-web 27 | 28 | 29 | org.springframework.boot 30 | spring-boot-starter-actuator 31 | 32 | 33 | org.springframework.boot 34 | spring-boot-starter-amqp 35 | 36 | 37 | 38 | 39 | org.springframework.cloud 40 | spring-cloud-starter-netflix-eureka-client 41 | 42 | 43 | org.springframework.cloud 44 | spring-cloud-starter-sleuth 45 | 46 | 47 | 48 | 49 | org.springdoc 50 | springdoc-openapi-ui 51 | 52 | 53 | 54 | 55 | org.projectlombok 56 | lombok 57 | 58 | 59 | 60 | -------------------------------------------------------------------------------- /transaction-server/src/main/java/io/learning/transaction/StartTransactionServerApplication.java: -------------------------------------------------------------------------------- 1 | package io.learning.transaction; 2 | 3 | import org.springframework.amqp.core.TopicExchange; 4 | import org.springframework.boot.SpringApplication; 5 | import org.springframework.boot.autoconfigure.SpringBootApplication; 6 | import org.springframework.context.annotation.Bean; 7 | 8 | @SpringBootApplication 9 | public class StartTransactionServerApplication { 10 | 11 | public static void main(String[] args) { 12 | SpringApplication.run(StartTransactionServerApplication.class, args); 13 | } 14 | 15 | @Bean 16 | public TopicExchange topic() { 17 | return new TopicExchange("txn-events"); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /transaction-server/src/main/java/io/learning/transaction/config/OpenApiConfig.java: -------------------------------------------------------------------------------- 1 | package io.learning.transaction.config; 2 | 3 | import org.springframework.context.annotation.Configuration; 4 | 5 | import io.swagger.v3.oas.annotations.OpenAPIDefinition; 6 | import io.swagger.v3.oas.annotations.info.Info; 7 | 8 | @Configuration 9 | @OpenAPIDefinition(info = @Info(title = "Transactional Server", description = "REST API for managing transaction", version = "1.0")) 10 | public class OpenApiConfig { 11 | 12 | } 13 | -------------------------------------------------------------------------------- /transaction-server/src/main/java/io/learning/transaction/controller/TransactionServerController.java: -------------------------------------------------------------------------------- 1 | package io.learning.transaction.controller; 2 | 3 | import java.util.List; 4 | import java.util.Optional; 5 | import java.util.stream.Collectors; 6 | 7 | import org.springframework.amqp.rabbit.core.RabbitTemplate; 8 | import org.springframework.beans.factory.annotation.Autowired; 9 | import org.springframework.web.bind.annotation.GetMapping; 10 | import org.springframework.web.bind.annotation.PathVariable; 11 | import org.springframework.web.bind.annotation.PostMapping; 12 | import org.springframework.web.bind.annotation.PutMapping; 13 | import org.springframework.web.bind.annotation.RequestBody; 14 | import org.springframework.web.bind.annotation.RequestMapping; 15 | import org.springframework.web.bind.annotation.RestController; 16 | 17 | import io.learning.core.domain.DistributedTransaction; 18 | import io.learning.core.domain.DistributedTransactionParticipant; 19 | import io.learning.core.domain.DistributedTransactionStatus; 20 | import io.learning.transaction.repo.DistributedTransactionRepo; 21 | import io.swagger.v3.oas.annotations.Operation; 22 | import io.swagger.v3.oas.annotations.tags.Tag; 23 | import lombok.extern.slf4j.Slf4j; 24 | 25 | /** 26 | * 27 | * Exposes REST API Interface for interacting with TransactionServer 28 | * Application. 29 | * 30 | * @author Anil Jaglan 31 | * @version 1.0 32 | */ 33 | @RestController 34 | @RequestMapping("/transactions") 35 | @Tag(name = "Transactions") 36 | @Slf4j 37 | public class TransactionServerController { 38 | 39 | @Autowired 40 | private DistributedTransactionRepo repository; 41 | 42 | @Autowired 43 | private RabbitTemplate rabbitTemplate; 44 | 45 | @PostMapping 46 | @Operation(summary = "Add a new transaction") 47 | public DistributedTransaction add(@RequestBody DistributedTransaction transaction) { 48 | return repository.save(transaction); 49 | } 50 | 51 | @GetMapping("/{id}") 52 | @Operation(summary = "Get transaction by id") 53 | public Optional findById(@PathVariable("id") String id) { 54 | return repository.findById(id); 55 | } 56 | 57 | @GetMapping 58 | @Operation(summary = "Get all transactions") 59 | public List findAll() { 60 | return repository.findAll(); 61 | } 62 | 63 | @PutMapping("/{id}/finish/{status}") 64 | @Operation(summary = "Finish a transaction with status") 65 | public void finish(@PathVariable("id") String id, @PathVariable("status") DistributedTransactionStatus status) { 66 | log.info("Finishing transaction as id: {}, status: {}", id, status); 67 | repository.findById(id).ifPresent(txn -> { 68 | txn.setStatus(status); 69 | repository.update(txn); 70 | log.info("Publishing transaction[{}] finish event with status: {}", id, status); 71 | this.publishEvent(new DistributedTransaction(id, status)); 72 | }); 73 | } 74 | 75 | @PutMapping("/{id}/participants") 76 | @Operation(summary = "Add participant in a transaction") 77 | public void addParticipant(@PathVariable("id") String id, @RequestBody DistributedTransactionParticipant participant) { 78 | log.info("Adding participant in transaction: {}, participant: {}", id, participant); 79 | repository.findById(id).ifPresent(txn -> { 80 | txn.getParticipants().add(participant); 81 | repository.update(txn); 82 | log.info("All participant: {}", txn.getParticipants()); 83 | }); 84 | } 85 | 86 | @PutMapping("/{id}/participants/{serviceId}/status/{status}") 87 | @Operation(summary = "Update participant status in a transaction") 88 | public void updateParticipant(@PathVariable("id") String id, @PathVariable("serviceId") String serviceId, @PathVariable("status") DistributedTransactionStatus status) { 89 | log.info("Updating participant id: {}, serviceId: {}, status: {}", id, serviceId, status); 90 | repository.findById(id).ifPresent(txn -> { 91 | List participants = txn.getParticipants().stream().map(p -> { 92 | if (p.getServiceId().equals(serviceId)) { 93 | p.setStatus(status); 94 | } 95 | return p; 96 | }).collect(Collectors.toList()); 97 | txn.setParticipants(participants); 98 | repository.update(txn); 99 | this.publishEvent(new DistributedTransaction(id, status)); 100 | log.info("Publishing transaction [{}] event with status: {}", id, status); 101 | }); 102 | } 103 | 104 | protected void publishEvent(DistributedTransaction transaction) { 105 | rabbitTemplate.convertAndSend("txn-events", "txn-events", transaction); 106 | } 107 | 108 | } 109 | -------------------------------------------------------------------------------- /transaction-server/src/main/java/io/learning/transaction/repo/DistributedTransactionRepo.java: -------------------------------------------------------------------------------- 1 | package io.learning.transaction.repo; 2 | 3 | import java.util.ArrayList; 4 | import java.util.Collections; 5 | import java.util.List; 6 | import java.util.Optional; 7 | import java.util.UUID; 8 | 9 | import org.springframework.stereotype.Repository; 10 | 11 | import io.learning.core.domain.DistributedTransaction; 12 | 13 | @Repository 14 | public class DistributedTransactionRepo { 15 | 16 | private List transactions = new ArrayList<>(); 17 | 18 | public Optional findById(String id) { 19 | return transactions.stream().filter(tx -> tx.getId().equalsIgnoreCase(id)).findAny(); 20 | } 21 | 22 | public List findAll() { 23 | return Collections.unmodifiableList(transactions); 24 | } 25 | 26 | public DistributedTransaction save(DistributedTransaction transaction) { 27 | transaction.setId(UUID.randomUUID().toString()); 28 | transactions.add(transaction); 29 | return transaction; 30 | } 31 | 32 | public DistributedTransaction update(DistributedTransaction transaction) { 33 | int index = transactions.indexOf(transaction); 34 | if (index >= 0) { 35 | transactions.remove(index); 36 | transactions.add(index, transaction); 37 | } 38 | return transaction; 39 | } 40 | 41 | public boolean deleteById(String id) { 42 | Optional txOpt = findById(id); 43 | if (txOpt.isPresent()) { 44 | transactions.remove(txOpt.get()); 45 | return true; 46 | } 47 | return false; 48 | } 49 | 50 | } 51 | -------------------------------------------------------------------------------- /transaction-server/src/main/resources/application.yml: -------------------------------------------------------------------------------- 1 | spring: 2 | application: 3 | name: transaction-server 4 | rabbitmq: 5 | host: localhost 6 | port: 5672 7 | username: guest 8 | password: guest 9 | 10 | server: 11 | port: 8888 12 | 13 | eureka: 14 | client: 15 | service-url: 16 | defaultZone: http://localhost:8761/eureka/ 17 | instance: 18 | appname: ${spring.application.name} 19 | prefer-ip-address: true 20 | 21 | springdoc: 22 | swagger-ui.path: /swagger-ui.html 23 | 24 | logging: 25 | level: 26 | org.springframework.data: ERROR 27 | com.netflix: ERROR 28 | pattern: 29 | console: '%d{yyyy-MM-dd HH:mm:ss:SSS} [S=${APP_NAME:-}] [T=%X{traceId}] %-5level %logger{36}.%L %msg%n' -------------------------------------------------------------------------------- /transactional-core/pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 4.0.0 7 | 8 | io.learning 9 | spring-boot-distributed-transaction 10 | 0.0.1-SNAPSHOT 11 | 12 | 13 | transactional-core 14 | transactional-core 15 | 16 | 17 | 18 | org.projectlombok 19 | lombok 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /transactional-core/src/main/java/io/learning/core/domain/Account.java: -------------------------------------------------------------------------------- 1 | package io.learning.core.domain; 2 | 3 | import lombok.AllArgsConstructor; 4 | import lombok.Builder; 5 | import lombok.Data; 6 | import lombok.NoArgsConstructor; 7 | 8 | @Data 9 | @Builder 10 | @NoArgsConstructor 11 | @AllArgsConstructor 12 | public class Account { 13 | 14 | private Long id; 15 | 16 | private Long customerId; 17 | 18 | private int balance; 19 | 20 | } 21 | -------------------------------------------------------------------------------- /transactional-core/src/main/java/io/learning/core/domain/DistributedTransaction.java: -------------------------------------------------------------------------------- 1 | package io.learning.core.domain; 2 | 3 | import static io.learning.core.domain.DistributedTransactionStatus.NEW; 4 | 5 | import java.io.Serializable; 6 | import java.util.Collections; 7 | import java.util.List; 8 | 9 | import lombok.AllArgsConstructor; 10 | import lombok.Builder; 11 | import lombok.Data; 12 | import lombok.NoArgsConstructor; 13 | 14 | @Data 15 | @Builder 16 | @NoArgsConstructor 17 | @AllArgsConstructor 18 | public class DistributedTransaction implements Serializable { 19 | 20 | private static final long serialVersionUID = -8594438501671636539L; 21 | 22 | private String id; 23 | 24 | @Builder.Default 25 | private DistributedTransactionStatus status = NEW; 26 | 27 | @Builder.Default 28 | private transient List participants = Collections.emptyList(); 29 | 30 | public DistributedTransaction(String id, DistributedTransactionStatus status) { 31 | super(); 32 | this.id = id; 33 | this.status = status; 34 | } 35 | 36 | public DistributedTransaction clone() { 37 | return DistributedTransaction.builder().id(this.id).status(this.status).participants(this.participants).build(); 38 | } 39 | 40 | @Override 41 | public boolean equals(Object obj) { 42 | if (this == obj) 43 | return true; 44 | if (obj == null) 45 | return false; 46 | if (getClass() != obj.getClass()) 47 | return false; 48 | DistributedTransaction other = (DistributedTransaction) obj; 49 | if (id == null) { 50 | if (other.id != null) 51 | return false; 52 | } else if (!id.equals(other.id)) 53 | return false; 54 | return true; 55 | } 56 | 57 | @Override 58 | public int hashCode() { 59 | final int prime = 31; 60 | int result = 1; 61 | result = prime * result + ((id == null) ? 0 : id.hashCode()); 62 | return result; 63 | } 64 | 65 | } 66 | -------------------------------------------------------------------------------- /transactional-core/src/main/java/io/learning/core/domain/DistributedTransactionParticipant.java: -------------------------------------------------------------------------------- 1 | package io.learning.core.domain; 2 | 3 | import lombok.AllArgsConstructor; 4 | import lombok.Builder; 5 | import lombok.Data; 6 | import lombok.NoArgsConstructor; 7 | 8 | @Data 9 | @Builder 10 | @NoArgsConstructor 11 | @AllArgsConstructor 12 | public class DistributedTransactionParticipant { 13 | 14 | private String serviceId; 15 | 16 | private DistributedTransactionStatus status; 17 | 18 | } 19 | -------------------------------------------------------------------------------- /transactional-core/src/main/java/io/learning/core/domain/DistributedTransactionStatus.java: -------------------------------------------------------------------------------- 1 | package io.learning.core.domain; 2 | 3 | public enum DistributedTransactionStatus { 4 | NEW, CONFIRMED, ROLLBACK, TO_ROLLBACK 5 | } 6 | -------------------------------------------------------------------------------- /transactional-core/src/main/java/io/learning/core/domain/Product.java: -------------------------------------------------------------------------------- 1 | package io.learning.core.domain; 2 | 3 | import lombok.AllArgsConstructor; 4 | import lombok.Builder; 5 | import lombok.Data; 6 | import lombok.NoArgsConstructor; 7 | 8 | @Data 9 | @Builder 10 | @NoArgsConstructor 11 | @AllArgsConstructor 12 | public class Product { 13 | 14 | private Long id; 15 | 16 | private String name; 17 | 18 | private int quantity; 19 | 20 | private int price; 21 | 22 | } 23 | -------------------------------------------------------------------------------- /transactional-core/src/main/java/io/learning/core/eventlistener/TransactionListener.java: -------------------------------------------------------------------------------- 1 | package io.learning.core.eventlistener; 2 | 3 | public interface TransactionListener { 4 | 5 | void handleEvent(T event); 6 | 7 | void handleAfterRollback(T event); 8 | 9 | void handleAfterCompletion(T event); 10 | } 11 | --------------------------------------------------------------------------------