Build CoreOrdered learning track

Generative Testing for Domain Models

Learn Java Formal Methods, Testing, Benchmarking, and Performance Engineering - Part 012

Generative testing untuk domain model Java: aggregate, command, event, workflow trace, stateful property, model-based oracle, invariant coverage, regulatory lifecycle, dan cross-entity behavior.

8 min read1579 words
PrevNext
Lesson 1240 lesson track0922 Build Core
#java#testing#property-based-testing#generative-testing+4 more

Part 012 — Generative Testing for Domain Models

Tujuan bagian ini: membawa property-based testing dari level value object dan fungsi murni ke domain model enterprise: aggregate, command, event, lifecycle, workflow, approval, escalation, idempotency, audit, dan cross-entity invariant.

Part sebelumnya membahas property-based testing sebagai teknik:

property + generator + oracle

Bagian ini membahas masalah yang lebih nyata:

Sistem tidak hanya menerima input.
Sistem berubah state.
Sistem memproses command.
Sistem menghasilkan event.
Sistem punya lifecycle.
Sistem punya invariant lintas entity.
Sistem harus benar setelah ratusan kombinasi aksi.

Example test untuk domain stateful sering terlihat seperti ini:

@Test
void submittedCaseCanBeApproved() {
    EnforcementCase c = EnforcementCase.draft(...);

    c.submit(...);
    c.approve(...);

    assertThat(c.status()).isEqualTo(CaseStatus.APPROVED);
}

Test ini baik sebagai dokumentasi. Tapi production bug sering terjadi dalam urutan aneh:

submit twice
cancel after submit
approve after reject
escalate after close
retry command with same id
receive event out of order
update stale version
apply compensation twice
change owner while approval pending

Generative testing untuk domain model mencoba menghasilkan urutan aksi dan mengecek invariant setelah tiap langkah.

Mental model:

Do not only generate values.
Generate histories.

1. Domain Generative Testing in One Sentence

Generative testing untuk domain model adalah teknik untuk menghasilkan banyak object, command, event, dan trace workflow secara otomatis, lalu memeriksa bahwa domain invariant tetap benar setelah setiap operasi.

Perbedaannya dengan Part 011:

Part 011Part 012
generate valuegenerate aggregate/command/event/history
function propertystate transition property
simple oraclemodel-based oracle
one input/outputsequence of inputs/outputs
counterexample is valuecounterexample is trace

2. Why Domain Generators Are Hard

Generator primitive mudah.

Arbitraries.integers().between(1, 100)

Generator domain sulit karena object valid punya constraints.

Contoh EnforcementCase:

caseId required
subject required
jurisdiction required
status must match lifecycle
submittedAt exists only after submit
closedAt exists only after close
assignedOfficer required after assignment
sanction cannot exist before decision
appeal deadline depends on decision date
case cannot be closed while active appeal exists

Kalau generator membuat object random tanpa constraint, test menjadi noise.

Buruk:

new EnforcementCase(
    randomId,
    randomStatus,
    randomSubmittedAt,
    randomClosedAt,
    randomDecision,
    randomAppeal
)

Object bisa invalid secara domain sebelum operation diuji. Test gagal bukan karena bug production code, tetapi karena generator menciptakan dunia mustahil.

Rule:

A domain generator must generate possible worlds.
Not random field assignments.

3. The Domain Generator Pyramid

Susun generator berlapis.

3.1 Primitive Values

static Arbitrary<String> safeAlphaNumeric(int min, int max) {
    return Arbitraries.strings()
        .withChars("abcdefghijklmnopqrstuvwxyz0123456789")
        .ofMinLength(min)
        .ofMaxLength(max);
}

3.2 Value Objects

static Arbitrary<CaseId> caseIds() {
    return safeAlphaNumeric(8, 16).map(CaseId::new);
}

3.3 Entities

static Arbitrary<Subject> subjects() {
    return Combinators.combine(
        safeAlphaNumeric(3, 20),
        Arbitraries.of(SubjectType.PERSON, SubjectType.COMPANY)
    ).as(Subject::new);
}

3.4 Aggregates

static Arbitrary<EnforcementCase> draftCases() {
    return Combinators.combine(
        caseIds(),
        subjects(),
        jurisdictions()
    ).as((id, subject, jurisdiction) ->
        EnforcementCase.openDraft(id, subject, jurisdiction)
    );
}

3.5 Commands

sealed interface CaseCommand permits SubmitCase, AssignOfficer, ApproveCase, RejectCase, CloseCase {}

3.6 Traces

record CaseTrace(EnforcementCase initial, List<CaseCommand> commands) {}

Trace adalah input stateful.


4. Possible Worlds, Not Random Objects

Bayangkan domain lifecycle:

Generator aggregate harus menghormati lifecycle.

Jangan generate:

status = DRAFT, submittedAt = 2026-07-02
status = CLOSED, closedAt = null
status = APPROVED, decision = null
status = CANCELLED, approvalEvent exists

Lebih baik generate melalui factory/transition method:

static EnforcementCase submittedCase(...) {
    EnforcementCase c = EnforcementCase.openDraft(...);
    c.submit(...);
    return c;
}

static EnforcementCase underReviewCase(...) {
    EnforcementCase c = submittedCase(...);
    c.assign(...);
    return c;
}

Principle:

Use domain operations to build states.
Do not bypass invariants through constructors.

Kalau test generator perlu constructor khusus, buat TestDomainBuilder yang tetap memanggil valid domain methods.


5. Aggregate Generator Pattern

Contoh aggregate order.

