Skip to content

Commit 3a21bc8

Browse files
✨ feat: add interceptor for paid commands, which should withdraw resources to be handled (#22)
+ resolve `RecruitCreature` as paid command Justification: This solution is a tradeoff: we couple slices on the interceptor level (can be treated as adapter, like REST API) within the same UnitOfWork / transaction, but we avoid custom orchestration logic with Saga pattern.
1 parent eb117ba commit 3a21bc8

18 files changed

Lines changed: 700 additions & 33 deletions

File tree

generated-requests.http

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,11 @@ Content-Type: application/json
2828
{
2929
"creatureId": "angel",
3030
"armyId": "army-1",
31-
"quantity": 3
31+
"quantity": 3,
32+
"expectedCost": {
33+
"gold": 9000,
34+
"gems": 3
35+
}
3236
}
3337

3438
### Dwelling read model
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package com.dddheroes.heroesofddd.creaturerecruitment;
2+
3+
import com.dddheroes.heroesofddd.creaturerecruitment.write.recruitcreature.RecruitCreature;
4+
import com.dddheroes.heroesofddd.resourcespool.application.CommandCostResolver;
5+
import com.dddheroes.heroesofddd.shared.domain.valueobjects.Resources;
6+
import org.springframework.context.annotation.Bean;
7+
import org.springframework.context.annotation.Configuration;
8+
9+
@Configuration
10+
class CreatureRecruitmentConfiguration {
11+
12+
@Bean
13+
CommandCostResolver<RecruitCreature> recruitCreatureCostResolver() {
14+
return new CommandCostResolver<>() {
15+
@Override
16+
public <T extends RecruitCreature> Resources resolve(T command) {
17+
return command.expectedCost();
18+
}
19+
20+
@Override
21+
public Class<? extends RecruitCreature> supportedCommandType() {
22+
return RecruitCreature.class;
23+
}
24+
};
25+
}
26+
}

src/main/java/com/dddheroes/heroesofddd/creaturerecruitment/write/Dwelling.java

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import com.dddheroes.heroesofddd.creaturerecruitment.write.changeavailablecreatures.IncreaseAvailableCreatures;
88
import com.dddheroes.heroesofddd.creaturerecruitment.write.changeavailablecreatures.OnlyBuiltDwellingCanHaveAvailableCreatures;
99
import com.dddheroes.heroesofddd.creaturerecruitment.events.CreatureRecruited;
10+
import com.dddheroes.heroesofddd.creaturerecruitment.write.recruitcreature.RecruitCostCannotDifferThanExpectedCost;
1011
import com.dddheroes.heroesofddd.creaturerecruitment.write.recruitcreature.RecruitCreature;
1112
import com.dddheroes.heroesofddd.creaturerecruitment.write.recruitcreature.RecruitCreaturesNotExceedAvailableCreatures;
1213
import com.dddheroes.heroesofddd.shared.domain.valueobjects.Amount;
@@ -22,7 +23,7 @@
2223
import static org.axonframework.modelling.command.AggregateLifecycle.*;
2324

2425
@Aggregate
25-
class Dwelling {
26+
public class Dwelling {
2627

2728
@AggregateIdentifier
2829
private DwellingId dwellingId;
@@ -84,13 +85,19 @@ void decide(RecruitCreature command) {
8485
command.quantity()
8586
).verify();
8687

88+
var recruitCost = costPerTroop.multiply(command.quantity());
89+
new RecruitCostCannotDifferThanExpectedCost(
90+
recruitCost,
91+
command.expectedCost()
92+
).verify();
93+
8794
apply(
8895
CreatureRecruited.event(
8996
command.dwellingId(),
9097
command.creatureId(),
9198
command.toArmy(),
9299
command.quantity(),
93-
costPerTroop.multiply(command.quantity())
100+
recruitCost
94101
)
95102
);
96103
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package com.dddheroes.heroesofddd.creaturerecruitment.write.recruitcreature;
2+
3+
import com.dddheroes.heroesofddd.shared.domain.DomainRule;
4+
import com.dddheroes.heroesofddd.shared.domain.valueobjects.Resources;
5+
6+
public record RecruitCostCannotDifferThanExpectedCost(
7+
Resources costPerTroop,
8+
Resources expectedCostPerTroop
9+
) implements DomainRule {
10+
11+
@Override
12+
public boolean isViolated() {
13+
return !costPerTroop.isSame(expectedCostPerTroop);
14+
}
15+
16+
@Override
17+
public String message() {
18+
return "Recruit cost cannot differ than expected cost";
19+
}
20+
}

src/main/java/com/dddheroes/heroesofddd/creaturerecruitment/write/recruitcreature/RecruitCreature.java

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,20 +5,33 @@
55
import com.dddheroes.heroesofddd.shared.domain.valueobjects.Amount;
66
import com.dddheroes.heroesofddd.shared.domain.identifiers.ArmyId;
77
import com.dddheroes.heroesofddd.shared.domain.identifiers.CreatureId;
8+
import com.dddheroes.heroesofddd.shared.domain.valueobjects.Resources;
89
import org.axonframework.modelling.command.TargetAggregateIdentifier;
910

11+
import java.util.Map;
12+
1013
public record RecruitCreature(
1114
@TargetAggregateIdentifier
1215
DwellingId dwellingId,
1316
CreatureId creatureId,
1417
ArmyId toArmy,
15-
Amount quantity
18+
Amount quantity,
19+
Resources expectedCost
1620
) implements DwellingCommand {
1721

18-
public static RecruitCreature command(String dwellingId, String creatureId, String toArmy, Integer quantity) {
19-
return new RecruitCreature(DwellingId.of(dwellingId),
20-
CreatureId.of(creatureId),
21-
ArmyId.of(toArmy),
22-
Amount.of(quantity));
22+
public static RecruitCreature command(
23+
String dwellingId,
24+
String creatureId,
25+
String toArmy,
26+
Integer quantity,
27+
Map<String, Integer> expectedCost
28+
) {
29+
return new RecruitCreature(
30+
DwellingId.of(dwellingId),
31+
CreatureId.of(creatureId),
32+
ArmyId.of(toArmy),
33+
Amount.of(quantity),
34+
Resources.fromRaw(expectedCost)
35+
);
2336
}
2437
}

src/main/java/com/dddheroes/heroesofddd/creaturerecruitment/write/recruitcreature/RecruitCreatureRestApi.java

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,14 @@
1010
import org.springframework.web.bind.annotation.RequestMapping;
1111
import org.springframework.web.bind.annotation.RestController;
1212

13+
import java.util.Map;
1314
import java.util.concurrent.CompletableFuture;
1415

1516
@RestController
1617
@RequestMapping("/games/{gameId}")
1718
class RecruitCreatureRestApi {
1819

19-
record Body(String creatureId, String armyId, Integer quantity) {
20+
record Body(String creatureId, String armyId, Integer quantity, Map<String, Integer> expectedCost) {
2021

2122
}
2223

@@ -37,7 +38,8 @@ CompletableFuture<Void> putDwellingsCreatureRecruitments(
3738
dwellingId,
3839
requestBody.creatureId(),
3940
requestBody.armyId(),
40-
requestBody.quantity()
41+
requestBody.quantity(),
42+
requestBody.expectedCost()
4143
);
4244
return commandGateway.send(command, GameMetaData.with(gameId, playerId));
4345
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package com.dddheroes.heroesofddd.resourcespool;
2+
3+
import com.dddheroes.heroesofddd.resourcespool.application.CommandCostResolver;
4+
import com.dddheroes.heroesofddd.resourcespool.application.ComposedCommandCostResolver;
5+
import com.dddheroes.heroesofddd.resourcespool.write.ResourcesPool;
6+
import com.dddheroes.heroesofddd.resourcespool.write.withdraw.PaidCommandInterceptor;
7+
import org.axonframework.modelling.command.Repository;
8+
import org.springframework.context.annotation.Bean;
9+
import org.springframework.context.annotation.Configuration;
10+
11+
import java.util.Set;
12+
13+
@Configuration
14+
class ResourcePoolConfiguration {
15+
16+
@Bean
17+
PaidCommandInterceptor paidCommandInterceptor(
18+
Set<CommandCostResolver<?>> commandCostResolvers,
19+
Repository<ResourcesPool> resourcesPoolRepository
20+
) {
21+
return new PaidCommandInterceptor(
22+
new ComposedCommandCostResolver(commandCostResolvers),
23+
resourcesPoolRepository
24+
);
25+
}
26+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
package com.dddheroes.heroesofddd.resourcespool.application;
2+
3+
import com.dddheroes.heroesofddd.shared.domain.valueobjects.Resources;
4+
import com.dddheroes.heroesofddd.shared.slices.write.Command;
5+
6+
public interface CommandCostResolver<C extends Command> {
7+
8+
default <T extends C> Resources cost(T command) {
9+
if (isSupported(command)) {
10+
return resolve(command);
11+
} else {
12+
return Resources.empty();
13+
}
14+
}
15+
16+
<T extends C> Resources resolve(T command);
17+
18+
default <T extends C> boolean isSupported(T command) {
19+
return supportedCommandType().isAssignableFrom(command.getClass());
20+
}
21+
22+
Class<? extends C> supportedCommandType();
23+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package com.dddheroes.heroesofddd.resourcespool.application;
2+
3+
import com.dddheroes.heroesofddd.shared.domain.valueobjects.Resources;
4+
import com.dddheroes.heroesofddd.shared.slices.write.Command;
5+
6+
import java.util.Map;
7+
import java.util.Set;
8+
import java.util.function.Function;
9+
import java.util.stream.Collectors;
10+
11+
public class ComposedCommandCostResolver implements CommandCostResolver<Command> {
12+
13+
private final Map<Class<? extends Command>, CommandCostResolver<?>> resolvers;
14+
15+
public ComposedCommandCostResolver(Set<CommandCostResolver<?>> commandCostResolvers) {
16+
this.resolvers = commandCostResolvers
17+
.stream()
18+
.collect(Collectors.toMap(
19+
CommandCostResolver::supportedCommandType,
20+
Function.identity()
21+
));
22+
}
23+
24+
25+
@Override
26+
public <T extends Command> Resources resolve(T command) {
27+
@SuppressWarnings("unchecked")
28+
var resolver = (CommandCostResolver<T>) resolvers.get(command.getClass());
29+
if (resolver == null) {
30+
return Resources.empty();
31+
}
32+
return resolver.resolve(command);
33+
}
34+
35+
@Override
36+
public Class<? extends Command> supportedCommandType() {
37+
return Command.class;
38+
}
39+
}

src/main/java/com/dddheroes/heroesofddd/resourcespool/write/ResourcesPool.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
import static org.axonframework.modelling.command.AggregateLifecycle.apply;
1717

1818
@Aggregate
19-
class ResourcesPool {
19+
public class ResourcesPool {
2020

2121
@AggregateIdentifier
2222
private ResourcesPoolId resourcesPoolId;
@@ -35,7 +35,7 @@ void evolve(ResourcesDeposited event) {
3535
}
3636

3737
@CommandHandler
38-
void decide(WithdrawResources command) {
38+
public void decide(WithdrawResources command) {
3939
new CannotWithdrawMoreThanDepositedResources(
4040
balance,
4141
command.resources()

0 commit comments

Comments
 (0)