Spring Transactions Nedir ve Nasıl Kullanılır?
18 min read

Spring Transactions Nedir ve Nasıl Kullanılır?

Spring Transactions, bir runtime exception durumunda işlemleri en başa almanızı sağlayan güçlü ve veri tutarlılığı sağlayan bir Spring yeteneğidir.
Spring Transactions Nedir ve Nasıl Kullanılır?
Photo by Mohamed Osama / Unsplash

Verileri yönetirken göz önünde bulundurduğumuz en önemli şeylerden biri doğru ve tutarlı veriler tutmaktır. Belirli senaryoların yanlış veya tutarsız verilerle sonuçlanmasını asla istemeyiz. Tam burada, tutarsız verilerden sakınmak ve korunmak için Transaction'lar devreye girer. Transaction'lara neden ihtiyacımız olduğunu, hangi problemi çözdüğünü ve nasıl kullanıldığını anlamak için basit bir örnekle başlayalım ve yavaş yavaş ilerleyelim.

Örnek olarak, para paylaşmak için kullanılan bir uygulamayı, yani bir elektronik cüzdanı implemente ettiğimizi varsayalım. Bu uygulamada, bir kullanıcının parasını sakladığı hesapları vardır ve kullanıcının bir hesaptan diğerine para transfer etmesine izin veren bir işlev uygularsınız. Örneğimiz için basit bir uygulama düşünüldüğünde, bu iki adımdan oluştuğu anlamına gelir (şekil 1):

  1. Kaynak hesaptan para çekin.
  2. Hedef hesaba çekilen parayı yatırın.
Şekil 1: Para transfer işleminin özeti

Bir hesaptan başka bir hesaba para aktarırken, uygulama iki işlem gerçekleştirir: Aktarılan parayı ilk hesaptan çıkarır ve ikinci hesaba ekler. Bu kullanım senaryosunu uygulayacağız ve yürütülmesinin verilerde tutarsızlıklar oluşturmayacağından emin olmaya çalışacağız.

Bu adımların her ikisi de verileri değiştiren işlemlerdir (mutable data operations) ve para transferinin doğru bir şekilde gerçekleştirilmesi için her iki işlemin de başarılı olması gerekir. Ama ya ikinci adım bir sorunla karşılaşılırsa ve tamamlanamazsa ne olacak? Eğer ilki tamamlanır ancak ikinci adım tamamlanamazsa veriler tutarsız hale gelir.

Yunus'un Selin'e 100TL gönderdiğini varsayalım. Yunus'un transferi yapmadan önce hesabında 1.000TL,Selin'in ise 500TL'si vardı. Aktarım tamamlandıktan sonra, Yunus'un hesabında 100TL daha az (yani 1.000TL - 100TL = 900TL) tutmasını, Selin'in ise 100TL almasını bekliyoruz. Selin'in parasının 500TL + 100TL = 600TL olması gerekir.

İkinci adım başarısız olursa, Yunus'un hesabından paranın çekildiği, ancak Selin'in bu parayı asla almadığı bir duruma düşeriz. Yunus'un 900TL'si olacak, Selin'in ise hala 500TL'si olacak. Peki 100TL nereye gitti? Şekil 2 bu davranışı göstermektedir.

Şekil 2: Yunus'tan para çekilir fakat Selin'e para yatırılamaz.

Kullanım senaryosunun adımlarından biri başarısız olursa veriler tutarsız hale gelir. Para transferi örneğinde, ilk hesaplardan parayı çıkaran işlem başarılı olur ama hedef hesaba ekleyen işlem başarısız olursa para kaybedilir.

Verilerin tutarsız hale geldiği bu tür senaryolardan kaçınmak için, her iki adımın da doğru şekilde yürütüldüğünden veya hiçbirinin çalışmadığından emin olmamız gerekir. Transaction bize, ya hepsini doğru bir şekilde yürüten ya da hiçbirini doğru şekilde yürütmeyen birden çok işlemi uygulama imkanı sunar.

1. Transaction Nedir?

Transaction, tümünün başarılı bir şekilde yürütüldüğü veya hata olması durumunda hiçbirinin yürütülmediği mutable operasyonların kümesidir. Biz buna atomicity de diyoruz. Bir hata olması durumunda verilerin tutarlı kalması için transaction'ları kullanmak uygulama açısından oldukça hayati ve önemllidir. İki adımdan oluşan (basitleştirilmiş) bir para transferi işlevini tekrar ele alalım:

  1. Kaynak hesaptan para çekin.
  2. Hedef hesaba çekilen parayı yatırın.

