DEV Community

Cover image for Explicit Design, Part 9. Decoupling Features with Events
Alex Bespoyasov
Alex Bespoyasov

Posted on • Originally published at bespoyasov.me

Explicit Design, Part 9. Decoupling Features with Events

Last time, we created a new feature and implemented it, exploring how to develop and refine various modules with different levels of depth depending on the requirements.

In this post, we will discuss how to make application slices more independent using events and when it may be justified.

But first, disclaimer

This is not a recommendation on how to write or not write code. I am not aiming to “show the correct way of coding” because everything depends on the specific project and its goals.

My goal in this series is to try to apply the principles from various books in a (fairly over-engineered) frontend application to understand where the scope of their applicability ends, how useful they are, and whether they pay off. You can read more about my motivation in the introduction.

Take the code examples in this series sceptically, not as a direct development guide, but as a source of ideas that may be useful to you.

By the way, all the source code for this series is available on GitHub. Give these repo a star if you like the series!

Vertical Slices Reviewed

Earlier, we mentioned that slices in the application help to scope bounded contexts and represent features as independent parts of the application.

For small applications, the benefits of such separation may not be particularly noticeable and may not outweigh the costs of “slicing” the app. However, for larger projects, this can be useful, especially for micro frontends, where each feature can be a separate application written in a different technology stack.

Of course, it is possible to link features through direct calls in micro frontends, but this greatly limits the freedom of development and undermines the meaning of micro frontends. (Teams will have to synchronize work and wait for each other to publish significant changes in the project.)

Instead of direct calls in such projects, it is more convenient to organize communication between features through events.

Coupling via Adapters

Last time, we connected features to each other through an adapter for the converter. In simple cases, this may be enough, but let's agree that our application is going to grow.

If, in addition to notes, the converter needs to notify other features about refreshed rates, the number of adapters can increase uncontrollably:

With each new feature, the converter has to add a new adapter and update the use case to send a signal to each of them

Each of the signals to other features is a point of coupling and a potential failure point. If we want to avoid direct coupling between features, we should not “notify each” feature by hand, but “send a signal” to the outside world that the rates have been updated. Following this principle, in response to this event, other features will decide for themselves which of them and how to react to this event.

Use case sends only one signal to the external world, to which other features react autonomously

In the second case, the point of contact between the use case and the external world is only one, and no matter how many other reactions to this event we need to add, we will add them separately “somewhere outside”. This way of organizing is sometimes called event-driven, and the place where events are sent is called an message bus.

Message Bus

Let's try to apply the idea of a message bus to our application. Instead of directly calling another feature, the converter will publish an event indicating that the rates have been updated and pass the values of these rates:

// core/ports.output
type PublishRefreshed = (rates: ExchangeRates) => void;
Enter fullscreen mode Exit fullscreen mode

These messages will be sent to a “hub”, which will forward these messages to all interested parts of the application. “Interest” will be expressed by modules subscribing to specific events that they need to process. We will call this “hub” a message bus.

Message bus, message queue, message broker, pub-sub model—each term has its own distinctive features, and in some aspects, they intersect. We won't go into detail about the differences between them, but I will leave useful links on the topic.

Our bus will provide 3 interfaces: for publishing events, subscribing to them, and unsubscribing. These interfaces will be shared among all modules, so we will place them in the Shared Kernel:

// shared/kernel

export type PublishEvent = (event: InternalEvent, data: EventPayload) => void;
export type SubscribeTo = (event: InternalEvent, handler: EventHandler) => void;
export type Unsubscribe = (event: InternalEvent, handler: EventHandler) => void;
Enter fullscreen mode Exit fullscreen mode

Also, in the Shared Kernel, we will describe the types of events that can occur in different parts of the application:

// shared/kernel

type ConverterEvent = 'RatesRefreshed';

// Maybe in the future, we'll also have:
// type NotesEvent = "NoteCreated"
// type UserEvent = "SessionExpired"

export type InternalEvent = ConverterEvent; /* | NotesEvent | UserEvent | etc */
Enter fullscreen mode Exit fullscreen mode

We can use Shared Kernel because events (and the bus interface) will be used in different parts of the application anyway. We don't create any “extra” coupling between modules, except for what is necessary for modules to “communicate.” For more information on what Shared Kernel is and why it is used, see this post.

The implementation can vary widely depending on the requirements. For our application, we will take a small library that will do almost everything for us:

// shared/infrastructure/bus

import mitt from 'mitt';

const emitter = mitt<Record<InternalEvent, Optional<string>>>();

export const publishEvent: PublishEvent = emitter.emit;
export const subscribeTo: SubscribeTo = emitter.on;
Enter fullscreen mode Exit fullscreen mode

Generally, the choice of library will strongly depend on requirements and conditions of use. For example, in distributed systems (such as micro frontends), it is important that the message is guaranteed to be delivered to all subscribers, and only once.

In our case, the application is “monolithic,” and delivery will mainly be synchronous, so we can afford a simple in-memory bus.

Decoupling Features

We use our message bus to decouple features. In the use case of the converter, the first thing we do is replace the adapter with event publishing:

// converter/refreshRates

type Dependencies = {
    // ...
    publishRefreshed: PublishRefreshed;
};

export const createRefreshRates =
    ({ publishRefreshed /* ... */ }: Dependencies): RefreshRates =>
    async () => {
        // ...
        publishRefreshed(rates);
    };
