Skip to main content
Version: 3.19

Create an Application That Supports Microservice Transactions by Using the Microservice Transaction API

This tutorial describes how to create a sample e-commerce application that supports microservice transactions by using the Microservice Transaction API.

The Microservice Transaction API lets you run a single transaction across several processes without implementing the two-phase commit protocol yourself. Your services never call prepare, validate, or commit; ScalarDB Cluster does that on their behalf.

Because of that, the same application code runs against either ScalarDB Cluster deployment pattern—the shared-cluster pattern or the separated-cluster pattern. This sample is arranged to make that concrete: it contains one copy of the application code and two sets of configuration files. Use the tabs throughout this page to choose a pattern; your choice carries through every step.

note

Both deployment patterns run ScalarDB Cluster nodes, so a license key is required whichever one you choose.

Overview of the sample microservice application​

The sample e-commerce application shows how users can order and pay for items by using a line of credit.

The sample application has two microservices called the Customer Service and the Order Service based on the database-per-service pattern:

  • The Customer Service manages customer information, including line-of-credit information, credit limit, and credit total.
  • The Order Service is responsible for order operations like placing an order and getting order histories.

Each service has gRPC endpoints. Clients call the endpoints, and the services call each endpoint as well.

The databases that you will be using in the sample application are MySQL, Cassandra, and PostgreSQL. The Customer Service and the Order Service use MySQL and Cassandra respectively, through ScalarDB Cluster, and PostgreSQL holds the Coordinator tables. Both deployment patterns use the same three databases and put each namespace in the same one, so the ScalarDB topology is the only thing that changes between them.

The two deployment patterns differ in how ScalarDB Cluster sits between the services and those databases.

In the shared-cluster pattern, both services connect to one cluster node, which reaches all three databases:

In the separated-cluster pattern, each service owns a cluster, a Transaction Coordinator drives the transaction across them, and the Coordinator tables move to a store of their own that every component reaches:

The two sides are symmetric: each owns a service, a cluster, and a database. What sits outside them is shared: the Transaction Coordinator, which drives the transaction, and the Coordinator tables, which the Transaction Coordinator and both clusters read from and write to.

The application code is the same in both. Only the configuration differs.

note

Since the focus of the sample application is to demonstrate using the Microservice Transaction API, application-specific error handling, authentication processing, and similar functions are not included in the sample application.

ScalarDB Auth is also not enabled, to keep the difference between the two deployment patterns easy to read. Before adapting this topology for a real system, add per-service users and namespace privileges. For details, see Authenticate and Authorize Users. For a microservice sample that has ScalarDB Auth enabled, see Create an Application That Supports Microservice Transactions in a Shared ScalarDB Cluster Environment by Using ScalarDB JDBC.

Service endpoints​

The endpoints defined in the services are as follows:

  • Customer Service

    • getCustomerInfo
    • payment
    • repayment
  • Order Service

    • placeOrder
    • getOrder
    • getOrders
note

The Customer Service exposes no prepare, validate, commit, or rollback endpoints. Not needing them is the point of the Microservice Transaction API.

What you can do in this sample application​

The sample application supports the following types of transactions:

  • Get customer information through the getCustomerInfo endpoint of the Customer Service.
  • Place an order by using a line of credit through the placeOrder endpoint of the Order Service and the payment endpoint of the Customer Service.
    • Checks if the cost of the order is below the customer's credit limit.
    • If the check passes, records the order history and updates the amount the customer has spent.
  • Get order information by order ID through the getOrder endpoint of the Order Service and the getCustomerInfo endpoint of the Customer Service.
  • Get order information by customer ID through the getOrders endpoint of the Order Service and the getCustomerInfo endpoint of the Customer Service.
  • Make a payment through the repayment endpoint of the Customer Service.
    • Reduces the amount the customer has spent.

placeOrder, getOrder, and getOrders each run as one transaction across both services, and therefore across MySQL and Cassandra. getCustomerInfo and repayment run within the Customer Service alone when called directly, so they use the ordinary Transaction API instead.

How the Microservice Transaction API works​

This section explains the API itself. It applies to both deployment patterns. For the full API reference, see ScalarDB Cluster Java API Guide.

Use this API only for transactions that span services. A transaction that stays within one service has nothing to coordinate across processes, so it uses the ordinary Transaction API (DistributedTransactionManager) instead. Both managers come from the same TransactionFactory and the same configuration file:

TransactionFactory factory = TransactionFactory.create("<CONFIGURATION_FILE_PATH>");
DistributedTransactionManager transactionManager = factory.getTransactionManager();
GlobalTransactionManager globalTransactionManager = factory.getGlobalTransactionManager();

A transaction that spans services has two kinds of handles:

  • A GlobalTransaction, obtained from begin() or beginReadOnly(). It drives the outcome of the transaction as a whole and holds no data.
  • A BranchTransaction per service, obtained from beginBranch(transactionId). It is the CRUD handle for that service's part of the work.

The initiator​

One service begins the transaction, shares its ID with the others, and decides the outcome. In this sample, the Order Service is the initiator:

GlobalTransaction global = globalTransactionManager.begin();
BranchTransaction branch = null;
try {
branch = globalTransactionManager.beginBranch(global.getId());
// ... this service's own CRUD ...
branch.end(BranchTransaction.Status.SUCCESS);
branch = null;

callCustomerService(global.getId()); // pass the transaction ID over gRPC

global.commit();
} catch (UnknownTransactionStatusException e) {
// ... end the branch with FAILURE; do not roll back ...
} catch (Exception e) {
// ... end the branch with FAILURE, then roll back ...
}
note

Notice the order: this service finishes its own work and ends its branch before calling the other service. Keeping the branch open across the remote call would also be correct, but ending it as soon as the service is done is the idiom the API is designed around.

The participant​

A service that receives a transaction ID joins the same transaction with its own branch, does its work, and ends the branch. It does not commit or roll back—that is the initiator's responsibility:

BranchTransaction branch = globalTransactionManager.beginBranch(transactionId);
// ... this service's own CRUD ...
branch.end(BranchTransaction.Status.SUCCESS);

In this sample, the Customer Service runs as a participant when the request carries a transaction ID. Otherwise its work stays within the service, so it runs as an ordinary transaction through the Transaction API rather than the Microservice Transaction API. The payment endpoint is always a participant; repayment never is; getCustomerInfo is either, depending on the caller.

Ending a branch is mandatory​

Every branch must be ended exactly once, whatever the outcome. That obligation belongs to the process running the branch, and no other process can discharge it. On a failure path, end the branch with Status.FAILURE:

branch.end(BranchTransaction.Status.FAILURE);

end(FAILURE) is lenient so that it can be called from a catch block: ending a branch that has already been ended is a no-op, and it does not mask the original failure.

note

end(Status), commit(), and rollback() each declare checked exceptions, so a catch block that calls them needs its own handling.

Error handling​

Only the initiator can restart a transaction, so only the initiator retries. In this sample, the Order Service carries a retry loop, as does the Customer Service for the transactions it runs on its own; the participant path does not.

Not every failure is worth retrying. The sample treats the gRPC NOT_FOUND and FAILED_PRECONDITION statuses as deterministic business failures—an unknown item, or an exceeded credit limit—and does not retry them.

UnknownTransactionStatusException is handled separately and is never retried. It means the transaction may or may not have been committed; retrying could duplicate a committed transaction. Determining the actual status is the application's responsibility.

Prerequisites​

warning

You need to have a license key (trial license or commercial license) to use ScalarDB Cluster. If you don't have a license key, please contact us.

Set up ScalarDB Cluster​

The following sections describe how to set up the sample application that supports microservice transactions by using the Microservice Transaction API in ScalarDB Cluster.

Clone the ScalarDB samples repository​

Open Terminal, then clone the ScalarDB samples repository by running the following command:

git clone https://github.com/scalar-labs/scalardb-samples

Then, go to the directory that contains the sample application by running the following command:

cd scalardb-samples/microservice-transaction-sample-with-cluster/

Choose a deployment pattern​

The sample contains one copy of the application code and two directories of configuration:

  • shared-cluster/: Both services connect to one ScalarDB Cluster node, which holds both namespaces and the Coordinator tables.
  • separated-clusters/: Each service owns a ScalarDB Cluster node, and a Transaction Coordinator drives two-phase commit across them. The Coordinator tables live in a store of their own, which every ScalarDB component in the pattern can reach.

For guidance on choosing between the patterns, see ScalarDB Cluster Deployment Patterns for Microservices.

Select a pattern below. Your selection applies to every step on this page.

Every command in this tutorial is run from the shared-cluster directory:

cd shared-cluster/

Set the license key​

Each ScalarDB Cluster node checks a license key at startup, so you need to set yours in the properties file for every node that the pattern you selected runs.

Set the license key and certificate for ScalarDB Cluster in scalardb-cluster-node.properties:

scalar.db.cluster.node.licensing.license_key=<YOUR_LICENSE_KEY>
scalar.db.cluster.node.licensing.license_check_cert_pem=<YOUR_LICENSE_CHECK_CERT_PEM>

For details, see How to Configure a Product License Key.

note