Birinci adımdan önce bir transaction'a başlayabilir ve ikinci adımdan sonra transaction'u kapatabiliriz (şekil 3). Böyle bir durumda, her iki adım da başarılı bir şekilde yürütülürse, transaction sona erdiğinde (2. adımdan sonra), uygulama her iki adımda da yapılan değişiklikleri sürdürür. Ayrıca, bu durumda, transaction'ın “commit” ettiğini söyleyebiliriz. "Commit" işlemi, işlem sona erdiğinde ve tüm adımlar başarıyla yürütüldüğünde gerçekleşir, böylece uygulama veri değişikliklerini sürdürür.

Şekil 3: Transaction kullandığımızda işlemlerde hata olursa veriler roll back edilir.

Bir transaction, bir kuse case adımlarından herhangi birinin başarısız olması durumunda ortaya çıkabilecek olası tutarsızlıkları çözer. Bir transaction ile, adımlardan herhangi biri başarısız olursa, veriler işlemin başlangıcında olduğu duruma geri döndürülür.

COMMIT: Transaction'un mutable işlemleri tarafından yapılan tüm değişiklikleri depoladığında transaction'un başarılı bir şekilde sonlandırılması.

Birinci adım sorunsuz bir şekilde yürütülürse, ancak ikinci adım herhangi bir nedenle başarısız olursa, uygulama birinci adımda yapılan değişiklikleri geri alır. Bu işleme rollback adı verilir.

ROLLBACK: Veri tutarsızlıklarını önlemek için verileri işlemin başında göründüğü şekilde geri yüklediğinde işlem rollback ile sona erer.

2. Spring Transaction Nasıl Çalışır?

Spring uygulamanızda transaction'ları nasıl kullanacağınızı göstermeden önce, transaction'ların Spring uygulamasında nasıl çalıştığını ve framework'ün Transaction kodunu implement etmek için size sunduğu özellikleri tartışalım. Aslında, sizinde tahmin edeceğiniz gibi Spring AOP bir transaction'un perde arkasındadır.

Bir aspect, belirli metodların yürütülmesini tanımladığınız şekilde yakalayan bir kod parçasıdır. Günümüzde çoğu durumda, aspect'in yakalaması ve değiştirmesi gereken metodları işaretlemek için anotasyonlar kullanıyoruz. Spring transaction'lar için de işler farklı değildir. Spring'in bir transaction'a almasını istediğimiz bir metodu işaretlemek için @Transactional adlı bir anotasyon kullanırız. Sahne arkasında Spring bir aspect'i yapılandırır ve bu metodla yürütülen işlemler için transaction mantığını uygular (şekil 4).

Şekil 4: @Transactional anotasyonu ile bir aspect yaratmış oluruz.

@Transactional anotasyonunu bir metodla kullandığınızda, Spring tarafından yapılandırılan bir aspect, metod çağrısını yakalar ve bu çağrı için transaction logic'ini uygular. Uygulama, metod bir runtime exception atarsa, metodun yaptığı değişiklikleri sürdürmez.

NOT: Transaction, temel olarak, atılan istisna unchecked bir exception ise transaction'ı geri alır (roll back). Bu nedenle yalnızca runtime exception'lar rollback'i tetikler. Spring dökümanında da ilgili yeri okuyabilirsiniz.

💡
In its default configuration, the Spring Framework’s transaction infrastructure code marks a transaction for rollback only in the case of runtime, unchecked exceptions. That is, when the thrown exception is an instance or subclass of `RuntimeException`. ( `Error` instances also, by default, result in a rollback). Checked exceptions that are thrown from a transactional method do not result in rollback in the default configuration.

Spring, metod bir runtime exception atarsa bir transaction'ı geri almayı bilir. Ama “atar" kelimesini vurgulamak istiyorum. Çoğu kişi genellikle transferMoney() metodunun içindeki bazı işlemlerin bir runtime exception atmasının yeterli olduğunu sanır. Ama bu yeterli değildir! Transactional metodu, exception'ı aspect'in ilerisine taşımalıdır. Böylece aspect, değişiklikleri geri alması gerektiğini bilir. Eğer bu metod kendi logic'i içinde bu exception'u ele alıyor ve daha ileri taşımıyorsa, aspect bir exception meydana geldiğini anlamaz (Şekil 5).

