Skip to main content

Connect with the Java thin client

Tutorial

Survey the Ignite 3 Java thin client: connect with partition awareness, define schema from annotations, navigate the Table package, and access data through RecordView, KeyValueView, and SQL.

ignite3gridgain9
Beginner|45 min|getting-started
Tested onApache Ignite 3.1.0GridGain 9.1.8

Introduction

You have a running cluster and a loaded dataset. In the first tutorial, you built the infrastructure with Docker and created 11 tables with SQL DDL. In the second, you explored the data through the SQL CLI. Now you connect a Java application and survey the API surface that the thin client provides.

The Ignite 3 Java thin client exposes four API surfaces through a single connection:

  • SQL runs queries against tables in the cluster.
  • Tables provides typed object operations over rows.
  • Catalog manages schema from Java annotations.
  • Transactions coordinates ACID operations across tables.

You start with the connection and work through four actions:

  • Define a table schema from an annotated Java class.
  • Navigate the Table package to reach different views of the same data.
  • Read the same row through each view to see what the variants offer.
  • Close with a SQL query that joins your new table with the existing Music Store catalog.

This tutorial works with both Apache Ignite 3 and GridGain 9. The Java API is identical; select your product version in the tabs where Maven coordinates differ.

Prerequisites

Returning to these tutorials? Verify your environment.

Check that the cluster is running and the Music Store data is loaded:

docker exec ignite3-node1 /opt/ignite3cli/bin/ignite3 sql \
"SELECT COUNT(*) AS tracks FROM Track;"

Expected result: 3503. If the query succeeds, your environment is ready.

If the containers are stopped, restart them from the directory containing your docker-compose.yml:

docker compose up -d

Data persists across restarts. Wait 15-30 seconds for the nodes to rejoin, then re-run the check above.

If the cluster was destroyed (docker compose down), start the containers and re-initialize:

docker compose up -d

Wait 10 seconds for the nodes to start, then initialize the cluster:

curl -X POST http://localhost:10300/management/v1/cluster/init \
-H "Content-Type: application/json" \
-d '{"metaStorageNodes":["node1","node2","node3"],"cmgNodes":[],"clusterName":"my-cluster"}'

Download the schema and data files:

curl -sO /assets/dataset/music-store-schema.sql
curl -sO /assets/dataset/music-store-data.sql

Copy the files into the container and load them:

docker cp music-store-schema.sql ignite3-node1:/tmp/
docker cp music-store-data.sql ignite3-node1:/tmp/
docker exec ignite3-node1 /opt/ignite3cli/bin/ignite3 sql --file /tmp/music-store-schema.sql
docker exec ignite3-node1 /opt/ignite3cli/bin/ignite3 sql --file /tmp/music-store-data.sql

The schema loader prints "Updated 0 rows" for each DDL statement. The data loader prints row counts per batch. Both commands emit jline reflection warnings that are cosmetic and safe to ignore.

Re-run the check above to verify 3503 tracks are loaded.

What You Will Learn

  • How the thin client connects with partition awareness
  • The four API surfaces: SQL, Tables, Catalog, and Transactions
  • How annotations define distributed table schema (schema-as-code)
  • How to navigate the Table package from client to view
  • How RecordView, KeyValueView, and SQL access the same data differently

What You Will Build

A Java application that connects to your 3-node cluster, creates a table from an annotated Java class, writes and reads data through RecordView, previews KeyValueView access to the same rows, and queries the data with SQL.

Step 1: Create the Project

Create a directory for the project and add a pom.xml with the Ignite thin client dependency:

mkdir t03-java-client && cd t03-java-client
pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>

<groupId>com.example</groupId>
<artifactId>t03-java-client</artifactId>
<version>1.0.0</version>

<properties>
<maven.compiler.release>17</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>

<dependencies>
<dependency>
<groupId>org.apache.ignite</groupId>
<artifactId>ignite-client</artifactId>
<version>3.1.0</version>
</dependency>
</dependencies>

