EventModeling Quickstart Guide
Getting started with Sliceworkz EventModeling
This guide gets you started with Sliceworkz EventModeling, an opinionated Java framework for building applications designed with Event Modeling.
Every box on an event model has a home in the framework: a command raises domain events, a read model answers questions out of them, an automation or a policy reacts to them, a translator takes in what other systems tell you, and a publisher and dispatcher tell other systems what happened. Each vertical slice of the model becomes a feature slice in the code. Underneath, everything is stored in the Sliceworkz Eventstore, and every command decides within a Dynamic Consistency Boundary (DCB) rather than an aggregate.
Quickstart Guide
We build a small banking context: open an account, deposit money into it, and read it back two ways — one read model projected on demand, one projected in the background. The guide uses version 0.8.0.
Prerequisites
- Java 21 or later
- Maven
Step 1: Add the Maven Dependencies
Sliceworkz EventModeling is available in Maven Central. Its modules — and the Eventstore modules it builds on — are versioned together in a Bill-of-Materials pom. Import it:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<properties>
<sliceworkz.eventmodeling.version>0.8.0</sliceworkz.eventmodeling.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.sliceworkz</groupId>
<artifactId>sliceworkz-eventmodeling-bom</artifactId>
<version>${sliceworkz.eventmodeling.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
Then add the framework implementation, an event storage, and the test support:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<dependencies>
<!-- the framework (brings the API with it) -->
<dependency>
<groupId>org.sliceworkz</groupId>
<artifactId>sliceworkz-eventmodeling-impl</artifactId>
</dependency>
<!-- in-memory event storage, for development and tests -->
<dependency>
<groupId>org.sliceworkz</groupId>
<artifactId>sliceworkz-eventstore-infra-inmem</artifactId>
</dependency>
<!-- given/when/then test bases (brings JUnit 5) -->
<dependency>
<groupId>org.sliceworkz</groupId>
<artifactId>sliceworkz-eventmodeling-testing</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
For production, use sliceworkz-eventstore-infra-postgres instead of the in-memory storage. For local development where events should survive a restart without a database, sliceworkz-eventstore-infra-inmem-fs keeps them in files.
PostgreSQL version support. EventModeling stores its events through Sliceworkz Eventstore and inherits its database support: the oldest supported PostgreSQL is 16, and 18+ generates event ids with the native server-side
uuidv7(). On 16 and 17 the ids are generated in Java, which needs the optionalcom.github.f4b6a3:uuid-creatordependency in your application; the right implementation is picked at startup. See the PostgreSQL EventStorage guide.
The framework logs through SLF4J; add the binding of your choice (Logback, for instance) to see what it reports.
Step 2: Define the Domain
A bounded context has three kinds of events: domain events it records itself, inbound events other systems tell it, and outbound events it publishes. Next to them sit the entities — the kinds of thing events are about — each with an id type of its own.
Create BankingDomain.java:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
package com.example.banking;
import java.math.BigDecimal;
import java.time.LocalDate;
import org.sliceworkz.eventmodeling.domain.Entity;
import org.sliceworkz.eventmodeling.domain.EntityId;
public interface BankingDomain {
// Entities: the kinds of thing events are about, each with its own id type
record AccountId ( String value ) implements EntityId { }
record CustomerId ( String value ) implements EntityId { }
Entity<AccountId> ACCOUNT = Entity.of("account", AccountId::new);
Entity<CustomerId> CUSTOMER = Entity.of("customer", CustomerId::new);
// Domain events: the facts this context records, named in the past tense
sealed interface BankingDomainEvent {
record AccountOpened ( AccountId accountId, CustomerId customerId, LocalDate date ) implements BankingDomainEvent { }
record MoneyDeposited ( AccountId accountId, BigDecimal amount ) implements BankingDomainEvent { }
}
// Inbound events: what other systems tell this context
sealed interface BankingInboundEvent {
record CustomerSuspended ( CustomerId customerId ) implements BankingInboundEvent { }
}
// Outbound events: what this context publishes to other systems
sealed interface BankingOutboundEvent {
record AccountAnnounced ( AccountId accountId ) implements BankingOutboundEvent { }
}
}
Key concepts:
- A
sealed interfaceper kind of event, with arecordper event type: the compiler then checks everyswitchover them for exhaustiveness. - Name domain events in the past tense (
AccountOpened, notOpenAccount). The record’s simple name is what the store keeps, so treat it as wire format — see Defining Events. - An
Entitynames a tag key and makes the ids of its kind:ACCOUNT.newId()mints one,ACCOUNT.tag(id)tags an event with it. BecauseAccountIdandCustomerIdare different types, swapping them is a compile error rather than a query that silently finds nothing. See Entities, Typed Ids and Tags. - Records carried inside events keep a lenient constructor: they are rebuilt from stored history on every read, so a constructor that throws turns a tightened rule into an unreadable event. Validation happens before events exist — see Where a Validation Goes.
Step 3: Declare the Bounded Context
The bounded context is the entry point to the application. Declare an interface for it, plus a narrower one for the code that only drives the domain:
1
2
3
4
5
6
7
8
package com.example.banking;
import org.sliceworkz.eventmodeling.boundedcontext.ApplicationCapabilities;
import com.example.banking.BankingDomain.BankingDomainEvent;
import com.example.banking.BankingDomain.BankingOutboundEvent;
public interface BankingApi extends ApplicationCapabilities<BankingDomainEvent, BankingOutboundEvent> {
}
1
2
3
4
5
6
7
8
9
10
package com.example.banking;
import org.sliceworkz.eventmodeling.boundedcontext.BoundedContext;
import com.example.banking.BankingDomain.BankingDomainEvent;
import com.example.banking.BankingDomain.BankingInboundEvent;
import com.example.banking.BankingDomain.BankingOutboundEvent;
public interface Banking
extends BoundedContext<BankingDomainEvent, BankingInboundEvent, BankingOutboundEvent>, BankingApi {
}
Key concepts:
Bankinggroups the three event types into one type, so feature slices and the builder nameBankinginstead of three type arguments.- A built
Bankingcan do everything: execute commands, erase personal data, stop automations, terminate itself.BankingApicarries only what application code needs —execute,executeWithRetry,evaluate,readandaggregate— and sinceBankingextends it, handing it out costs nothing but the reference type. See Who May Do What.
Step 4: A State Change — Opening an Account
Every slice of the event model becomes a feature slice: a package holding a class annotated @FeatureSlice and the components of the slice.
Create features/openaccount/OpenAccountFeatureSlice.java:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package com.example.banking.features.openaccount;
import org.sliceworkz.eventmodeling.boundedcontext.BoundedContextBuilder;
import org.sliceworkz.eventmodeling.slices.FeatureSlice;
import org.sliceworkz.eventmodeling.slices.Slice;
import com.example.banking.Banking;
@FeatureSlice(chapter = "Account management")
public class OpenAccountFeatureSlice implements Slice<Banking> {
@Override
public void configureCommand ( BoundedContextBuilder<Banking> builder ) {
builder.command(OpenAccountCommand.class);
}
}
The caller of this command needs the id of the new account, so it is a CommandWithResult. Create features/openaccount/OpenAccountCommand.java:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
package com.example.banking.features.openaccount;
import java.time.LocalDate;
import org.sliceworkz.eventmodeling.commands.CommandContext;
import org.sliceworkz.eventmodeling.commands.CommandWithResult;
import org.sliceworkz.eventstore.events.Tags;
import com.example.banking.BankingDomain.AccountId;
import com.example.banking.BankingDomain.BankingDomainEvent;
import com.example.banking.BankingDomain.BankingDomainEvent.AccountOpened;
import com.example.banking.BankingDomain.CustomerId;
import static com.example.banking.BankingDomain.ACCOUNT;
import static com.example.banking.BankingDomain.CUSTOMER;
public class OpenAccountCommand implements CommandWithResult<BankingDomainEvent, AccountId> {
private final CustomerId customerId;
public OpenAccountCommand ( CustomerId customerId ) {
if ( customerId == null ) {
throw new IllegalArgumentException("customerId is required");
}
this.customerId = customerId;
}
@Override
public AccountId execute ( CommandContext<BankingDomainEvent, BankingDomainEvent> context ) {
// opening an account depends on no earlier fact, so there is nothing to decide on
var result = context.noDecisionModels();
AccountId accountId = ACCOUNT.newId();
result.raiseEvent(
new AccountOpened(accountId, customerId, LocalDate.now()),
Tags.of(ACCOUNT.tag(accountId), CUSTOMER.tag(customerId)));
return accountId; // handed to the caller once the event is stored
}
}
Key concepts:
@FeatureSlicemarks the class for discovery. It carries only what the code cannot say — thechapterof the event model the slice sits in. The slice’s name comes from the class name, its bounded context fromSlice<Banking>, and its type (state change, state read, automation, translation) is derived from what it registers.configureCommandis where a slice registers its command-side components.builder.command(...)is declarative: commands are instantiated by the caller, and registering one makes it part of the slice’s inventory from startup.- A command implements
Command<E>(the caller gets the reference of the appended event) orCommandWithResult<E, R>(the caller also gets whatexecutereturns, once the events are stored). noDecisionModels()is the explicit statement that this command decides on nothing; it hands back the result to raise events on.- Every event is tagged with the entities it is about. Tags are how read models and decision models find events.
Step 5: A State Change with a Decision — Depositing Money
A deposit is only meaningful on an account that exists, so this command decides on history. What a command decides on is a decision model: a small projection the command builds inline.
Create features/deposit/AccountExistsDecisionModel.java:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
package com.example.banking.features.deposit;
import org.sliceworkz.eventmodeling.commands.DecisionModel;
import org.sliceworkz.eventstore.events.Event;
import org.sliceworkz.eventstore.query.EventQuery;
import org.sliceworkz.eventstore.query.EventTypesFilter;
import com.example.banking.BankingDomain.AccountId;
import com.example.banking.BankingDomain.BankingDomainEvent;
import com.example.banking.BankingDomain.BankingDomainEvent.AccountOpened;
import static com.example.banking.BankingDomain.ACCOUNT;
public class AccountExistsDecisionModel implements DecisionModel<BankingDomainEvent> {
private final AccountId accountId;
private boolean exists;
public AccountExistsDecisionModel ( AccountId accountId ) {
this.accountId = accountId;
}
@Override
public EventQuery eventQuery ( ) {
return EventQuery.forEvents(EventTypesFilter.of(AccountOpened.class), ACCOUNT.tags(accountId));
}
@Override
public void when ( Event<BankingDomainEvent> event ) {
switch ( event.data() ) {
case AccountOpened ignored -> exists = true;
default -> { }
}
}
public boolean exists ( ) {
return exists;
}
}
Create features/deposit/DepositCommand.java:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
package com.example.banking.features.deposit;
import java.math.BigDecimal;
import org.sliceworkz.eventmodeling.commands.BusinessException;
import org.sliceworkz.eventmodeling.commands.Command;
import org.sliceworkz.eventmodeling.commands.CommandContext;
import com.example.banking.BankingDomain.AccountId;
import com.example.banking.BankingDomain.BankingDomainEvent;
import com.example.banking.BankingDomain.BankingDomainEvent.MoneyDeposited;
import static com.example.banking.BankingDomain.ACCOUNT;
public class DepositCommand implements Command<BankingDomainEvent> {
private final AccountId accountId;
private final BigDecimal amount;
public DepositCommand ( AccountId accountId, BigDecimal amount ) {
// a malformed request is rejected before any history is read
if ( accountId == null || amount == null || amount.signum() <= 0 ) {
throw new IllegalArgumentException("a deposit needs an account and a positive amount");
}
this.accountId = accountId;
this.amount = amount;
}
@Override
public void execute ( CommandContext<BankingDomainEvent, BankingDomainEvent> context ) {
// 1. select the decision models: projects them, and pins the consistency boundary
var account = new AccountExistsDecisionModel(accountId);
var result = context.decisionModels(account);
// 2. check what history says
BusinessException.when(!account.exists(), "Account " + accountId.value() + " does not exist");
// 3. raise
result.raiseEvent(new MoneyDeposited(accountId, amount), ACCOUNT.tags(accountId));
}
}
Register it from features/deposit/DepositFeatureSlice.java, just like the first slice:
1
2
3
4
5
6
7
8
@FeatureSlice(chapter = "Money movements")
public class DepositFeatureSlice implements Slice<Banking> {
@Override
public void configureCommand ( BoundedContextBuilder<Banking> builder ) {
builder.command(DepositCommand.class);
}
}
Key concepts:
- Select, check, raise is the shape of every command that decides.
decisionModels(...)projects the models and makes their queries the command’s consistency boundary. The append is checked against it: if an event matching any of those queries is appended between the read and the append, the command fails with anOptimisticLockingExceptioninstead of deciding on stale facts. Re-executing re-reads and re-decides;executeWithRetrydoes that for you with a bounded number of attempts.- A rule history rejects is a
BusinessException, not anIllegalStateException: the framework reports it as a rejection (CommandRejected), not as a failure, and the caller can tell the two apart. - Commands never decide on a read model. A read model read from a command sits outside the consistency boundary — see Commands and Decision Models.
Step 6: A State Read — Account Details
A read model answers a question out of events. This one is live: projected when it is read, so it is always current.
Create features/accountdetails/AccountDetailsReadModel.java:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
package com.example.banking.features.accountdetails;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.Optional;
import org.sliceworkz.eventmodeling.readmodels.ReadModel;
import org.sliceworkz.eventstore.events.Event;
import org.sliceworkz.eventstore.query.EventQuery;
import com.example.banking.BankingDomain.AccountId;
import com.example.banking.BankingDomain.BankingDomainEvent;
import com.example.banking.BankingDomain.BankingDomainEvent.AccountOpened;
import com.example.banking.BankingDomain.BankingDomainEvent.MoneyDeposited;
import static com.example.banking.BankingDomain.ACCOUNT;
public class AccountDetailsReadModel implements ReadModel<BankingDomainEvent> {
private final AccountId accountId;
private AccountDetails details;
public AccountDetailsReadModel ( AccountId accountId ) {
this.accountId = accountId;
}
@Override
public EventQuery eventQuery ( ) {
// every event about this one account, whatever its type
return EventQuery.forTags(ACCOUNT.tags(accountId));
}
@Override
public void when ( Event<BankingDomainEvent> event ) {
switch ( event.data() ) {
case AccountOpened opened ->
details = new AccountDetails(opened.accountId().value(), opened.customerId().value(), opened.date(), BigDecimal.ZERO);
case MoneyDeposited deposited ->
details = details.withBalance(details.balance().add(deposited.amount()));
}
}
public Optional<AccountDetails> getAccountDetails ( ) {
return Optional.ofNullable(details);
}
// a read model's answer is never stored, so it may validate as strictly as it likes
public record AccountDetails ( String accountId, String customerId, LocalDate openDate, BigDecimal balance ) {
public AccountDetails {
if ( accountId == null || customerId == null || openDate == null || balance == null ) {
throw new IllegalArgumentException("all fields are required");
}
}
AccountDetails withBalance ( BigDecimal newBalance ) {
return new AccountDetails(accountId, customerId, openDate, newBalance);
}
}
}
Register it from features/accountdetails/AccountDetailsFeatureSlice.java:
1
2
3
4
5
6
7
8
@FeatureSlice(chapter = "Account management")
public class AccountDetailsFeatureSlice implements Slice<Banking> {
@Override
public void configureQuery ( BoundedContextBuilder<Banking> builder ) {
builder.readmodel(AccountDetailsReadModel.class).live();
}
}
Key concepts:
- A read model implements
ReadModel<E>:eventQuery()says which events it is projected from, andwhen(Event<E>)handles each one. The domain event isevent.data(); its tags, timestamp and reference sit beside it on theEvent. - A live read model is registered by class, and constructed per read with the arguments the read passes (
read(AccountDetailsReadModel.class, accountId)). - Every registration says how the read model is projected —
.live()or.eventuallyConsistent().build()rejects one that does not. - A live read model is right whenever the events it replays are bounded by design, as here: one account’s events. See Choosing a Read Model for what to do when they are not.
Step 7: A State Read in the Background — Account Overview
A list of every account spans all accounts, so replaying it per read would get slower forever. It is projected once, in the background, and reads become free. That makes it eventually consistent: it lags the latest events by a moment.
Create features/accountoverview/AccountOverviewReadModel.java:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
package com.example.banking.features.accountoverview;
import java.time.LocalDate;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.Set;
import org.sliceworkz.eventmodeling.readmodels.PublishingReadModel;
import org.sliceworkz.eventstore.events.Event;
import org.sliceworkz.eventstore.query.EventQuery;
import com.example.banking.BankingDomain.BankingDomainEvent;
import com.example.banking.BankingDomain.BankingDomainEvent.AccountOpened;
public class AccountOverviewReadModel
extends PublishingReadModel<BankingDomainEvent, Set<AccountOverviewReadModel.AccountSummary>> {
// registered as an instance: the framework projects this one in the background
public static final AccountOverviewReadModel INSTANCE = new AccountOverviewReadModel();
@Override
public EventQuery eventQuery ( ) {
return EventQuery.forTypes(AccountOpened.class);
}
@Override
protected Set<AccountSummary> initialState ( ) {
return Set.of();
}
@Override
protected Set<AccountSummary> apply ( Set<AccountSummary> accounts, Event<BankingDomainEvent> event ) {
return switch ( event.data() ) {
case AccountOpened opened -> with(accounts,
new AccountSummary(opened.accountId().value(), opened.customerId().value(), opened.date()));
default -> accounts;
};
}
private static Set<AccountSummary> with ( Set<AccountSummary> accounts, AccountSummary added ) {
Set<AccountSummary> next = new LinkedHashSet<>(accounts);
next.add(added);
return Collections.unmodifiableSet(next);
}
public record AccountSummary ( String accountId, String customerId, LocalDate openDate ) { }
}
Register the instance from features/accountoverview/AccountOverviewFeatureSlice.java:
1
2
3
4
5
6
7
8
@FeatureSlice(chapter = "Account management")
public class AccountOverviewFeatureSlice implements Slice<Banking> {
@Override
public void configureQuery ( BoundedContextBuilder<Banking> builder ) {
builder.readmodel(AccountOverviewReadModel.INSTANCE).eventuallyConsistent();
}
}
Key concepts:
- An eventually consistent read model is registered as an instance: the framework projects that one object on a background thread, from a bookmark.
PublishingReadModelis the base to extend. You write a state type, an initial value and a fold (apply); it publishes the state together with the position it reflects, once per batch, as one immutable value. A reader on another thread can never see a half-applied batch, andpublished()tells the caller exactly how current the answer is.- Keep the state immutable:
applyreturns a new value rather than changing the old one.
Step 8: Build, Start and Use the Bounded Context
Create BankingApplication.java:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
package com.example.banking;
import java.math.BigDecimal;
import java.util.Set;
import org.sliceworkz.eventmodeling.boundedcontext.BoundedContext;
import org.sliceworkz.eventmodeling.commands.CommandExecutionResult;
import org.sliceworkz.eventmodeling.events.InstanceFactory;
import org.sliceworkz.eventmodeling.readmodels.ReadModelResult;
import org.sliceworkz.eventstore.events.EventReference;
import org.sliceworkz.eventstore.infra.inmem.InMemoryEventStorage;
import org.sliceworkz.eventstore.spi.EventStorage;
import com.example.banking.BankingDomain.AccountId;
import com.example.banking.features.accountdetails.AccountDetailsReadModel;
import com.example.banking.features.accountoverview.AccountOverviewReadModel;
import com.example.banking.features.accountoverview.AccountOverviewReadModel.AccountSummary;
import com.example.banking.features.deposit.DepositCommand;
import com.example.banking.features.openaccount.OpenAccountCommand;
public class BankingApplication {
public static void main ( String[] args ) {
// 1. the event storage: in memory here, PostgreSQL in production
EventStorage eventStorage = InMemoryEventStorage.newBuilder().build();
// 2. build the bounded context; feature slices are found by scanning the package
Banking banking = BoundedContext.newBuilder(Banking.class)
.name("banking")
.eventStorage(eventStorage)
.instance(InstanceFactory.determine("banking-app"))
.features()
.rootPackage(BankingApplication.class.getPackage())
.done()
.build();
// 3. start it: processors start, eventually consistent read models catch up
banking.start();
// 4. application code holds the narrow surface: execute commands, read read models
BankingApi app = banking;
CommandExecutionResult<AccountId> opened =
app.execute(new OpenAccountCommand(BankingDomain.CUSTOMER.newId()));
AccountId accountId = opened.response();
EventReference openedAt = opened.eventReference().orElseThrow();
System.out.println("Account opened: " + accountId.value());
app.execute(new DepositCommand(accountId, new BigDecimal("100.00")));
// 5. a live read model is projected when it is read: always current
AccountDetailsReadModel details = app.read(AccountDetailsReadModel.class, accountId);
System.out.println("Details: " + details.getAccountDetails().orElseThrow());
// 6. an eventually consistent read model answers with the position it reflects
ReadModelResult<Set<AccountSummary>> overview = AccountOverviewReadModel.INSTANCE.published();
boolean includesOurAccount = overview.upTo() != null
&& !openedAt.happenedAfter(overview.upTo());
System.out.println("Overview: " + overview.data() + " (includes our account: " + includesOurAccount + ")");
// 7. shut down from the outside in: the context first, then the storage it was given
banking.terminate();
eventStorage.close();
}
}
Key concepts:
BoundedContext.newBuilder(Banking.class)resolves the three event types from the interface.namenames the context (and the streams it keeps its events in),instanceidentifies this deployment, andfeatures().rootPackage(...)scans for feature slices declared forBanking.build()assembles the context and checks every registration — a read model without a projection mode, a component of another context’s event types, two components sharing a name.start()runs it.read(AccountDetailsReadModel.class, accountId)is typed by the class it asks for: no cast.published()hands back the state and the position it reflects as one observation, so a caller can check whether its own write is included instead of guessing.- The context closes the event store it built; the storage came from you, so you close it, after the context.
Step 9: Run the Application
1
mvn clean compile exec:java -Dexec.mainClass="com.example.banking.BankingApplication"
You should see output similar to:
1
2
3
Account opened: b8c6c4ec-ad2a-44ab-80fc-e23c07887391
Details: AccountDetails[accountId=b8c6c4ec-..., customerId=57db479c-..., openDate=2026-10-12, balance=100.00]
Overview: [AccountSummary[accountId=b8c6c4ec-..., customerId=57db479c-..., openDate=2026-10-12]] (includes our account: true)
The overview may say includes our account: false on another run: the background projector had not reached the new account yet. That is what eventually consistent means, and published() is what lets the caller see it.
Step 10: Test a Command
The testing module’s CommandTest runs a command against a fresh bounded context, given a history:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
package com.example.banking.features.deposit;
import java.math.BigDecimal;
import java.time.LocalDate;
import org.junit.jupiter.api.Test;
import org.sliceworkz.eventmodeling.testing.CommandTest;
import org.sliceworkz.eventstore.events.Tags;
import com.example.banking.BankingDomain.AccountId;
import com.example.banking.BankingDomain.BankingDomainEvent;
import com.example.banking.BankingDomain.BankingDomainEvent.AccountOpened;
import com.example.banking.BankingDomain.BankingDomainEvent.MoneyDeposited;
import com.example.banking.BankingDomain.BankingInboundEvent;
import com.example.banking.BankingDomain.BankingOutboundEvent;
import com.example.banking.BankingDomain.CustomerId;
import static com.example.banking.BankingDomain.ACCOUNT;
import static com.example.banking.BankingDomain.CUSTOMER;
class DepositCommandTest extends CommandTest<BankingDomainEvent, BankingInboundEvent, BankingOutboundEvent> {
@Override public Class<BankingDomainEvent> domainEventType ( ) { return BankingDomainEvent.class; }
@Override public Class<BankingInboundEvent> inboundEventType ( ) { return BankingInboundEvent.class; }
@Override public Class<BankingOutboundEvent> outboundEventType ( ) { return BankingOutboundEvent.class; }
AccountId account = ACCOUNT.id("acc-1");
CustomerId customer = CUSTOMER.id("cust-1");
@Test
void depositOnAnOpenAccount ( ) {
given()
.event(new AccountOpened(account, customer, LocalDate.now()), Tags.of(ACCOUNT.tag(account), CUSTOMER.tag(customer)))
.when(new DepositCommand(account, new BigDecimal("100")))
.then()
.event(new MoneyDeposited(account, new BigDecimal("100")), ACCOUNT.tags(account));
}
@Test
void depositOnAnUnknownAccountIsRejected ( ) {
given()
.when(new DepositCommand(account, new BigDecimal("100")))
.then()
.businessError("Account acc-1 does not exist");
}
}
- Seed history with the tags production would put on it: the decision model finds events by tag, so an untagged
AccountOpenedwould not count as an open account. .event(expected, tags)checks the raised event and that it carries at least those tags..businessError(message)asserts the command was rejected with aBusinessException, so a bug throwing some other exception with the same message does not pass.
Annotate a test @ForEachBackend instead of @Test to run it against every event storage you register, PostgreSQL included. See Testing Feature Slices.
Project Structure
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
src/main/java/com/example/banking/
├── BankingDomain.java # entities and events
├── Banking.java # the bounded context
├── BankingApi.java # what application code may do with it
├── BankingApplication.java # builds, starts and uses it
└── features/
├── openaccount/
│ ├── OpenAccountFeatureSlice.java
│ └── OpenAccountCommand.java
├── deposit/
│ ├── DepositFeatureSlice.java
│ ├── AccountExistsDecisionModel.java
│ └── DepositCommand.java
├── accountdetails/
│ ├── AccountDetailsFeatureSlice.java
│ └── AccountDetailsReadModel.java
└── accountoverview/
├── AccountOverviewFeatureSlice.java
└── AccountOverviewReadModel.java
src/test/java/com/example/banking/features/deposit/
└── DepositCommandTest.java
Next Steps
The guides take each part further:
- Bounded Contexts and Feature Slices — slices, aspects, building and starting a context, and what
build()checks - Entities, Typed Ids and Tags — how events are tagged and found
- Commands and Decision Models — the consistency boundary, retries, idempotency keys and aggregates
- Where a Validation Goes — value objects, input checks, rules, uniqueness
- Business Rules and Enforcement Levels — rules people may override, and previewing a command before submitting it
- Choosing a Read Model and Durable SQL Read Models
- Automations and Policies — reacting to events
- Translators, Publishers and Dispatchers — talking to other systems through events
- Ports, Adapters and Monitored Calls — talking to other systems through calls
- Who May Do What — handing each caller only the capabilities it needs
- Testing Feature Slices
- Running in Production, Observability and Tracing and Personal Data and Erasure
The examples module in the repository holds a larger banking context — closing the books, business rules, a policy — and a payments context with an automation whose external calls can fail.