con un clic
state-view-slice
Knows how to build state change slices
Menú
Knows how to build state change slices
| name | state view slice |
| description | Knows how to build state change slices |
A STATE_VIEW slice represents a read-only projection/view of data. It does NOT contain commands - only read models that are populated by events from other slices. This is the "Query" side of CQRS.
{
"sliceType": "STATE_VIEW",
"title": "slice: <Name>",
"context": "<BoundedContext>",
"commands": [], // Always empty for STATE_VIEW
"events": [], // Always empty for STATE_VIEW
"readmodels": [...],
"screens": [...],
"specifications": [...]
}
Events (from STATE_CHANGE slices) → @EventHandler Projector → ReadModel Entity → @QueryHandler → Screen
Look at the readmodel's dependencies with type: "INBOUND" and elementType: "EVENT":
"dependencies": [
{
"type": "INBOUND",
"title": "Law Firm Created",
"elementType": "EVENT"
},
{
"type": "INBOUND",
"title": "Law Firm Updated",
"elementType": "EVENT"
}
]
These events must already exist (from STATE_CHANGE slices).
Before creating entities, determine which persistence pattern to use based on the readmodel's fields:
Decision Criteria:
| Scenario | Pattern | When to Use |
|---|---|---|
Single idAttribute: true field | Single Key | Entity represents one unique aggregate/record |
Multiple idAttribute: true fields | Multi Key (Composite) | Entity represents a combination (e.g., assignment, membership) |
listElement: true on readmodel | Multi Key (Composite) | ReadModel returns a list of related entities |
| Has parent/child relationship | Multi Key (Composite) | Entity belongs to another entity (e.g., person-to-case) |
Use when the readmodel has exactly ONE idAttribute: true field (typically aggregateId or similar).
Location: src/main/kotlin/de/alex/<module>/<readmodelname>/<ReadModelName>ReadModel.kt
package de.alex.<module>.<readmodelname>
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.Id
import jakarta.persistence.Table
import java.util.UUID
data class <ReadModelName>ReadModelQuery(val aggregateId: UUID)
@Table(name = "<table_name>_read_model_entity", schema = "public")
@Entity
class <ReadModelName>ReadModelEntity {
@Id
@Column(name = "<id_field_name>")
var <idFieldName>: UUID? = null
// Add fields from readmodel definition (non-id fields)
@Column(name = "<field_name>")
var <fieldName>: <Type>? = null
// Common: lawfirmId for multi-tenancy
@Column(name = "lawfirm_id")
var lawfirmId: UUID? = null
}
data class <ReadModelName>ReadModel(val data: <ReadModelName>ReadModelEntity)
Real Example (CaseDetailsReadModel):
package de.alex.cases.casedetails
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.Id
import jakarta.persistence.Table
import java.math.BigDecimal
import java.util.UUID
data class CaseDetailsReadModelQuery(val aggregateId: UUID)
@Table(name = "case_details_read_model_entity", schema = "public")
@Entity
class CaseDetailsReadModelEntity {
@Id @Column(name = "caseId") var caseId: UUID? = null
@Column(name = "ablagenummer") var ablagenummer: String? = null
@Column(name = "description") var description: String? = null
@Column(name = "disputeAmount") var disputeAmount: BigDecimal? = null
@Column(name = "active_case") var activeCase: Boolean? = null
@Column(name = "lawfirm_id") var lawfirmId: UUID? = null
}
data class CaseDetailsReadModel(val data: CaseDetailsReadModelEntity)
Location: src/main/kotlin/de/alex/<module>/<readmodelname>/internal/<ReadModelName>ReadModelRepository.kt (or inline in Projector)
package de.alex.<module>.<readmodelname>.internal
import de.alex.<module>.<readmodelname>.<ReadModelName>ReadModelEntity
import org.springframework.data.jpa.repository.JpaRepository
import java.util.UUID
interface <ReadModelName>ReadModelRepository : JpaRepository<<ReadModelName>ReadModelEntity, UUID> {
// Optional: query methods for filtering
fun findByLawfirmId(lawfirmId: UUID): List<<ReadModelName>ReadModelEntity>
}
package de.alex.<module>.<readmodelname>.internal
import de.alex.<module>.ProcessingGroups
import de.alex.<module>.<readmodelname>.<ReadModelName>ReadModelEntity
import de.alex.events.*
import org.axonframework.config.ProcessingGroup
import org.axonframework.eventhandling.EventHandler
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Component
import java.util.UUID
interface <ReadModelName>ReadModelRepository : JpaRepository<<ReadModelName>ReadModelEntity, UUID>
@ProcessingGroup(ProcessingGroups.<MODULE>)
@Component
class <ReadModelName>ReadModelProjector(var repository: <ReadModelName>ReadModelRepository) {
@EventHandler
fun on(event: <CreatedOrUpdatedEvent>) {
// findById returns Optional, use orElse for upsert pattern
val entity = repository.findById(event.aggregateId).orElse(<ReadModelName>ReadModelEntity())
entity.apply {
<idFieldName> = event.aggregateId
<fieldName> = event.<eventFieldName>
}.also { repository.save(it) }
}
@EventHandler
fun on(event: <DeleteEvent>) {
// Simple delete by primary key
repository.deleteById(event.aggregateId)
}
}
package de.alex.<module>.<readmodelname>.internal
import de.alex.<module>.<readmodelname>.<ReadModelName>ReadModel
import de.alex.<module>.<readmodelname>.<ReadModelName>ReadModelQuery
import org.axonframework.queryhandling.QueryHandler
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Component
@Component
class <ReadModelName>ReadModelQueryHandler(
private val repository: <ReadModelName>ReadModelRepository
) {
@QueryHandler
fun handleQuery(query: <ReadModelName>ReadModelQuery): <ReadModelName>ReadModel? {
val entity = repository.findByIdOrNull(query.aggregateId) ?: return null
return <ReadModelName>ReadModel(entity)
}
}
CREATE TABLE IF NOT EXISTS public.<table_name>_read_model_entity (
"<id_column>" UUID NOT NULL,
"<field_column>" <SQL_TYPE>,
"lawfirm_id" UUID,
PRIMARY KEY ("<id_column>")
);
CREATE INDEX IF NOT EXISTS idx_<table>_lawfirmid ON public.<table_name>_read_model_entity("lawfirm_id");
Use when the readmodel has MULTIPLE idAttribute: true fields or represents a many-to-many/parent-child relationship.
There are two common approaches for composite keys:
Option 1: Simple class with @NoArgConstructor (preferred for simple cases)
import de.alex.common.NoArgConstructor
@NoArgConstructor
class <ReadModelName>Key(
var <firstIdField>: UUID,
var <secondIdField>: UUID
)
Option 2: @Embeddable data class (when using embedded queries)
import jakarta.persistence.Embeddable
@Embeddable
data class <ReadModelName>Key(
var <firstIdField>: UUID,
var <secondIdField>: <Type> // Can be UUID, String, etc.
)
Location: src/main/kotlin/de/alex/<module>/<readmodelname>/<ReadModelName>ReadModel.kt
package de.alex.<module>.<readmodelname>
import de.alex.common.NoArgConstructor
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.Id
import jakarta.persistence.IdClass
import jakarta.persistence.Table
import java.util.UUID
class <ReadModelName>ReadModelQuery() // Often empty for list queries
@NoArgConstructor
class <ReadModelName>Key(
var <firstIdField>: UUID,
var <secondIdField>: UUID
)
@IdClass(<ReadModelName>Key::class)
@Table(name = "<table_name>_read_model_entity", schema = "public")
@Entity
class <ReadModelName>ReadModelEntity {
@Id @Column(name = "<first_id>")
var <firstIdField>: UUID? = null
@Id @Column(name = "<second_id>")
var <secondIdField>: UUID? = null
// Add remaining non-id fields
@Column(name = "<field_name>")
var <fieldName>: <Type>? = null
@Column(name = "lawfirmId")
var lawfirmId: UUID? = null
}
// For composite keys, ReadModel typically wraps a List
data class <ReadModelName>ReadModel(val data: List<<ReadModelName>ReadModelEntity>)
Real Example (CaseAssignmentsReadModel):
package de.alex.cases.caseassignments
import de.alex.common.NoArgConstructor
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.Id
import jakarta.persistence.IdClass
import jakarta.persistence.Table
import java.util.UUID
class CaseAssignmentsReadModelQuery()
@NoArgConstructor
class CaseAssignmentsKey(var caseId: UUID, var assignedPersonId: UUID)
@IdClass(CaseAssignmentsKey::class)
@Table(name = "case_assignments_read_model_entity", schema = "public")
@Entity
class CaseAssignmentsReadModelEntity {
@Id @Column(name = "caseId") var caseId: UUID? = null
@Id @Column(name = "assigned_person") var assignedPersonId: UUID? = null
@Column(name = "assigned_role") var assignedRole: String? = null
@Column(name = "lawfirmId") var lawfirmId: UUID? = null
}
data class CaseAssignmentsReadModel(val data: List<CaseAssignmentsReadModelEntity>)
Real Example with @Embeddable (TaskReadModel):
package de.alex.tasks.tasks
import jakarta.persistence.Column
import jakarta.persistence.Embeddable
import jakarta.persistence.Entity
import jakarta.persistence.Id
import jakarta.persistence.IdClass
import jakarta.persistence.Table
import java.util.UUID
@Embeddable
data class TaskReadModelKey(var taskId: UUID, var caseId: UUID)
@IdClass(TaskReadModelKey::class)
@Table(name = "tasks_read_model_entity", schema = "public")
@Entity
class TaskReadModelEntity {
@Id @Column(name = "taskId") var taskId: UUID? = null
@Id @Column(name = "caseId") var caseId: UUID? = null
@Column(name = "title") var title: String? = null
@Column(name = "lawfirm_id") var lawFirmId: UUID? = null
}
data class TaskReadModel(val data: TaskReadModelEntity)
package de.alex.<module>.<readmodelname>.internal
import de.alex.<module>.<readmodelname>.<ReadModelName>Key
import de.alex.<module>.<readmodelname>.<ReadModelName>ReadModelEntity
import org.springframework.data.jpa.repository.JpaRepository
import java.util.UUID
interface <ReadModelName>ReadModelRepository :
JpaRepository<<ReadModelName>ReadModelEntity, <ReadModelName>Key> {
// Query by first part of composite key
fun findBy<FirstIdField>(<firstIdField>: UUID): List<<ReadModelName>ReadModelEntity>
// Query by second part of composite key
fun findBy<SecondIdField>(<secondIdField>: UUID): List<<ReadModelName>ReadModelEntity>
// Query by lawfirmId for multi-tenancy
fun findByLawfirmId(lawfirmId: UUID): List<<ReadModelName>ReadModelEntity>
}
package de.alex.<module>.<readmodelname>.internal
import de.alex.<module>.ProcessingGroups
import de.alex.<module>.<readmodelname>.<ReadModelName>Key
import de.alex.<module>.<readmodelname>.<ReadModelName>ReadModelEntity
import de.alex.events.*
import org.axonframework.config.ProcessingGroup
import org.axonframework.eventhandling.EventHandler
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Component
import java.util.UUID
interface <ReadModelName>ReadModelRepository :
JpaRepository<<ReadModelName>ReadModelEntity, <ReadModelName>Key>
@ProcessingGroup(ProcessingGroups.<MODULE>)
@Component
class <ReadModelName>ReadModelProjector(var repository: <ReadModelName>ReadModelRepository) {
@EventHandler
fun on(event: <AddedEvent>) {
// Construct composite key for lookup
val key = <ReadModelName>Key(event.<firstIdField>, event.<secondIdField>)
val entity = repository.findById(key).orElse(<ReadModelName>ReadModelEntity())
entity.apply {
<firstIdField> = event.<firstIdField>
<secondIdField> = event.<secondIdField>
<fieldName> = event.<eventFieldName>
lawfirmId = event.lawFirmId
}.also { repository.save(it) }
}
@EventHandler
fun on(event: <RemovedEvent>) {
// Delete using composite key
val key = <ReadModelName>Key(event.<firstIdField>, event.<secondIdField>)
repository.deleteById(key)
}
}
Real Example (CaseAssignmentsProjector):
@ProcessingGroup(ProcessingGroups.CASES)
@Component
class CaseAssignmentsReadModelProjector(var repository: CaseAssignmentsReadModelRepository) {
@EventHandler
fun on(event: CasePersonDeletedEvent) {
repository.deleteById(CaseAssignmentsKey(event.aggregateId, event.personId))
}
@EventHandler
fun on(event: CasePersonAddedEvent) {
val entity = repository
.findById(CaseAssignmentsKey(event.aggregateId, event.personId))
.orElse(CaseAssignmentsReadModelEntity())
entity.apply {
caseId = event.aggregateId
assignedPersonId = event.personId
assignedRole = event.role
lawfirmId = event.lawFirmId
}.also { repository.save(it) }
}
}
package de.alex.<module>.<readmodelname>.internal
import de.alex.<module>.<readmodelname>.<ReadModelName>ReadModel
import de.alex.<module>.<readmodelname>.<ReadModelName>ReadModelQuery
import org.axonframework.queryhandling.QueryHandler
import org.springframework.stereotype.Component
@Component
class <ReadModelName>ReadModelQueryHandler(
private val repository: <ReadModelName>ReadModelRepository
) {
@QueryHandler
fun handleQuery(query: <ReadModelName>ReadModelQuery): <ReadModelName>ReadModel {
// For composite keys, typically return a list
val entities = repository.findBy<FirstIdField>(query.<firstIdField>)
return <ReadModelName>ReadModel(entities)
}
}
CREATE TABLE IF NOT EXISTS public.<table_name>_read_model_entity (
"<first_id_column>" UUID NOT NULL,
"<second_id_column>" UUID NOT NULL,
"<field_column>" <SQL_TYPE>,
"lawfirmId" UUID,
PRIMARY KEY ("<first_id_column>", "<second_id_column>")
);
-- Indexes for querying by individual parts of composite key
CREATE INDEX IF NOT EXISTS idx_<table>_<first_id> ON public.<table_name>_read_model_entity("<first_id_column>");
CREATE INDEX IF NOT EXISTS idx_<table>_<second_id> ON public.<table_name>_read_model_entity("<second_id_column>");
CREATE INDEX IF NOT EXISTS idx_<table>_lawfirmid ON public.<table_name>_read_model_entity("lawfirmId");
Field Mapping Rules:
| Slice Type | Kotlin Type |
|---|---|
| UUID | UUID |
| String | String |
| Date | LocalDate |
| Integer | Int |
| Boolean | Boolean |
| Decimal | BigDecimal |
| Multiple cardinality | List |
| optional: true | Nullable (T?) |
| idAttribute: true | Use as @Id field |
Location: src/main/kotlin/de/alex/<module>/<readmodelname>/internal/<ReadModelName>ReadModelRepository.kt
package de.alex.<module>.<readmodelname>.internal
import de.alex.<module>.<readmodelname>.<ReadModelName>ReadModelEntity
import org.springframework.data.jpa.repository.JpaRepository
import java.util.UUID
interface <ReadModelName>ReadModelRepository : JpaRepository<<ReadModelName>ReadModelEntity, UUID> {
// Add custom query methods based on screen requirements
fun findByLawfirmId(lawfirmId: UUID): List<<ReadModelName>ReadModelEntity>
fun findByAggregateId(aggregateId: UUID): <ReadModelName>ReadModelEntity?
}
Location: src/main/kotlin/de/alex/<module>/<readmodelname>/internal/<ReadModelName>ReadModelProjector.kt
package de.alex.<module>.<readmodelname>.internal
import de.alex.<module>.ProcessingGroups
import de.alex.events.* // Import source events
import org.axonframework.eventhandling.EventHandler
import org.axonframework.config.ProcessingGroup
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Component
@ProcessingGroup(ProcessingGroups.<MODULE>)
@Component
class <ReadModelName>ReadModelProjector(
private val repository: <ReadModelName>ReadModelRepository
) {
@EventHandler
fun on(event: <SourceEvent1>Event) {
val entity = repository.findByIdOrNull(event.aggregateId)
?: <ReadModelName>ReadModelEntity()
entity.apply {
aggregateId = event.aggregateId
// Map event fields to entity fields
// Use field.mapping if specified to map different field names
<fieldName> = event.<eventFieldName>
}.also { repository.save(it) }
}
@EventHandler
fun on(event: <SourceEvent2>Event) {
val entity = repository.findByIdOrNull(event.aggregateId)
?: return // Or create new if appropriate
entity.apply {
// Update only the fields this event provides
<fieldName> = event.<eventFieldName>
}.also { repository.save(it) }
}
// Handle delete events if applicable
@EventHandler
fun on(event: <DeleteEvent>Event) {
repository.deleteById(event.aggregateId)
}
}
Location: src/main/kotlin/de/alex/<module>/<readmodelname>/<ReadModelName>ReadModelQuery.kt
package de.alex.<module>.<readmodelname>
import java.util.UUID
data class <ReadModelName>ReadModelQuery(
val aggregateId: UUID? = null,
val lawFirmId: UUID? = null
// Add query parameters based on how screens need to filter data
)
Location: src/main/kotlin/de/alex/<module>/<readmodelname>/internal/<ReadModelName>ReadModelQueryHandler.kt
package de.alex.<module>.<readmodelname>.internal
import de.alex.<module>.<readmodelname>.<ReadModelName>ReadModel
import de.alex.<module>.<readmodelname>.<ReadModelName>ReadModelQuery
import org.axonframework.queryhandling.QueryHandler
import org.springframework.stereotype.Component
@Component
class <ReadModelName>ReadModelQueryHandler(
private val repository: <ReadModelName>ReadModelRepository
) {
@QueryHandler
fun handleQuery(query: <ReadModelName>ReadModelQuery): <ReadModelName>ReadModel? {
// For single entity lookup
val entity = repository.findByIdOrNull(query.aggregateId) ?: return null
return <ReadModelName>ReadModel(entity)
// OR for list queries
// return <ReadModelName>ReadModel(repository.findByLawfirmId(query.lawFirmId))
}
}
Location: src/main/resources/db/migration/V<version>__create_<table_name>.sql
CREATE TABLE IF NOT EXISTS public.<table_name> (
"aggregateid" UUID NOT NULL,
-- Add columns for each field in readmodel
"<column_name>" <SQL_TYPE>,
PRIMARY KEY ("aggregateid")
-- Or for composite key:
-- PRIMARY KEY ("aggregateid", "<secondary_id>")
);
-- Add indexes for frequently queried fields
CREATE INDEX IF NOT EXISTS idx_<table>_lawfirmid ON public.<table_name>("lawfirmid");
If the readmodel has an apiEndpoint field, create a REST controller:
Location: src/main/kotlin/de/alex/<module>/<readmodelname>/<ReadModelName>Controller.kt
package de.alex.<module>.<readmodelname>
import org.axonframework.queryhandling.QueryGateway
import org.springframework.web.bind.annotation.*
import java.util.UUID
@RestController
@RequestMapping("<apiEndpoint base>")
class <ReadModelName>Controller(
private val queryGateway: QueryGateway
) {
@GetMapping("/{aggregateId}")
fun get(@PathVariable aggregateId: UUID): <ReadModelName>ReadModel? {
return queryGateway.query(
<ReadModelName>ReadModelQuery(aggregateId),
<ReadModelName>ReadModel::class.java
).join()
}
}
STATE_VIEW specifications use Given/Then format (no When since no commands):
Location: src/test/kotlin/de/alex/<module>/<ReadModelName>ProjectionTest.kt
package de.alex.<module>
import de.alex.BaseIntegrationTest
import de.alex.ProjectionFixtureConfiguration
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
class <ReadModelName>ProjectionTest : BaseIntegrationTest() {
@Autowired
lateinit var projectionFixture: ProjectionFixtureConfiguration
@Autowired
lateinit var repository: <ReadModelName>ReadModelRepository
@Test
fun `<spec title>`() {
// Given: Apply events from specification
projectionFixture.apply(<SourceEvent>(...)) // From spec.given
// Then: Verify read model state
awaitUntilAsserted {
val result = repository.findById(<id>).orElse(null)
assertThat(result).isNotNull
assertThat(result.<field>).isEqualTo(<expectedValue>) // From spec.then
}
}
}
Input:
{
"sliceType": "STATE_VIEW",
"title": "slice: Law Firm Details for Auth",
"readmodels": [{
"title": "Law Firm Details for Auth",
"fields": [
{"name": "aggregateId", "type": "UUID", "idAttribute": true},
{"name": "name", "type": "String"}
],
"dependencies": [
{"type": "INBOUND", "title": "Law Firm Created", "elementType": "EVENT"},
{"type": "INBOUND", "title": "Law Firm Updated", "elementType": "EVENT"}
]
}],
"screens": [...]
}
Generated Files:
src/main/kotlin/de/alex/lawfirm/lawfirmdetailsforauth/LawFirmDetailsForAuthReadModel.kt (Entity + DTO)src/main/kotlin/de/alex/lawfirm/lawfirmdetailsforauth/internal/LawFirmDetailsForAuthReadModelRepository.ktsrc/main/kotlin/de/alex/lawfirm/lawfirmdetailsforauth/internal/LawFirmDetailsForAuthReadModelProjector.ktsrc/main/kotlin/de/alex/lawfirm/lawfirmdetailsforauth/LawFirmDetailsForAuthReadModelQuery.ktsrc/main/kotlin/de/alex/lawfirm/lawfirmdetailsforauth/internal/LawFirmDetailsForAuthReadModelQueryHandler.ktsrc/main/resources/db/migration/V<n>__create_law_firm_details_for_auth.sqlWhen a readmodel field has a mapping attribute:
{
"name": "lawfirmId",
"type": "UUID",
"mapping": "aggregateId"
}
This means the lawfirmId field in the readmodel should be populated from the aggregateId field in the source event.
Screens in STATE_VIEW slices typically:
This separation keeps the query side clean while allowing screens to trigger write operations.