<build>
<plugins>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>exec-maven-plugin</artifactId>
<version>3.4.1</version>
<configuration>
<mainClass>com.example.musicstore.MusicStoreClient</mainClass>
</configuration>
</plugin>
</plugins>
</build>
</project>

The ignite-client dependency is the only one you need. It pulls in ignite-api (which contains the annotation classes and table interfaces) as a transitive dependency.

Create the source directory and an empty main class:

mkdir -p src/main/java/com/example/musicstore
src/main/java/com/example/musicstore/MusicStoreClient.java
package com.example.musicstore;

public class MusicStoreClient {
public static void main(String[] args) {
System.out.println("Project compiles.");
}
}

Verify the project compiles:

mvn compile exec:java
Project compiles.
Checkpoint:The project compiles and prints "Project compiles." with mvn compile exec:java.

Step 2: Connect and Survey the Client

Replace the contents of MusicStoreClient.java with the connection code:

src/main/java/com/example/musicstore/MusicStoreClient.java
package com.example.musicstore;

import org.apache.ignite.client.IgniteClient;
import org.apache.ignite.tx.Transaction;
import java.util.logging.Level;
import java.util.logging.Logger;

public class MusicStoreClient {
public static void main(String[] args) {
// Suppress Ignite's internal partition-assignment log messages
Logger.getLogger("org.apache.ignite").setLevel(Level.WARNING);

// Connect to all three cluster nodes for partition awareness
try (IgniteClient client = IgniteClient.builder()
.addresses("localhost:10800", "localhost:10801", "localhost:10802")
.build()) {

System.out.println("Connected to the cluster.");

// The IgniteClient exposes four API surfaces:
//
// client.sql() - execute SQL queries and DML statements
// client.tables() - access tables and their typed views (RecordView, KeyValueView)
// client.catalog() - manage schema: create and drop tables and zones from code
// client.transactions() - begin explicit ACID transactions (covered later)
//
// This tutorial uses the first three. Each operates on the same
// distributed catalog, so a table created through catalog() is
// immediately queryable through sql() and accessible through tables().

// Verify the connection by querying Music Store data loaded earlier
try (var result = client.sql().execute((Transaction) null,
"SELECT COUNT(*) AS track_count FROM Track")) {
System.out.println("Music Store tracks: "
+ result.next().longValue("track_count"));
}
}

// Force JVM exit so Maven does not wait for Ignite's background threads
System.exit(0);
}
}

Key details in this code:

  • try-with-resources creates the client and closes it when the block exits. The inner try around execute() closes the ResultSet, which releases server-side cursor resources.
  • IgniteClient.builder().addresses(...) accepts multiple node addresses. The client connects to the first available node, discovers the full topology, and opens direct connections to every node. This is partition awareness: each operation routes directly to the node that owns the data. If a node fails, the client reconnects to the remaining nodes.
  • client.sql().execute((Transaction) null, ...) runs a SQL query. The explicit (Transaction) cast ensures the code compiles across all Ignite 3 and GridGain 9 versions. The null value means the query runs in its own implicit transaction. This is the same SQL API you used through the CLI in the previous tutorial.

System.exit(0) after the try block forces the JVM to shut down immediately. Without it, the Ignite client's background timer thread keeps the process alive for 15 seconds after the connection closes.

Run the application:

mvn compile exec:java -q
Connected to the cluster.
Music Store tracks: 3503

The -q flag suppresses Maven's build output so you see only the application's System.out lines.

Checkpoint:The application connects to the cluster and prints "Music Store tracks: 3503".

Step 3: Define a Table Schema with Annotations

In the first tutorial, you created tables with CREATE TABLE statements. The Catalog API offers an alternative: define the schema as an annotated Java class and deploy it with client.catalog().createTable(). This is schema-as-code.

Replace the contents of MusicStoreClient.java:

src/main/java/com/example/musicstore/MusicStoreClient.java
package com.example.musicstore;

import org.apache.ignite.client.IgniteClient;
import org.apache.ignite.catalog.annotations.Column;
import org.apache.ignite.catalog.annotations.Id;
import org.apache.ignite.catalog.annotations.Table;
import org.apache.ignite.tx.Transaction;
import java.util.logging.Level;
import java.util.logging.Logger;