For the sake of quickly setting up this sample application, ScalarDB Cluster runs in standalone mode and wire encryption is not enabled. For a production environment, we recommend enabling wire encryption. For details, see ScalarDB Cluster Standalone Mode and Wire encryption.

ScalarDB Cluster configuration​

The properties files for both patterns are already configured for the sample application, so there is nothing to change in this section or the next beyond the license key you set above. Both sections explain what the settings do.

The cluster-side configuration is where the two patterns are structurally different. The microservice configuration that follows mostly reflects the settings here.

One node serves both services, so it needs access to both namespaces and to the Coordinator tables. It uses multi-storage to reach MySQL, Cassandra, and PostgreSQL, in scalardb-cluster-node.properties:

scalar.db.storage=multi-storage
scalar.db.multi_storage.storages=mysql,cassandra,coordinatordb

scalar.db.multi_storage.storages.mysql.storage=jdbc
scalar.db.multi_storage.storages.mysql.contact_points=jdbc:mysql://mysql:3306/?sslMode=REQUIRED
scalar.db.multi_storage.storages.mysql.username=root
scalar.db.multi_storage.storages.mysql.password=mysql

scalar.db.multi_storage.storages.cassandra.storage=cassandra
scalar.db.multi_storage.storages.cassandra.contact_points=cassandra
scalar.db.multi_storage.storages.cassandra.username=cassandra
scalar.db.multi_storage.storages.cassandra.password=cassandra

scalar.db.multi_storage.storages.coordinatordb.storage=jdbc
scalar.db.multi_storage.storages.coordinatordb.contact_points=jdbc:postgresql://postgres:5432/postgres
scalar.db.multi_storage.storages.coordinatordb.username=postgres
scalar.db.multi_storage.storages.coordinatordb.password=postgres

scalar.db.multi_storage.namespace_mapping=customer_service:mysql,coordinator:coordinatordb
scalar.db.multi_storage.default_storage=cassandra

scalar.db.cluster.node.standalone_mode.enabled=true

namespace_mapping sends each namespace to a storage, and default_storage catches any namespace not listed, which is why order_service needs no entry. The Coordinator tables get a database of their own here as well as in the other pattern, so that each namespace lives in the same database in both and the topology is the only difference.

standalone_mode.enabled=true runs the node as a single instance with static membership. Without it, the node tries to discover peers through Kubernetes.

For details about multi-storage, see How to configure ScalarDB to support multi-storage transactions. For details about standalone mode, see ScalarDB Cluster Standalone Mode.

Microservice configuration​

This is where the two patterns actually differ. The application code is identical; only these properties change.

Both services connect to the same cluster node, and that node holds the transaction. No Transaction Coordinator is involved, so customer-service.properties and order-service.properties are identical:

scalar.db.transaction_manager=cluster
scalar.db.contact_points=indirect:scalardb-cluster-node
scalar.db.cluster.client.transaction_coordinator.enabled=false

transaction_coordinator.enabled=false is the default and could be omitted. It is written out so that the difference from the other pattern is visible in the file rather than only in its absence.

Start the databases and ScalarDB Cluster​

Start the infrastructure before the microservices, since each service connects to ScalarDB Cluster when it starts. The number of containers differs between the patterns.

Start MySQL, Cassandra, PostgreSQL, and one ScalarDB Cluster node:

docker compose up -d mysql cassandra postgres scalardb-cluster-node

The cluster node uses the multi-storage setting: MySQL holds the customer_service namespace, Cassandra holds order_service, and PostgreSQL holds the Coordinator tables. For details, see How to configure ScalarDB to support multi-storage transactions.

note

Starting the Docker containers may take more than one minute depending on your development environment.

Load the schema​

The schema is defined in JSON files in the directory you selected. To apply it, go to the Releases of ScalarDB and download the ScalarDB Cluster Schema Loader (scalardb-cluster-schema-loader-<X.Y.Z>-all.jar) for the version of ScalarDB Cluster that you want to use.

Then run the following commands, replacing <X.Y.Z> with the version that you downloaded.

One cluster holds everything, so one run applies both services' tables and the Coordinator tables:

java -jar scalardb-cluster-schema-loader-<X.Y.Z>-all.jar \
--config scalardb-cluster-node-client.properties \
--schema-file schema.json --coordinator

The --coordinator option is required. The Coordinator tables cannot be declared in a JSON schema file.

Schema details​

All the tables for the Customer Service are created in the customer_service namespace:

  • customer_service.customers: a table that manages customers' information
    • credit_limit: the maximum amount of money a lender will allow each customer to spend when using a line of credit
    • credit_total: the amount of money that each customer has already spent by using their line of credit

All the tables for the Order Service are created in the order_service namespace:

  • order_service.orders: a table that manages order information
  • order_service.statements: a table that manages order statement information
  • order_service.items: a table that manages information of items to be ordered

The Entity Relationship Diagram for the schema is as follows:

ERD

Start the microservices​

Build the Docker images for the two services and start them. The Gradle build is defined at the root of the sample, so -p .. points Gradle there while you stay in the directory for your pattern:

../gradlew -p .. :customer-service:docker :order-service:docker
docker compose up -d customer-service order-service

The images carry no configuration. Each pattern's docker-compose.yml mounts the properties file it needs, which is why the same image tag runs against both patterns. You can confirm that the images are the same across patterns:

docker image inspect --format '{{.Id}}' sample-mstx-order-service:1.0

Each service loads its own initial data when it starts, so there is no separate data-loading step. The Customer Service seeds three customers, and the Order Service seeds the five items that can be ordered.

Execute transactions and retrieve data in the sample application​

The following sections describe how to execute transactions and retrieve data in the sample e-commerce application. The commands and their results are the same in both deployment patterns.

Run these commands from the sample's root directory:

cd ..

Get customer information​

Start with getting information about the customer whose ID is 1 by running the following command:

./gradlew :client:run --args="GetCustomerInfo 1" -q

You should see the following output:

{
"id": 1,
"name": "Yamada Taro",
"creditLimit": 10000
}

At this time, creditTotal isn't shown, which means the current value of creditTotal is 0.

Place an order​

Then, have customer ID 1 place an order for two apples and one orange by running the following command:

note

The order format in this command is ./gradlew :client:run --args="PlaceOrder <CUSTOMER_ID> <ITEM_ID>:<COUNT>,<ITEM_ID>:<COUNT>,...".

./gradlew :client:run --args="PlaceOrder 1 1:2,2:1" -q

You should see a similar output as below, with a different UUID for orderId, which confirms that the order was successful:

{
"orderId": "bcb15594-9a0a-449e-ba20-e1dfed9e6e1e"
}

This single command ran a transaction across both services: the Order Service wrote the order and its statements, and the Customer Service charged the customer's line of credit. Either both happened or neither did.

Check the order details​

Check details about the order by running the following command, replacing <ORDER_ID_UUID> with the UUID for the orderId that was shown after running the previous command:

./gradlew :client:run --args="GetOrder <ORDER_ID_UUID>" -q

You should see a similar output as below, with a different UUID for orderId and a different value for timestamp:

{
"order": {
"orderId": "bcb15594-9a0a-449e-ba20-e1dfed9e6e1e",
"timestamp": "1788401092031",
"customerId": 1,
"customerName": "Yamada Taro",
"statement": [{
"itemId": 1,
"itemName": "Apple",
"price": 1000,
"count": 2,
"total": 2000
}, {
"itemId": 2,
"itemName": "Orange",
"price": 2000,
"count": 1,
"total": 2000
}],
"total": 4000
}
}

customerName comes from the Customer Service. This is a read-only transaction—the Order Service began it with beginReadOnly()—that still spans both services.

Check the credit total​

Check the customer's credit total by running the following command:

./gradlew :client:run --args="GetCustomerInfo 1" -q

You should see the following output, which shows that customer ID 1 has used 4000 of their credit:

{
"id": 1,
"name": "Yamada Taro",
"creditLimit": 10000,
"creditTotal": 4000
}

See a transaction roll back​

Now try an order that exceeds the customer's remaining credit. With 4000 already spent against a limit of 10000, three melons at 3000 each would come to 9000:

./gradlew :client:run --args="PlaceOrder 1 5:3" -q

The command fails with FAILED_PRECONDITION: Credit limit exceeded. Check both services afterwards:

./gradlew :client:run --args="GetCustomerInfo 1" -q
./gradlew :client:run --args="GetOrders 1" -q

creditTotal is still 4000 and no new order appears. The Customer Service rejected the payment, and the Order Service's write of the order was rolled back with it, even though that write had already succeeded on its own branch. Neither service implemented any of that.

Make a payment​

Make a payment by running the following command:

./gradlew :client:run --args="Repayment 1 1000" -q

Then check the credit total again:

./gradlew :client:run --args="GetCustomerInfo 1" -q

You should see that creditTotal has been reduced to 3000. This transaction involves only the Customer Service, so it runs as an ordinary transaction through the Transaction API rather than as a microservice transaction.

Stop the sample application​

To stop the sample application, go back to the directory for the pattern you selected and stop the containers:

cd shared-cluster/
docker compose down -v
note

If you want to try the other deployment pattern, stop the current one as shown above before starting the other one. The two patterns use the same container names and host ports, and Docker Compose treats the two directories as separate projects, so it will not stop the other pattern's containers for you.

See also​