Skip to main content

Choose the Right Data Access Pattern

Tutorial

Build a data access layer that selects KeyValueView, RecordView, or SQL at every call site based on key availability, operation shape, and safety requirements.

ignite3gridgain9
Intermediate|75 min|data-modeling
Tested onApache Ignite 3.1.0GridGain 9.1.8

Introduction

The entity classes from the previous tutorial carry two responsibilities: they define the schema, and they map fields to columns at runtime. The @Id annotations that declared composite primary keys for colocation have a third effect that surfaces the moment your application makes its first data access call. Those annotations constrain which API calls are possible, which write operations are safe, and which consistency guarantees are available.

In this tutorial, you build a data access layer for the Music Store: nine service methods, each selecting its API based on key availability, operation shape, and safety requirements.

Product compatibility

Apache Ignite 3 and GridGain 9 share the same RecordView, KeyValueView, SQL, and transaction APIs. All Java code in this tutorial is identical on both products. Select your product version in the tabs for Maven coordinates and CLI commands. GridGain 9 adds near-cache for client-side caching, covered in a dedicated section at the end.

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. See Start Your Local Ignite 3 Development Cluster for the full setup process.

What you will learn

  • Why RecordView and KeyValueView require the full composite primary key for point lookups
  • How to select the right API based on key availability, operation shape, and safety needs
  • When to use insert instead of upsert for write safety
  • How to mix RecordView, KeyValueView, and SQL in a single transaction
  • How to evaluate the near-cache consistency tradeoff (GridGain 9)

Set Up the Project

Create a new Maven project in your IDE. This is a separate project from the annotations tutorial. A fresh project avoids dependencies on any modifications you may have made, and the entity classes are portable because the annotations carry the schema definition with the class.

Replace the generated pom.xml with the following:

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>music-store-access</artifactId>
<version>1.0-SNAPSHOT</version>

<properties>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<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>
</project>

Copy four entity classes from the annotations tutorial into a model package (src/main/java/com/example/musicstore/model/): Artist, Album, Customer, and Invoice. These are the tables the service class operates on through the programmatic APIs. SQL queries reference other tables (Track, InvoiceLine) by name without needing entity classes.

As you paste each class, count the @Id annotations. Artist has one. Album has two. Customer has one. Invoice has two. That count determines what your application must provide for every programmatic data access call.

Entity classes (copy from the annotations tutorial)
src/main/java/com/example/musicstore/model/Artist.java
package com.example.musicstore.model;

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.catalog.annotations.Zone;

@Table(zone = @Zone(value = "MusicStore", storageProfiles = "default",
partitions = 25, replicas = 2))
public class Artist {
@Id
@Column(value = "ArtistId", nullable = false)
private Integer artistId;

@Column(value = "Name", nullable = false, length = 120)
private String name;

public Artist() {}
public Artist(Integer artistId, String name) {
this.artistId = artistId;
this.name = name;
}

public Integer getArtistId() { return artistId; }
public void setArtistId(Integer artistId) { this.artistId = artistId; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }

@Override
public String toString() {
return "Artist{artistId=" + artistId + ", name='" + name + "'}";
}
}
src/main/java/com/example/musicstore/model/Album.java
package com.example.musicstore.model;

import org.apache.ignite.catalog.annotations.*;

@Table(zone = @Zone(value = "MusicStore", storageProfiles = "default",
partitions = 25, replicas = 2),
colocateBy = @ColumnRef("ArtistId"),
indexes = @Index(value = "IFK_AlbumArtistId",
columns = @ColumnRef("ArtistId")))
public class Album {
@Id
@Column(value = "AlbumId", nullable = false)
private Integer albumId;

@Id
@Column(value = "ArtistId", nullable = false)
private Integer artistId;

@Column(value = "Title", nullable = false, length = 160)
private String title;

public Album() {}
public Album(Integer albumId, Integer artistId, String title) {
this.albumId = albumId;
this.artistId = artistId;
this.title = title;
}

public Integer getAlbumId() { return albumId; }
public void setAlbumId(Integer albumId) { this.albumId = albumId; }
public Integer getArtistId() { return artistId; }
public void setArtistId(Integer artistId) { this.artistId = artistId; }
public String getTitle() { return title; }
public void setTitle(String title) { this.title = title; }

@Override
public String toString() {
return "Album{albumId=" + albumId + ", artistId=" + artistId
+ ", title='" + title + "'}";
}
}
src/main/java/com/example/musicstore/model/Customer.java
package com.example.musicstore.model;

import org.apache.ignite.catalog.annotations.*;

@Table(zone = @Zone(value = "MusicStore", storageProfiles = "default",
partitions = 25, replicas = 2))
public class Customer {
@Id
@Column(value = "CustomerId", nullable = false)
private Integer customerId;

@Column(value = "FirstName", nullable = false, length = 40)
private String firstName;

@Column(value = "LastName", nullable = false, length = 20)
private String lastName;

@Column(value = "Company", length = 80)
private String company;

@Column(value = "Address", length = 70)
private String address;

@Column(value = "City", length = 40)
private String city;

@Column(value = "State", length = 40)
private String state;

@Column(value = "Country", length = 40)
private String country;

@Column(value = "PostalCode", length = 10)
private String postalCode;

@Column(value = "Phone", length = 24)
private String phone;

@Column(value = "Fax", length = 24)
private String fax;

@Column(value = "Email", nullable = false, length = 60)
private String email;

@Column(value = "SupportRepId")
private Integer supportRepId;

public Customer() {}

public Integer getCustomerId() { return customerId; }
public void setCustomerId(Integer customerId) { this.customerId = customerId; }
public String getFirstName() { return firstName; }
public void setFirstName(String firstName) { this.firstName = firstName; }
public String getLastName() { return lastName; }
public void setLastName(String lastName) { this.lastName = lastName; }
public String getCompany() { return company; }
public void setCompany(String company) { this.company = company; }
public String getAddress() { return address; }
public void setAddress(String address) { this.address = address; }
public String getCity() { return city; }
public void setCity(String city) { this.city = city; }
public String getState() { return state; }
public void setState(String state) { this.state = state; }
public String getCountry() { return country; }
public void setCountry(String country) { this.country = country; }
public String getPostalCode() { return postalCode; }
public void setPostalCode(String postalCode) { this.postalCode = postalCode; }
public String getPhone() { return phone; }
public void setPhone(String phone) { this.phone = phone; }
public String getFax() { return fax; }
public void setFax(String fax) { this.fax = fax; }
public String getEmail() { return email; }
public void setEmail(String email) { this.email = email; }
public Integer getSupportRepId() { return supportRepId; }
public void setSupportRepId(Integer supportRepId) { this.supportRepId = supportRepId; }

@Override
public String toString() {
return "Customer{customerId=" + customerId + ", name='"
+ firstName + " " + lastName + "'}";
}
}
src/main/java/com/example/musicstore/model/Invoice.java
package com.example.musicstore.model;

