DZone
Thanks for visiting DZone today,
Edit Profile
  • Manage Email Subscriptions
  • How to Post to DZone
  • Article Submission Guidelines
Sign Out View Profile
  • Post an Article
  • Manage My Drafts
Over 2 million developers have joined DZone.
Log In / Join
Refcards Trend Reports
Events Video Library
Refcards
Trend Reports

Events

View Events Video Library

Zones

Culture and Methodologies Agile Career Development Methodologies Team Management
Data Engineering AI/ML Big Data Data Databases IoT
Software Design and Architecture Cloud Architecture Containers Integration Microservices Performance Security
Coding Frameworks Java JavaScript Languages Tools
Testing, Deployment, and Maintenance Deployment DevOps and CI/CD Maintenance Monitoring and Observability Testing, Tools, and Frameworks
Culture and Methodologies
Agile Career Development Methodologies Team Management
Data Engineering
AI/ML Big Data Data Databases IoT
Software Design and Architecture
Cloud Architecture Containers Integration Microservices Performance Security
Coding
Frameworks Java JavaScript Languages Tools
Testing, Deployment, and Maintenance
Deployment DevOps and CI/CD Maintenance Monitoring and Observability Testing, Tools, and Frameworks

Modernize your data layer. Learn how to design cloud-native database architectures to meet the evolving demands of AI and GenAI workkloads.

Secure your stack and shape the future! Help dev teams across the globe navigate their software supply chain security challenges.

Releasing software shouldn't be stressful or risky. Learn how to leverage progressive delivery techniques to ensure safer deployments.

Avoid machine learning mistakes and boost model performance! Discover key ML patterns, anti-patterns, data strategies, and more.

Related

  • Java Bean Validation: Applying Constraints Programmatically
  • Advanced Brain-Computer Interfaces With Java
  • Simplifying Data Entities in Spring Data With Java Records
  • JQueue: A Library to Implement the Outbox Pattern

Trending

  • Segmentation Violation and How Rust Helps Overcome It
  • Emerging Data Architectures: The Future of Data Management
  • Setting Up Data Pipelines With Snowflake Dynamic Tables
  • Beyond Linguistics: Real-Time Domain Event Mapping with WebSocket and Spring Boot
  1. DZone
  2. Coding
  3. Java
  4. Understanding Java Signals

Understanding Java Signals

Learn the challenges of the Observer pattern in Java and how it can be improved with Signals for clearer, more reusable code, using a Todo app as an example.

By 
STEPHEN Maina user avatar
STEPHEN Maina
·
Apr. 29, 25 · Analysis
Likes (4)
Comment
Save
Tweet
Share
3.3K Views

Join the DZone community and get the full member experience.

Join For Free

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

  1. In any Todoitem, when:
    • The title is changed
    • The completion status is toggled
  2. In the TodosList, when:
    • A new item is added
    • An existing item is removed

Diving In

Here is a basic representation of the respective domain classes:

Java
 
@Data
@AllArgsConstructor
public class Todo {

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


Java
 
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 in the state of an object when the events of interest described above are triggered can be accomplished through different techniques, which I will review here. A basic solution would require explicitly implementing some kind of observation mechanism, like a listener.

Java
 
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 perform the action necessary when the events of interest are fired. Different implementations of the Listener interface would be required if different behavior is required. Below is one such implementation, which only acknowledges the event happening by printing its details to the console.

Java
 
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 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, too) 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.

Java
 
public class TodosList {

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

    public Todo add(String title){
        Todo todo = new Todo(UUID.randomUUID(), title, false);
        todos.add(todo);
        listener.onItemAdded(todo, todos);
        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);
    }

    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);
    }

    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);
    }
}


Java
 
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 may look as described above, and it 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 event data.

Java
 
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.

Java
 
public class TodosList {

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

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

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

    public Todo add(String title){
        Todo todo = new Todo(UUID.randomUUID(), title, false);
        todos.add(todo);
        listeners.forEach(l -> l.onItemAdded(todo, todos));
        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);
        listeners.forEach(l -> l.onTitleChanged(todo));
    }

    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());
        listeners.forEach(l -> l.onCompletionChanged(todo));
    }

    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);
        listeners.forEach(l -> l.onItemRemoved(todo, todos));
    }
}


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