Şekil 6: Eğer bir exception catch ile yakalnırsa, aspect exception'u farketmez ve rollback gerçekleşmez.

Metodun içinde bir runtime exception atılırsa, ancak metod exception'u ele alır ve metodu çağırana geri atmazsa, aspect bu exception'u farketmez ve transaction'u commit eder. Bu durumda olduğu gibi transactional bir mteodda bir exception'u ele aldığınızda, transaction'u yöneten aspect, exception'u  göremediğinden transaction'un rollback edilemeyeceğinin farkında olmanız gerekir.

Transaction'larda Checked Exception'lar Ne Olacak?

Şimdiye kadar, yalnızca runtime exception'ları tartıştık. Peki checked exception'lar ne olacak? Java'da checked exception'lar, ele almanız veya throw etmeniz gereken exception'lardandır; aksi takdirde, uygulamanız compile etmez. Peki bir metod bunları throw ederse, transactionlar'ın rollback etmesine de neden olurlar mı? Varsayılan olarak hayır! Spring'in varsayılan davranışı, yalnızca bir runtime exception durumuyla karşılaştığında bir transaction'u rollback etmektir. Neredeyse tüm gerçek dünya senaryolarında kullanılan transaction'ları da bu şekilde bulacaksınız.

Checked exception ile çalıştığınızda, metod imzasına “throws” yan tümcesini eklemeniz gerekir; aksi takdirde, kodunuz derlenmez. Throws sayesinde logic'inizin ne zaman böyle bir exception atabileceğini her zaman bilirsiniz. Bu nedenle, checked exception ile temsil edilen bir durum, veri tutarsızlığına neden olabilecek bir sorun değil, bunun yerine geliştiricinin uyguladığı logic tarafından yönetilmesi gereken kontrollü bir senaryodur.

Bununla birlikte, yinede Spring'in checked exception için transaction'ı geri almasını istiyorsanız, Spring'in varsayılan davranışını değiştirebilirsiniz. Başlık 3'te kullanmayı öğreneceğiniz @Transactional anotasyonu, Spring'in transaction'ları geri almasını istediğiniz exception'ları tanımlamaya yönelik niteliklere sahiptir.

Ancak, uygulamanızı her zaman basit tutmanızı ve gerekmedikçe framework'ün varsayılan davranışına güvenmenizi öneririm.

3. Spring Transaction Nasıl Kullanılır?

Bir Spring uygulamasında transaction'ları nasıl kullanacağınızı öğreten bir örnekle başlayalım. Bir Spring uygulamasında transaction tanımlamak @Transactional anotasyonunu kullanmak kadar kolaydır. Spring'in bir transaction ile sarmalamasını istediğiniz metodu işaretlemek için @Transactional kullanırsınız. Başka bir şey yapmanıza gerek yoktur. Spring, @Transactional anotasyonu verdiğiniz metodları yakalayan bir aspect'i yapılandırır. Bu aspect bir transaction başlatır ve her şey yolunda giderse metodun değişikliklerini tamamlar veya herhangi bir runtime exception oluştuysa değişiklikleri geri alır.

Hesap ayrıntılarını bir veritabanı tablosunda depolayan uygulama yazacağız. Bunun implement ettiğiniz bir elektronik cüzdan uygulamasının endpointi olduğunu düşünün. Bir hesaptan diğerine para aktarma yeteneği de oluşturacağız. Bu kullanım örneği için, bir exception oluşursa verilerin tutarlı kalmasını sağlamak için bir transaction kullanmamız gerekir.

Şekil 7: Yeni örneğimizde kullanacağımız akış

Para transferi örneğini bir servis sınıfında implemente ediyoruz ve bu servis metodunu bir REST endpoint ile kullanıma açıyoruz. Servis metodu, veritabanındaki verilere erişmek ve değiştirmek için bir repository kullanır. Servis metodu (business logic'i uygulayan) metodu yürütme sırasında sorun oluşursa veri tutarsızlıklarını önlemek için bir transaction'a sarılmalıdır.

Şimdi bir Spring Boot projesi oluşturacağız ve bağımlılıkları bir sonraki kod snippet'inde sunulduğu gibi pom.xml dosyasına ekleyeceğiz. Spring JDBC (Spring Data Source ve JDBC Kullanımı başlıklı yazımızda yaptığımız gibi) ve H2 in-memory veritabanını kullanmaya devam ediyoruz:

<dependency>
   <groupId>org.springframework.boot</groupId>
   <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
   <groupId>org.springframework.boot</groupId>
   <artifactId>spring-boot-starter-data-jdbc</artifactId>
</dependency>
<dependency>
   <groupId>com.h2database</groupId>
   <artifactId>h2</artifactId>
   <scope>runtime</scope>
</dependency>

Uygulama veritabanında yalnızca bir tabloyla çalışıyor. Bu tabloyu "account" olarak adlandırıyoruz ve aşağıdaki alanları ekliyoruz:

  • id — Primary key. Bu alanı, auto increment bir INT değeri olarak tanımlarız.
  • name — Hesabın sahibinin adı.
  • amount — Sahibinin hesapta sahip olduğu para miktarı.

Tabloyu oluşturmak için projenin resources klasöründe bir "schema.sql" dosyası kullanıyoruz. Bu dosyada, bir sonraki kod snippet'inde sunulduğu gibi tabloyu oluşturmak için SQL sorgusunu yazıyoruz:

create table account (
    id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(50) NOT NULL,
    amount DOUBLE NOT NULL
);

Ayrıca, daha sonra test etmek için kullanacağımız iki kayıt oluşturmak için resources klasöründeki "schema.sql" in yanına bir "data.sql" dosyası ekliyoruz. "data.sql" dosyası, veritabanına iki firma kaydı eklemek için SQL sorguları içerir. Bu sorguları aşağıdaki kod snippet'inde bulabilirsiniz:

INSERT INTO account VALUES (NULL, 'Helen Down', 1000);
INSERT INTO account VALUES (NULL, 'Peter Read', 1000);

Uygulamamızdaki verilere başvurmanın bir yolu olması için account tablosunu modelleyen bir sınıfa ihtiyacımız var, bu nedenle aşağıdaki kodda gösterildiği gibi veritabanındaki hesap kayıtlarını modellemek için Account adlı bir sınıf oluşturuyoruz.

public class Account {
 
  private long id;
  private String name;
  private BigDecimal amount;
 
  // Omitted getters and setters
}

"Para transferi" use case'i uygulamak için repository katmanında aşağıdaki özelliklere ihtiyacımız vardır:

  1. account ID kullanarak bir hesabın ayrıntılarını bulun.
  2. Belirli bir hesabın tutarını güncelleştirin.

Bu özellikleri bir önceki yazımızda anlatıldığı gibi JdbcTemplate kullanarak implement edeceğiz. 1. adım için, account ID parametresini alan ve veritabanından bu ID'yi içeren hesabın ayrıntılarını almak için JdbcTemplate kullanan findAccountById (long id) metodunu yazıyoruz. 2. adım için changeAmount (long id, BigDecimal amount) adlı bir metod yazıyoruz. Bu metod, ilk parametrede aldığı id ile hesaba ikinci parametre olarak aldığı amount'u ayarlar. Bir sonraki kod, bu iki metodun implementasyonunu gösterir.

@Repository                                                       ❶
public class AccountRepository {
 
  private final JdbcTemplate jdbc;   
 
  public AccountRepository(JdbcTemplate jdbc) {                   ❷
    this.jdbc = jdbc;
  }
 
  public Account findAccountById(long id) {   
    String sql = "SELECT * FROM account WHERE id = ?";            ❸
    return jdbc.queryForObject(sql, new AccountRowMapper(), id);
  }
 
  public void changeAmount(long id, BigDecimal amount) {
    String sql = "UPDATE account SET amount = ? WHERE id = ?";
    jdbc.update(sql, amount, id);                                 ❹
  }
}

❶ Bu bean'i daha sonra servis sınıfında kullandığımız yere enjekte etmek için @Repository anotasyonu kullanarak Spring Context'e bu sınıfın bir bean'i olarak ekliyoruz.

❷ Bir JdbcTemplate nesnesini veritabanıyla çalışmasını sağlamak için constructor injection ile alıyoruz.

❸ JdbcTemplate queryForObject() metodunu kullanarak SELECT sorgusunu DBMS'ye gönderip bir hesabın ayrıntılarını alır. Ayrıca, JdbcTemplate'e model nesnemize sonuçtaki bir satırın nasıl eşlendiğini söylemek için bir RowMapper sağlamamız gerekir.

❹ JdbcTemplate update() metodunu kullanarak DBMS'ye bir UPDATE sorgusu göndererek bir hesabın amount'unu değiştiririz.

Daha önce öğrendiğiniz gibi, SELECT sorgusu kullanarak veritabanından veri almak için JdbcTemplate kullandığınızda, JdbcTemplate'e sonucun her satırını veritabanından belirli model nesnenize nasıl eşletmenizi söyleyen bir RowMapper nesnesi sağlamanız gerekir. Bizim durumumuzda, JdbcTemplate'e sonuçtaki bir satırın Account nesnesine nasıl eşlendiğini söylememiz gerekir. Sonraki kod RowMapper nesnesinin nasıl uygulanılacağını gösterir.

public class AccountRowMapper 
  implements RowMapper<Account> {                      ❶
 
  @Override
  public Account mapRow(ResultSet resultSet, int i)    ❷
    throws SQLException {
    Account a = new Account();                         ❸
    a.setId(resultSet.getInt("id"));                   ❸
    a.setName(resultSet.getString("name"));            ❸
    a.setAmount(resultSet.getBigDecimal("amount"));    ❸
    return a;                                          ❹
  }
}

❶ RowMapper interfaec'ini implement ediyoruz ve model sınıfımızı generic olarak bu implementasyona geçiyoruz.

❷ Sorgu sonucunu parametre olarak alan (ResultSet nesnesi) ve geçerli sonuç satırını eşlediğimiz Account instance'ını döndüren mapRow() metodunu implemente ediyoruz.

❸ Geçerli sonuç satırındaki değerleri Account attribute'leriyle eşleriz.

❹ Sonuç değerlerini eşledikten sonra Account instance'ını döndürüyoruz.

Uygulamayı daha kolay test etmek için, aşağıdaki kodda gösterildiği gibi veritabanından tüm hesap ayrıntılarını alma özelliğini de ekleyelim. Uygulamanın beklediğimiz gibi çalıştığını doğrularken bu özelliği kullanacağız.

@Repository
public class AccountRepository {
 
  // Omitted code
 
  public List<Account> findAllAccounts() {
    String sql = "SELECT * FROM account";
    return jdbc.query(sql, new AccountRowMapper());
  }
  
}

Servis sınıfında, "para transferi" use case'i için logic'i implement ediyoruz. TransferService sınıfı, account tablosundaki verileri yönetmek için AccountRepository sınıfını kullanır. Metodun uyguladığı logic aşağıdaki gibidir:

  1. Her iki hesaptaki tutarı öğrenmek için kaynak ve hedef hesap ayrıntılarını alın.
  2. Kaynak hesaptaki miktarı, transfer edilecek tutar kadar azaltarak kaydedin.
  3. Çekilen tutar kadar miktarı, hedef hesaba yatırın.

Bir sonraki kod, servis sınıfının transferMoney() metodunun bu mantığı nasıl uyguladığını gösterir. 2. ve 3. adımların mutable işlemler olduğuna dikkat edin. Her iki işlem de kalıcı verileri değiştirir (yani, bazı hesap tutarlarını güncelleştirir). Bunları bir transaction'a sarmazsak, adımlardan biri başarısız olduğu için verilerin tutarsız hale geldiği durumlarla karşılaşabiliriz.

Neyse ki, metodu transactional olarak işaretlemek ve Spring'e bu metodun yürütmelerini yakalaması ve transaction ile sarması gerektiğini söylemek için @Transactional anotasyonunu kullanmamız yeterlidir. Aşağıdaki kod, servis sınıfında para transferi use case logic'in implementasyonunu gösterir.

@Service
public class TransferService {
 
  private final AccountRepository accountRepository;
 
  public TransferService(AccountRepository accountRepository) {
    this.accountRepository = accountRepository;
  }
 
  @Transactional                                     ❶
  public void transferMoney(long idSender, 
                            long idReceiver, 
                            BigDecimal amount) {
    Account sender =                                 ❷
      accountRepository.findAccountById(idSender);   ❷
    Account receiver =                               ❷
      accountRepository.findAccountById(idReceiver); ❷
 
    BigDecimal senderNewAmount =                     ❸
      sender.getAmount().subtract(amount);           ❸
    BigDecimal receiverNewAmount =                   ❹
      receiver.getAmount().add(amount);              ❹
 
    accountRepository                                ❺
     .changeAmount(idSender, senderNewAmount);       ❺
 
    accountRepository                                ❻
     .changeAmount(idReceiver, receiverNewAmount);   ❻
  }
}

❶ Spring'e transactional metodun çağrılarını sarmasını söylemek için @Transactional anotasyonunu kullanırız.

❷ Her hesaptaki geçerli tutarı bulmak için hesapların ayrıntılarını alırız.

❸ Gönderen hesabı için yeni tutarı hesaplıyoruz.

❹ Hedef hesabın yeni tutarını hesaplıyoruz.

❺ Gönderen hesabı için yeni tutar değerini belirledik.

❻ Hedef hesap için yeni tutar değerini belirledik.

Şekil 7, transferMoney() metodunun yürüttüğü işlem kapsamını ve adımları görsel olarak sunar.

Şekil 7: Transaction ile sardığımız metodun yürütülme sırası

Transaction, servis metodunun yürütülmesinden hemen önce başlar ve metod başarıyla sona erdikten hemen sonra sona erer. Metod herhangi bir runtime exception oluşturmazsa, transaction işlemi tamamlar. Herhangi bir adım runtime exception fırlatırsa, uygulama verileri transaction başlamadan önceki haline geri yükler.

Ayrıca tüm hesapları listeleyen bir metod implemente edelim. Bu metodu daha sonra tanımlayacağımız controller sınıfında bir endpoint ile kullanıma koyacağız. Para transferi use case'i test ederken verilerin doğru şekilde değiştirildiğini kontrol etmek için kullanacağız.

@Transactional Kullanımı

@Transactional anotasyonu doğrudan sınıfa da uygulanabilir. Sınıfta kullanılırsa (bir sonraki kod snippet'inde sunulduğu gibi), anotasyon tüm sınıf metodları için geçerli olur. Genellikle gerçek dünyadaki uygulamalarda, bir servis sınıfının metodları use case'leri tanımladığından ve genel olarak tüm use case'lerin tutarlılık açısından transactional olması gerektiğinden, sınıftlarda kullanılan @Transactional anotasyonunu görürsünüz. Her metodda anotasyon kullanımının yinelenmesini önlemek için, sınıfı bir kez işaretlemek daha kolaydır. Hem sınıfta hem de yöntemde @Transactional kullanırken, metod düzeyinin yapılandırması sınıftakini geçersiz kılar:

@Service
@Transactional                    ❶
public class TransferService {
  // Omitted code
  
  public void transferMoney(long idSender, 
                            long idReceiver, 
                            BigDecimal amount) {
 
    // Omitted code
  }
}

❶ Genellikle @Transactional anotasyonunu doğrudan sınıfla kullanırız. Sınıfın birden çok metodu varsa, @Transactional hepsi için geçerli olur.

Sonraki kod, veritabanının tüm hesap kayıtlarının bir listesini döndüren getAllAccounts() metodunun implementasyonunu gösterir.

@Service
public class TransferService {
 
  // Omitted code
 
  public List<Account> getAllAccounts() {
    return accountRepository.findAllAccounts();
  }
}

Aşağıdaki kodda, servis metodalarını kullanıma çıkaran endpoint'leri tanımlayan AccountController sınıfının implementasyonunu bulabilirsiniz.

@RestController
public class AccountController {
 
  private final TransferService transferService;
 
  public AccountController(TransferService transferService) {
    this.transferService = transferService;
  }
 
  @PostMapping("/transfer")                  ❶
  public void transferMoney(
      @RequestBody TransferRequest request   ❷
      ) {
    transferService.transferMoney(           ❸
        request.getSenderAccountId(),
        request.getReceiverAccountId(),
        request.getAmount());
  }
 
  @GetMapping("/accounts")
  public List<Account> getAllAccounts() {
    return transferService.getAllAccounts();
  }
}

❶ Veritabanının verilerinde değişiklikler yaptığı için /transfer endpoint'ini HTTP POST olarak kullanıyoruz.

❷ Gerekli değerleri (kaynak hesap kimliği, hedef hesap kimliği ve aktarılacak tutar) almak için bir request body kullanırız.

❸ Para transferi use case'i uygulayan transferMoney() isimli transactional metodu çağırıyoruz.

TransferMoney() controller parametresi olarak TransferRequest tipinde bir nesne kullanıyoruz. TransferRequest nesnesi yalnızca HTTP request body'i modeller. Sorumluluğu iki uygulama arasında aktarılan verileri modellemek olan bu tür nesneler DTO (Data Transfer Object) olarak adlandırılır. Aşağıdaki kodda TransferRequest DTO'nun tanımı görebilirsiniz.

public class TransferRequest {
 
  private long senderAccountId;
  private long receiverAccountId;
  private BigDecimal amount;
 
  // Omitted code
}

Uygulamayı başlatıp transaction'un nasıl çalıştığını test edelim. Uygulamanın ortaya çıkardığı endpoint'i çağırmak için cURL veya Postman kullanıyoruz. İlk olarak, herhangi bir para transfer işlemini gerçekleştirmeden önce verilerin nasıl göründüğünü kontrol etmek için /accounts endpointi çağıralım. Sonraki snippet, /accounts endpointi çağırmak için kullanılacak cURL komutunu gösterir:

curl http://localhost:8080/accounts

Bu komutu çalıştırdıktan sonra, konsolda bir sonraki snippet'te sunulana benzer bir çıktı bulmalısınız:

[
 {"id":1,"name":"Yunus","amount":1000.0},
 {"id":2,"name":"Selin","amount":1000.0}
]

Veritabanında iki hesabımız var (bunları "data.sql" dosyasında daha önce tanımladık). Yunus ve Selen'in her birinin 1000TL si var. Şimdi Yunus'tan Selin'e 100TL transfer etmek için para transfer use case'ini yürütelim. Bir sonraki kod snippet'inde, Yunus'tan Selin'e 100TL göndermek için /transfer endpointini çağırmak için çalıştırmanız gereken cURL komutunu bulursunuz:

curl -XPOST -H "content-type:application/json" -d '{"senderAccountId":1, 
➥ "receiverAccountId":2, "amount":100}' http://localhost:8080/transfer

/accounts endpointi yeniden çağırırsanız, farkı değerler gözlemlemeniz gerekir. Para transferi operasyonundan sonra Yunus'un 900, Selin'in ise 1100TL si oldu:

curl http://localhost:8080/accounts

Para transferi işleminden sonra /accounts endpointi çağırmanın sonucu bir sonraki snippet'te sunulur:

[
 {"id":1,"name":"Yunus","amount":900.0},
 {"id":2,"name":"Selin","amount":1100.0}
]

Uygulama çalışıyor ve use case beklenen sonucu veriyor. Ancak işlemin gerçekten işe yaradığını nereden kanıtlayacağız? Uygulama, her şey yolunda gittiğinde verileri doğru bir şekilde devam ettirir, ancak metoddaki bir şey bir runtime exception atarsa, uygulamanın gerçekten verileri geri yüklediğini nasıl bilebiliriz? Sadece olduğuna güvenmeli miyiz? Tabii ki değil!

NOT: Uygulamalar hakkında öğrendiğim en önemli şeylerden biri, doğru şekilde test etmediğiniz sürece bir şeyin çalıştığına asla güvenmemeniz gerektiğidir!

Uygulamanızın herhangi bir özelliğini test edene kadar Schrödinger durumunda olduğunu söylemek isterim. Durumunu kanıtlayana kadar hem çalışır hem de çalışmaz. Tabii ki, bu sadece kuantum mekaniğinden temel bir kavramla yaptığım kişisel bir benzetme.

Bir runtime exception atıldığında, transaction'ın beklendiği gibi rollback etmesini test edelim. Aşağıdaki kodda gösterildiği gibi transferMoney() servis metodunun sonuna bir runtime exception atan bir kod satırı ekliyorum.

@Service
public class TransferService {
 
  // Omitted code
 
  @Transactional
  public void transferMoney(
   long idSender, 
   long idReceiver, 
   BigDecimal amount) {
 
    Account sender = accountRepository.findAccountById(idSender);
    Account receiver = accountRepository.findAccountById(idReceiver);
 
    BigDecimal senderNewAmount = sender.getAmount().subtract(amount);
    BigDecimal receiverNewAmount = receiver.getAmount().add(amount);
 
    accountRepository.changeAmount(idSender, senderNewAmount);
    accountRepository.changeAmount(idReceiver, receiverNewAmount);
 
    throw new RuntimeException("Oh no! Something went wrong!");    ❶
  }
 
}

❶ Transaction'da meydana gelen bir sorunu simüle etmek için servis metodunun sonunda bir runtime exception atıyoruz.

Şekil 8, transferMoney() servis metodundaki yaptığımız değişikliği göstermektedir.

Şekil 8: Bir runtime exception fırlatıldığında transaction roll back ederek verileri ilk haline döndürür.

Metod bir runtime exception fırlattığında, Spring transaction'ı geri alır. Veriler üzerinde yapılan tüm başarılı değişiklikler kalıcı olmaz. Uygulama, verileri transaction başladığında olduğu gibi geri yükler.

Uygulamayı başlatıyoruz ve veritabanındaki tüm hesapları döndüren /accounts endpointi çağırarak hesap kayıtlarını kontrol ediyoruz:

curl http://localhost:8080/accounts

Bu komutu çalıştırdıktan sonra, konsolda bir sonraki snippette sunulana benzer bir çıktı bulmalısınız:

[
 {"id":1,"name":"Yunus","amount":1000.0},
 {"id":2,"name":"Selin","amount":1000.0}
]

Önceki testte olduğu gibi, bir sonraki snippet'te gösterilen cURL komutunu kullanarak Yunus'tan Selin'e 100TL transfer etmek için /transfer endpointi çağırıyoruz:

curl -XPOST -H "content-type:application/json" -d '{"senderAccountId":1, 
➥ "receiverAccountId":2, "amount":100}' http://localhost:8080/transfer

Şimdi, servis sınıfının transferMoney() metodu, istemciye gönderilen yanıtta 500 hatasıyla sonuçlanan bir exception atar. Bu exception'ı uygulamanın konsolunda bulmalısınız. Exception'ın stack trace'i, sonraki kod parçacığında sunulana benzer:

java.lang.RuntimeException: Oh no! Something went wrong!
    at
com.example.services.TransferService.transferMoney(TransferService.java:30) 
➥ ~[classes/:na]
    at
com.example.services.TransferService$$FastClassBySpringCGLIB$$338bad6b.invoke
➥ (<generated>) ~[classes/:na]
    at
org.springframework.cglib.proxy.MethodProxy.invoke(MethodProxy.java:218) 
➥ ~[spring-core-5.3.3.jar:5.3.3]

/accounts endpoint'i tekrar çağıralım ve uygulamanın hesapları değiştirip değiştirmediğini görelim:

curl http://localhost:8080/accounts

Bu komutu çalıştırdıktan sonra, konsolda bir sonraki parçada sunulana benzer bir çıktı bulmalısınız:

[
 {"id":1,"name":"Yunus","amount":1000.0},
 {"id":2,"name":"Selin","amount":1000.0}
]

Hesaplardaki tutarları değiştiren iki işlemden sonra exception olsa bile verilerin değişmediğini gözlemlediniz. Yunus'un 900TL si ve Selin'in 1100TL si olması gerekirdi, ancak ikisinin de hesaplarında hala aynı miktarlar var. Bu sonuç, transaction'ın uygulama tarafından rollback edilmesinin sonucudur ve bu da verilerin işlemin başlangıcındaki haline geri yüklenmesine neden olur. Her iki mutable adım yürütülmüş olsa bile, Spring transaction aspect runtime exception aldığında, transaction'ı geri aldı.

4. Özet

  • Transaction, tümünün başarılı bir şekilde yürütüldüğü veya hata olması durumunda hiçbirinin yürütülmediği mutable operasyonların kümesidir. Gerçek dünya senaryosunda, veri tutarsızlıklarından kaçınmak için hemen hemen her use case bir transaction olmalıdır.
  • İşlemlerden herhangi biri başarısız olursa, uygulama verileri transaction'ın başlangıcındaki haline geri yükler (rollback). Bu olduğunda, transaction'ın geri alındığını söylüyoruz.
  • Tüm işlemler başarılı olursa, transaction'ın commit edildiğini söyleriz; bu, uygulamanın, use case'i tamamladığını ve yaptığı tüm değişiklikleri kalıcı hale getirdiği anlamına gelir.
  • Spring'te transaction kodunu uygulamak için @Transactional anotasyonunu kullanırsınız. Spring'in bir transaction'a sarmasını beklediğiniz bir metodu işaretlemek için @Transactional anotasyonunu kullanırsınız. Ayrıca, Spring'e herhangi bir sınıf metodunun transactional olması gerektiğini söylemek için @Transactional ile bir sınıfa anotasyon ekleyebilirsiniz.
  • Yürütme sırasında, bir Spring aspect, @Transactional ile anotated edilmiş metodları yakalar. Aspect, transaction'ı başlatır ve bir exception oluşursa, aspect transaction'ı geri alır. Metod bir istisna atmazsa, transaction commit edilir ve uygulama, metodun değişikliklerini kalıcı hale getirir.