import java.math.BigDecimal;
import java.time.LocalDate;
import org.apache.ignite.catalog.annotations.*;

@Table(zone = @Zone(value = "MusicStore", storageProfiles = "default",
partitions = 25, replicas = 2),
colocateBy = @ColumnRef("CustomerId"),
indexes = @Index(value = "IFK_InvoiceCustomerId",
columns = @ColumnRef("CustomerId")))
public class Invoice {
@Id
@Column(value = "InvoiceId", nullable = false)
private Integer invoiceId;

@Id
@Column(value = "CustomerId", nullable = false)
private Integer customerId;

@Column(value = "InvoiceDate", nullable = false)
private LocalDate invoiceDate;

@Column(value = "BillingAddress", length = 70)
private String billingAddress;

@Column(value = "BillingCity", length = 40)
private String billingCity;

@Column(value = "BillingState", length = 40)
private String billingState;

@Column(value = "BillingCountry", length = 40)
private String billingCountry;

@Column(value = "BillingPostalCode", length = 10)
private String billingPostalCode;

@Column(value = "Total", nullable = false, precision = 10, scale = 2)
private BigDecimal total;

public Invoice() {}

public Integer getInvoiceId() { return invoiceId; }
public void setInvoiceId(Integer invoiceId) { this.invoiceId = invoiceId; }
public Integer getCustomerId() { return customerId; }
public void setCustomerId(Integer customerId) { this.customerId = customerId; }
public LocalDate getInvoiceDate() { return invoiceDate; }
public void setInvoiceDate(LocalDate invoiceDate) { this.invoiceDate = invoiceDate; }
public String getBillingAddress() { return billingAddress; }
public void setBillingAddress(String billingAddress) { this.billingAddress = billingAddress; }
public String getBillingCity() { return billingCity; }
public void setBillingCity(String billingCity) { this.billingCity = billingCity; }
public String getBillingState() { return billingState; }
public void setBillingState(String billingState) { this.billingState = billingState; }
public String getBillingCountry() { return billingCountry; }
public void setBillingCountry(String billingCountry) { this.billingCountry = billingCountry; }
public String getBillingPostalCode() { return billingPostalCode; }
public void setBillingPostalCode(String billingPostalCode) { this.billingPostalCode = billingPostalCode; }
public BigDecimal getTotal() { return total; }
public void setTotal(BigDecimal total) { this.total = total; }

@Override
public String toString() {
return "Invoice{invoiceId=" + invoiceId + ", customerId=" + customerId
+ ", date=" + invoiceDate + ", total=" + total + "}";
}
}
Checkpoint:The project compiles. Your model package contains Artist (1 @Id), Album (2 @Id), Customer (1 @Id), and Invoice (2 @Id).

Discover the Composite Key Constraint

You need to display Album 5 on a detail page. You know the AlbumId from the URL, but not the ArtistId. Try looking it up through RecordView, the same way the foundations tutorials accessed single-key tables.

Create ExploreKeys.java in the com.example.musicstore package:

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

import com.example.musicstore.model.Album;
import org.apache.ignite.client.IgniteClient;
import org.apache.ignite.sql.ResultSet;
import org.apache.ignite.sql.SqlRow;
import org.apache.ignite.table.KeyValueView;
import org.apache.ignite.table.RecordView;
import org.apache.ignite.table.Table;
import org.apache.ignite.table.Tuple;
import org.apache.ignite.tx.Transaction;

public class ExploreKeys {
public static void main(String[] args) {
try (IgniteClient client = IgniteClient.builder()
.addresses("127.0.0.1:10800")
.build()) {

Table albumTable = client.tables().table("Album");
RecordView<Album> albums = albumTable.recordView(Album.class);

// --- Attempt 1: look up Album 5 by AlbumId alone ---
// Album has a composite PK (AlbumId + ArtistId) because
// ArtistId is the colocation column. Try with only AlbumId.
System.out.println("--- Partial key: RecordView ---");
Album partialKey = new Album();
partialKey.setAlbumId(5); // ArtistId is null
try {
Album result = albums.get((Transaction) null, partialKey);
System.out.println("Result: " + result);
} catch (Exception e) {
System.out.println(e.getClass().getSimpleName()
+ ": " + e.getMessage());
}

// --- Attempt 2: provide the full composite key ---
// Both @Id fields set. Album 5 is "Big Ones" by Aerosmith (ArtistId = 3).
System.out.println("\n--- Full key: RecordView ---");
Album fullKey = new Album();
fullKey.setAlbumId(5);
fullKey.setArtistId(3);
Album album = albums.get((Transaction) null, fullKey);
System.out.println(album);

// --- Same constraint on KeyValueView ---
KeyValueView<Tuple, Tuple> kvAlbums = albumTable.keyValueView();

System.out.println("\n--- Partial key: KeyValueView ---");
Tuple partialTupleKey = Tuple.create().set("AlbumId", 5);
try {
kvAlbums.get((Transaction) null, partialTupleKey);
} catch (Exception e) {
System.out.println(e.getClass().getSimpleName()
+ ": " + e.getMessage());
}

System.out.println("\n--- Full key: KeyValueView ---");
Tuple fullTupleKey = Tuple.create()
.set("AlbumId", 5).set("ArtistId", 3);
Tuple value = kvAlbums.get((Transaction) null, fullTupleKey);
System.out.println(value);

// --- EXPLAIN plans: three routing behaviors on the same table ---
System.out.println("\n--- EXPLAIN: Full PK ---");
explain(client,
"EXPLAIN PLAN FOR SELECT * FROM Album "
+ "WHERE AlbumId = 5 AND ArtistId = 1");

System.out.println("--- EXPLAIN: Colocation column only ---");
explain(client,
"EXPLAIN PLAN FOR SELECT * FROM Album WHERE ArtistId = 1");

System.out.println("--- EXPLAIN: Non-colocation column only ---");
explain(client,
"EXPLAIN PLAN FOR SELECT * FROM Album WHERE AlbumId = 5");
}
System.exit(0);
}

private static void explain(IgniteClient client, String query) {
try (ResultSet<SqlRow> rs = client.sql()
.execute((Transaction) null, query)) {
while (rs.hasNext()) {
System.out.println(" " + rs.next().stringValue(0));
}
}
System.out.println();
}
}