record Order(
    OrderId id,
    String currency,
    List<OrderLine> lines,
    OrderStatus status,
    Instant submittedAt,
    int version
) {}

Generator draft order:

final class OrderArbitraries {

    static Arbitrary<Order> draftOrders() {
        Arbitrary<String> currency = Arbitraries.of("IDR", "USD");

        return currency.flatMap(c ->
            Combinators.combine(
                orderIds(),
                orderLines(c).list().ofMinSize(1).ofMaxSize(20)
            ).as((id, lines) -> Order.draft(id, c, lines))
        );
    }

    static Arbitrary<OrderLine> orderLines(String currency) {
        return Combinators.combine(
            skus(),
            Arbitraries.integers().between(1, 100),
            money(currency)
        ).as(OrderLine::new);
    }
}

Perhatikan currency.flatMap(...).

Ini penting agar semua line punya currency yang sama dengan order.

Bad generator:

Combinators.combine(orderCurrency(), lineCurrency(), amount())

Bisa menghasilkan order USD dengan line IDR, kecuali domain memang mendukung multi-currency.


6. Valid vs Invalid Domain Generators

Kita butuh dua jenis generator:

valid generator   = object yang harus diterima
invalid generator = object yang harus ditolak

Jangan campur.

6.1 Valid Generator

@Provide
Arbitrary<Order> validDraftOrders() {
    return OrderArbitraries.draftOrders();
}

Property:

@Property
void validDraftOrdersCanBePriced(@ForAll("validDraftOrders") Order order) {
    Money total = pricing.total(order);

    assertThat(total).isGreaterThanOrEqualTo(Money.zero(order.currency()));
}

6.2 Invalid Generator via Mutation

@Property
void orderWithoutLinesIsRejected(@ForAll("validDraftOrders") Order valid) {
    Order invalid = valid.withLines(List.of());

    assertThatThrownBy(() -> pricing.total(invalid))
        .isInstanceOf(DomainValidationException.class)
        .hasMessageContaining("at least one line");
}

Pattern:

generate valid base
mutate one invariant
assert exact rejection

Ini jauh lebih kuat daripada generate random invalid object yang punya 10 error sekaligus.


7. Command Generator Pattern

Untuk aggregate stateful, command generator harus sadar status.

Command:

sealed interface CaseCommand {
    CommandId commandId();
}

record SubmitCase(CommandId commandId, OfficerId submittedBy, Instant submittedAt) implements CaseCommand {}
record AssignOfficer(CommandId commandId, OfficerId officerId) implements CaseCommand {}
record ApproveCase(CommandId commandId, OfficerId approvedBy, Instant decidedAt) implements CaseCommand {}
record RejectCase(CommandId commandId, OfficerId rejectedBy, String reason, Instant decidedAt) implements CaseCommand {}
record CloseCase(CommandId commandId, OfficerId closedBy, Instant closedAt) implements CaseCommand {}

Generator command by state:

static Arbitrary<CaseCommand> validCommandFor(CaseStatus status) {
    return switch (status) {
        case DRAFT -> Arbitraries.oneOf(submitCommands(), cancelCommands());
        case SUBMITTED -> assignOfficerCommands();
        case UNDER_REVIEW -> Arbitraries.oneOf(approveCommands(), rejectCommands());
        case APPROVED, REJECTED -> closeCommands();
        case CANCELLED, CLOSED -> Arbitraries.of();
    };
}

Tapi property-based frameworks biasanya mengharapkan generator diketahui sebelum value runtime. Untuk state-dependent generation, kita bisa:

  1. generate trace dari lifecycle grammar;
  2. generate list command lalu filter/apply valid subset;
  3. use action sequence/stateful testing support jika framework menyediakan;
  4. build custom trace generator dengan finite state machine.

Untuk enterprise domain, custom trace generator sering lebih eksplisit dan maintainable.


8. Trace Generator as Lifecycle Grammar

Lifecycle grammar:

DraftTrace := Cancel | Submit SubmittedTrace
SubmittedTrace := Assign ReviewTrace
ReviewTrace := Approve ClosedTrace | Reject ClosedTrace
ClosedTrace := Close | epsilon

Implementasi conceptual:

static Arbitrary<List<CaseCommand>> validCaseTraces() {
    return Arbitraries.oneOf(
        cancelTrace(),
        submitAssignApproveCloseTrace(),
        submitAssignRejectCloseTrace(),
        submitAssignApproveTrace(),
        submitAssignRejectTrace()
    );
}

Ini tidak random-field. Ini random-path.

static Arbitrary<List<CaseCommand>> submitAssignApproveCloseTrace() {
    return Combinators.combine(
        submitCommands(),
        assignOfficerCommands(),
        approveCommands(),
        closeCommands()
    ).as((submit, assign, approve, close) ->
        List.of(submit, assign, approve, close)
    );
}

Kelemahan: manual untuk lifecycle besar.

Kelebihan: mudah dibaca, mudah direview, shrinking trace lebih masuk akal.


9. Stateful Property: Apply Trace, Check Invariant

@Property
void validCaseTraceAlwaysPreservesInvariants(
    @ForAll("draftCases") EnforcementCase initial,
    @ForAll("validCaseTraces") List<CaseCommand> commands
) {
    EnforcementCase c = initial;

    for (CaseCommand command : commands) {
        c = c.handle(command);

        assertCaseInvariants(c);
    }
}

Invariant assertion:

private static void assertCaseInvariants(EnforcementCase c) {
    assertThat(c.id()).isNotNull();
    assertThat(c.subject()).isNotNull();

    switch (c.status()) {
        case DRAFT -> {
            assertThat(c.submittedAt()).isNull();
            assertThat(c.closedAt()).isNull();
            assertThat(c.decision()).isNull();
        }
        case SUBMITTED -> {
            assertThat(c.submittedAt()).isNotNull();
            assertThat(c.closedAt()).isNull();
        }
        case UNDER_REVIEW -> {
            assertThat(c.assignedOfficer()).isNotNull();
            assertThat(c.closedAt()).isNull();
        }
        case APPROVED, REJECTED -> {
            assertThat(c.decision()).isNotNull();
            assertThat(c.closedAt()).isNull();
        }
        case CLOSED -> {
            assertThat(c.closedAt()).isNotNull();
            assertThat(c.decision()).isNotNull();
        }
        case CANCELLED -> {
            assertThat(c.cancelledAt()).isNotNull();
        }
    }
}

Ini inti generative domain testing:

Generate history.
Execute history.
Assert invariant after each step.

10. Testing Invalid Transitions

Valid traces penting. Invalid traces juga penting.

Kita ingin memastikan domain menolak illegal transition.

Contoh:

@Property
void terminalCasesRejectAllMutatingCommands(
    @ForAll("terminalCases") EnforcementCase terminal,
    @ForAll("mutatingCommands") CaseCommand command
) {
    assertThatThrownBy(() -> terminal.handle(command))
        .isInstanceOf(IllegalStateTransitionException.class);
}

Generator terminal cases:

static Arbitrary<EnforcementCase> terminalCases() {
    return Arbitraries.oneOf(
        closedCases(),
        cancelledCases()
    );
}

Invalid transition matrix:

Current StateInvalid Command Examples
DRAFTapprove, reject, close
SUBMITTEDapprove before assign, close
UNDER_REVIEWsubmit again, cancel maybe invalid depending domain
APPROVEDsubmit, assign, approve again
REJECTEDsubmit, assign, reject again
CLOSEDany mutating command
CANCELLEDany mutating command

Property:

@Property
void invalidCommandsAreRejectedWithoutChangingState(
    @ForAll("caseAndInvalidCommand") CaseInvalidCommand sample
) {
    EnforcementCase before = sample.caseAggregate();
    CaseCommand invalid = sample.command();

    assertThatThrownBy(() -> before.handle(invalid))
        .isInstanceOf(IllegalStateTransitionException.class);

    assertThat(before).isEqualTo(sample.caseAggregate());
}

Important: for mutable aggregate, snapshot before state.

EnforcementCase snapshot = before.copy();

Then compare after rejection.


11. Idempotency Properties

Enterprise systems retry. Duplicate command happens.

Idempotency property:

Applying the same command twice with the same commandId must not duplicate effects.

Example:

@Property
void duplicateSubmitCommandIsIdempotent(
    @ForAll("draftCases") EnforcementCase draft,
    @ForAll("submitCommands") SubmitCase submit
) {
    EnforcementCase once = draft.handle(submit);
    EnforcementCase twice = once.handle(submit);

    assertThat(twice.status()).isEqualTo(once.status());
    assertThat(twice.domainEvents())
        .filteredOn(e -> e instanceof CaseSubmitted)
        .hasSize(1);
}

But idempotency semantics must be explicit.

Ada beberapa pilihan:

Duplicate CasePossible Semantics
same command id, same payloadreturn same result, no new event
same command id, different payloadreject as conflict
different command id, same business payloadmaybe duplicate, maybe semantic conflict
retry after timeoutresume or return known outcome

Property untuk conflict:

@Property
void sameCommandIdWithDifferentPayloadIsRejected(
    @ForAll("draftCases") EnforcementCase draft,
    @ForAll("submitCommands") SubmitCase submit,
    @ForAll("differentOfficerIds") OfficerId otherOfficer
) {
    SubmitCase conflicting = new SubmitCase(
        submit.commandId(),
        otherOfficer,
        submit.submittedAt()
    );

    EnforcementCase submitted = draft.handle(submit);

    assertThatThrownBy(() -> submitted.handle(conflicting))
        .isInstanceOf(IdempotencyConflictException.class);
}

12. Event Generation and Event-Sourced Domains

Jika domain event-sourced, aggregate dibangun dari event history.

sealed interface CaseEvent {}
record CaseOpened(CaseId id, Subject subject, Jurisdiction jurisdiction) implements CaseEvent {}
record CaseSubmitted(CaseId id, OfficerId by, Instant at) implements CaseEvent {}
record OfficerAssigned(CaseId id, OfficerId officerId) implements CaseEvent {}
record CaseApproved(CaseId id, OfficerId by, Instant at) implements CaseEvent {}
record CaseClosed(CaseId id, OfficerId by, Instant at) implements CaseEvent {}

Property:

@Property
void replayingGeneratedValidEventHistoryBuildsValidAggregate(
    @ForAll("validEventHistories") List<CaseEvent> history
) {
    EnforcementCase c = EnforcementCase.replay(history);

    assertCaseInvariants(c);
}

Roundtrip property:

@Property
void commandGeneratedEventsCanBeReplayedToSameState(
    @ForAll("draftCases") EnforcementCase initial,
    @ForAll("validCaseTraces") List<CaseCommand> commands
) {
    EnforcementCase live = initial;
    List<CaseEvent> allEvents = new ArrayList<>();

    for (CaseCommand command : commands) {
        HandlingResult result = live.handle(command);
        live = result.newState();
        allEvents.addAll(result.events());
    }

    EnforcementCase replayed = EnforcementCase.replay(allEvents);

    assertThat(replayed).usingRecursiveComparison()
        .ignoringFields("transientRuntimeFields")
        .isEqualTo(live);
}

