2

Consider the following fictitious toy example:

There is an aggregate root DayChecklist that holds a list of Tasks planned for the day:

class DayChecklist {
    String id;
    DateTime today;
    ...
    List<Task> tasks;
 
    // lots of other business logic
}

Task itself is modeled as:

abstract class Task {
    DateTime startTime;
    DateTime endTime;
    
    // lots of other common business logic
}

Let subtypes of Task be:

  1. TemperatureTask
class TemperatureTask extends Task {
    double temperatureAtStart;
    double temperatureAtEnd;

    public recordStartingTemperature(double temperature) {
        // some business logic
        temperatureAtStart = temperature;
    }

    public recordEndingTemperature(double temperature) {
        // some business logic
        temperatureAtEnd = temperature;
    }
    ...
}
  1. PressureTask
class PressureTask extends Task {
    double pressureAtStart;
    double pressureAtEnd;

    public recordStartingPressure(double pressure) {
        // some business logic
        pressureAtStart = pressure;
    }

    public recordEndingPressure(double pressure) {
        // some business logic
        pressureAtEnd = pressure;
    }
    ...
}

Please assume that the Task subtypes have a lot of common business logic like state transitions (i.e. is-a relationship makes sense) and the AR and Tasks are behavior-rich models (i.e. DDD makes sense). What differs is some specific properties (like the temperature and pressure readings above).

Now, when a Task needs to be updated (say recordStartingPressure() for a PressureTask), the update should ideally pass through the aggregate root.

  1. Does this mean that the aggregate root has a specific method defined for each subtask? In the example above, it is possible to have polymorphic recordStartReading and recordEndReading on the subtasks and the aggregate root could delegate such behavior. However, I am asking about the more general case when such polymorphism cannot easily be extracted.
  2. Also, what if it is certain that more Task types will be added in the future. Does that mean that corresponding methods also get added to the AR? This will violate OCP. Should some pattern be used for such a situation to allow extensibility?

Thanks!

Edit

To highlight the crux of the problem, consider the following Task:

PressureIntervalTask

class PressureIntervalTask extends Task {
    List<Pair<DateTime, Double>> instantPressurePairs;

    public recordPressureAtInstant(double pressure, DateTime instant) {
        // some business rules
        instantPressurePairs.add(new Pair(instant, pressure));
    }
    ...
}

Now, PressureIntervalTask has a method recordPressureAtInstant() that has a completely different signature. So, should the AR have a corresponding recordPressureAtInstant() method as well?

Essentially, the Tasks can have metadata that can differ in terms of number of fields and their type. One extreme solution could be to model metadata as a JSON Object that the specific Task knows how to parse and apply. Then the AR could have a boolean applyMetadataUpdate(JSONObject metadata) and each Task can implement a polymorphic version, where the target Task may use a type identifier (for example) and return True if the update was handled/applied.

2
  • Could the downvoter(s) explain what the issue with this question is? I can't improve it without knowing the perceived issue.
    – Cosmica
    Commented Jun 10, 2021 at 8:10
  • Even if only theoretically, how do you intend to handle cases where there's two temperature tasks in your day checklist? Odds are that your reading is specific to a particular task. If so, that would suggest that your "reading provider" needs to already have an explicit reference to the specific task for which it is doing a reading. At this point, the reading provider could hold this reference using the specific (i.e. derived, not abstract) task type, therefore exposing the necessary logic that is specific to that type of task.
    – Flater
    Commented Oct 29, 2023 at 23:27

2 Answers 2

1

Tulains Córdova's answer is a good answer and the basis of my solution. It's a great pattern if you can iterate over a list of tasks of the same type. If you need to iterate over a more general list (and can't/don't want to type the list as Object), then what I am proposing may be a better option.

Let's define a similar interface without the type parameter:

interface ITask {
    void recordStarting();
    void recordEnding();
    void recordAtInstant(DateTime instant)
    void printValues();
}

In the implementation classes, you can inject a service that gets the values you need at each step. For instance:

class TemperatureTask implements ITask {
    private ITemperatureService tempService;
    private double tempAtStart;
    private double tempAtEnd;

    public TemperatureTask(ITemperatureService tempService) {
        this.tempService = tempService;
    }

    public void recordStarting() {
        tempAtStart = tempService.getTemperature();
    }

    public void recordEnding() {
        tempAtEnd = tempService.getTemperature();
    }