Run ExploreKeys. The first output is the most important:

ExploreKeys output
--- Partial key: RecordView ---
MarshallerException: Column 'ARTISTID' does not allow NULLs

--- Full key: RecordView ---
Album{albumId=5, artistId=3, title='Big Ones'}

--- Partial key: KeyValueView ---
MarshallerException: Missed key column: ARTISTID

--- Full key: KeyValueView ---
ClientTuple [TITLE=Big Ones]

Album 5 exists. You loaded it in the annotations tutorial. But the partial-key call never reaches the cluster.

The client's MarshallerException tells you exactly what went wrong: ArtistId is null, and the @Column(nullable = false) annotation on that @Id field prohibits it.

This is three design decisions converging into one exception. In the schema tutorial, you made ArtistId the colocation column for Album. That required ArtistId in the composite primary key. In the annotations tutorial, you marked every @Id field with @Column(nullable = false) because PK columns cannot be nullable. Now, at runtime, the marshaller enforces that annotation before the request leaves the client.

KeyValueView throws the same exception class with a different message ("Missed key column") because the Tuple marshaller checks for missing key columns explicitly rather than checking nullable constraints. Both reject the request before it reaches the cluster. The error messages differ, but the constraint is the same: the schema's composite primary key requires all key columns.

Open Album.java in your IDE and count the @Id annotations. Two: albumId and artistId. Every RecordView and KeyValueView operation on this table needs both values. Now open Artist.java. One @Id. Artist lookups need only one value. The number of @Id annotations on a class tells you how many fields your application must provide for a point lookup.

The EXPLAIN plans reveal what SQL does differently. These are the same three routing behaviors from the schema tutorial, but the question has shifted from how the engine routes a query to which API your application should use.

EXPLAIN output (trimmed)
--- EXPLAIN: Full PK ---
KeyValueGet
table: PUBLIC.ALBUM
key: [5, 1]

--- EXPLAIN: Colocation column only ---
Exchange
distribution: single
IndexScan
table: PUBLIC.ALBUM
index: IFK_ALBUMARTISTID
predicate: =(ARTISTID, 1)

--- EXPLAIN: Non-colocation column only ---
Exchange
distribution: single
TableScan
table: PUBLIC.ALBUM
predicate: =(ALBUMID, 5)

Three queries on the same table produce three different routing strategies:

  1. Full primary key: the engine performs a direct KeyValueGet to the exact partition.
  2. Colocation column only (ArtistId): the engine scans a single partition using the secondary index.
  3. Non-colocation column only (AlbumId): the engine scans all partitions because it cannot determine which node holds the data without the colocation column.

SQL accepts any column combination in a WHERE clause. RecordView and KeyValueView require the full primary key. When your caller does not have the full key, SQL is the only option.

Checkpoint:ExploreKeys runs successfully. Partial-key lookups throw MarshallerException on both RecordView and KeyValueView. Full-key lookups return Album 5. The three EXPLAIN plans show KeyValueGet, IndexScan, and TableScan.

Create the Service Class

The exploration showed three situations where different APIs are the right choice: RecordView and KeyValueView for point lookups with the full key, SQL for everything else. A realistic data access layer holds references to all three and selects the API at each call site based on the operation.

Create MusicStoreService.java in a service package (src/main/java/com/example/musicstore/service/):

src/main/java/com/example/musicstore/service/MusicStoreService.java (skeleton)
package com.example.musicstore.service;

import com.example.musicstore.model.Album;
import com.example.musicstore.model.Artist;
import com.example.musicstore.model.Customer;
import com.example.musicstore.model.Invoice;

import org.apache.ignite.client.IgniteClient;
import org.apache.ignite.sql.IgniteSql;
import org.apache.ignite.sql.ResultSet;
import org.apache.ignite.sql.SqlRow;
import org.apache.ignite.table.KeyValueView;
import org.apache.ignite.table.RecordView;
import org.apache.ignite.table.Table;
import org.apache.ignite.tx.IgniteTransactions;
import org.apache.ignite.tx.Transaction;

import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;

public class MusicStoreService {

private final RecordView<Artist> artists;
private final RecordView<Album> albums;
private final RecordView<Customer> customers;
private final RecordView<Invoice> invoices;
private final KeyValueView<Integer, String> artistNames;
private final IgniteSql sql;
private final IgniteTransactions transactions;

public MusicStoreService(IgniteClient client) {
Table artistTable = client.tables().table("Artist");
Table albumTable = client.tables().table("Album");
Table customerTable = client.tables().table("Customer");
Table invoiceTable = client.tables().table("Invoice");

// RecordView: full-entity access with type-safe POJOs
this.artists = artistTable.recordView(Artist.class);
this.albums = albumTable.recordView(Album.class);
this.customers = customerTable.recordView(Customer.class);
this.invoices = invoiceTable.recordView(Invoice.class);

// KeyValueView: key-to-single-value access. Maps ArtistId -> Name
// without deserializing the full Artist object.
this.artistNames = artistTable.keyValueView(
Integer.class, String.class);

this.sql = client.sql();
this.transactions = client.transactions();
}

// Helper: create a Customer key object from an ID
private Customer customerKey(int customerId) {
Customer key = new Customer();
key.setCustomerId(customerId);
return key;
}

// --- Methods added in the following steps ---
}

The constructor obtains four RecordView references, one KeyValueView, the SQL interface, and the transaction manager. Keeping all view types in one class makes the API comparisons visible side by side. A production application would typically separate by domain boundary: a CatalogService for artists and albums, a CommerceService for invoices and customers.

Checkpoint:The MusicStoreService class compiles. Your IDE resolves all imports.

Implement Read Methods

Incremental build

This step and the next add methods to MusicStoreService.java one at a time. Add each method inside the class body, after the constructor and before the closing brace. The complete file is provided in a verification block at the end of the next step.

A sidebar displays the artist name next to each album listing. The caller knows the ArtistId and needs only the name, not the full Artist record. Add this method to MusicStoreService.java after the customerKey helper:

Add to MusicStoreService.java
// Artist has a single-column primary key, so a KeyValueView
// lookup reaches the right partition directly. Only the name
// is needed for display, so KeyValueView avoids deserializing
// the full Artist object.
public String getArtistName(int artistId) {
return artistNames.get((Transaction) null, artistId);
}

A user clicks an album to see its details. The page URL encodes both the AlbumId and ArtistId. Add the next method below getArtistName:

Add to MusicStoreService.java
// Album has a composite primary key: AlbumId + ArtistId.
// Both @Id fields must be set on the key object. RecordView
// returns the full typed POJO with all mapped fields.
public Album getAlbum(int albumId, int artistId) {
Album key = new Album();
key.setAlbumId(albumId);
key.setArtistId(artistId);
return albums.get((Transaction) null, key);
}

A user browses AC/DC's discography. The application knows the ArtistId but not the AlbumIds. RecordView and KeyValueView cannot help because Album requires both key columns. SQL handles any column combination in a WHERE clause. Add findAlbumsByArtist below getAlbum:

Add to MusicStoreService.java
// The caller has only the colocation column (ArtistId). The
// engine routes this query to a single partition because
// ArtistId is the colocation column for Album. All albums by
// this artist share a partition with the Artist row.
public List<Album> findAlbumsByArtist(int artistId) {
List<Album> results = new ArrayList<>();
try (ResultSet<SqlRow> rs = sql.execute(
(Transaction) null,
"SELECT AlbumId, ArtistId, Title FROM Album "
+ "WHERE ArtistId = ?",
artistId)) {
while (rs.hasNext()) {
SqlRow row = rs.next();
results.add(new Album(
row.intValue("AlbumId"),
row.intValue("ArtistId"),
row.stringValue("Title")));
}
}
return results;
}

A customer shares an album link. The URL contains only the AlbumId. Without the colocation column, the engine cannot determine which partition holds this album, so it scans all partitions. This full scan is the tradeoff for optimizing Artist-to-Album colocated joins: browsing by artist (the common case) is fast, while direct album-by-ID lookups (the rare case) cost more. Add findAlbumById next:

Add to MusicStoreService.java
// The caller has only AlbumId (the non-colocation column).
// The engine scans all partitions because it cannot determine
// which node holds the album without ArtistId.
public Album findAlbumById(int albumId) {
try (ResultSet<SqlRow> rs = sql.execute(
(Transaction) null,
"SELECT AlbumId, ArtistId, Title FROM Album "
+ "WHERE AlbumId = ?",
albumId)) {
if (rs.hasNext()) {
SqlRow row = rs.next();
return new Album(
row.intValue("AlbumId"),
row.intValue("ArtistId"),
row.stringValue("Title"));
}
}
return null;
}

A dashboard shows the top-selling tracks across the entire catalog. This query joins four tables across both colocation chains: InvoiceLine to Track to Album to Artist. No programmatic API supports joins. Add getTopSellingTracks as the last read method:

Add to MusicStoreService.java
// This join spans both colocation chains. EXPLAIN shows
// Exchange nodes where data moves from the commerce chain
// (InvoiceLine) to the catalog chain (Track, Album, Artist).
public List<String> getTopSellingTracks(int limit) {
List<String> results = new ArrayList<>();
try (ResultSet<SqlRow> rs = sql.execute(
(Transaction) null,
"SELECT a.Name AS ArtistName, t.Name AS TrackName, "
+ "COUNT(il.InvoiceLineId) AS TimesSold, "
+ "SUM(il.UnitPrice * il.Quantity) AS Revenue "
+ "FROM InvoiceLine il "
+ "JOIN Track t ON il.TrackId = t.TrackId "
+ "JOIN Album al ON t.AlbumId = al.AlbumId "
+ "JOIN Artist a ON al.ArtistId = a.ArtistId "
+ "GROUP BY a.Name, t.Name "
+ "ORDER BY Revenue DESC LIMIT ?",
limit)) {
while (rs.hasNext()) {
SqlRow row = rs.next();
results.add(String.format(
"%-30s %-40s sold: %d revenue: %s",
row.stringValue("ArtistName"),
row.stringValue("TrackName"),
row.longValue("TimesSold"),
row.decimalValue("Revenue")));
}
}
return results;
}

Five read methods, and the pattern is already visible:

  • Full primary key, one field: KeyValueView returns the value without deserializing the full entity.
  • Full primary key, whole entity: RecordView returns the typed POJO with every mapped field.
  • Partial key with the colocation column: SQL reaches a single partition because the engine knows where the data lives.
  • Partial key without the colocation column: SQL scans all partitions because the engine has no routing hint.
  • Join across tables: SQL is the only option, and colocation decides whether rows stay on their nodes or cross the network.

Verify your progress by adding a temporary main method to MusicStoreService (or a quick test class) that calls the first two methods:

// Quick verification (remove after confirming)
public static void main(String[] args) {
try (IgniteClient client = IgniteClient.builder()
.addresses("127.0.0.1:10800").build()) {
MusicStoreService svc = new MusicStoreService(client);
System.out.println(svc.getArtistName(1));
System.out.println(svc.getAlbum(5, 3));
}
System.exit(0);
}

Expected output:

AC/DC
Album{albumId=5, artistId=3, title='Big Ones'}
Checkpoint:getArtistName(1) returns AC/DC and getAlbum(5, 3) returns Album{albumId=5, artistId=3, title='Big Ones'}. All five read methods compile.

Implement Write Methods

Write operations add a second dimension to API selection: beyond which API can reach the data, what safety guarantees does the operation require? The next four methods each demonstrate a different answer. Continue adding them to MusicStoreService.java below the read methods.

An editor corrects a typo in an album title. The application has both the AlbumId and ArtistId, so RecordView is technically possible. But the operation touches a single column. RecordView would need a get-modify-upsert cycle: fetch the entire Album, change the title, write the whole object back. That is two network round-trips and a full-entity write for a one-column change.

Add to MusicStoreService.java
// SQL is the right choice for writing a single column even
// when the full primary key is available. One statement, one
// round-trip, one column touched. RecordView would require
// get() + setTitle() + upsert() for the same result.
public void updateAlbumTitle(
int albumId, int artistId, String newTitle) {
sql.execute((Transaction) null,
"UPDATE Album SET Title = ? "
+ "WHERE AlbumId = ? AND ArtistId = ?",
newTitle, albumId, artistId);
}

This is the method that breaks the assumption that "full key means programmatic API." When the operation only touches a subset of columns, SQL does the job in one statement without loading the full entity. The shape of the operation overrides the routing answer.

An order arrives from the payment gateway. The application needs to create an invoice, but duplicate messages are possible. If the same invoice arrives twice, the second attempt must be rejected, not silently overwritten.

Add to MusicStoreService.java
// insert() returns false if the invoice already exists.
// Unlike upsert(), which silently overwrites, insert() detects
// duplicates. In an event-processing pipeline, insert() is the
// deduplication primitive: process the event only if insert()
// returns true.
//
// insert() is not idempotent. The first call returns true; the
// second returns false. upsert() is idempotent and safe to
// retry. Choose based on whether the workflow needs to detect
// duplicates (insert) or tolerate retries (upsert).
public boolean createInvoice(Invoice invoice) {
return invoices.insert((Transaction) null, invoice);
}

The foundations tutorials used upsert for every write. Here, insert is the right choice because the business logic demands duplicate detection. Two write methods exist on RecordView because they serve different safety requirements.

A checkout flow creates an invoice and updates the customer's billing address. Both operations must succeed or both must roll back. If the insert succeeds but the address update fails, the invoice references a stale address.

Add to MusicStoreService.java
// Creating an invoice and updating the customer's billing
// address must happen atomically. runInTransaction() wraps the
// closure in an explicit transaction with automatic retry on
// lock conflicts, timeouts, or primary replica changes.
//
// The closure may execute more than once, so it must not have
// side effects outside the database: no emails, no external
// API calls, no console output. The transaction guarantees
// atomicity for the database operations; external side effects
// are not covered.
//
// All three APIs (RecordView, KeyValueView, SQL) accept the
// same Transaction object. This method uses insert() and
// upsert() from RecordView, but SQL writes work in the same
// transaction.
public boolean createInvoiceWithBillingUpdate(
Invoice invoice, String newAddress) {
return transactions.runInTransaction(tx -> {
// Create the invoice. If it already exists, return false.
boolean created = invoices.insert(tx, invoice);
if (!created) {
return false;
}

// Read the customer to update the billing address.
Customer customer = customers.get(
tx, customerKey(invoice.getCustomerId()));
customer.setAddress(newAddress);

// Write the updated customer. Both the insert and this
// upsert commit atomically when the closure returns.
customers.upsert(tx, customer);
return true;
});
}

Every tutorial before this one passed null as the transaction parameter. This method shows why the parameter exists: when business logic demands that multiple operations succeed or fail together, an explicit transaction is the only way to guarantee it. The runInTransaction wrapper handles retry logic, so the application code stays focused on the business rule.

A personalization feature tags each customer with their favorite artist. This operation reads from one API and writes through another. The artist name is a single field (best served by KeyValueView), but the customer update needs all fields for validation before saving (RecordView).

Add to MusicStoreService.java
// This method applies the API decision independently to the
// read side and the write side. The artist name is one field
// (KeyValueView). The customer needs all fields for the
// read-modify-write cycle (RecordView). Using two APIs in one
// operation is not inconsistent; each call site chooses its
// own best API.
//
// This method is intentionally not transactional. The company
// field stores display metadata, not financial state, so a
// lost update from a concurrent writer is acceptable. For
// operations where concurrent writes matter, wrap the
// read-modify-write cycle in runInTransaction as shown in
// createInvoiceWithBillingUpdate.
public void updateCustomerWithArtistReference(
int customerId, int favoriteArtistId) {
String artistName = artistNames.get(
(Transaction) null, favoriteArtistId);

Customer customer = customers.get(
(Transaction) null, customerKey(customerId));
if (customer == null) {
throw new IllegalArgumentException(
"Customer " + customerId + " not found");
}

customer.setCompany("Fan of " + artistName);
customers.upsert((Transaction) null, customer);
}

Four write methods, and two distinct reasons why the "obvious" API choice was wrong:

  • updateAlbumTitle shows that the shape of the operation can override key availability. The full primary key was available, but SQL was still the right choice for a single-column write.
  • createInvoice writes a full entity through RecordView and uses insert for duplicate detection in an event-processing pipeline.
  • createInvoiceWithBillingUpdate also writes through RecordView, but wraps the work in an explicit transaction for multi-table atomicity.
  • updateCustomerWithArtistReference confirms that each call site within a single operation makes its own independent API decision. The read uses KeyValueView, the write uses RecordView.
Complete MusicStoreService.java (for verification)
src/main/java/com/example/musicstore/service/MusicStoreService.java
package com.example.musicstore.service;

import com.example.musicstore.model.Album;
import com.example.musicstore.model.Artist;
import com.example.musicstore.model.Customer;
import com.example.musicstore.model.Invoice;

import org.apache.ignite.client.IgniteClient;
import org.apache.ignite.sql.IgniteSql;
import org.apache.ignite.sql.ResultSet;
import org.apache.ignite.sql.SqlRow;
import org.apache.ignite.table.KeyValueView;
import org.apache.ignite.table.RecordView;
import org.apache.ignite.table.Table;
import org.apache.ignite.tx.IgniteTransactions;
import org.apache.ignite.tx.Transaction;

import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;

public class MusicStoreService {

private final RecordView<Artist> artists;
private final RecordView<Album> albums;
private final RecordView<Customer> customers;
private final RecordView<Invoice> invoices;
private final KeyValueView<Integer, String> artistNames;
private final IgniteSql sql;
private final IgniteTransactions transactions;

public MusicStoreService(IgniteClient client) {
Table artistTable = client.tables().table("Artist");
Table albumTable = client.tables().table("Album");
Table customerTable = client.tables().table("Customer");
Table invoiceTable = client.tables().table("Invoice");

this.artists = artistTable.recordView(Artist.class);
this.albums = albumTable.recordView(Album.class);
this.customers = customerTable.recordView(Customer.class);
this.invoices = invoiceTable.recordView(Invoice.class);
this.artistNames = artistTable.keyValueView(
Integer.class, String.class);
this.sql = client.sql();
this.transactions = client.transactions();
}

public String getArtistName(int artistId) {
return artistNames.get((Transaction) null, artistId);
}

public Album getAlbum(int albumId, int artistId) {
Album key = new Album();
key.setAlbumId(albumId);
key.setArtistId(artistId);
return albums.get((Transaction) null, key);
}

public List<Album> findAlbumsByArtist(int artistId) {
List<Album> results = new ArrayList<>();
try (ResultSet<SqlRow> rs = sql.execute(
(Transaction) null,
"SELECT AlbumId, ArtistId, Title FROM Album "
+ "WHERE ArtistId = ?",
artistId)) {
while (rs.hasNext()) {
SqlRow row = rs.next();
results.add(new Album(
row.intValue("AlbumId"),
row.intValue("ArtistId"),
row.stringValue("Title")));
}
}
return results;
}

public Album findAlbumById(int albumId) {
try (ResultSet<SqlRow> rs = sql.execute(
(Transaction) null,
"SELECT AlbumId, ArtistId, Title FROM Album "
+ "WHERE AlbumId = ?",
albumId)) {
if (rs.hasNext()) {
SqlRow row = rs.next();
return new Album(
row.intValue("AlbumId"),
row.intValue("ArtistId"),
row.stringValue("Title"));
}
}
return null;
}

public List<String> getTopSellingTracks(int limit) {
List<String> results = new ArrayList<>();
try (ResultSet<SqlRow> rs = sql.execute(
(Transaction) null,
"SELECT a.Name AS ArtistName, t.Name AS TrackName, "
+ "COUNT(il.InvoiceLineId) AS TimesSold, "
+ "SUM(il.UnitPrice * il.Quantity) AS Revenue "
+ "FROM InvoiceLine il "
+ "JOIN Track t ON il.TrackId = t.TrackId "
+ "JOIN Album al ON t.AlbumId = al.AlbumId "
+ "JOIN Artist a ON al.ArtistId = a.ArtistId "
+ "GROUP BY a.Name, t.Name "
+ "ORDER BY Revenue DESC LIMIT ?",
limit)) {
while (rs.hasNext()) {
SqlRow row = rs.next();
results.add(String.format(
"%-30s %-40s sold: %d revenue: %s",
row.stringValue("ArtistName"),
row.stringValue("TrackName"),
row.longValue("TimesSold"),
row.decimalValue("Revenue")));
}
}
return results;
}

public void updateAlbumTitle(
int albumId, int artistId, String newTitle) {
sql.execute((Transaction) null,
"UPDATE Album SET Title = ? "
+ "WHERE AlbumId = ? AND ArtistId = ?",
newTitle, albumId, artistId);
}

public boolean createInvoice(Invoice invoice) {
return invoices.insert((Transaction) null, invoice);
}

public boolean createInvoiceWithBillingUpdate(
Invoice invoice, String newAddress) {
return transactions.runInTransaction(tx -> {
boolean created = invoices.insert(tx, invoice);
if (!created) {
return false;
}
Customer customer = customers.get(
tx, customerKey(invoice.getCustomerId()));
customer.setAddress(newAddress);
customers.upsert(tx, customer);
return true;
});
}

public void updateCustomerWithArtistReference(
int customerId, int favoriteArtistId) {
String artistName = artistNames.get(
(Transaction) null, favoriteArtistId);
Customer customer = customers.get(
(Transaction) null, customerKey(customerId));
if (customer == null) {
throw new IllegalArgumentException(
"Customer " + customerId + " not found");
}
customer.setCompany("Fan of " + artistName);
customers.upsert((Transaction) null, customer);
}

private Customer customerKey(int customerId) {
Customer key = new Customer();
key.setCustomerId(customerId);
return key;
}
}
Checkpoint:All nine methods compile. The MusicStoreService class has five read methods, three write methods, and one mixed-API method.

Run and Verify All Methods

Create RunService.java to exercise every method and verify the results. Test data uses IDs above 9000 to avoid conflicts with the existing Music Store dataset. Each test cleans up after itself so you can run the program multiple times.

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

import com.example.musicstore.model.Album;
import com.example.musicstore.model.Customer;
import com.example.musicstore.model.Invoice;
import com.example.musicstore.service.MusicStoreService;

import org.apache.ignite.client.IgniteClient;
import org.apache.ignite.sql.ResultSet;
import org.apache.ignite.sql.SqlRow;
import org.apache.ignite.tx.Transaction;

import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;

public class RunService {

public static void main(String[] args) {
try (IgniteClient client = IgniteClient.builder()
.addresses("127.0.0.1:10800")
.build()) {

MusicStoreService service = new MusicStoreService(client);

// --- Read methods ---

System.out.println("--- getArtistName(1) ---");
String name = service.getArtistName(1);
System.out.println("Result: " + name);

System.out.println("\n--- getAlbum(5, 3) ---");
Album album = service.getAlbum(5, 3);
System.out.println("Result: " + album);

System.out.println("\n--- findAlbumsByArtist(1) ---");
List<Album> albums = service.findAlbumsByArtist(1);
System.out.println("Found " + albums.size() + " albums:");
albums.forEach(a -> System.out.println(" " + a));

System.out.println("\n--- findAlbumById(5) ---");
Album found = service.findAlbumById(5);
System.out.println("Result: " + found);

System.out.println("\n--- getTopSellingTracks(5) ---");
List<String> tracks = service.getTopSellingTracks(5);
tracks.forEach(t -> System.out.println(" " + t));

// --- Write methods ---

System.out.println("\n--- updateAlbumTitle ---");
service.updateAlbumTitle(5, 3, "Big Ones (Remastered)");
Album updated = service.getAlbum(5, 3);
System.out.println("After SQL update: " + updated);
service.updateAlbumTitle(5, 3, "Big Ones");
System.out.println("Restored: "
+ service.getAlbum(5, 3));

System.out.println("\n--- createInvoice ---");
Invoice testInvoice = new Invoice();
testInvoice.setInvoiceId(9001);
testInvoice.setCustomerId(1);
testInvoice.setInvoiceDate(LocalDate.now());
testInvoice.setBillingCity("Sao Paulo");
testInvoice.setBillingCountry("Brazil");
testInvoice.setTotal(new BigDecimal("9.99"));
boolean first = service.createInvoice(testInvoice);
System.out.println("First insert: " + first);
boolean second = service.createInvoice(testInvoice);
System.out.println("Second insert: " + second);
client.tables().table("Invoice")
.recordView(Invoice.class)
.delete((Transaction) null, testInvoice);
System.out.println("Cleanup: test invoice deleted");

System.out.println(
"\n--- createInvoiceWithBillingUpdate ---");
Invoice txInvoice = new Invoice();
txInvoice.setInvoiceId(9002);
txInvoice.setCustomerId(1);
txInvoice.setInvoiceDate(LocalDate.now());
txInvoice.setBillingCity("Rio de Janeiro");
txInvoice.setTotal(new BigDecimal("15.99"));
Customer before = readCustomer(client, 1);
String originalAddress = before.getAddress();
boolean txResult =
service.createInvoiceWithBillingUpdate(
txInvoice, "456 New Billing St");
System.out.println("Transaction result: " + txResult);
Customer after = readCustomer(client, 1);
System.out.println("Address after: "
+ after.getAddress());
// Restore original state
client.tables().table("Invoice")
.recordView(Invoice.class)
.delete((Transaction) null, txInvoice);
after.setAddress(originalAddress);
client.tables().table("Customer")
.recordView(Customer.class)
.upsert((Transaction) null, after);
System.out.println("Cleanup: invoice deleted, "
+ "address restored");

// --- Mixed API ---

System.out.println(
"\n--- updateCustomerWithArtistReference ---");
Customer beforeMix = readCustomer(client, 1);
String originalCompany = beforeMix.getCompany();
System.out.println("Before: company='"
+ originalCompany + "'");
service.updateCustomerWithArtistReference(1, 1);
Customer afterMix = readCustomer(client, 1);
System.out.println("After: company='"
+ afterMix.getCompany() + "'");
afterMix.setCompany(originalCompany);
client.tables().table("Customer")
.recordView(Customer.class)
.upsert((Transaction) null, afterMix);
System.out.println("Cleanup: restored original company");

// --- EXPLAIN verification ---

System.out.println(
"\n--- EXPLAIN: findAlbumsByArtist ---");
try (ResultSet<SqlRow> rs = client.sql().execute(
(Transaction) null,
"EXPLAIN PLAN FOR "
+ "SELECT AlbumId, ArtistId, Title "
+ "FROM Album WHERE ArtistId = 1")) {
while (rs.hasNext()) {
System.out.println(
" " + rs.next().stringValue(0));
}
}
}
System.exit(0);
}

private static Customer readCustomer(
IgniteClient client, int id) {
Customer key = new Customer();
key.setCustomerId(id);
return client.tables().table("Customer")
.recordView(Customer.class)
.get((Transaction) null, key);
}
}

Run the program. Here is the expected output:

RunService output
--- getArtistName(1) ---
Result: AC/DC

--- getAlbum(5, 3) ---
Result: Album{albumId=5, artistId=3, title='Big Ones'}

--- findAlbumsByArtist(1) ---
Found 2 albums:
Album{albumId=4, artistId=1, title='Let There Be Rock'}
Album{albumId=1, artistId=1, title='For Those About To Rock We Salute You'}

--- findAlbumById(5) ---
Result: Album{albumId=5, artistId=3, title='Big Ones'}

--- getTopSellingTracks(5) ---
Iron Maiden The Trooper sold: 5 revenue: 4.95
Heroes How to Stop an Exploding Man sold: 2 revenue: 3.98
Aquaman Pilot sold: 2 revenue: 3.98
Battlestar Galactica The Woman King sold: 2 revenue: 3.98
Heroes The Fix sold: 2 revenue: 3.98

--- updateAlbumTitle ---
After SQL update: Album{albumId=5, artistId=3, title='Big Ones (Remastered)'}
Restored: Album{albumId=5, artistId=3, title='Big Ones'}

--- createInvoice ---
First insert: true
Second insert: false
Cleanup: test invoice deleted

--- createInvoiceWithBillingUpdate ---
Transaction result: true
Address after: 456 New Billing St
Cleanup: invoice deleted, address restored

--- updateCustomerWithArtistReference ---
Before: company='Embraer - Empresa Brasileira de Aeronáutica S.A.'
After: company='Fan of AC/DC'
Cleanup: restored original company

--- EXPLAIN: findAlbumsByArtist ---
Exchange
distribution: single
est: (rows=116)
IndexScan
table: PUBLIC.ALBUM
index: IFK_ALBUMARTISTID
type: SORTED
predicate: =(ARTISTID, 1)
searchBounds: [ExactBounds [bound=1]]
fieldNames: [ALBUMID, ARTISTID, TITLE]
collation: [ARTISTID ASC]
est: (rows=116)
note

The findAlbumsByArtist and getTopSellingTracks results may appear in a different order on your cluster. SQL queries without an ORDER BY clause do not guarantee row ordering.

Walk through the output section by section:

The read methods show the three API paths in action. getArtistName returns a single string through KeyValueView. getAlbum returns a full POJO through RecordView, requiring both key columns. findAlbumsByArtist and findAlbumById both use SQL, but the colocation column determines whether one partition or all partitions are scanned. getTopSellingTracks joins four tables across both colocation chains.

The updateAlbumTitle result confirms cross-API visibility: the SQL UPDATE writes the new title, and the subsequent RecordView get() reads it back. All three APIs operate on the same underlying storage. A write through one API is immediately visible to reads through any other API.

The createInvoice output shows the safety distinction. The first insert() returns true (new record created). The second call with the same key returns false (duplicate detected, no overwrite). Compare this to upsert(), which would silently overwrite the existing record without returning any signal.

The createInvoiceWithBillingUpdate result demonstrates an explicit transaction spanning two tables. The invoice insert and the customer address update either both commit or both roll back. The cleanup restores the original address so subsequent runs produce the same output.

The EXPLAIN plan at the bottom confirms the routing for findAlbumsByArtist: an IndexScan on IFK_ALBUMARTISTID within a single partition. The Exchange node with distribution: single means the engine knew to route the query to exactly one partition based on the colocation column. The colocation decisions from the schema tutorial are visible in the query plan.

Open MusicStoreService.java in your IDE and use Find Usages on sql.execute. Every SQL call exists because the caller either lacked the full primary key or because SQL was more efficient for the operation's shape (the single-column update). Now search for albums.get and artistNames.get. Every programmatic API call exists because the caller had the full key and needed either the complete entity or a single field.

Checkpoint:RunService completes with all methods returning correct results. createInvoice returns true then false. The EXPLAIN plan shows IndexScan on IFK_ALBUMARTISTID with distribution: single.

Near-Cache: Trading Consistency for Speed

Apache Ignite 3 does not include near-cache. All KeyValueView and RecordView reads go to the cluster on every call, guaranteeing strong consistency. Skip to the next step.

Checkpoint:GridGain 9 users: NearCacheDemo shows sub-millisecond reads after the first fetch, staleness on cross-client writes, and an UnsupportedOperationException on transactional access. Apache Ignite 3 users: this step is informational only.

The Access Pattern Matrix

Nine methods, three APIs, and every choice was driven by the same three considerations: does the caller have the full primary key, what does the operation need, and what safety guarantees does it require. The matrix below maps each method to its decision:

OperationFull Key?What's NeededSafetyAPI
Get artist name (sidebar)YesOne field (read)Auto-commitKeyValueView
Get album details (detail page)YesFull entity (read)Auto-commitRecordView
Find albums by artist (browse)No (colocation col)Query resultAuto-commitSQL (single partition)
Find album by ID (shared link)No (non-coloc col)Query resultAuto-commitSQL (all partitions)
Top-selling tracks (dashboard)No (join)AggregationAuto-commitSQL (cross-chain)
Update album title (editor fix)YesOne column (write)Auto-commitSQL
Create invoice (order intake)YesFull entity (write)Create-only (dedup)RecordView insert
Invoice + billing update (checkout)YesTwo-table writeExplicit transactionRecordView in tx
Tag customer with artist (personalization)YesMixed read/writeAuto-commitKV read + RV write
High-frequency artist lookup (GridGain 9)YesOne field (read)Tolerates stalenessKV + near-cache

The Album table appears four times with four different APIs. The table does not determine the API. The call site does.

Look at the "Update album title" row. The full primary key is available, which makes RecordView possible. But the operation only changes one column, so SQL is more efficient. The shape of the write overrides the key availability.

Look at "Create invoice" and "Invoice + billing update." Both have the full key and write full entities. Without the safety column, both would be "RecordView with auto-commit." The safety requirement differentiates them: duplicate detection needs insert, and multi-table atomicity needs an explicit transaction.

The last row (GridGain 9 only) introduces a consistency tradeoff. Near-cache makes reads faster by accepting staleness and giving up transaction support. For stable reference data like artist names, this tradeoff is invisible to users. For rapidly changing data like account balances, it would be dangerous.

Every sync method in the service has an async counterpart (getAsync, putAsync, executeAsync). The API selection logic is the same for async operations. Bulk variants (upsertAll, putAll, getAll) follow the same full-key requirement.

Checkpoint:The access pattern matrix maps all nine service methods to their API choices. Each row's API follows from the three columns to its left.

Summary

What you built

A data access layer with nine methods using KeyValueView, RecordView, and SQL on the same Music Store schema. Read methods, write methods, a transactional method, and a mixed-API method, each demonstrating a different combination of key availability, operation shape, and safety requirements.

Schema design has three consequences

The schema tutorial established that schema design is performance design: colocation determines which queries are fast. The annotations tutorial expressed that design as code, with annotations carrying the schema alongside the application. This tutorial revealed two more consequences of those same decisions.

Colocation shapes your API options. Composite primary keys, created to enable colocated joins, require the full key for every RecordView and KeyValueView operation. When the caller does not have the full key, SQL is the only path. The number of @Id annotations on an entity class tells you what every programmatic call site must provide.

Safety requirements drive the final choice. Even when the full key is available and the operation shape fits RecordView, the business logic may demand insert instead of upsert (for duplicate detection), an explicit transaction (for multi-table atomicity), or SQL (for single-column writes that avoid a read-modify-write cycle). On GridGain 9, near-cache adds a consistency dimension: client-side speed at the cost of staleness and the loss of transaction support.

At every call site, three considerations determine the API: key availability, operation shape, and safety requirements. The colocation decisions made during schema design set the boundaries: they determine which API paths are available, which operations are efficient, and which safety guarantees are practical.

What's next: Stream Data with the Data Streamer API (coming soon) covers the maximum-throughput write path. Data Streamer provides at-least-once delivery with automatic batching and backpressure, building on the same schema and colocation chains.