This catches:

event missing field
apply method diverges from command handler
snapshot/replay mismatch
event order bug
version increment bug

13. Model-Based Oracle

Untuk stateful system, oracle paling kuat sering berupa model sederhana.

Production aggregate:

rich entity, validation, audit, events, persistence concerns

Reference model:

small immutable state machine with expected transitions

Model:

record CaseModel(CaseStatus status, boolean hasAssignee, boolean hasDecision) {

    CaseModel apply(CaseCommand command) {
        return switch (status) {
            case DRAFT -> switch (command) {
                case SubmitCase ignored -> new CaseModel(CaseStatus.SUBMITTED, false, false);
                case CancelCase ignored -> new CaseModel(CaseStatus.CANCELLED, false, false);
                default -> throw new ModelInvalidTransition();
            };
            case SUBMITTED -> switch (command) {
                case AssignOfficer ignored -> new CaseModel(CaseStatus.UNDER_REVIEW, true, false);
                default -> throw new ModelInvalidTransition();
            };
            case UNDER_REVIEW -> switch (command) {
                case ApproveCase ignored -> new CaseModel(CaseStatus.APPROVED, true, true);
                case RejectCase ignored -> new CaseModel(CaseStatus.REJECTED, true, true);
                default -> throw new ModelInvalidTransition();
            };
            case APPROVED, REJECTED -> switch (command) {
                case CloseCase ignored -> new CaseModel(CaseStatus.CLOSED, hasAssignee, hasDecision);
                default -> throw new ModelInvalidTransition();
            };
            case CLOSED, CANCELLED -> throw new ModelInvalidTransition();
        };
    }
}

Property:

@Property
void aggregateMatchesReferenceModel(
    @ForAll("validCaseTraces") List<CaseCommand> commands
) {
    EnforcementCase aggregate = EnforcementCase.openDraft(...);
    CaseModel model = new CaseModel(CaseStatus.DRAFT, false, false);

    for (CaseCommand command : commands) {
        aggregate = aggregate.handle(command);
        model = model.apply(command);

        assertThat(aggregate.status()).isEqualTo(model.status());
        assertThat(aggregate.assignedOfficer().isPresent()).isEqualTo(model.hasAssignee());
        assertThat(aggregate.decision().isPresent()).isEqualTo(model.hasDecision());
    }
}

This is powerful because model is simpler than implementation.

But model must not be a copy-paste of production logic.


14. Cross-Entity Invariants

Real systems rarely have one aggregate only.

Examples:

A case cannot be closed if there is an active appeal.
A customer cannot have more active credit exposure than limit.
A quote cannot be ordered after expiry.
A payment cannot be captured above authorized amount.
A user cannot approve their own request.
An enforcement action cannot reference a deleted subject.

Cross-entity generator:

record CaseWorld(
    EnforcementCase enforcementCase,
    List<Appeal> appeals,
    List<OfficerAssignment> assignments,
    List<Sanction> sanctions
) {}

World generator:

static Arbitrary<CaseWorld> worldsWithActiveAppeal() {
    return Combinators.combine(
        casesWithDecision(),
        activeAppeals().list().ofMinSize(1).ofMaxSize(3),
        assignments(),
        sanctions()
    ).as(CaseWorld::new);
}

Property:

@Property
void caseWithActiveAppealCannotBeClosed(
    @ForAll("worldsWithActiveAppeal") CaseWorld world,
    @ForAll("closeCommands") CloseCase close
) {
    CaseService service = serviceWith(world);

    assertThatThrownBy(() -> service.handle(world.enforcementCase().id(), close))
        .isInstanceOf(DomainPolicyViolation.class)
        .hasMessageContaining("active appeal");
}

This is beyond aggregate unit test.

Ini menguji policy-level invariant.


15. Temporal Domain Generators

Banyak domain rule bergantung waktu.

Examples:

appeal must be submitted within 14 days after decision
quote expires after validUntil
SLA escalates after 2 business days
payment capture must happen before authorization expiry
case cannot be reopened after retention lock

Generator waktu harus eksplisit.

static Arbitrary<Instant> instantsAround(Instant anchor) {
    return Arbitraries.longs().between(-20, 20)
        .map(days -> anchor.plus(Duration.ofDays(days)));
}

Boundary-focused generator:

static Arbitrary<Instant> appealSubmissionTimes(Instant decisionAt) {
    return Arbitraries.of(
        decisionAt,
        decisionAt.plus(Duration.ofDays(13)),
        decisionAt.plus(Duration.ofDays(14)),
        decisionAt.plus(Duration.ofDays(15)),
        decisionAt.minus(Duration.ofSeconds(1))
    );
}

Property:

@Property
void appealDeadlineBoundaryIsEnforced(
    @ForAll("decidedCases") EnforcementCase decided,
    @ForAll("appealSubmissionTimes") Instant submittedAt
) {
    boolean withinDeadline = !submittedAt.isAfter(decided.decisionAt().plus(Duration.ofDays(14)));

    if (withinDeadline) {
        assertThatCode(() -> decided.submitAppeal(submittedAt)).doesNotThrowAnyException();
    } else {
        assertThatThrownBy(() -> decided.submitAppeal(submittedAt))
            .isInstanceOf(AppealDeadlineExceeded.class);
    }
}