    public void recordAtInstant(DateTime instant) {
        // If you don't need to record anything at this instant,
        // then leave this blank.

        // If you throw an exception, then you have to handle it
        // in whatever code is iterating over tasks.
        // Of course, maybe that's exactly is what you want.
    }

    public void printValues() {
        // Logic goes here.
    }
}

In the above answer's comments, you asked:

What if the PressureTask and TemperatureTask do not need recordAtInstant?

As you can see in the implementation of recordAtInstant, you can leave the method empty or throw an exception. Note that throwing an exception indicates a failure to the caller, and you probably just want to continue. You can apply this same reasoning to other Task classes, e.g. if they don't need any action at at recordingStart().

You also asked:

What if there is a DeliveryTask that requires adding an image of the parcel only at the end when a parcel has been delivered?

With this approach, you can inject as many services as you like to retrieve as many values as you need, and they don't need to be the same at the start, end, or instances in between.

1
  • Not implementing the interfaces (by leaving them empty) or throwing an exception will violate ISP and can lead to general unwieldiness. Unfortunately, in the domain I am modelling, the values cannot be retrieved from a service on the fly -- they are provided by the caller. Thanks!
    – Cosmica
    Commented Jun 11, 2021 at 19:01
1

One have to avoid if-then-else-if-then-else-if... or switch case structures as much as it is posible to avoid them, because it means code has to be changed more often than not.

There are several ways to achieve that. Several behavioral design patterns can be used to decouple the aggregate root from the tasks. The AR must not depend on specific behaviors of certain implementations of Task, basically implementors should adhere to an interface and the agregator should not expect anything outside of such interface as well.

But you can go with using Generics and making the abstract class method names more agnostic, like you mentioned.

import java.util.Date;
import java.time.LocalDateTime;

public class Test {
    public static void main(String[] args){
        ITask<Double> t = new TemperatureTask<Double>();
        t.recordStarting(0.5d);
        t.recordEnding(0.8d);
        t.printValues();

        ITask<LocalDateTime> t2 = new TemperatureTask<LocalDateTime>();
        t2.recordStarting(LocalDateTime.now());
        t2.recordEnding(LocalDateTime.now().plusSeconds(120000));
        t2.printValues();
    }
}

interface ITask<T>{
    void recordStarting(T value);
    void recordEnding(T value);
    void recordAtInstant(T value, DateTime instant)
    void printValues();
}

class Task<T> implements ITask<T>{
    private T startingValue;
    private T endingValue;
    private List<Pair<DateTime, T>> instantValuePairs;

    public void recordStarting(T value){
        this.startingValue = value;
    }

    public void recordEnding(T value){
        this.endingValue = value;
    }

    void recordAtInstant(T value, DateTime instant) {
        instantValuePairs.add(new Pair(instant, value));
        ...extra functionality can be added by subclasses
        ...or injected
    }

    public void printValues(){
        System.out.println("Starting: "+this.startingValue);
        System.out.println("Ending: "+this.endingValue);
    }
}

I believe these are some of the behavioral patterns that can help you achieve extendibility of the AR if more task types are added.

enter image description here

4
  • I think the question was not doing a good job highlighting the exact problem I'm trying to solve (sorry about that). I've made an edit with an additional Task that is more in line with the core problem. Thanks!
    – Cosmica
    Commented Jun 10, 2021 at 19:29
  • I read your update and edited this answer to reflect in the interface and abstract class. Basically the interface and implementors set values at instants in every case, only the new method gets an specfic instant in time as a parameter. The rest of functionality can be extended by inheritance or injection at building time. Still extendable and flexible. Commented Jun 10, 2021 at 19:49
  • What if the PressureTask and TemperatureTask do not need recordAtInstant? If the Task interface defines this method, PressureTask and TemperatureTask will need to throw a NotImplementedException (or do something similar). What if there is a DeliveryTask that requires adding an image of the parcel only at the end when a parcel has been delivered? It seems difficult to model all these different varieties with rigid methods even when using generics.
    – Cosmica
    Commented Jun 10, 2021 at 20:07
  • I thought more about this and at present the Command Pattern you suggested seems like the only way to manage the generic nature of the problem. Essentially, the updates get modelled into Commands that are delegated to individual Tasks.
    – Cosmica
    Commented Jun 11, 2021 at 19:03

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.