§ learn

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:

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

Next

Sharding strategies — go deeper into groups, members, embedded-bits IDs, and cross-shard trade-offs.

See also