For business calendars, jangan pakai Duration.ofDays jika rule memakai working day.

Inject BusinessCalendar.


16. Regulatory Lifecycle Example

Domain:

Case DRAFT
Case submitted
Case assigned
Finding created
Notice issued
Response received
Decision made
Sanction imposed
Appeal possible
Case closed

Simplified lifecycle:

Invariants:

notice cannot be issued before investigation starts
response cannot be received before notice issued
decision cannot be made while response window open unless response received
sanction requires adverse decision
appeal exists only for sanction decision
case cannot close with unresolved appeal
all externally visible decisions must be audit logged

Trace property:

@Property
void regulatoryLifecycleTracePreservesLegalInvariants(
    @ForAll("regulatoryTraces") RegulatoryTrace trace
) {
    RegulatoryCase c = RegulatoryCase.openDraft(trace.caseId(), trace.subject());

    for (RegulatoryCommand command : trace.commands()) {
        c = c.handle(command);
        assertLegalInvariants(c);
    }
}

Legal invariant assertion:

private static void assertLegalInvariants(RegulatoryCase c) {
    if (c.status().isAfterOrEqual(RegulatoryStatus.NOTICE_ISSUED)) {
        assertThat(c.notice()).isPresent();
        assertThat(c.investigationStartedAt()).isPresent();
    }

    if (c.response().isPresent()) {
        assertThat(c.notice()).isPresent();
        assertThat(c.response().get().receivedAt())
            .isAfterOrEqualTo(c.notice().get().issuedAt());
    }

    if (c.sanction().isPresent()) {
        assertThat(c.decision()).isPresent();
        assertThat(c.decision().get().type()).isEqualTo(DecisionType.ADVERSE);
    }

    if (c.status() == RegulatoryStatus.CLOSED) {
        assertThat(c.appeals()).allMatch(Appeal::isResolved);
    }
}

This is the kind of testing that finds “legally impossible state” bugs.


17. Testing Escalation Logic

Escalation is state + time + policy.

Rule:

If a submitted case remains unassigned for more than 2 business days, escalate to supervisor.

Generator:

record EscalationScenario(
    EnforcementCase submittedCase,
    Instant now,
    boolean expectedEscalation
) {}
static Arbitrary<EscalationScenario> escalationScenarios() {
    Instant submittedAt = Instant.parse("2026-07-01T00:00:00Z");

    return Arbitraries.of(
        new EscalationScenario(submittedCaseAt(submittedAt), submittedAt.plus(1, DAYS), false),
        new EscalationScenario(submittedCaseAt(submittedAt), submittedAt.plus(2, DAYS), false),
        new EscalationScenario(submittedCaseAt(submittedAt), submittedAt.plus(3, DAYS), true)
    );
}

Property:

@Property
void escalationPolicyMatchesScenario(
    @ForAll("escalationScenarios") EscalationScenario scenario
) {
    EscalationDecision decision = policy.evaluate(
        scenario.submittedCase(),
        scenario.now()
    );

    assertThat(decision.shouldEscalate()).isEqualTo(scenario.expectedEscalation());
}

For richer testing, generate calendars with holidays and weekends.


18. Testing Approval Rules

Rule:

Requester cannot approve own request.
Approval amount limit depends on approver role.
At least two approvals required for amount > threshold.

World:

record ApprovalWorld(
    Request request,
    User requester,
    User approver,
    ApprovalPolicy policy
) {}

Property:

@Property
void requesterCannotApproveOwnRequest(
    @ForAll("approvalWorldsWhereApproverIsRequester") ApprovalWorld world
) {
    assertThatThrownBy(() -> approvalService.approve(
        world.request().id(),
        world.approver().id()
    )).isInstanceOf(SelfApprovalNotAllowed.class);
}

Limit property:

@Property
void approvalRequiresSufficientAuthority(
    @ForAll("approvalWorlds") ApprovalWorld world
) {
    boolean allowed = world.approver().limit().compareTo(world.request().amount()) >= 0
        && !world.approver().id().equals(world.requester().id());

    if (allowed) {
        assertThatCode(() -> approve(world)).doesNotThrowAnyException();
    } else {
        assertThatThrownBy(() -> approve(world)).isInstanceOf(ApprovalPolicyViolation.class);
    }
}

Again, avoid mirrored implementation. If policy comes from decision table, use the table as independent oracle.


19. Testing Event Ordering and Out-of-Order Delivery

Distributed systems receive events out of order.

Event handler property:

For all valid event histories, if events are delivered with duplicates and permitted reorderings, projection remains eventually consistent.

Input:

record DeliveryPlan(
    List<DomainEvent> original,
    List<DomainEvent> delivered
) {}

Generator:

static Arbitrary<DeliveryPlan> deliveryPlans() {
    return validEventHistories().flatMap(history ->
        duplicateAndReorderWithinConstraints(history)
            .map(delivered -> new DeliveryPlan(history, delivered))
    );
}

Property:

@Property
void projectionIsIdempotentUnderDuplicateDelivery(
    @ForAll("deliveryPlans") DeliveryPlan plan
) {
    Projection projection = new Projection();

    for (DomainEvent event : plan.delivered()) {
        projection.handle(event);
    }

    Projection expected = new Projection();
    for (DomainEvent event : plan.original()) {
        expected.handle(event);
    }

    assertThat(projection.snapshot()).isEqualTo(expected.snapshot());
}

This catches:

handler not idempotent
duplicate event increments counters twice
stale event overwrites newer state
missing version guard
projection assumes perfect ordering

