Recientemente, en un proyecto en el que estoy trabajando, me encontré con un escenario interesante. Estuve un tiempo explorando posibles enfoques hasta que se me ocurrió uno que me pareció bastante elegante.
Simplificando: recibimos mensajes que, según una combinación de códigos, deben disparar distintas tareas.
Mi idea fue aplicar un diseño basado en una factory que devuelva una estrategia dependiendo de esa combinación. Gráficamente se vería así:

El service, usando esta petición, consulta a la factory por una estrategia para el caso. La factory, según la combinación de códigos, retorna una estrategia que el service puede ejecutar. El service nunca sabe que estrategia ejecuta — eso lo determina la factory.
Cada estrategia es granular, fácil de testear y Open/Closed: puedo agregar nuevas estrategias sin modificar las existentes, solo creo una nueva estrategia anotada con @Component.
Vamos al código
Tenemos una interfaz Strategy muy simple, solo nos dice que combinacion de códigos soporta y expone un método que ejecuta la lógica de negocio para el mensaje dado.
public interface Strategy {
void consumeMessage(Message message);
TypeEnum supportsType();
SubTypeEnum supportsSubType();
}
Una implementación típica para la combinacion FOO|BAR, por ejemplo, se vería así:
@Component
public class FooBarStrategy implements Strategy {
private static final Logger log = LoggerFactory.getLogger(FooBarStrategy.class);
@Override
public void consumeMessage(Message message) {
log.info("Hi from FooBar");
}
@Override
public TypeEnum supportsType() {
return TypeEnum.FOO;
}
@Override
public SubTypeEnum supportsSubType() {
return SubTypeEnum.BAR;
}
}
Los metodos supportsType y supportsSubType le dicen la factory para que combinación de códigos está pensado esta estrategia.
La factory por su lado se vería asi:
@Component
public class StrategiesFactory {
private final Map<Pair<TypeEnum, SubTypeEnum>, Strategy> strategyMap;
public StrategiesFactory(List<Strategy> strategies) {
strategyMap = strategies.stream()
.collect(
toMap(strategy -> Pair.of(strategy.supportsType(), strategy.supportsSubType()), Function.identity())
);
}
public Strategy getStrategy(Message message) {
var strategy = strategyMap.get(Pair.of(message.type(), message.subTypeEnum()));
if (isNull(strategy)) {
throw new IllegalArgumentException("Combination not supported");
}
return strategy;
}
}
Spring permite inyectar listas de beans de un mismo tipo.
Así, puedo recibir todas las estrategias registradas sin declararlas una por una. Baeldung tiene un gran artículo al respecto! Pero si quieres que hable sobre esto a detalle en otro post, házmelo saber en los comentarios!
Para testear la implementación podemos usar un test parametrizado que valide que cada combinación de códigos retorna la estrategia esperada:
@SpringBootTest
class BeansdemoApplicationTests {
@Autowired StrategiesFactory strategiesFactory;
static Stream<Arguments> codeCombosAndExpectedStrategy() {
return Stream.of(
Arguments.of(TypeEnum.FOO, SubTypeEnum.BAR, FooBarStrategy.class),
Arguments.of(TypeEnum.FOO, SubTypeEnum.BUZ, FooBuzStrategy.class),
Arguments.of(TypeEnum.QUX, SubTypeEnum.BAR, QuxBarStrategy.class),
Arguments.of(TypeEnum.QUX, SubTypeEnum.BUZ, QuxBuzStrategy.class)
);
}
@ParameterizedTest
@MethodSource("codeCombosAndExpectedStrategy")
void shouldGetByCodesCombo(TypeEnum typeEnum, SubTypeEnum subTypeEnum, Class<Strategy> expectedStrategyClass) {
var actualStrategy = strategiesFactory.getStrategy(new Message(typeEnum, subTypeEnum));
assertThat(actualStrategy).isInstanceOf(expectedStrategyClass);
}
}
El problema
Todo marchaba bien hasta que me doy con que ciertas combinaciones de códigos compartían la misma lógica: QUX|BAR y QUX|BUZ ejecutaban exactamente el mismo flujo:
@Component
public class QuxBarStrategy implements Strategy {
private static final Logger log = LoggerFactory.getLogger(QuxBarStrategy.class);
@Override
public void consumeMessage(Message message) {
log.info("Same impl here!");
}
@Override
public TypeEnum supportsType() {
return TypeEnum.QUX;
}
@Override
public SubTypeEnum supportsSubType() {
return SubTypeEnum.BAR;
}
}
@Component
public class QuxBuzStrategy implements Strategy {
private static final Logger log = LoggerFactory.getLogger(QuxBuzStrategy.class);
@Override
public void consumeMessage(Message message) {
log.info("Same impl here!");
}
@Override
public TypeEnum supportsType() {
return TypeEnum.QUX;
}
@Override
public SubTypeEnum supportsSubType() {
return SubTypeEnum.BUZ;
}
}
Podemos ver que los tipos que soportan son distintos, pero la implementacion de consumeMessage es idéntica: log.info("Same impl here!");
Como ambas estrategias tenían la misma implementación, esto violaba el principio DRY y escalaba mal a medida que aparecían más combinaciones similares.
¿Una solución?
La clave está en el método consumeMessage: es un Consumer<Message>.
Si lo pienso así, toda la clase podría reducirse a una estructura de datos que almacene ese consumer junto con los tipos que soporta:
public record DelegateStrategy(
Consumer<Message> messageConsumer,
TypeEnum typeSupported,
SubTypeEnum subTypeEnum
) implements Strategy {
@Override
public void consumeMessage(Message message) {
messageConsumer.accept(message);
}
@Override
public TypeEnum supportsType() {
return typeSupported();
}
@Override
public SubTypeEnum supportsSubType() {
return subTypeEnum();
}
}
Con esta abstracción, puedo reutilizar la misma lógica (Consumer<Message>) para múltiples combinaciones de códigos, eliminando duplicación y manteniendo el diseño extensible.
Pre Spring Framework 7
En versiones anteriores no es posible registrar beans de forma dinámica, o por lo menos no para mi caso, BeanPostProcessor o similares son ejecutados demasiado tarde, cuando mi factory ya esta creada, estos se usan principalmente para hacer configuraciones posteriores.
En 2009 alguien abrió este issue pidiendo exactamente lo que necesito... que fue cerrado como invalid.
La única alternativa era registrar manualmente cada estrategia, repitiendo código y perdiendo flexibilidad.
Ahora, con Spring 7
Con Spring Framework 7 esto cambia. Ahora podemos registrar beans de forma programática usando la nueva API BeanRegistry, recordemos que (al momento de escribir este post) ésta version esta en construcción, por lo que no es estable.
@Override
public void register(BeanRegistry registry, Environment env) {
if (Boolean.parseBoolean(env.getProperty("strategies.registrar"))) {
Consumer<Message> commonImpl = _ -> log.info("Same impl here!");
registry.registerBean("quxBarStrategy", DelegateStrategy.class, spec -> spec.prototype()
.lazyInit()
.description("QuxBarStrategy bean")
.supplier(_ -> new DelegateStrategy(commonImpl, TypeEnum.QUX, SubTypeEnum.BAR)));
registry.registerBean("quxBuzStrategy", DelegateStrategy.class, spec -> spec.prototype()
.lazyInit()
.primary()
.description("QuxBarStrategy bean")
.supplier(_ -> new DelegateStrategy(commonImpl, TypeEnum.QUX, SubTypeEnum.BUZ)));
}
}
Esto me permite registrar estrategias dinámicamente —incluso a partir de datos externos— y mantener el sistema abierto a nuevas combinaciones sin tocar la factory.
Ultimando Detalles
Como ahora tengo dos formas de registrar las estrategias, cree la property strategies.registrar para determinar si usar la nueva API o el registro clásico.
@SpringBootTest
class BeansdemoApplicationTests {
@Autowired StrategiesFactory strategiesFactory;
static Stream<Arguments> codeCombosAndExpectedStrategy() {
return Stream.of(
Arguments.of(TypeEnum.FOO, SubTypeEnum.BAR, FooBarStrategy.class),
Arguments.of(TypeEnum.FOO, SubTypeEnum.BUZ, FooBuzStrategy.class),
Arguments.of(TypeEnum.QUX, SubTypeEnum.BAR, QuxBarStrategy.class),
Arguments.of(TypeEnum.QUX, SubTypeEnum.BUZ, QuxBuzStrategy.class)
);
}
@ParameterizedTest
@DisabledIf(expression = "#{'${strategies.registrar}' == 'true'}", loadContext = true)
@MethodSource("codeCombosAndExpectedStrategy")
void shouldGetByCodesCombo(TypeEnum typeEnum, SubTypeEnum subTypeEnum, Class<Strategy> expectedStrategyClass) {
var actualStrategy = strategiesFactory.getStrategy(new Message(typeEnum, subTypeEnum));
assertThat(actualStrategy).isInstanceOf(expectedStrategyClass);
}
static Stream<Arguments> codeCombosAndExpectedStrategyWhenRegistrar() {
return Stream.of(
Arguments.of(TypeEnum.FOO, SubTypeEnum.BAR, FooBarStrategy.class),
Arguments.of(TypeEnum.FOO, SubTypeEnum.BUZ, FooBuzStrategy.class),
Arguments.of(TypeEnum.QUX, SubTypeEnum.BAR, DelegateStrategy.class),
Arguments.of(TypeEnum.QUX, SubTypeEnum.BUZ, DelegateStrategy.class)
);
}
@ParameterizedTest
@DisabledIf(expression = "#{'${strategies.registrar}' == 'false'}", loadContext = true)
@MethodSource("codeCombosAndExpectedStrategyWhenRegistrar")
void shouldGetByCodesCombo_registrar(TypeEnum typeEnum, SubTypeEnum subTypeEnum, Class<Strategy> expectedStrategyClass) {
var actualStrategy = strategiesFactory.getStrategy(new Message(typeEnum, subTypeEnum));
assertThat(actualStrategy).isInstanceOf(expectedStrategyClass);
}
}
De esta manera, si el registrar está habilitado, verifico que las combinaciones QUX|BAR y QUX|BUZ sean instancias de DelegateStrategy. Si no, pruebo las clases concretas como antes.
Este patrón de estrategias con factory me permitió mantener un diseño limpio, flexible y fácil de extender.
Con las nuevas capacidades de registro programático en Spring 7, se abre una puerta interesante para generar beans dinámicos y reducir duplicación de código sin perder claridad.
Todo el código mostrado aquí esta disponible en éste proyecto en mi Github 🚀🚀
Te invito a contarme que opinas, como lo habrías hecho vos o si queres que profundice en alguno de los puntos mencionados en los comentarios! 👇
Top comments (1)
Excelente post!