public class MusicStoreClient {

/**
* Schema definition for the Favorite table.
*
* Each annotation maps to a distributed schema concept:
* @Table("Favorite") - names the table in the cluster catalog
* @Id - primary key, determines which node owns each row
* @Column - column definition (SQL type inferred from Java type)
* @Column(length=200) - adds a VARCHAR(200) constraint
*
* This class produces the same result as:
* CREATE TABLE Favorite (
* FavoriteId INT PRIMARY KEY,
* TrackId INT,
* Note VARCHAR(200)
* );
*
* Additional annotations available but not used here:
* @Zone - assign to a specific distribution zone (replication, partitions)
* @Index - define secondary indexes for query performance
* @ColumnRef - reference columns in colocation and index definitions
*/
@Table("Favorite")
static class Favorite {
@Id
private Integer favoriteId;

@Column
private Integer trackId;

@Column(length = 200)
private String note;

// Default constructor required for deserialization from the cluster
Favorite() {}

Favorite(Integer favoriteId, Integer trackId, String note) {
this.favoriteId = favoriteId;
this.trackId = trackId;
this.note = note;
}

public Integer getFavoriteId() { return favoriteId; }
public void setFavoriteId(Integer favoriteId) { this.favoriteId = favoriteId; }
public Integer getTrackId() { return trackId; }
public void setTrackId(Integer trackId) { this.trackId = trackId; }
public String getNote() { return note; }
public void setNote(String note) { this.note = note; }

@Override
public String toString() {
return "Favorite{favoriteId=" + favoriteId
+ ", trackId=" + trackId
+ ", note='" + note + "'}";
}
}

public static void main(String[] args) {
Logger.getLogger("org.apache.ignite").setLevel(Level.WARNING);

try (IgniteClient client = IgniteClient.builder()
.addresses("localhost:10800", "localhost:10801", "localhost:10802")
.build()) {

System.out.println("Connected to the cluster.");

// Verify the connection against existing Music Store data
try (var countResult = client.sql().execute((Transaction) null,
"SELECT COUNT(*) AS track_count FROM Track")) {
System.out.println("Music Store tracks: "
+ countResult.next().longValue("track_count"));
}

// Clean slate: drop the table if a previous run left it behind
client.sql().execute((Transaction) null, "DROP TABLE IF EXISTS Favorite").close();

// Deploy the schema from the annotated POJO (Catalog API)
client.catalog().createTable(Favorite.class);
System.out.println("Created the Favorite table from annotations.");

// Verify: the table appears in the cluster's system catalog
try (var tableCheck = client.sql().execute((Transaction) null,
"SELECT NAME, ZONE FROM SYSTEM.TABLES WHERE NAME = 'FAVORITE'")) {
var row = tableCheck.next();
System.out.println("Catalog entry: " + row.stringValue("NAME")
+ " in zone " + row.stringValue("ZONE"));
}
}

System.exit(0);
}
}

The @Table annotation transforms a Java class into a distributed table definition. Three annotations define the schema:

  • @Table("Favorite") names the table in the cluster catalog. Without a @Zone annotation, the table lands in the Default zone with a single replica. For development, this is fine. The Music Store tables you created in the first tutorial use an explicit MusicStore zone with 3 replicas, which is what you would use in production.
  • @Id marks the primary key. In a distributed system, the primary key determines partition routing: the cluster hashes this value to decide which node owns each row. When you look up favoriteId=2, the client routes directly to the node that owns that partition.
  • @Column maps a Java field to a table column. The SQL type is inferred from the Java type: Integer becomes INT, String becomes VARCHAR. The optional length parameter constrains the string size.

Three more annotations are available for production use:

  • @Zone assigns the table to a specific distribution zone (replication factor, partition count, storage profile).
  • @Index defines secondary indexes for query performance.
  • @ColumnRef references columns in colocation and index definitions.

You saw the effect of colocation on join performance in the previous tutorial's EXPLAIN plans.

DROP TABLE IF EXISTS before createTable() keeps the application re-runnable. catalog().dropTable() has no "if exists" guard, so the SQL API handles the idempotent cleanup instead.