20. Testing Optimistic Locking and Versioning

For aggregate version:

successful command increments version by 1
rejected command does not increment version
stale command is rejected
idempotent duplicate returns same version or no-op according to policy

Property:

@Property
void successfulCommandIncrementsVersionByOne(
    @ForAll("caseWithValidNextCommand") CaseWithCommand sample
) {
    EnforcementCase before = sample.caseAggregate();
    int versionBefore = before.version();

    EnforcementCase after = before.handle(sample.command());

    assertThat(after.version()).isEqualTo(versionBefore + 1);
}

Rejected command property:

@Property
void rejectedCommandDoesNotChangeVersion(
    @ForAll("caseWithInvalidCommand") CaseWithCommand sample
) {
    EnforcementCase before = sample.caseAggregate();
    int versionBefore = before.version();

    assertThatThrownBy(() -> before.handle(sample.command()))
        .isInstanceOf(DomainException.class);

    assertThat(before.version()).isEqualTo(versionBefore);
}

For immutable aggregate this is easier. For mutable aggregate, snapshot.


21. Recursive Generators for Nested Domain Models

Some domains are recursive:

organizational hierarchy
approval delegation tree
product bundle
policy expression tree
query filter expression
workflow graph

Example policy expression:

sealed interface PolicyExpr permits And, Or, Not, Condition {}
record And(PolicyExpr left, PolicyExpr right) implements PolicyExpr {}
record Or(PolicyExpr left, PolicyExpr right) implements PolicyExpr {}
record Not(PolicyExpr expr) implements PolicyExpr {}
record Condition(String field, Operator op, String value) implements PolicyExpr {}

Generator must bound depth.

Conceptual:

static Arbitrary<PolicyExpr> policyExpressions(int depth) {
    if (depth == 0) {
        return conditions();
    }

    return Arbitraries.oneOf(
        conditions(),
        Combinators.combine(policyExpressions(depth - 1), policyExpressions(depth - 1)).as(And::new),
        Combinators.combine(policyExpressions(depth - 1), policyExpressions(depth - 1)).as(Or::new),
        policyExpressions(depth - 1).map(Not::new)
    );
}

Properties:

@Property
void doubleNegationPreservesPolicyMeaning(@ForAll("policyExpressions") PolicyExpr expr,
                                          @ForAll("facts") Facts facts) {
    boolean original = evaluator.evaluate(expr, facts);
    boolean doubleNegated = evaluator.evaluate(new Not(new Not(expr)), facts);

    assertThat(doubleNegated).isEqualTo(original);
}

This is excellent for rule engines and policy DSLs.


22. Shrinking Domain Traces

When trace fails, raw failure can be huge:

open -> submit -> assign -> add finding -> issue notice -> receive response -> update response -> decide -> sanction -> appeal -> update appeal -> close

Shrinking should reduce to:

open -> issue notice

or:

open -> submit -> assign -> close

Good trace design helps shrinker.

Tips:

Keep commands immutable and small.
Avoid random unrelated fields.
Prefer lifecycle grammar over arbitrary object graph.
Use value objects with shrinkable primitives.
Keep max trace length bounded.
Avoid generator constraints that break shrinking.

A minimal trace is a better bug report than a page of random JSON.


23. Classification for Domain Traces

For trace tests, classify by path.

@Property
void validTracePreservesInvariants(@ForAll("validTraces") CaseTrace trace) {
    Statistics.label("trace-path").collect(trace.pathName());
    Statistics.label("trace-length").collect(lengthBucket(trace.commands().size()));
    Statistics.label("terminal-state").collect(trace.expectedTerminalState());

    EnforcementCase c = trace.initial();
    for (CaseCommand command : trace.commands()) {
        c = c.handle(command);
        assertCaseInvariants(c);
    }
}

Classification categories:

cancel path
approve path
reject path
appeal path
close path
retry path
duplicate command path
invalid transition path
boundary deadline path

If 95% traces are approve path, generator is biased.

Bias can be good if intentional. It is dangerous if accidental.


24. Domain Generator Governance

For large codebase, domain generators become shared infrastructure.

Recommended structure:

src/testFixtures/java
  com.company.testing.domain
    MoneyArbitraries.java
    OrderArbitraries.java
    CaseArbitraries.java
    ApprovalArbitraries.java
    EventHistoryArbitraries.java
    DomainAssertions.java

Guidelines:

Generators must be deterministic under seed.
Generators must be documented by domain intent.
Do not expose low-level random constructors everywhere.
Provide valid and invalid generators separately.
Keep edge cases visible.
Keep max sizes bounded.
Use builders for readability.
Review generators like production code.

Generator is not test boilerplate. It is executable domain model.


25. Handling Persistence in Generative Domain Tests

Pure aggregate property is fast.

Service-level property with DB is slower.

Pattern:

@Property(tries = 25)
void commandHandlingPersistsSameStateAsPureModel(
    @ForAll("validCaseTraces") CaseTrace trace
) {
    CaseId id = repository.save(trace.initial());
    CaseModel model = CaseModel.initial();

    for (CaseCommand command : trace.commands()) {
        service.handle(id, command);
        model = model.apply(command);

        EnforcementCase loaded = repository.findById(id).orElseThrow();
        assertThat(loaded.status()).isEqualTo(model.status());
    }
}

Operational constraints:

transaction rollback per property run
small tries
isolated database schema/container
unique IDs
no shared mutable static state
tag as integration/property

26. Generative Testing for APIs

API layer property:

For all valid commands expressible through API payload,
HTTP handler maps payload to domain command correctly,
and response reflects domain result.