Java
 
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 problems with this method is that the concerns of the listener (registration, deregistration and dispatching), must be manually woven into observable, which opens up a lot of opportunities for error creep and therefore requires extensive testing. More importantly, the listener implementation is very tightly coupled to the observable, and hence impossible to reuse in any other situation without major modifications.

A slightly more idiomatic approach would be to take advantage of Java's built-in Observer and Observable framework to offload much of the observing concerns, listener registration, and listener deregistration to the framework and just focus on the effects, or more explicitly, focus on the corresponding behavior after events are triggered. This method is as intrusive as the basic idea implemented above, and has actually been deprecated since Java 9, and as a result, I would not even encourage anyone to use it.

Java
 
@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();
        notifyObservers(this);
    }

    public void setCompleted(Boolean completed) {
        this.completed = completed;
        setChanged();
        notifyObservers(this);
    }
}


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

Java
 
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 other Observer would take similar modifications to the ones made in the first one.

Java
 
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 notifyObserver(obj, arg) in the Observable takes two objects as parameters makes it difficult to be expressive when using this method, because it becomes challenging to detect what attributes changed in the Observable.

Java
 
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();
        notifyObservers(todos);
        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();
        notifyObservers(this);
    }
}


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

Java
 
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 heyday, but in today's technology landscape, those glory days of the past are far behind. It's certainly an improvement over the previous approach 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 very complex, detecting what attributes changed, so that the correct routing may be dispatched to the Listener methods.

 So what else is out there that will perform the same role as Observer/Observable, but without the difficulty of use associated with the previous two approaches? Enter Signals. This is a concept which 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.

Shell
 
// https://mvnrepository.com/artifact/com.akilisha.oss/signals

# maven
<dependency>
    <groupId>com.akilisha.oss</groupId>
    <artifactId>signals</artifactId>
    <version>0.0.1</version>
</dependency>

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


Signals uses the concept of targeting attributes that need observing, and the registration of listeners is then implicitly accomplished in the construction phase by simply accessing these observed attributes. Let me illustrate, because that explanation is certainly not exhaustive. The Todo class in this case clearly shows which attributes are candidates for observation.

Java
 
@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 to complement classes instrumented with Signal attributes. Although not used in this presentation, TodoItem is nonetheless an example of such a data carrier object.

Java
 
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 the 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 here, so that I may focus on effects.

Java
 
@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;
    }
}


When observing a Collection or a Map attribute, 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.

Java
 
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.

  • Scalar (anything that is neither a Collection nor a Map)
  • Collection (Lists, Sets, etc)
  • Dictionary (Map)

Scalar

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.

Collection

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 exist since they serve no useful purpose in this case. 

So, to trigger effects, only three methods are currently instrumented for that purpose in the SignalCollection: add, addAll, and remove.

Dictionary

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 exist since they serve no useful purpose in this case. 

So, to trigger effects, only three methods 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 through the forEach() method 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 that triggered the effect.

Java
 
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
}


In the illustration above, todos.get() will register the effect, and this effect will be triggered by any add() or remove() invocations on the SignalCollection.

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

Java
 
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());
    }
}


The output produced will tell the whole story of what happens when the TodosList and TodoItems are updated in the main method above.

Shell
 
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}


Source Code

The source code for the above example can be viewed in this GitLab repository for convenience. 

Signal entity Event Java (programming language)

Opinions expressed by DZone contributors are their own.

Related

  • Java Bean Validation: Applying Constraints Programmatically
  • Advanced Brain-Computer Interfaces With Java
  • Simplifying Data Entities in Spring Data With Java Records
  • JQueue: A Library to Implement the Outbox Pattern

Partner Resources

×

Comments

The likes didn't load as expected. Please refresh the page and try again.

ABOUT US

  • About DZone
  • Support and feedback
  • Community research
  • Sitemap

ADVERTISE

  • Advertise with DZone

CONTRIBUTE ON DZONE

  • Article Submission Guidelines
  • Become a Contributor
  • Core Program
  • Visit the Writers' Zone

LEGAL

  • Terms of Service
  • Privacy Policy

CONTACT US

  • 3343 Perimeter Hill Drive
  • Suite 100
  • Nashville, TN 37211
  • [email protected]

Let's be friends: