Java Signals and Effects

Observables done right To tee off this presentation, consider a TodosList that contains Todo items. You wish to be able to react to the following events. In any Todo item, when: the title is changed the completion status is toggled In the TodosList, when: a new item is added an existing item is removed The behaviors executed in response to these reactions are called effects, and they are set off when a signal, or more explicitly, an observed class property, is modified in the course of program execution. Approach 1 Dialing it back to begin from first principles, here is a basic representation of the respective domain classes: Domain class with basic fields @Data @AllArgsConstructor public class Todo { private UUID id; private String title; private Boolean completed; } Domain class with collection field public class TodosList { private final Collection todos = new ArrayList(); public Todo add(String title){ Todo todo = new Todo(UUID.randomUUID(), title, false); todos.add(todo); return todo; } public void update(String id, String title){ Todo todo = todos.stream().filter(t -> t.getId().toString().equals(id)).findFirst() .orElseThrow(() -> new RuntimeException("no task found with matching id")); todo.setTitle(title); } public void toggle(String id){ Todo todo = todos.stream().filter(t -> t.getId().toString().equals(id)).findFirst() .orElseThrow(() -> new RuntimeException("no task found with matching id")); todo.setCompleted(!todo.getCompleted()); } public void delete(String id){ Todo todo = todos.stream().filter(t -> t.getId().toString().equals(id)).findFirst() .orElseThrow(() -> new RuntimeException("no task found with matching id")); todos.remove(todo); } } Observing changes to the state of an object when the events of interest described above are triggered can be achieved through different techniques, which I will review here. A basic solution would require explicitly implementing some kind of observation mechanism, like a Listener. public interface Listener { void onTitleChanged(Todo todo); void onCompletionChanged(Todo todo); void onItemAdded(Todo entity, Collection todos); void onItemRemoved(Todo entity, Collection todos); } Then a concrete implementation of the listener would execute the business requirements necessary when the events of interest are fired. Different concrete classes of the listener interface would have to be implemented if different behavior becomes a necessity. Below is one such implementation which only acknowledges the event happening by printing its details to the console. public class BasicListener implements Listener { @Override public void onTitleChanged(Todo todo) { System.out.printf("Task title changed to %s\n", todo.getTitle()); } @Override public void onCompletionChanged(Todo todo) { System.out.printf("Task completion changed to %s\n", todo.getCompleted()); } @Override public void onItemAdded(Todo entity, Collection todos) { System.out.printf("Event: add, entity: %s\n", entity); todos.forEach(System.out::println); } @Override public void onItemRemoved(Todo entity, Collection todos) { System.out.printf("Event: remove, entity: %s\n", entity); todos.forEach(System.out::println); } } These two classes (observable and listener implementation) represent functionality that needs to be woven together in some way, to be able to react to state changes. The easiest (and unfortunately pretty invasive as well) is to add statements in the TodosList object to invoke methods in the BasicListener when the events of interest are happening. The updated TodosList would therefore look something like this. public class TodosList { private final Collection todos = new ArrayList(); private final Listener listener = new BasicListener(); // require listener to accept events public Todo add(String title){ Todo todo = new Todo(UUID.randomUUID(), title, false); todos.add(todo); listener.onItemAdded(todo, todos); // explicitly fire listener function return todo; } public void update(String id, String title){ Todo todo = todos.stream().filter(t -> t.getId().toString().equals(id)).findFirst() .orElseThrow(() -> new RuntimeException("no task found with matching id")); todo.setTitle(title); listener.onTitleChanged(todo); // explicitly fire listener function } public void toggle(String id){ Todo todo = todos.stream().filter(t -> t.getId().toString().equals(id)).findFirst() .orElseThrow(() -> new RuntimeException("no task found with matching id")); todo.setCompleted(!todo.getCompleted()); listener.onComple

Apr 18, 2025 - 18:55
 0
Java Signals and Effects

Observables done right

To tee off this presentation, consider a TodosList that contains Todo items. You wish to be able to react to the following events.

In any Todo item, when:

  1. the title is changed
  2. the completion status is toggled

In the TodosList, when:

  1. a new item is added
  2. an existing item is removed

The behaviors executed in response to these reactions are called effects, and they are set off when a signal, or more explicitly, an observed class property, is modified in the course of program execution.

Approach 1

Dialing it back to begin from first principles, here is a basic representation of the respective domain classes:

Domain class with basic fields

@Data
@AllArgsConstructor
public class Todo {

    private UUID id;
    private String title;
    private Boolean completed;
}

Domain class with collection field

public class TodosList {

    private final Collection<Todo> todos = new ArrayList<>();

    public Todo add(String title){
        Todo todo = new Todo(UUID.randomUUID(), title, false);
        todos.add(todo);
        return todo;
    }

    public void update(String id, String title){
        Todo todo = todos.stream().filter(t -> t.getId().toString().equals(id)).findFirst()
                .orElseThrow(() -> new RuntimeException("no task found with matching id"));
        todo.setTitle(title);
    }

    public void toggle(String id){
        Todo todo = todos.stream().filter(t -> t.getId().toString().equals(id)).findFirst()
                .orElseThrow(() -> new RuntimeException("no task found with matching id"));
        todo.setCompleted(!todo.getCompleted());
    }

    public void delete(String id){
        Todo todo = todos.stream().filter(t -> t.getId().toString().equals(id)).findFirst()
                .orElseThrow(() -> new RuntimeException("no task found with matching id"));
        todos.remove(todo);
    }
}

Observing changes to the state of an object when the events of interest described above are triggered can be achieved through different techniques, which I will review here. A basic solution would require explicitly implementing some kind of observation mechanism, like a Listener.

public interface Listener {

    void onTitleChanged(Todo todo);

    void onCompletionChanged(Todo todo);

    void onItemAdded(Todo entity, Collection<Todo> todos);

    void onItemRemoved(Todo entity, Collection<Todo> todos);
}

Then a concrete implementation of the listener would execute the business requirements necessary when the events of interest are fired. Different concrete classes of the listener interface would have to be implemented if different behavior becomes a necessity. Below is one such implementation which only acknowledges the event happening by printing its details to the console.

public class BasicListener implements Listener {

    @Override
    public void onTitleChanged(Todo todo) {
        System.out.printf("Task title changed to %s\n", todo.getTitle());
    }

    @Override
    public void onCompletionChanged(Todo todo) {
        System.out.printf("Task completion changed to %s\n", todo.getCompleted());
    }

    @Override
    public void onItemAdded(Todo entity, Collection<Todo> todos) {
        System.out.printf("Event: add, entity: %s\n", entity);
        todos.forEach(System.out::println);
    }

    @Override
    public void onItemRemoved(Todo entity, Collection<Todo> todos) {
        System.out.printf("Event: remove, entity: %s\n", entity);
        todos.forEach(System.out::println);
    }
}

These two classes (observable and listener implementation) represent functionality that needs to be woven together in some way, to be able to react to state changes. The easiest (and unfortunately pretty invasive as well) is to add statements in the TodosList object to invoke methods in the BasicListener when the events of interest are happening. The updated TodosList would therefore look something like this.

public class TodosList {

    private final Collection<Todo> todos = new ArrayList<>();
    private final Listener listener = new BasicListener(); // require listener to accept events

    public Todo add(String title){
        Todo todo = new Todo(UUID.randomUUID(), title, false);
        todos.add(todo);
        listener.onItemAdded(todo, todos); // explicitly fire listener function
        return todo;
    }

    public void update(String id, String title){
        Todo todo = todos.stream().filter(t -> t.getId().toString().equals(id)).findFirst()
                .orElseThrow(() -> new RuntimeException("no task found with matching id"));
        todo.setTitle(title);
        listener.onTitleChanged(todo); // explicitly fire listener function
    }

    public void toggle(String id){
        Todo todo = todos.stream().filter(t -> t.getId().toString().equals(id)).findFirst()
                .orElseThrow(() -> new RuntimeException("no task found with matching id"));
        todo.setCompleted(!todo.getCompleted());
        listener.onCompletionChanged(todo); // explicitly fire listener function
    }

    public void delete(String id){
        Todo todo = todos.stream().filter(t -> t.getId().toString().equals(id)).findFirst()
                .orElseThrow(() -> new RuntimeException("no task found with matching id"));
        todos.remove(todo);
        listener.onItemRemoved(todo, todos); // explicitly fire listener function
    }
}

A main class would then be used set off the orchestration of events as shown below

public class Main {

    public static void main(String[] args) {
        TodosList list = new TodosList();

        Todo t1 = list.add("wake up");
        Todo t2 = list.add("make breakfast");
        Todo t3 = list.add("watch tv");

        list.update(t2.getId().toString(), "work out");
        list.toggle(t1.getId().toString());
        list.delete(t3.getId().toString());
    }
}

Putting it all together, the main class shown above would certainly do a decent job of capturing all the targeted events and executing the prescribed effects.

If multiple listener implementations need to be invoked when these state changes happen, then it would require having a collection of such listeners and calling them all sequentially to dispatch the events' data.

public class AnotherListener implements Listener {

    @Override
    public void onTitleChanged(Todo todo) {
        System.out.printf("[**] Task title changed to %s\n", todo.getTitle());
    }

    @Override
    public void onCompletionChanged(Todo todo) {
        System.out.printf("[**] Task completion changed to %s\n", todo.getCompleted());
    }

    @Override
    public void onItemAdded(Todo entity, Collection<Todo> todos) {
        System.out.printf("[**] Event: add, entity: %s\n", entity);
        todos.forEach(System.out::println);
    }

    @Override
    public void onItemRemoved(Todo entity, Collection<Todo> todos) {
        System.out.printf("[**] Event: remove, entity: %s\n", entity);
        todos.forEach(System.out::println);
    }
}

The TodosList would now need to save a reference to the subscribers in a collection, and invoke them all when an event is patched.

public class TodosList {

    private final Collection<Todo> todos = new ArrayList<>();
    private final Collection<Listener> listeners = new LinkedList<>(); // use a collection to hold multiple listeners

    // convenience method to register listener
    public void addListener(Listener listener) {
        this.listeners.add(listener);
    }

    // convenience method to unregister listener
    public void removeListener(Listener listener) {
        this.listeners.remove(listener);
    }

    public Todo add(String title){
        // omitted for brevity
        listeners.forEach(l -> l.onItemAdded(todo, todos));
        return todo;
    }

    public void update(String id, String title){
        // omitted for brevity
        listeners.forEach(l -> l.onTitleChanged(todo));
    }

    public void toggle(String id){
        // omitted for brevity
        listeners.forEach(l -> l.onCompletionChanged(todo));
    }

    public void delete(String id){
        // omitted for brevity
        listeners.forEach(l -> l.onItemRemoved(todo, todos));
    }
}

Lastly, the main class would then be used to register (and perhaps even to unregister) listeners

public class Main {

    public static void main(String[] args) {
        TodosList list = new TodosList();

        // register listeners
        list.addListener(new BasicListener());
        list.addListener(new AnotherListener());

        // continue in the same way as before
        Todo t1 = list.add("wake up");
        Todo t2 = list.add("make breakfast");
        Todo t3 = list.add("watch tv");

        list.update(t2.getId().toString(), "work out");
        list.toggle(t1.getId().toString());
        list.delete(t3.getId().toString());
    }
}

The main problem with this method is that the concerns of the listener (registering, unregistering and dispatching) must be manually woven into observable, which opens up a lot of opportunities for errors to creep in and therefore requires careful design and extensive testing. Even more importantly, however, the listener implementation is very tightly coupled to the Observable, and hence impossible to reuse in any other situation without major modifications.

Approach 2

A slightly more idiomatic approach would be to take advantage of Java's built-in Observer/Observable framework to offload much of the observing concerns, registering and unregistering of listeners to the framework and instead focus more on the effects or more explicitly, focus on the corresponding behavior after events are triggered.

This method is similarly as intrusive as the approach 1 implemented previously, and has actually been deprecated since java 9, and as a result, I would not even encourage anyone to use it.

@Getter
@AllArgsConstructor
public class Todo extends Observable {

    @Setter
    private UUID id;
    private String title;
    private Boolean completed;

    public void setTitle(String title) {
        this.title = title;
        setChanged();           // mark the field as dirty
        notifyObservers(this);  // inform listeners to do their thing
    }

    public void setCompleted(Boolean completed) {
        this.completed = completed;
        setChanged();           // mark the field as dirty
        notifyObservers(this);  // inform listeners to do their thing
    }
}

The setters in an Observable need to be instrumented to notify observers of some change in state. The existing Listener implementations can be repurposed into Observers by implementing Java's own Observer interface.

public class BasicListener implements Listener, Observer {

    @Override
    public void onTitleChanged(Todo todo) {
        System.out.printf("Task title changed to %s\n", todo.getTitle());
    }

    @Override
    public void onCompletionChanged(Todo todo) {
        System.out.printf("Task completion changed to %s\n", todo.getCompleted());
    }

    @Override
    public void onItemAdded(Todo entity, Collection<Todo> todos) {
        System.out.printf("Event: add, entity: %s\n", entity);
        todos.forEach(System.out::println);
    }

    @Override
    public void onItemRemoved(Todo entity, Collection<Todo> todos) {
        System.out.printf("Event: remove, entity: %s\n", entity);
        todos.forEach(System.out::println);
    }

    @Override
    public void update(Observable obj, Object arg) {
        if (obj instanceof Todo todo) {
            System.out.println("[Observer] received event -> todo: " + todo);
        }
        if (obj instanceof TodosList list) {
            System.out.println("[Observer] received event -> todos: " + list);
        }
    }
}

The second Observer would take similar modifications to the ones made in the first one.

public class AnotherListener implements Listener, Observer {

    @Override
    public void onTitleChanged(Todo todo) {
        System.out.printf("[**] Task title changed to %s\n", todo.getTitle());
    }

    @Override
    public void onCompletionChanged(Todo todo) {
        System.out.printf("[**] Task completion changed to %s\n", todo.getCompleted());
    }

    @Override
    public void onItemAdded(Todo entity, Collection<Todo> todos) {
        System.out.printf("[**] Event: add, entity: %s\n", entity);
        todos.forEach(System.out::println);
    }

    @Override
    public void onItemRemoved(Todo entity, Collection<Todo> todos) {
        System.out.printf("[**] Event: remove, entity: %s\n", entity);
        todos.forEach(System.out::println);
    }

    @Override
    public void update(Observable obj, Object arg) {
        if (obj instanceof Todo todo) {
            System.out.println("[**Observer**] received event -> todo: " + todo);
        }
        if (obj instanceof TodosList list) {
            System.out.println("[**Observer**] received event -> todos: " + list);
        }
    }
}

The fact that the notifyObservers(obj) in the Observable takes just one argument of Object type makes it difficult to be expressive when using this method. It becomes tricky to detect what attributes changed in which Observable when the event is received over on the Listener side.

public class TodosList extends Observable {

    private final Collection<Todo> todos = new ArrayList<>();

    public Todo add(String title){
        Todo todo = new Todo(UUID.randomUUID(), title, false);
        todos.add(todo);
        setChanged();               // mark the field as dirty
        notifyObservers(todos);     // inform listeners to do their thing
        return todo;
    }

    public void update(String id, String title){
        Todo todo = todos.stream().filter(t -> t.getId().toString().equals(id)).findFirst()
                .orElseThrow(() -> new RuntimeException("no task found with matching id"));
        todo.setTitle(title);
    }

    public void toggle(String id){
        Todo todo = todos.stream().filter(t -> t.getId().toString().equals(id)).findFirst()
                .orElseThrow(() -> new RuntimeException("no task found with matching id"));
        todo.setCompleted(!todo.getCompleted());
    }

    public void delete(String id){
        Todo todo = todos.stream().filter(t -> t.getId().toString().equals(id)).findFirst()
                .orElseThrow(() -> new RuntimeException("no task found with matching id"));
        todos.remove(todo);
        setChanged();               // mark the field as dirty
        notifyObservers(this);      // inform listeners to do their thing
    }
}

The main class now changes pretty dramatically since the Observers need to be registered with each Observable party.

public class Main {

    public static void main(String[] args) {
        TodosList list = new TodosList();

        BasicListener basic = new BasicListener();
        AnotherListener another = new AnotherListener();
        // register listeners
        list.addObserver(basic);
        list.addObserver(another);

        Todo t1 = list.add("wake up");
        // register listeners
        list.addObserver(basic);
        list.addObserver(another);

        Todo t2 = list.add("make breakfast");
        // register listeners
        list.addObserver(basic);
        list.addObserver(another);

        Todo t3 = list.add("watch tv");
        // register listeners
        list.addObserver(basic);
        list.addObserver(another);

        // proceed in the usual manner
        list.update(t2.getId().toString(), "work out");
        list.toggle(t1.getId().toString());
        list.delete(t3.getId().toString());
    }
}

As mentioned earlier, this approach may have been cutting-edge in its heydays, but in today's technology landscape, those glory days are far in the rearview mirror now. It's certainly an improvement over the approach 1 in that the observing responsibility is delegated to the underlying framework, but it lacks the critical versatility of
reusability, since it's not easy to use without a lot of customization, and hence any solution in which it is a part of is not easily reusable without major refactoring.

I have skipped the details of demultiplexing events arriving in the void update(Observable obj, Objects arg) methods of the Observers because it can get unnecessarily complex detecting what attributes changed, so that the routing may be dispatched to the correct Listener methods.

Approach 3

So what else is out there that will perform the same role as Observer/Observable, but without the difficulty of use associated with approaches 1 & 2 illustrated before? Enter Signals. This is a concept that I have used extensively in the JavaScript ecosystem, and its non-existence in the Java universe is pretty saddening.

This is an attempt to narrow that gap.

# using maven

    com.akilisha.oss
    signals
    0.0.1


# using gradle
implementation("com.akilisha.oss:signals:0.0.1")

Signals uses the concept of instrumenting attributes that need to be observed and the registration of listeners is then implicitly achieved in the construction phase of an Observable by literary accessing these observed attributes.

Allow me illustrate because that explanation was certainly not exhaustive. The Todo class in this case clearly shows which attributes are candidates for observation.

@Getter
@AllArgsConstructor
public class Todo {

    private final Signal<String> title = Signals.signal("");
    private final Signal<Boolean> completed = Signals.signal(false);
    @Setter
    private UUID id;

    public Todo(String title) {
        this(UUID.randomUUID(), title, false);
    }

    public Todo(UUID id, String title, Boolean completed) {
        this.id = id;
        this.title.value(title);
        this.completed.value(completed);
    }

    @Override
    public String toString() {
        return "Todo{" +
                "title=" + title.value() +
                ", completed=" + completed.value() +
                ", id=" + id +
                '}';
    }
}

It's always convenient in the majority of cases to work with data carriers implemented as Java's Records (or Class) to complement domain classes instrumented with Signal attributes. Although not used in this presentation, TodoItem is nonetheless an example of such a data carrier object. They are a totally optional but very convenient.

public record TodoItem (UUID id, String title, Boolean completed){

    public TodoItem(String title){
        this(UUID.randomUUID(), title, false);
    }
}

Now instead of explicitly implementing Listener interfaces, the effects of changes to the title and completed attributes of a Todo class can be captured during construction of the Todo objects in a factory method. Each call to the .observe() method will return a Subscription object which can be stored and then used later on to cancel the captured effect from getting invoked again (similar to unsubscribing a listener). In this presentation, I will not be using the Subscription object so that I may focus on effects.

@Getter
@AllArgsConstructor
public class Todo {

    // code omitted from brevity

    public static Todo from(String title){
        Todo todo = new Todo(title);
        // observe title attribute - multiple Observer effects can be captured here
        Signals.observe(() -> System.out.printf("Task title changed to %s\n", todo.getTitle().get()));
        // observe completed attribute - multiple Observer effects can be captured here
        Signals.observe(() -> System.out.printf("Task completion changed to %s\n", todo.getCompleted().get()));
        return todo;
    }

    @Override
    public String toString() {
        // omitted for brevity
    }
}

When observing a Collection or a Map attributes, values are wrapped in the SignalCollection and SignalDictionary classes respectively because they have unique distinguishing characteristics that need to be handled differently.

In this case, the TodosList needs the todos Collection to be Observable too.

public class TodosList {

    private final SignalCollection<Todo> todos = Signals.signal(new ArrayList<>());

    public Todo add(String title){
        // using factory method to create Todo object
        Todo todo = Todo.from(title);
        todos.add(todo);
        return todo;
    }

    public void update(String id, String title){
        Todo todo = todos.value().stream().filter(t -> t.getId().toString().equals(id)).findFirst()
                .orElseThrow(() -> new RuntimeException("no task found with matching id"));
        todo.getTitle().set(title);
    }

    public void toggle(String id){
        Todo todo = todos.value().stream().filter(t -> t.getId().toString().equals(id)).findFirst()
                .orElseThrow(() -> new RuntimeException("no task found with matching id"));
        todo.getCompleted().set(!todo.getCompleted().value());
    }

    public void delete(String id){
        Todo todo = todos.value().stream().filter(t -> t.getId().toString().equals(id)).findFirst()
                .orElseThrow(() -> new RuntimeException("no task found with matching id"));
        todos.remove(todo);
    }
}

The magic sauce is in the choice of methods in the Signal object used to access the underlying Signal values. There are three categories of values that are Observable.

  1. Scalar (anything that is neither a Collection nor a Map)
  2. Collection (Lists, Sets, etc)
  3. Dictionary (Map)

For all Scalar values the .value() and .value(arg) methods are used to access and set respectively the underlying values without triggering effects. The .get() and .set(arg) methods however will register and trigger effects respectively. An effect is the behavior triggered when an Observable attribute is changed.

For Collection values, the .value() method is used to access the underlying Collection value without triggering effects. The .get(), .forEach() and .iterator() methods will register effects. The .set(arg) and .value(arg) methods don't exists since they serve no useful purpose in this case. So to trigger effects, only three method are currently instrumented for that purpose in the SignalCollection - add, addAll and remove.

For Dictionary values, the .value() method is equally used to access the underlying Map value without triggering effects. The .get(), .forEach() and .iterator() methods will register effects. The .set(arg) and .value(arg) methods don't exists since they serve no useful purpose in this case. So to trigger effects, only three method are currently instrumented for that purpose in the SignalDictionary - put, putAll and remove.

When observing a Collection or a Map, the Signals.observe() method takes different arguments to accommodate the differences in these categories of classes. In this case, the TodosList registers an effect because todos.get() method got accessed in the constructor, and the registered handler receives an event name and the affected entity as parameters. The event name represents the name of the method which triggered the effect.

public class TodosList {

    private final SignalCollection<Todo> todos = Signals.signal(new ArrayList<>());

    public TodosList() {
        Signals.observe((event, entity) -> {
            switch (event) {
                case "add", "remove" -> System.out.printf("Event: %s, entity: %s\n", event, entity);
                default -> System.out.printf("todos size: %d\n", todos.get().size());
            }
        });
    }

    // code omitted for brevity
}

To reiterate what just happened, accessing todos.get() causes the registration of an effect function to happen, and this effect will subsequently be triggered by any add() or remove() invocations on the SignalCollection.

The main class will now look vastly cleaner than the previous times.

public class Main {

    public static void main(String[] args) {
        TodosList list = new TodosList();

        // continue as normal
        Todo t1 = list.add("wake up");
        Todo t2 = list.add("make breakfast");
        Todo t3 = list.add("watch tv");

        list.update(t2.getId().toString(), "work out");
        list.toggle(t1.getId().toString());
        list.delete(t3.getId().toString());
    }
}

And the output produced will tell the whole story of what is happening when the TodosList and TodoItems are getting updated in the main method above.

todos size: 0
Task title changed to wake up
Task completion changed to false
Event: add, entity: Todo{title=wake up, completed=false, id=4b2e720e-5510-4f35-bd13-4925ff6c6f57}
Task title changed to make breakfast
Task completion changed to false
Event: add, entity: Todo{title=make breakfast, completed=false, id=8be14779-0ec9-44c4-aa94-572d2d21aac0}
Task title changed to watch tv
Task completion changed to false
Event: add, entity: Todo{title=watch tv, completed=false, id=bd665225-8dba-421c-91d6-0b6fb78f5f75}
Task title changed to work out
Task completion changed to true
Event: remove, entity: Todo{title=watch tv, completed=false, id=bd665225-8dba-421c-91d6-0b6fb78f5f75}
The source code for the above example can be viewed in this gitlab repository for convenience.

The source code to this presentation can be found in this repo. A more detailed and non-trivial example is also presented in the same repo.