DEV Community

loading...

Using domain events with legacy software

Pascal GARCIA
R&D engineer @ Bonitasoft
・7 min read

by @pgarcia and @baptistemesta , BonitaSoft R&D Engineers

If you are using legacy software that is mature and working well, but has a large, complex code base - and you want to add new features without adding more complexity, we have a way of doing that with Domain Driven Design, Clean Architecture, or CQRS techniques that could help you.

This article offers a step by step guide on how to add this feature on your own legacy software based on a real life example.

Context

In the case we are using as an example, we are starting with software that executes business processes. The goal is to add analytics to these business processes.

This software handles its business logic mostly through Create Read Update Delete (CRUD) operations. The goal is to produce domain events from these operations, that is, what happened in the system at a business level.

We could refactor the software but that comes at a cost. Instead, to quickly start playing with domain events, we chose to develop an extension in the existing software to produce them.

The software itself is a classic Java application, based on Spring and Hibernate, and all code samples in this article are in Kotlin.

Methodology

Here are the steps we followed.

  1. Design domain events
  2. Assess what is possible with current technical events
  3. Create domain events
  4. Publish the domain events

Design domain events

To design domain events, first, it’s necessary to understand the business needs these events are relevant to: who will use them, and how they will be used.

But, be careful, these domain events should represent what is happening in the business and not address only a single current business need.

In our case, we want to do analytics on business processes. This means that we want to have an event each time a change happens on the business process, for example:

  • when a new case of a process starts
  • when it ends
  • when a task of that process is submitted

The best way we found to design these events is to design them as you would for a greenfield development.

The goal is to create domain events that are as close as possible to the business logic.

For example, for the domain event for the "new case of a process starts," we named the event "CASE_STARTED" to contain information about the user that started that case, the data the user started the case with, and what the process related to that case is.

We strongly advise you to read about Domain Driven Design techniques. There are many articles on how to design these events and how to understand the business, and we have listed some references at the end of this article.

In addition, this phase should be done with stakeholders who know and understand the business processes.

Assess what is possible with current technical events

When you have the first version of your domain events defined, see if it might be possible to produce them from what is currently available in your legacy software.

As a second step, dive into your current codebase and list all data you will be able to extract from current technical events, already produced. This could be data from:

  • listeners already implemented in your software
  • listeners on your framework (as most frameworks provide ways to listen to changes, explore them and verify what information you can gather from them)
  • probes on your system
  • combination of a set of all other data. As a trivial example: if you have the start state of something, combined with the current date, you have the duration of that event.

The illustration below shows the simplified architecture of our software.

In our case, we can use several extension points:

  • Transaction synchronization
  • Event handlers
  • Hibernate event listeners

In the next section, we’ll explain more how these can be leveraged for our needs.

alt_text

Adapt the design in collaboration with the stakeholders

The original design of events might not be possible with your current architecture. In that case you might need to ask few questions:

  • Can you find a smaller functional scope for your events in that first iteration?
  • Is it possible to keep your original design by using post processing?
  • Can you identify what needs to change in the software to have the full scope of functionality?

You might now have a first version for your domain event, along with a backlog of improvements for the software and your events.

For example, in our case, we were not able to link the update on what we call BusinessData to the Start of a case. What we decided was to simply record both events separately, and in a future iteration add post-processing to combine them..

Check that the events follow some important rules

A critical point to keep in mind is that events should follow a set of rules to avoid common pitfalls. Events should be:

  • Immutable
  • Unique: you’ll need to generate a unique identifier.
  • Set in the past: all events should be named in the past tense
  • Have a timestamp
  • Have a context which will help identify the scope of that event

The book Implementing Domain-Driven Design[1] details in great depth what rules events should follow.

How to create domain events

After going through the design process, we ended up with the following event types:

CASE_STARTED, CASE_COMPLETED, TASK_STARTED, TASK_ASSIGNED, TASK_UNASSIGNED, TASK_SUSPENDED, TASK_EXECUTED, TASK_COMPLETED, CONNECTOR_STARTED, CONNECTOR_COMPLETED, BDM_INSERTED, BDM_UPDATED
Enter fullscreen mode Exit fullscreen mode

and the following event structure:

{
 "id": "6c3b3501-f454-4eb3-be55-63176f47e767",
 "tenantId": 1,
 "name": "CASE_STARTED",
 "bpmObjectType": "CASE",
 "bpmObjectName": "LoanRequest",
 "caseExecutorId": 1,
 "contractData": {
   "amount": 100000,
   "type": "house"
 },
 "businessData": {
   "loanData": {
     "ids": [
       84
     ],
     "dataClassName": "com.company.loan.Loan"
   }
 },
 "timestamp": 1582712293203,
 "context": {
   "processId": 7564421046497327000,
   "caseId": 1052,
   "rootCaseId": 1052,
   "executorId": 1
 }
}

Enter fullscreen mode Exit fullscreen mode