Example:

@Property(tries = 50)
void submitCaseApiMatchesDomainBehavior(
    @ForAll("submitCaseRequests") SubmitCaseRequest request
) {
    HttpResponse response = api.post("/cases/{id}/submit", request);

    if (request.isValid()) {
        assertThat(response.status()).isEqualTo(200);
    } else {
        assertThat(response.status()).isEqualTo(400);
    }
}

Better with valid/invalid separated:

@Property(tries = 50)
void validSubmitRequestsReturnOk(@ForAll("validSubmitRequests") SubmitCaseRequest request) {
    HttpResponse response = api.post(..., request);

    assertThat(response.status()).isEqualTo(200);
}

@Property(tries = 50)
void invalidSubmitRequestsReturnValidationError(@ForAll("invalidSubmitRequests") SubmitCaseRequest request) {
    HttpResponse response = api.post(..., request);

    assertThat(response.status()).isEqualTo(400);
    assertThat(response.body().errors()).isNotEmpty();
}

API property should not replace contract tests. It complements them.


27. Generative Testing for Event Consumers

Consumer property:

For all valid event payloads,
consumer processes event idempotently,
updates projection consistently,
and records poison events without crashing worker.

Example:

@Property(tries = 100)
void consumerIsIdempotentForDuplicateEvents(
    @ForAll("caseEvents") CaseEvent event
) {
    consumer.handle(event);
    ProjectionSnapshot once = projection.snapshotFor(event.caseId());

    consumer.handle(event);
    ProjectionSnapshot twice = projection.snapshotFor(event.caseId());

    assertThat(twice).isEqualTo(once);
}

Invalid payload property:

@Property(tries = 50)
void malformedEventsGoToDeadLetterWithoutCrashing(
    @ForAll("malformedEventPayloads") byte[] payload
) {
    assertThatCode(() -> consumer.handleRaw(payload))
        .doesNotThrowAnyException();

    assertThat(deadLetterQueue).containsPayload(payload);
}

28. Testing Compensation and Saga-Like Behavior

Saga/domain process has sequence:

reserve inventory
authorize payment
create shipment
capture payment

Failures create compensation.

Property:

For all failure points, final world has no leaked reservation or unauthorized capture.

Model:

record SagaScenario(Order order, FailurePoint failurePoint) {}
@Property
void sagaDoesNotLeakResourcesOnFailure(
    @ForAll("sagaScenarios") SagaScenario scenario
) {
    TestWorld world = TestWorld.withFailureAt(scenario.failurePoint());

    saga.execute(scenario.order(), world);

    assertThat(world.inventory().hasLeakedReservation()).isFalse();
    assertThat(world.payment().hasCapturedWithoutShipment()).isFalse();
}

This catches real production incidents:

reserved but never released
authorized but never voided
captured without downstream completion
compensation command emitted twice
retry creates duplicate shipment

29. Combining PBT with Mutation Testing

Generative domain tests can still be weak.

Mutation testing helps answer:

If someone breaks this rule, does the property fail?

Example mutant:

// original
if (riskScore <= 30) allow();

// mutant
if (riskScore < 30) allow();

A good generator should include boundary 30.

If mutation survives, either:

generator missed boundary
property assertion weak
oracle duplicated mutant behavior
test did not reach rule

This is why Part 013 follows immediately after PBT/generative testing.


30. Combining Generative Testing with Formal Models

Formal model can define valid traces.

Flow:

Practical approach:

Use TLA+/Alloy to discover impossible states.
Encode discovered invariants into Java property assertions.
Encode allowed transition traces into generators.
Use Java PBT for implementation-level verification.

Formal model checks design. Generative test checks implementation.


31. Case Study: Approval Workflow

Domain:

Request starts DRAFT.
Requester submits request.
Approver can approve if not requester and within limit.
Large requests need two distinct approvals.
Rejected request becomes terminal.
Approved request can be executed once.

Commands:

sealed interface RequestCommand {}
record Submit(UserId requester) implements RequestCommand {}
record Approve(UserId approver) implements RequestCommand {}
record Reject(UserId approver, String reason) implements RequestCommand {}
record Execute() implements RequestCommand {}

Invariant:

executed implies approved
approved large request has at least two distinct approvers
requester not in approvers
terminal request cannot mutate

Property:

@Property
void approvalWorkflowPreservesInvariants(
    @ForAll("approvalTraces") ApprovalTrace trace
) {
    ApprovalRequest request = trace.initial();

    for (RequestCommand command : trace.commands()) {
        try {
            request = request.handle(command);
        } catch (DomainException expectedForInvalidCommand) {
            // invalid trace variant: rejection must not corrupt state
        }

        assertApprovalInvariants(request);
    }
}

Invariant assertion:

private static void assertApprovalInvariants(ApprovalRequest request) {
    if (request.status() == RequestStatus.EXECUTED) {
        assertThat(request.statusHistory()).contains(RequestStatus.APPROVED);
    }

    if (request.amount().isGreaterThan(request.policy().largeRequestThreshold())
        && request.status().isAtLeast(RequestStatus.APPROVED)) {
        assertThat(request.approvers()).hasSizeGreaterThanOrEqualTo(2);
    }

    assertThat(request.approvers()).doesNotContain(request.requester());

    if (request.status().isTerminal()) {
        assertThat(request.openTasks()).isEmpty();
    }
}

32. Case Study: Pricing Rule Matrix

Pricing domain has combinatorial explosion:

customer tier
region
currency
product family
quantity bracket
voucher
promotion
tax
rounding mode
contract override

Generator should produce scenario, not random fields.

record PricingScenario(
    Customer customer,
    Product product,
    int quantity,
    Optional<Voucher> voucher,
    Region region,
    PricingExpectation expectation
) {}

Properties:

@Property
void totalIsNeverNegative(@ForAll("pricingScenarios") PricingScenario scenario) {
    Price price = pricing.price(scenario);

    assertThat(price.total()).isGreaterThanOrEqualTo(Money.zero(price.currency()));
}

@Property
void higherQuantityDoesNotIncreaseUnitPriceWhenVolumeDiscountApplies(
    @ForAll("volumeDiscountScenarios") PricingScenario scenario,
    @ForAll @IntRange(min = 1, max = 1000) int extraQuantity
) {
    PricingScenario higher = scenario.withQuantity(scenario.quantity() + extraQuantity);

    Price original = pricing.price(scenario);
    Price increased = pricing.price(higher);

    assertThat(increased.unitPrice()).isLessThanOrEqualTo(original.unitPrice());
}

This is metamorphic domain testing.


33. Case Study: Order Management Idempotency

Commands:

place order
reserve stock
authorize payment
confirm order
cancel order
ship order

Properties:

duplicate place order with same idempotency key creates one order
cancel after ship is rejected
payment capture never exceeds authorized amount
shipment cannot exist before confirmation
stock reservation released when order cancelled before shipment

Property example:

@Property
void duplicatePlaceOrderCreatesSingleOrder(
    @ForAll("placeOrderCommands") PlaceOrder command
) {
    OrderResult first = service.place(command);
    OrderResult second = service.place(command);

    assertThat(second.orderId()).isEqualTo(first.orderId());
    assertThat(orderRepository.countByIdempotencyKey(command.idempotencyKey())).isEqualTo(1);
    assertThat(eventStore.eventsFor(first.orderId()))
        .filteredOn(e -> e instanceof OrderPlaced)
        .hasSize(1);
}

This property catches duplicate side-effect bugs.


34. Practical Generator Implementation Style

Avoid giant @Provide methods.

Bad:

@Provide
Arbitrary<Order> orders() {
    // 150 lines of nested generator code
}

Better:

public final class OrderArbitraries {

    public static Arbitrary<Order> draftOrders() { ... }

    public static Arbitrary<Order> submittedOrders() { ... }

    public static Arbitrary<OrderLine> lines(String currency) { ... }

    public static Arbitrary<PlaceOrder> placeOrderCommands() { ... }

    public static Arbitrary<OrderTrace> validOrderTraces() { ... }
}

Use domain language:

validSubmittedCases()
casesWithActiveAppeal()
ordersEligibleForCancellation()
expiredQuotes()
paymentsAuthorizedButNotCaptured()
largeRequestsNeedingTwoApprovals()

Names matter because generator is executable documentation.


35. Anti-Patterns in Domain Generative Testing

35.1 Random Constructor Abuse

new Case(randomStatus, randomDecision, randomDates)

Creates impossible states.

35.2 Testing Implementation Against Itself

Reference model copies production transition logic exactly.

35.3 No Invariant Assertion After Each Step

Only asserting final state misses transient corruption.

35.4 Too Large Object Graphs

Generator creates huge worlds, shrinking becomes useless.

35.5 Invalid and Valid Paths Mixed Without Intent

Test failure becomes ambiguous.

35.6 No Boundary Events

Deadline tests without exact boundary dates are weak.

35.7 State Hidden in External Services

If service uses real clock, random UUID, shared DB, or async queue without seam, trace reproducibility breaks.


36. Review Checklist

Before merging domain generative tests:

[ ] Generator creates possible domain worlds.
[ ] Valid and invalid generators are separated.
[ ] Aggregates are built through domain operations where possible.
[ ] Trace length is bounded.
[ ] Trace paths are classified.
[ ] Invariants are checked after every step.
[ ] Oracle is independent enough from production logic.
[ ] Idempotency/retry behavior is explicit.
[ ] Temporal boundaries are included.
[ ] Cross-entity invariants are tested at service/world level.
[ ] Persistence tests are isolated and tagged.
[ ] Failing traces shrink to readable counterexamples.

37. How This Connects to Part 013

Part 011 and 012 help us generate strong tests.

Next question:

Are those tests actually strong?

Mutation testing answers that by intentionally changing production code and checking whether tests detect it.

If a mutation survives, it tells us:

property too weak
generator missed important input
oracle not independent
path not exercised
assertion not specific

That is why Part 013 is mutation testing with PIT.


38. References


39. Summary

Generative testing untuk domain model adalah tentang history, bukan hanya value.

Core skill:

Generate possible worlds.
Generate meaningful traces.
Apply commands/events.
Check invariants after every step.
Compare against a simpler model where possible.
Shrink failure into readable story.

PBT level dasar menemukan input buruk.

Domain generative testing menemukan cerita buruk:

open -> submit -> assign -> close while appeal active
place order -> duplicate retry -> duplicate event
approve -> stale reject -> overwritten decision
issue notice -> decision before response deadline

Itu jenis bug yang sering mahal di production.

Kalau kamu ingin naik level sebagai engineer, belajar menulis generator domain adalah salah satu leverage terbesar. Ia memaksa kita memahami domain, state, invariant, policy, dan failure model secara presisi.

Lesson Recap

You just completed lesson 12 in build core. Use the series map if you want to review the broader track, or continue directly into the next lesson while the context is still warm.

Continue The Track

Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.