Adding a shard
Split the wallet example across two databases. New wallets choose a shard when they are created, and later reads or deposits route to the right database from the wallet ID.
In the previous lessons, every wallet lived in one database. In this lesson, we add a second Postgres database and route wallets between them.
The example uses a simple rule:
- US/global wallets go to shard
(0, 0) - Mexico wallets go to shard
(1, 0)
The important part is when the decision happens. A wallet chooses its shard once, when it is created. After that, the wallet ID carries the shard information, so a deposit action can receive only the wallet ID and still reach the right database.
Before After
────── ─────
Wallet Wallet
id : Id<Wallet> id : ShardedId<Wallet> ← change here
└─ embeds (group, member)
one database group=0 member=0 group=1 member=0
┌────────────┐ ┌────────────┐ ┌────────────┐
│ wallets │ │ wallets │ │ wallets │
│ eventlog │ │ eventlog │ │ eventlog │
└────────────┘ └────────────┘ └────────────┘
(e.g. global) (e.g. mexico)
In Ekbatan, a shard is addressed as (group, member). The group is the business or policy boundary, such as global or mexico. The member is the database inside that group. This lesson uses one member in each group, so the two databases are (0, 0) and (1, 0).
We’ll use EmbeddedBitsShardingStrategy, Ekbatan’s default strategy for sharded IDs. It stores the shard (group, member) inside the wallet UUID, so any code with a Wallet ID can route to the right database without a lookup table. You can also provide your own ShardingStrategy when the shard should be resolved in a different way. The deeper model behind groups, members, and other sharding strategies lives in Learn → Sharding strategies.
1. Change the model’s ID type
Id<Wallet> → ShardedId<Wallet>:
@AutoBuilder
public final class Wallet extends Model<Wallet, ShardedId<Wallet>, WalletState> {
// ^^^^^^^^^^^^^^^^ was: Id<Wallet>
public final UUID ownerId;
public final Currency currency;
public final BigDecimal balance;
// constructor unchanged...
// copy() unchanged...
// deposit() unchanged...
}
ShardedId<T> extends Id<T>, so existing call sites that took Id<Wallet> keep compiling. The difference: ShardedId.generate(Wallet.class, shard) encodes the shard (group, member) into the UUID’s rand_b bits — see Learn → Sharding § Self-describing IDs for the bit layout.
2. Pick the shard at creation
The factory in your Wallet (or your WalletCreateAction) now takes a ShardIdentifier and uses ShardedId.generate:
public static WalletBuilder createWallet(
ShardIdentifier shard,
UUID ownerId,
Currency currency,
BigDecimal initialBalance,
Instant createdDate) {
final var id = ShardedId.generate(Wallet.class, shard); // shard bits live in the ID forever
return WalletBuilder.wallet()
.id(id)
.state(OPENED)
.ownerId(ownerId)
.currency(currency)
.balance(initialBalance)
.createdDate(createdDate)
.withInitialVersion()
.withEvent(new WalletCreatedEvent(id, ownerId, currency, initialBalance));
}
Inside WalletCreateAction, decide the shard from business properties:
public static final ShardIdentifier GLOBAL_SHARD = ShardIdentifier.of(0, 0);
public static final ShardIdentifier MEXICO_SHARD = ShardIdentifier.of(1, 0);
@EkbatanAction
public class WalletCreateAction extends Action<WalletCreateAction.Params, Wallet> {
@Override
protected Wallet perform(Principal principal, Params params) {
final var shard = params.country().equals("MX") ? MEXICO_SHARD : GLOBAL_SHARD;
final var wallet = Wallet.createWallet(shard, params.ownerId(), ...).build();
return plan().add(wallet);
}
}
The shard is chosen once, at creation. From here on every query / update through walletRepository reads the shard out of the ID — no lookup table.
3. Wire the second database
# application.yml
ekbatan:
namespace: example.wallet
sharding:
default-shard:
group: 0
member: 0
groups:
- group: 0
name: global
members:
- member: 0
name: global
configs:
primary-config:
jdbc-url: jdbc:postgresql://localhost:5432/wallet_global
username: wallet
password: wallet
driver-class-name: org.postgresql.Driver
maximum-pool-size: 20
- group: 1
name: mexico
members:
- member: 0
name: mexico
configs:
primary-config:
jdbc-url: jdbc:postgresql://localhost:5433/wallet_mexico
username: wallet
password: wallet
driver-class-name: org.postgresql.Driver
maximum-pool-size: 20
Spring Boot, Quarkus, and Micronaut all bind this same ekbatan.sharding structure. If you wire Ekbatan manually, provide the same ShardingConfig before creating the DatabaseRegistry — see Plain Java wiring.
The FlywayMigrator.migrate(shardingConfig) startup hook from project setup runs the same migrations against each shard independently. No multi-tenant column to add; both shards have the same schema.
4. Spin up the second database
docker run --rm -d --name wallet-mexico \
-p 5433:5432 \
-e POSTGRES_USER=wallet \
-e POSTGRES_PASSWORD=wallet \
-e POSTGRES_DB=wallet \
postgres:17
(For real deployments, this is a separate RDS instance / VPC / region — that’s exactly the policy axis the group is for.)
5. Run it
Restart your app. Create two wallets:
curl -X POST http://localhost:8080/wallets -d '{"country":"US", "ownerId":"...", "currency":"USD", ...}'
# → wallet on group=0 (global) → port 5432
curl -X POST http://localhost:8080/wallets -d '{"country":"MX", "ownerId":"...", "currency":"MXN", ...}'
# → wallet on group=1 (mexico) → port 5433
Check both databases:
-- on localhost:5432 (global)
SELECT id, owner_id, currency, balance FROM wallets;
-- on localhost:5433 (mexico)
SELECT id, owner_id, currency, balance FROM wallets;
The US wallet is in the global DB; the Mexican one is in mexico. Try to deposit to either by ID — the framework reads the shard bits from the URL parameter and routes to the right database automatically:
curl -X POST http://localhost:8080/wallets/<mexicanWalletId>/deposit -d '{"amount":10}'
# → routes to localhost:5433, eventlog.events row appears there
WalletDepositMoneyAction and WalletController didn’t change. The shard routing happens entirely in the framework based on ShardedId.resolveShardIdentifier() → DatabaseRegistry.transactionManager(shard).
6. (Optional) Cross-shard transfer
A “send money from a US wallet to a Mexican one” action touches two shards. By default the framework rejects it with CrossShardException. The action is still a normal discovered action:
@EkbatanAction
public class WalletTransferAction extends Action<WalletTransferAction.Params, Void> {
@Override
protected Void perform(Principal principal, Params params) {
final var source = walletRepository.getById(params.sourceId().getValue());
final var dest = walletRepository.getById(params.destId().getValue());
plan().update(source.withdraw(params.amount()));
plan().update(dest.deposit(params.amount()));
return null;
}
}
Opt in at the call site by passing an ExecutionConfiguration:
var config = ExecutionConfiguration.Builder.executionConfiguration()
.allowCrossShard(true)
.build();
executor.execute(principal, WalletTransferAction.class, params, config);
Watch the consistency model carefully: per-shard atomicity, NOT global atomicity. The source-shard transaction can commit while the destination-shard one rolls back. For real transfers, use a saga — split into InitiateTransferAction (source only) + CompleteTransferAction (dest only), chained by @EkbatanEventHandler, with RefundTransferAction as compensation if the dest leg fails. The runnable example: ekbatan-examples/spring-boot-wallet-saga-gradle-pg.
What just happened
- The action code is unchanged. Sharding is a routing concern handled by the framework, not by your domain layer.
- The ID is self-describing. Any code with a
ShardedId<Wallet>(a controller param, an outbox row, an event payload) can recover the shard without a directory lookup. - Two databases, two transactions. The outbox guarantee (state + events atomic) still holds within each shard. Cross-shard work is opt-in and uses sagas for cross-shard consistency.
- Adding a third shard is the same recipe. New
ShardIdentifier, new entry inapplication.yml, new Postgres. No action signature changes anywhere.
Next
→ Sharding strategies — go deeper into groups, members, embedded-bits IDs, and cross-shard trade-offs.
See also
- Learn → Sharding strategies — the conceptual model behind the two axes and the three strategies
- Reference → Sharding —
ShardedUUIDbit layout,DatabaseRegistry.effectiveShard,ShardingStrategyAPI, declarative config - Reference → Multi-database — dialect-specific column types and converters
ekbatan-examples/spring-boot-wallet-rest-gradle-sharded-pg— the runnable two-shard wallet this lesson is distilled fromekbatan-examples/spring-boot-wallet-rest-gradle-native-sharded-pg— the same two-shard wallet with native-image test coverage