Enter fullscreen mode Exit fullscreen mode

Let's rewrite the adapter so that it doesn't call a specific feature, but triggers the publish method on the bus:

// converter/infrastructure/bus

import type { PublishEvent } from '~/shared/kernel';
import type { PublishRefreshed } from '../../core/ports.output';

export const createPublisher =
    (publish: PublishEvent): PublishRefreshed =>
    (rates) => {
        const noteContent = JSON.stringify(rates, null, 2);
        publish('RatesRefreshed', noteContent);
    };

// converter/infrastructure/bus.composition

export const publishRefreshed: PublishRefreshed = createPublisher(publishEvent);
Enter fullscreen mode Exit fullscreen mode

Then, we'll register the publisher:

// converter/refreshRates.composition

import { publishRefreshed } from '../../infrastructure/bus';

export const refreshRates: RefreshRates = withAnalytics(
    createRefreshRates({
        fetchRates,
        readConverter,
        saveConverter,
        publishRefreshed
    })
);
Enter fullscreen mode Exit fullscreen mode

...And update tests:

// converter/refreshRates.test

// ...
const publishRefreshed = vi.fn();
const refreshRates = createRefreshRates({ publishRefreshed /*...*/ });

describe('when called', () => {
    // ...

    it('calls a message bus with the rates refreshed event', async () => {
        await refreshRates();
        expect(publishRefreshed).toHaveBeenCalledWith(rates);
    });
});
Enter fullscreen mode Exit fullscreen mode

At first glance, nothing much has changed, but now, instead of changing the refreshRates use case code to notify the third feature about the update, we can allow the third feature to subscribe to the event itself, if it needs to.

The use case also does not need to know about the format in which other features want to work with the data. The event format is the same throughout the application, so it is enough to publish the event with the necessary data, and each subscriber will decide for itself how to convert this data into the format it needs for its work.

Subscription to Events

Inside the notes feature, we will create a mechanism for subscribing to an event:

// notes/infrastructure/bus

import { subscribeTo, unsubscribeFrom } from '~/shared/infrastructure/bus';
import { createNote } from '../../core/createNote';

const subscribe = () => subscribeTo('RatesRefreshed', createNote);
const unsubscribe = () => unsubscribeFrom('RatesRefreshed', createNote);
Enter fullscreen mode Exit fullscreen mode

In general, this could be a separate use case, but for simplicity we will skip this step. Let's subscribe to the event when the component is mounted, for example:

export const useBus = () => {
    useEffect(() => {
        subscribe();
        return unsubscribe;
    }, []);
};
Enter fullscreen mode Exit fullscreen mode

...And initialize the subscription:

export function Notes() {
    useBus();
    // return ...
}
Enter fullscreen mode Exit fullscreen mode

Using hooks is not necessary again because subscribing to the event bus is not dependent on the framework and generally not dependent on the UI.

But isn't it... Redux? 🤨

The working principle of our message bus is suspiciously similar to Redux. If we don't delve into the details, the mental model almost coincides: events are actions, the bus is the store, subscriptions are well... subscriptions.

The difference here is probably in who initiates the subscription: in Redux, the entry point is the global store, while in our example, features themselves decide when and what to subscribe to. Events only come to those parts of the code that have decided to follow them.

Actions are also slightly different from events. Events describe what has already happened, while actions often say what should happen. But in general, the approaches are indeed similar.

By the way, I have even seen implementations of event buses made with Redux. I wouldn't recommend pulling in the entire RTK just for a pub-sub pattern, as it seems excessive, but if the application is already using Redux, you could, in principle, create a separate store and use it as a “message bus.”

However, not only Redux, but any tool that helps to establish “more or less decoupled” communication between modules is similar to a “message bus.” If you want to, you can reduce to an event-based model even atomic stores or observers.

The essence of the idea is in low coupling and communication through contracts (events, messages, actions, etc.), so “borrowing” ideas from such tools comes naturally 🙃

Events and DDD

In DDD, events play an even more important role. They help coordinate the work of different bounded contexts and serve as the result of business workflows.

In the use case of the converter, we did something similar. We published the RatesRefreshed event based on the results of the user scenario:

Handle Command:       Perform Use Case:        Publish Event:
RefreshButtonClick -> [ FetchRates        ] -> RatesRefreshed
                      [ -> ReadConverter  ]
                      [ -> LookupRate     ]
                      [ -> CalculateQuote ]
                      [ -> SaveConverter  ]
Enter fullscreen mode Exit fullscreen mode

This approach helps to think about workflows in the application as a transformation of commands into events.

DDD in its “canonical” form is not always necessary, but the idea of a proper separation of subdomains, representing processes as sequences of transformations and events, can help in designing an application.

Next Time

In this series, we looked at how to write an application using various principles from books and discussed their benefits and usefulness. In the next post, we will summarize everything we've done, create a list of topics we haven't covered yet, and make a plan for continuing the series some time in the future.

Sources and References

Links to books, articles, and other materials I mentioned in this post.

Architecture and Micro Frontends

Messaging and Messaging Patterns

DDD and “Dogmatism”

Other Topics

P.S. This post was originally published at bespoyasov.me. Subscribe to my blog to read posts like this earlier!

Top comments (0)