Once we have our domain events correctly defined, we can start creating them. We will describe more technical aspects of what we did there.

Data sources at our disposal

In our case, we had two kinds of data sources we could use. One was home-made listeners on CRUD operations on most of our data. These listeners produced custom technical events.

The other data source was the Hibernate entity manager on which you can register: org.hibernate.event.spi.PostUpdateEventListenerandorg.hibernate.event.spi.PostInsertEventListener

These listeners are registered on the Hibernate session factory, and are called when an entity is updated or inserted.

Below is an example of registration for these handlers:

val registry = sessionFactory.serviceRegistry.getService(EventListenerRegistry::class.java)
registry.appendListeners(EventType.POST_INSERT, bdmHibernateInsertListener)
registry.appendListeners(EventType.POST_UPDATE, bdmHibernateUpdateListener)

Enter fullscreen mode Exit fullscreen mode

For more information on Hibernate listeners, see https://vladmihalcea.com/hibernate-event-listeners/

Process technical events

We want to combine information from our home-made event (captured by the BonitaEventHandler) and Hibernate events (captured by PostUpdateEventListeners) into the domain events.

The following schema summarizes that flow:

alt_text

We are working in a transactional environment. This means that all actions will really be applied only at the end of the transaction. The choice we made was to register a javax.transaction.Synchronization and to gather all technical events during the execution of the transaction, and publish them at the commit of the transaction.

Here is how we register a transaction synchronization on the transaction manager:

private val bonitaEventSynchronizations: ThreadLocal<BonitaEventSynchronization> = ThreadLocal()


   private fun getSynchro(): BonitaEventSynchronization {
       return bonitaEventSynchronizations.getOrSet {
           val bonitaEventSynchronization = BonitaEventSynchronization(bonitaEventSynchronizations, bonitaEventProcessor, domainEventPublisher)
           txManager.getTransaction().registerSynchronization(bonitaEventSynchronization);
       }
   }

Enter fullscreen mode Exit fullscreen mode

When the transaction is completed, we combine these technical events into one or more domain events.

The goal here is to gather enough information, from technical events (BonitaEventTypes), to avoid having to actively retrieve missing information. This will ensure that producing these new events will have a minimal impact on performance.

We have a set of DomainEventProcessor, each one producing a single type of domain event.

interface DomainEventProcessor {
   fun createDomainEvent(events: MutableList<SEvent>): List<DomainEvent>
}
Enter fullscreen mode Exit fullscreen mode

For example, the domain event processor below is in charge of publishing the TASK_STARTEDdomain event:

class TaskStartedProcessor : DomainEventProcessor {

   companion object {
       val eventTypes = setOf(
               ACTIVITYINSTANCE_CREATED.name,
               EVENT_INSTANCE_CREATED.name,
               GATEWAYINSTANCE_CREATED.name)
   }

   override fun createDomainEvent(events: MutableList<SEvent>): List<DomainEvent> {
       return events.stream().filter { eventTypes.contains(it.type) }.map {
           val activity = it.`object` as SFlowNodeInstance
           val taskStartedEvent = TaskDomainEvent(UUID.randomUUID(), activity.tenantId, TASK_STARTED, activity.toBPMObjectType(), activity.name)
           taskStartedEvent.timestamp = activity.lastUpdateDate
           taskStartedEvent.context = activity.toContext()
           return@map taskStartedEvent;
       }.toList()
   }
}

Enter fullscreen mode Exit fullscreen mode

How to publish domain events

alt_text

Once all domain events are created, we send it to a domain event publisher (DomainEventPublisher). The publisher should use its own thread to ensure minimal impact on the rest of the software execution.

private val executor: ExecutorService = Executors.newSingleThreadExecutor(CustomizableThreadFactory("pulsar-events"))

Enter fullscreen mode Exit fullscreen mode

Each time the transaction completes, these events are pushed as below.

executor.submit {
   producer?.send(domainEvent)
}
Enter fullscreen mode Exit fullscreen mode

In our case, we publish these events to the broker Apache Pulsar. When the transaction is committed, the Pulsar client handles the serialization of domain events to a JSON format and sends them to the Pulsar broker.

Finally, by configuring a connector in Pulsar, we can publish wherever we want. In our case we publish to an Elastic search server (see https://pulsar.apache.org/docs/en/io-elasticsearch/).

Conclusion

Through this article we showed how it is possible to publish domain events from an existing software without modifying it (too much). Our methodology can serve as a basic guide on which you can start experimenting. That's what we did with our business process software.

The approach we took gave us a technical solution to our business need - which was to have domain events published to a data store that is easy to query in order to do data analytics on it.

As this approach is easy and quick to implement, it's also a great opportunity to try out domain events and validate if they are well designed before implementing them directly in the core of the software.

At a low entry cost, it opens new possibilities of creating values with less coupling from the legacy software. All we have to do now is subscribe to these events.

In our case we were able to experiment with doing process analytics from these events, satisfying the original business requirement.

References

Discussion (0)