Run the application:

mvn compile exec:java -q
Connected to the cluster.
Music Store tracks: 3503
Created the Favorite table from annotations.
Catalog entry: FAVORITE in zone Default

The annotated class produced the same result as a CREATE TABLE statement. The table is now in the cluster catalog alongside the 11 Music Store tables, queryable through SQL and accessible through the Table API.

Checkpoint:The application prints "Catalog entry: FAVORITE in zone Default".

Step 4: The Table Package

The Favorite table is in the catalog. Every interaction with table data follows a three-level path:

  1. client.tables() returns the IgniteTables interface, the entry point for all table operations.
  2. .table("Favorite") returns a Table object representing the physical table in the cluster.
  3. From the Table object, you choose a view that determines how you interact with the data.

A single Table offers four primary view types, organized into two families:

ViewMethodDescription
RecordView<Favorite>.recordView(Favorite.class)Each row is a complete typed POJO
RecordView<Tuple>.recordView()Each row is a dynamic Tuple (no POJO needed)
KeyValueView<K, V>.keyValueView(K.class, V.class)Each row split into typed key and value objects
KeyValueView<Tuple, Tuple>.keyValueView()Each row split into key and value Tuples

RecordView treats each row as a single object. KeyValueView splits each row into a separate key (the @Id columns) and value (everything else). Both families offer typed (POJO) and untyped (Tuple) variants. All four views read and write the same underlying data. The next tutorial covers the full RecordView and KeyValueView operation set.

Add the following code after the System.out.println("Catalog entry: ..." line, before the closing } of the try block:

// Add after the catalog entry verification, inside the try block

// --- Navigate the Table package ---

// Level 1: IgniteTables - entry point for all table operations
var tables = client.tables();

// Level 2: Table - represents the physical table in the cluster
var favoriteTable = tables.table("Favorite");

// Level 3: RecordView - typed POJO access to table rows
var favorites = favoriteTable.recordView(Favorite.class);

// RecordView offers four categories of write operations:
// upsert / upsertAll - insert or update (idempotent)
// insert / insertAll - insert only, returns false if the key exists
// replace - update only, returns false if the key is missing
// delete / deleteAll - remove by key
//
// Each category has sync, async (CompletableFuture), and bulk variants.
// This tutorial uses upsert and get. The next tutorial covers the full set.

// Batch write: upsertAll sends all records in a single network round-trip
var favList = java.util.List.of(
new Favorite(1, 1613, "Eight minutes of rock history"),
new Favorite(2, 2254, "Six sections, zero repetition"),
new Favorite(3, 1990, "Four chords that ended the 1980s"));
favorites.upsertAll(null, favList);
System.out.println("Saved " + favList.size() + " favorites via RecordView.");

// Key-based read: get routes directly to the partition that owns favoriteId=2
// Only the @Id field needs a value; non-key fields are null in the lookup key
Favorite retrieved = favorites.get(null, new Favorite(2, null, null));
System.out.println("RecordView lookup: " + retrieved);

upsertAll() writes all three records in a single call. The operation is idempotent: running the application twice overwrites existing rows instead of failing on duplicate keys. The first parameter is the transaction context: null means each operation runs in its own implicit transaction.

get() performs a key-based lookup. The client uses the @Id field to compute the partition hash and routes the request directly to the node that owns that partition. Only the key field needs a value in the lookup object; the cluster fills in the remaining columns from storage and returns the complete record.

The three TrackIds (1613, 2254, 1990) reference real rows in the Music Store's Track table. Step 5 resolves these IDs to track names with a SQL JOIN.

Run the application:

mvn compile exec:java -q
Connected to the cluster.
Music Store tracks: 3503
Created the Favorite table from annotations.
Catalog entry: FAVORITE in zone Default
Saved 3 favorites via RecordView.
RecordView lookup: Favorite{favoriteId=2, trackId=2254, note='Six sections, zero repetition'}
Checkpoint:The application prints "Saved 3 favorites via RecordView." and retrieves favorite 2 with trackId 2254.

