Skip to content

Commit ab57cc9

Browse files
kamiazyaclaude
andcommitted
feat(domain): migrate ScopeAggregate to jMolecules AggregateRoot
Changes: - Implement jMolecules AggregateRoot<ScopeAggregate, ScopeId> - Add getId(): ScopeId implementation - Internalize event sourcing support (raiseEvent, applyEvent, uncommittedEvents) - Maintain dual identity system (_id + deprecated aggregateId) for compatibility - Remove platform-dependent methods (handleCreate, decideUpdateTitle, handleUpdateTitle) - Update factory methods to use new constructor signature - Fix event creation to use deprecated aggregateId field Design decisions: - jMolecules AggregateRoot is a marker interface, event sourcing logic moved to internal implementation - Deprecated aggregateId field maintained for backward compatibility during migration - Removed decide/evolve pattern methods that depended on platform utilities - Simplified API focused on core aggregate operations Build status: ✅ SUCCESS (with expected deprecation warnings) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
1 parent 6c03179 commit ab57cc9

File tree

1 file changed

+67
-126
lines changed
  • contexts/scope-management/domain/src/main/kotlin/io/github/kamiazya/scopes/scopemanagement/domain/aggregate

1 file changed

+67
-126
lines changed

contexts/scope-management/domain/src/main/kotlin/io/github/kamiazya/scopes/scopemanagement/domain/aggregate/ScopeAggregate.kt

Lines changed: 67 additions & 126 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,10 @@ import arrow.core.Either
44
import arrow.core.raise.either
55
import arrow.core.raise.ensure
66
import arrow.core.raise.ensureNotNull
7-
import io.github.kamiazya.scopes.platform.domain.aggregate.AggregateResult
8-
import io.github.kamiazya.scopes.platform.domain.aggregate.AggregateRoot
9-
import io.github.kamiazya.scopes.platform.domain.event.EventEnvelope
10-
import io.github.kamiazya.scopes.platform.domain.event.evolveWithPending
117
import io.github.kamiazya.scopes.platform.domain.value.AggregateId
128
import io.github.kamiazya.scopes.platform.domain.value.AggregateVersion
139
import io.github.kamiazya.scopes.platform.domain.value.EventId
10+
import org.jmolecules.ddd.types.AggregateRoot
1411
import io.github.kamiazya.scopes.scopemanagement.domain.entity.Scope
1512
import io.github.kamiazya.scopes.scopemanagement.domain.error.ScopeError
1613
import io.github.kamiazya.scopes.scopemanagement.domain.error.ScopesError
@@ -33,7 +30,7 @@ import kotlinx.datetime.Clock
3330
import kotlinx.datetime.Instant
3431

