This is a follow up/related question to a previous question I asked regarding Interface Segregation. I'll build up on the same example, providing a bit more context.
In the game I'm developing, a card is structured in the following manner:
- A card must have a primary ability
- A card may have an "ally" ability. If it does, it can be triggered, given a certain condition is satisfied. Actual condition is not relevant to the discussion.
- A card may have a "sacrifice" ability. If it does, it can be sacrificed, removing it from play, to trigger this ability.
Note that a card may have none, one, or both, of the optional abilities, it is not limited to just one.
My initial design was to have one single Card
interface, with the following methods:
public interface Card {
/* other relevant methods snipped */
void useAllyAbility(GameContext context);
void useSacrificeAbility(GameContext context);
}
However, since not every card is going to have the corresponding abilities, I realised I would have to throw something along the lines of UnsupportedOperationException
in the cases where a Card
's useXxxAbility
is called, but that card does not have the required ability. In practice, this should rarely happen, of course, since the game's UI client would not allow it, but it has to be implemented nevertheless.
My other approach was to separate the two methods in different interfaces, like so:
public interface Card {
/* other required Card behavior */
}
public interface Sacrificable extends Card {
void useSacrificeAbility(GameContext context);
}
public interface Allyable extends Card {
void useAllyableAbility(GameContext context);
}
For reference, the Player
interface that would end up invoking these cards would be:
public interface Player {
void useSacrificeAbility(Sacrificable card, GameContext context);
void useAllyAbility(Allyable card, GameContext context);
}
Where the Player
implementation would ensure that the card is indeed playable (along with any other validations/restrictions). During initialization, depending on the card data, I would instantiate the correct class, and add it to a generic Collection<Card>
(or domain-specific wrapping class). The UI Layer would then be responsible for casting the card into a more specific type based on user input.
Implementation-wise, however, I found this second approach to be much more complex than I initially thought. Some context here. Since it would be impractical to have a separate class for every single card in the game, I broke it up into the following hierarchy:
class AbstractCard implements Card {}
class SacrificableCard implements Sacrificable {
public SacrificableCard(Card card, CardAbility sacrificeAbility) {}
}
class AllyableCard implements Allyable {
public AllyableCard(Card card, CardAbiltiy allyAbility) {}
}
With a few more classes in between, which are irrelevant for the discussion at the moment (Like the linked question suggested, there are ItemCard
and ChampionCard
etc.). My main problem is how to implement a card that is both Sacrificable
and Allyable
. Would I need a new class, SacrificableAllyableCard
? I figured this would get out of hand quickly if, somewhere down the line, I happen to need a third such interface, for example, Comboable
. Then I would need additional classes for every single possible combination of interfaces.
I read about the Bridge
pattern, which attempts to solve a similar problem, but I do not know how to apply it to this scenario, since I am adding new behaviour in each interface.
My only other thought was to use DynamicProxy
, so that SacrificableCard
and AllyableCard
could wrap any type of Card
, instead of the generic Card
. But I fear this may complicate the code even more than just simply having both of these methods declared in the Card
interface, which was the initial design.
My question(s), then, are the following:
- Does "Interface Segregation" make sense in this case? Since the "client" of this interface expects to work with
Card
s, is there any benefit to separating the interfaces forSacrificable
/Allyable
? - If everything is implemented in 1 interface, and I throw
UnsupportedOperationException
, does that violate LSP? My reasoning is that, even though bothAllyableCard
andSacrificableCard
both implement the same interface, they would not be interchangeable with each other, since aSacrificableCard
may not supportuseAllyAbility
, which would result in a thrown exception. - Are dynamic proxies a "bigger" evil than simply declaring 1 wide interface?
- What other approach would be recommended in this scenario?
CardAbiltiy
? You might be on the right track there.CardAbility
is an interface/abstract class (haven't made the final decision yet) that represents an ability a card might have. For example,DrawCardsAbility
,GainHealthAbility
would be concrete implementationsrecord CardDrawnEvent(String name, CardAbility primaryAbiltiy, CardAbility sacrificeAbility, CardAbiltiy allyAbility)
. The client would use the data from these events to build up its own classes/state, and add whatever validations might be helpful, or convenient for a user.