Step 5: Access the Same Data Through KeyValueView and SQL

The three favorites you wrote through RecordView are visible to every other view of the same table. This step reads the same data through an untyped KeyValueView lookup and a SQL JOIN that crosses zone boundaries.

Add the following code after the System.out.println("RecordView lookup: ..." line, before the closing } of the try block:

// Add after the RecordView lookup, inside the try block

// --- KeyValueView: the same table, split into key and value ---

// The untyped KeyValueView uses Tuple instead of POJOs.
// Key Tuple contains only the @Id columns.
// Value Tuple contains the remaining columns.
var kvView = favoriteTable.keyValueView();

var key = org.apache.ignite.table.Tuple.create()
.set("favoriteId", 2);
var value = kvView.get(null, key);
System.out.println("KeyValueView lookup: trackId="
+ value.intValue("trackId")
+ ", note=" + value.stringValue("note"));

// --- SQL: the same table, accessed through queries ---

// SQL joins the Favorite table (Default zone, created from annotations)
// with the Track table (MusicStore zone, created from DDL earlier).
// Zone assignment controls replication policy, not query interoperability.
System.out.println("\nFavorites with track names (SQL JOIN):");
try (var joinResult = client.sql().execute((Transaction) null,
"SELECT f.FavoriteId, t.Name AS Track, f.Note "
+ "FROM Favorite f "
+ "JOIN Track t ON f.TrackId = t.TrackId "
+ "ORDER BY f.FavoriteId")) {

while (joinResult.hasNext()) {
var r = joinResult.next();
System.out.println(" " + r.intValue("FavoriteId") + ": "
+ r.stringValue("Track") + " - "
+ r.stringValue("Note"));
}
}

Three access patterns, one table:

  • KeyValueView splits each row into a key (the @Id columns) and a value (everything else). The keyValueView() call here returns the untyped KeyValueView<Tuple, Tuple>. A typed variant, keyValueView(Key.class, Value.class), maps to POJOs instead. The next tutorial covers both.
  • Tuple is Ignite's dynamic column container. Write with .set("favoriteId", 2). Read with typed accessors: .intValue("trackId"), .stringValue("note"). No POJO class required.
  • SQL JOIN confirms that data written through RecordView appears immediately in SQL queries. It also confirms that tables in different zones (Favorite in Default, Track in MusicStore) join without additional configuration.

Run the application:

mvn compile exec:java -q
Connected to the cluster.
Music Store tracks: 3503
Created the Favorite table from annotations.
Catalog entry: FAVORITE in zone Default
Saved 3 favorites via RecordView.
RecordView lookup: Favorite{favoriteId=2, trackId=2254, note='Six sections, zero repetition'}
KeyValueView lookup: trackId=2254, note=Six sections, zero repetition

Favorites with track names (SQL JOIN):
1: Stairway To Heaven - Eight minutes of rock history
2: Bohemian Rhapsody - Six sections, zero repetition
3: Smells Like Teen Spirit - Four chords that ended the 1980s

The same favoriteId=2 record appears three times across three access patterns: as a Favorite POJO from RecordView, as a key-value Tuple pair from KeyValueView, and as a joined SQL row with the track name resolved. One schema, multiple views.

Checkpoint:The KeyValueView lookup returns trackId=2254. The SQL JOIN returns all 3 favorites with track names from the Music Store.

Step 6: Clean Up

In Step 3, you used client.catalog().createTable() to deploy a schema. The Catalog API also handles the reverse: dropTable() removes a table from the cluster. Drop the Favorite table so future tutorials start with a clean 11-table schema.

Add the following code after the while loop that prints the favorites, before the closing } of the try block:

// Add after the SQL JOIN loop, inside the try block

// Clean up: drop the Favorite table using the Catalog API
client.catalog().dropTable("Favorite");
System.out.println("\nDropped the Favorite table.");

// Verify: Favorite is gone, Music Store tables are intact
try (var verifyDrop = client.sql().execute((Transaction) null,
"SELECT COUNT(*) AS remaining FROM SYSTEM.TABLES "
+ "WHERE NAME = 'FAVORITE'")) {
System.out.println("Favorite table count: "
+ verifyDrop.next().longValue("remaining"));
}

