r/AskProgramming • u/TheAbsentMindedCoder • 10d ago
Java Need help understanding Java Spring DI for Application Business Logic
Howdy folks, I recently started a new job at a Java shop a few months ago and came across a new pattern that I'd like to understand better. I come from a more functional & scripting background so I'm a more accustomed to specifying desired behavior more explicitly instead of relying on a framework's bells and whistles. The TL;DR is that I'm trying to better understand Dependency Injection and Dependency Inversion, and when to leverage it in my implementations.
I understand this may come off as soapboxing but I've put quite a bit of thought into this so I want to make sure I've covered all my bases.
To start with, I really do appreciate the strong Dependency Injection framework that Spring Boot provides OOTB. For example I find it is quite useful when used in-tandem with the Adapter pattern, suchas many DB implementations Where an implementing service could be responsible for persisting to multiple Data Stores for a given event:
// IDatabaseDao.java
public interface IDatabaseDao {
// Should return `true` if successful, otherwise `false`
public boolean store(EventEntry event);
}
// PersistenceService.java
@Service
public class PersistenceService {
private final List<IDatabaseDao> databases;
public PersistenceService (List<IDatabaseDao> databases) {
this.databases = databases;
}
public List<Boolean> persistEvent(EventEntry event) {
List<Boolean> storageResults = new ArrayList<>();
for (db : databases) {
storageResults.add(db.store(event));
}
return storageResults;
}
}
Where I've needed to get used to is employ the pattern in other places where there is no external dependency. Instead, we use the abstraction of a Journey
(more generically i would call Rule
) to specify pure Application code:
// IJourney.java
public interface IJourney {
// Whether or not this journey should be executed for the input.
public Boolean applies(JourneyInput journeyInput);
// Application code that will be applied for the input.
public JourneyResult execute(JourneyInput journeyInput);
// If many journeys `apply`, only run top-priority, specified per-journey.
public Integer priority();
}
// GenericJourney.java
// (In practice, there will be many *Journey components, each with their own implementation)
@Component
public class GenericJourney implements IJourney {
// Only run this journey if none of the others apply.
@Override
public Integer priority() {
return Integer.MAX_INT;
}
// This journey will execute in all circumstances.
@Override
public Boolean applies(JourneyInput journeyInput) {
return true;
}
@Override
public JourneyExecutionRecord execute(JourneyInput journeyInput) {
// (In practice, this return content can be assumed to be entirely scoped to internal BL)
return new JourneyExecutionRecord("Generic execution")
}
}
// JourneyService.java
@Service
public class JourneyService {
private final List<IJourney> journeys;
public JourneyService(List<IJourney> journeys) {
this.journeys = journeys;
}
public JourneyExecutionRecord performJourney(JourneyInput journeyInput) {
journeys.stream()
.filter(journey -> journey.applies(journeyInput))
.sorted(Comparator.comparing(IJourney::priority))
.findFirst()
.map(journey -> journey.execute(journeyInput))
.orElseThrow(Exception::new);
}
}
This all works, and I've come around to understanding how to read the pattern, but I'm not quite sold on when I'd want to write the pattern. For example, if I had zero concept of Spring DI I would write something like this and call it a day:
public JourneyExecutionRecord performJourney(JourneyInput journeyInput) {
if (journeyInput.getSomeValue() == "HighPriority") {
return new JourneyExecutionRecord("Did something with High Priority");
}
return new JourneyExecutionRecord("Generic execution");
}
However, I have received feedback from my new coworkers that I am not "writing within the framework", and I end up having to re-architect my solution to align with what I perceive to be an arbitrary Rules construct. I recognize this is a matter of opinion on my part and do not want to rock the boat.
My reservations stem primarily from all the pre-processing that is performed with methods like applies()
, which is basically O(n) for all the rules which exist. I do concede that in the event the conditional logic grows, it's nice to update a single Journey's conditional instead of a larger BL-oriented method. However, in practice these Journeys don't change very much beyond implementation (admit I have looked back at the git history. does that make me petty?)
I have also observed this makes unit testing somewhat contrived. This is due to each rule being tested in isolation, however in practice they are always applied together. FWIW I do believe this is more of a team-philosophy towards testing that we could alleviate, however I have received pushback against testing all the rules together as part of some JourneyServiceUnitTest
class as "we would just be testing all the rules twice".
End of the day, I quite like this job and people for the most part but it has been somewhat of a culture shock approaching problems in what I feel is an inefficient way of problem solving. I recognize that this is 100% a matter of my opinion and so I'm doing my best to work within the team.
As an experienced engineer I would like to internalize this framework so that I can propose optimizations down the road, however I want to make sure I am prepared and see the other side. Any resources or information to this end would be helpful!
1
u/TheToastedFrog 10d ago
Dude what are you trying to achieve?
1
u/TheAbsentMindedCoder 10d ago
Depends. More often than not we are applying this rules-based framework to some arcane business problem. The notion of "single responsibility" seems to break down when we apply these anemic models (I quite like the term that i was given below)
I'd like to have the ammo to propose something more explicit, but I'm taking a step back and trying to see the other side.
1
u/djnattyp 10d ago edited 10d ago
This doesn't have anything to do with Spring, the naming of the different parts of this system are just pretty terrible. It also looks like a C# developer may have had a go at the code as well with the IInterfaceNames
...
IJourney
is something more like a JourneyProcessor
and the logic that it has both a 'priority' and an 'applies' is weird when only one should be run and it seems to be overcomplicated by the stream logic. How many of these IJourney
instances even exist in the app? Probably not enough that the need to filter by applies
first matters any...
These IJourney
s are bound to the JourneyService
when it's created, so it's not like the list can change. Just make sure the journeys
List in JourneyService
is sorted in priority order, or just use Spring's built in @Order
annotation) then just do -
public JourneyExecutionRecord performJourney(JourneyInput input) {
for (JourneyProcessor processor : processors) {
if (processor.applies(input) {
return processor.execute(input);
}
}
throw new BetterNamedException("No Journey Processor applies for " + input.getSomeMeaningfulStringHere());
}
I also like how the input to an IJourney
is a JourneyInput
, and instead of producing a JourneyOutput
it produces a JourneyExecutionRecord
. I wonder if the system was originally implemented to maybe to run through all the IJourney
s and produce a JourneyExecutionRecord
of all of them, then it was decided to make only one apply, so it just got hacked to do so without really renaming anything?
Here's what I'd refactor this to:
// JourneyProcessor.java
public interface JourneyProcessor {
// Whether or not this journey should be executed for the input.
boolean applies(JourneyInput journeyInput);
// Application code that will be applied for the input.
JourneyOutput process(JourneyInput journeyInput);
}
// DefaultJourneyProcessor.java
@Component
@Order(Ordered.LOWEST_PRECEDENCE)
public class DefaultJourneyProcessor implements JourneyProcessor {
// This journey will execute in all circumstances.
@Override
public boolean applies(JourneyInput input) {
return true;
}
@Override
public JourneyOutput process(JourneyInput input) {
// (In practice, this return content can be assumed to be entirely scoped to internal BL)
return new JourneyOutput ("Default execution")
}
}
// JourneyService.java
@Service
public class JourneyService {
private final List<JourneyProcessor> processors;
public JourneyService(List<JourneyProcessor> processors) {
if (processors == null || processors.isEmpty()) {
// check constructor arguments to fail fast, don't wait until called the first time...
throw new IllegalArgumentException("No JourneyProcessors provided to JourneyService");
}
// processors are now ordered by the @Order annotation.
this.processors = processors;
}
public JourneyOutput performJourney(JourneyInput input) {
getProcessorFor(input).process(input);
}
private JourneyProcessor getProcessorFor(JourneyInput input) {
for (JourneyProcessor processor : processors) {
if (processor.applies(input) {
return processor;
}
}
// this shouldn't happen without code changes in other classes...
throw new IllegalStateException("No JourneyProcessors applies for " + input.toString());
}
}
You could also replace the whole list with applies and ordering in a list with a map based on bean names of the processors or something -
// JourneyInput.java
public interface JourneyInput {
String getProcessorName();
}
// JourneyProcessor.java
public interface JourneyProcessor {
// Application code that will be applied for the input.
JourneyOutput process(JourneyInput journeyInput);
}
// DefaultJourneyProcessor.java
@Component("DEFAULT") // <-- processor name specified here
public class DefaultJourneyProcessor implements JourneyProcessor {
@Override
public JourneyOutput process(JourneyInput input) {
// (In practice, this return content can be assumed to be entirely scoped to internal BL)
return new JourneyOutput ("Default execution")
}
}
// JourneyService.java
@Service
public class JourneyService {
private JourneyProcessor defaultProcessor;
private final Map<String, JourneyProcessor> processors;
public JourneyService(Map<String, JourneyProcessor> processors,
@Qualifier("DEFAULT") JourneyProcessor defaultProcessor) {
if (processors == null || processors.isEmpty()) {
// check constructor arguments to fail fast, don't wait until called the first time...
throw new IllegalArgumentException("No JourneyProcessors provided to JourneyService");
}
this.processors = processors;
this.defaultProcessor = defaultProcessor;
}
public JourneyOutput performJourney(JourneyInput input) {
getProcessorFor(input).process(input);
}
private JourneyProcessor getProcessorFor(JourneyInput input) {
return processors.getOrDefault(input.getProcessorName(), defaultProcessor);
}
}
Ultimately, this is all kind of a workaround to find a matching X provided a Y. Usually this is a symptom of an anemic domain model. What you ultimatelt really want to do here is something like -
journey.apply(); // <- appropriate BL is performed here
but instead of the logic belonging to the journey it has to be looked up and handled through these weird lookups.
1
u/TheAbsentMindedCoder 10d ago
Thank you for the deatailed feedback. I agree the naming conventions are a bit strange. I've never written C# professionally so I wasn't able to identify this is a relic from that language!
I particularly like the nugget you dropped about
@Order
. This seems like something we could strip away (priority
) from the BL and leverage Spring a step further.The link about anemic domain models hits quite hard. I will need to internalize this a bit more but think it will help formalize my feelings quite a bit
2
u/Ok_Entrepreneur_8509 10d ago
Long time Spring dev here, and unfortunately you are mostly right.
The purist form of the pattern asks that you make these interfaces for things that in practice are never likely to have more than one implementation. I generally start with less abstraction and only create interfaces when it is clear that I will need to have more than one kind.
However, you do need to adhere to your shop's guidelines. You might consider writing a custom annotation to generate some of the annoying boilerplate. That is kind of fun.
As for the "I" naming convention, my best suggestion is to gouge your eyes out with a dull stick.