3532
/**
36-
* Scope aggregate root implementing event sourcing pattern.
33+
* Scope aggregate root implementing event sourcing pattern with jMolecules DDD types.
3734
*
3835
* This aggregate encapsulates all business logic related to Scopes,
3936
* ensuring that all state changes go through proper domain events.
@@ -44,16 +41,66 @@ import kotlinx.datetime.Instant
4441
* - Business rules are validated before generating events
4542
* - The aggregate can be reconstructed from its event history
4643
* - Commands return new instances (immutability)
44+
* - Implements jMolecules AggregateRoot<ScopeAggregate, ScopeId> for explicit DDD modeling
4745
*/
4846
data class ScopeAggregate(
49-
override val id: AggregateId,
50-
override val version: AggregateVersion,
47+
private val _id: ScopeId,
48+
@Deprecated("Use ScopeId directly instead of AggregateId", ReplaceWith("_id"))
49+
val aggregateId: AggregateId,
50+
val version: AggregateVersion,
5151
val createdAt: Instant,
5252
val updatedAt: Instant,
5353
val scope: Scope?,
5454
val isDeleted: Boolean = false,
5555
val isArchived: Boolean = false,
56-
) : AggregateRoot<ScopeAggregate, ScopeEvent>() {
56+
) : AggregateRoot<ScopeAggregate, ScopeId> {
57+
58+
override fun getId(): ScopeId = _id
59+
60+
// Event sourcing support - uncommitted events tracking
61+
private val uncommittedEventsList = mutableListOf<ScopeEvent>()
62+
val uncommittedEvents: List<ScopeEvent> get() = uncommittedEventsList.toList()
63+
64+
/**
65+
* Raise a new domain event and apply it to update state.
66+
*/
67+
protected fun raiseEvent(event: ScopeEvent): ScopeAggregate {
68+
uncommittedEventsList.add(event)
69+
return applyEvent(event)
70+
}
71+
72+
/**
73+
* Mark all uncommitted events as committed.
74+
* Called after successful persistence.
75+
*/
76+
fun markEventsAsCommitted() {
77+
uncommittedEventsList.clear()
78+
}
79+
80+
/**
81+
* Get and clear uncommitted events atomically.
82+
*/
83+
fun getAndClearUncommittedEvents(): List<ScopeEvent> {
84+
val events = uncommittedEventsList.toList()
85+
uncommittedEventsList.clear()
86+
return events
87+
}
88+
89+
/**
90+
* Check if there are any uncommitted changes.
91+
*/
92+
fun hasUncommittedChanges(): Boolean = uncommittedEventsList.isNotEmpty()
93+
94+
/**
95+
* Replay events to rebuild aggregate state.
96+
*/
97+
fun replayEvents(events: List<ScopeEvent>): ScopeAggregate {
98+
var aggregate = this
99+
for (event in events) {
100+
aggregate = aggregate.applyEvent(event)
101+
}
102+
return aggregate
103+
}
57104

58105
companion object {
59106
/**
@@ -85,7 +132,8 @@ data class ScopeAggregate(
85132
)
86133

87134
val initialAggregate = ScopeAggregate(
88-
id = aggregateId,
135+
_id = scopeId,
136+
aggregateId = aggregateId,
89137
version = AggregateVersion.initial(),
90138
createdAt = now,
91139
updatedAt = now,
@@ -97,63 +145,14 @@ data class ScopeAggregate(
97145
initialAggregate.raiseEvent(event)
98146
}
99147

100-
/**
101-
* Creates a scope using decide/evolve pattern.
102-
* Returns an AggregateResult with the new aggregate and pending events.
103-
*/
104-
fun handleCreate(
105-
title: String,
106-
description: String? = null,
107-
parentId: ScopeId? = null,
108-
scopeId: ScopeId? = null,
109-
now: Instant = Clock.System.now(),
110-
): Either<ScopesError, AggregateResult<ScopeAggregate, ScopeEvent>> = either {
111-
val validatedTitle = ScopeTitle.create(title).bind()
112-
val validatedDescription = ScopeDescription.create(description).bind()
113-
val scopeId = scopeId ?: ScopeId.generate()
114-
val aggregateId = scopeId.toAggregateId().bind()
115-
116-
val initialAggregate = ScopeAggregate(
117-
id = aggregateId,
118-
version = AggregateVersion.initial(),
119-
createdAt = now,
120-
updatedAt = now,
121-
scope = null,
122-
isDeleted = false,
123-
isArchived = false,
124-
)
125-
126-
// Decide phase - create events with dummy version
127-
val event = ScopeCreated(
128-
aggregateId = aggregateId,
129-
eventId = EventId.generate(),
130-
occurredAt = now,
131-
132-
aggregateVersion = AggregateVersion.initial(), // Dummy version
133-
scopeId = scopeId,
134-
title = validatedTitle,
135-
description = validatedDescription,
136-
parentId = parentId,
137-
)
138-
139-
val pendingEvents = listOf(EventEnvelope.Pending(event))
140-
141-
// Evolve phase - apply events to aggregate
142-
val evolvedAggregate = initialAggregate.evolveWithPending(pendingEvents)
143-
144-
AggregateResult(
145-
aggregate = evolvedAggregate,
146-
events = pendingEvents,
147-
baseVersion = AggregateVersion.initial(),
148-
)
149-
}
150148

151149
/**
152150
* Creates an empty aggregate for event replay.
153151
* Used when loading an aggregate from the event store.
154152
*/
155-
fun empty(aggregateId: AggregateId): ScopeAggregate = ScopeAggregate(
156-
id = aggregateId,
153+
fun empty(scopeId: ScopeId, aggregateId: AggregateId): ScopeAggregate = ScopeAggregate(
154+
_id = scopeId,
155+
aggregateId = aggregateId,
157156
version = AggregateVersion.initial(),
158157
createdAt = Instant.DISTANT_PAST,
159158
updatedAt = Instant.DISTANT_PAST,
@@ -182,7 +181,7 @@ data class ScopeAggregate(
182181
}
183182

184183
val event = ScopeTitleUpdated(
185-
aggregateId = id,
184+
aggregateId = aggregateId,
186185
eventId = EventId.generate(),
187186
occurredAt = now,
188187

@@ -195,64 +194,6 @@ data class ScopeAggregate(
195194
this@ScopeAggregate.raiseEvent(event)
196195
}
197196

198-
/**
199-
* Decides whether to update the title (decide phase).
200-
* Returns pending events or empty list if no change needed.
201-
*/
202-
fun decideUpdateTitle(title: String, now: Instant = Clock.System.now()): Either<ScopesError, List<EventEnvelope.Pending<ScopeEvent>>> = either {
203-
val currentScope = scope
204-
ensureNotNull(currentScope) {
205-
ScopeError.NotFound(ScopeId.create(id.value.substringAfterLast("/")).bind())
206-
}
207-
ensure(!isDeleted) {
208-
ScopeError.AlreadyDeleted(currentScope.id)
209-
}
210-
211-
val newTitle = ScopeTitle.create(title).bind()
212-
if (currentScope.title == newTitle) {
213-
return@either emptyList()
214-
}
215-
216-
val event = ScopeTitleUpdated(
217-
aggregateId = id,
218-
eventId = EventId.generate(),
219-
occurredAt = now,
220-
221-
aggregateVersion = AggregateVersion.initial(), // Dummy version
222-
scopeId = currentScope.id,
223-
oldTitle = currentScope.title,
224-
newTitle = newTitle,
225-
)
226-
227-
listOf(EventEnvelope.Pending(event))
228-
}
229-
230-
/**
231-
* Handles update title command using decide/evolve pattern.
232-
* Returns an AggregateResult with the updated aggregate and pending events.
233-
*/
234-
fun handleUpdateTitle(title: String, now: Instant = Clock.System.now()): Either<ScopesError, AggregateResult<ScopeAggregate, ScopeEvent>> = either {
235-
val pendingEvents = decideUpdateTitle(title, now).bind()
236-
237-
if (pendingEvents.isEmpty()) {
238-
return@either AggregateResult(
239-
aggregate = this@ScopeAggregate,
240-
events = emptyList(),
241-
baseVersion = version,
242-
)
243-
}
244-
245-
// Evolve phase - apply events to aggregate
246-
val evolvedAggregate = pendingEvents.fold(this@ScopeAggregate) { agg, envelope ->
247-
agg.applyEvent(envelope.event)
248-
}
249-
250-
AggregateResult(
251-
aggregate = evolvedAggregate,
252-
events = pendingEvents,
253-
baseVersion = version,
254-
)
255-
}
256197

257198
/**
258199
* Updates the scope description after validation.
@@ -272,7 +213,7 @@ data class ScopeAggregate(
272213
}
273214

274215
val event = ScopeDescriptionUpdated(
275-
aggregateId = id,
216+
aggregateId = aggregateId,
276217
eventId = EventId.generate(),
277218
occurredAt = now,
278219

@@ -303,7 +244,7 @@ data class ScopeAggregate(
303244
}
304245

305246
val event = ScopeParentChanged(
306-
aggregateId = id,
247+
aggregateId = aggregateId,
307248
eventId = EventId.generate(),
308249
occurredAt = now,
309250

@@ -330,7 +271,7 @@ data class ScopeAggregate(
330271
}
331272

332273
val event = ScopeDeleted(
333-
aggregateId = id,
274+
aggregateId = aggregateId,
334275
eventId = EventId.generate(),
335276
occurredAt = now,
336277

@@ -358,7 +299,7 @@ data class ScopeAggregate(
358299
}
359300

360301
val event = ScopeArchived(
361-
aggregateId = id,
302+
aggregateId = aggregateId,
362303
eventId = EventId.generate(),
363304
occurredAt = now,
364305

@@ -386,7 +327,7 @@ data class ScopeAggregate(
386327
}
387328

388329
val event = ScopeRestored(
389-
aggregateId = id,
330+
aggregateId = aggregateId,
390331
eventId = EventId.generate(),
391332
occurredAt = now,
392333

@@ -405,7 +346,7 @@ data class ScopeAggregate(
405346
* We don't use event.aggregateVersion directly, but instead
406347
* increment based on the number of events applied.
407348
*/
408-
override fun applyEvent(event: ScopeEvent): ScopeAggregate = when (event) {
349+
fun applyEvent(event: ScopeEvent): ScopeAggregate = when (event) {
409350
is ScopeCreated -> copy(
410351
version = version.increment(),
411352
createdAt = event.occurredAt,

0 commit comments

Comments
 (0)