Joins aren't expensive; they are indexed (ConstraintStreams uses incremental calculation so it only recalculates what need to be recalculated). Although it might run on all N * M pairs on the first run, on each other run, we only check the impacted pairs (for instance, if you use the `equal` joiner on Employee, and Shift A's employee change to B, it only checks the Shifts who employees are B and ignore all others, changing it from needing to run on 10000 pairs to 100 (as an example). What isn't indexed are filters, which run on all possibly impacted tuples (so if Shift A changes in any way, then the tuples (*, Shift A), (Shift A, *) (where * is anything) are tested against the filters; the tuples (Shift B, Shift C) is not tested though (since it could not be impacted by Shift A changing).
Just a note: the join API only works on classes and UniStreams, so you cannot join a stream whose tuples have more than 1 element to another stream.
I notice you did
```
constraintFactory.fromUnfiltered(Shift.class)
.filter(shift -> shift.getEmployee() != null)
```
assuming Employee the only planning variable, that is exactly the same as
```
constraintFactory.from(Shift.class)
```
which is more efficient (note: in overconstrained planning (the employee planning variable is nullable)), then you still need the null check.
Is the constraint something like "the number of nurses in the hospital must be at least N each morning"/"the number of nurses in the hospital must be at least N each evening"? Then something like this should work:
```
Constraint atLeast10NursesInEvening(ConstraintFactory constraintFactory) {
return constraintFactory.from(Shift.class)
.filter(shift -> isShiftInEvening(shift)) // isShiftInEvening return true if it 12 hour day or 8 hours evening
.groupBy(shift -> shift.getEmployee(), countDistinct())
.filter(employeeCount -> employeeCount < 10)
.penalize("Less than 10 nurses in the evening", HardSoftScore.ONE_HARD, employeeCount -> 10 - employeeCount);
}
```