try (var totalTables = client.sql().execute((Transaction) null,
"SELECT COUNT(*) AS total FROM SYSTEM.TABLES")) {
System.out.println("Total tables: "
+ totalTables.next().longValue("total"));
}

The Catalog API also includes methods not used in this tutorial:

  • createZone(ZoneDefinition) and dropZone(String) manage distribution zones programmatically, the same zones you created with CREATE ZONE in the first tutorial.
  • tableDefinition(String) and zoneDefinition(String) inspect existing schema definitions.

Every Catalog and Table API method has an async variant that returns CompletableFuture for non-blocking workflows.

Run the complete application one final time:

mvn compile exec:java -q
Connected to the cluster.
Music Store tracks: 3503
Created the Favorite table from annotations.
Catalog entry: FAVORITE in zone Default
Saved 3 favorites via RecordView.
RecordView lookup: Favorite{favoriteId=2, trackId=2254, note='Six sections, zero repetition'}
KeyValueView lookup: trackId=2254, note=Six sections, zero repetition

Favorites with track names (SQL JOIN):
1: Stairway To Heaven - Eight minutes of rock history
2: Bohemian Rhapsody - Six sections, zero repetition
3: Smells Like Teen Spirit - Four chords that ended the 1980s

Dropped the Favorite table.
Favorite table count: 0
Total tables: 11

The Favorite table is gone and all 11 Music Store tables remain intact.

Checkpoint:The Favorite table count is 0. The total table count is 11.

Summary

You connected a Java application to the Ignite 3 cluster and exercised the thin client's four API surfaces. Here is a map of every API call you made, organized by the surface it belongs to.

DDL, DML, and where SQL fits

The thin client separates schema management from data operations:

  • client.catalog() is the DDL surface. It manages tables and zones from Java code.
  • client.tables() is the DML surface. It reads and writes data through typed views.
  • client.sql() crosses both. CREATE TABLE is DDL through SQL. SELECT is DML through SQL.
  • client.transactions() coordinates ACID transactions across multiple operations.
OperationSurfaceStep
catalog().createTable(Favorite.class)DDL via Catalog APIStep 3
catalog().dropTable("Favorite")DDL via Catalog APIStep 6
sql().execute("DROP TABLE IF EXISTS ...")DDL via SQL APIStep 3
sql().execute("SELECT COUNT(*) ...")DML via SQL APISteps 2, 5, 6
favorites.upsertAll(...)DML via Table API (RecordView)Step 4
favorites.get(...)DML via Table API (RecordView)Step 4
kvView.get(...)DML via Table API (KeyValueView)Step 5

Schema-as-code

Annotations define the schema in Java:

  • @Table names the table that the Catalog API creates.
  • @Id marks the primary key, which determines partition routing.
  • @Column maps each field to a column in the table.

client.catalog().createTable(Favorite.class) deploys the schema. The result is identical to the CREATE TABLE statements you wrote in the first tutorial. Additional annotations (@Zone, @Index, @ColumnRef) handle distribution zones, secondary indexes, and colocation.

The Table package hierarchy

Every data interaction follows the same three-level path: client.tables() returns IgniteTables, .table("Favorite") returns the physical Table, and from there you choose a view. A single Table offers four primary view types across two families: RecordView (each row is one object) and KeyValueView (each row split into key and value). Both families offer typed (POJO) and untyped (Tuple) variants.

One table, multiple access patterns

You accessed the same favoriteId=2 record three ways: as a Favorite POJO through RecordView, as a key-value Tuple pair through KeyValueView, and as a joined SQL row with the track name resolved. All views read and write the same underlying data. Choose RecordView for type-safe business logic, KeyValueView for key-oriented access patterns, and SQL for queries that are easier to express relationally (joins, aggregations, filtering across tables).

Next step

The next tutorial covers RecordView and KeyValueView with the Music Store dataset: insert, replace, delete, bulk operations, and the criteria for choosing between views. (Coming soon.)