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.
- Design domain events
- Assess what is possible with current technical events
- Create domain events
- 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.
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
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
}
}
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.PostUpdateEventListener
andorg.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)
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:
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);
}
}
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>
}
For example, the domain event processor below is in charge of publishing the TASK_STARTED
domain 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()
}
}
How to publish domain events
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"))
Each time the transaction completes, these events are pushed as below.
executor.submit {
producer?.send(domainEvent)
}
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
- [1] Implementing Domain-Driven Design: ISBN-13 : 978-0321834577
- [2] Domain-Driven Design: Tackling Complexity in the Heart of Software: \ ISBN-13 : 978-0321125217
- [3] https://docs.microsoft.com/en-us/dotnet/architecture/microservices/microservice-ddd-cqrs-patterns/domain-events-design-implementation
- [4] https://www.martinfowler.com/eaaDev/DomainEvent.html
- [5] https://vladmihalcea.com/hibernate-event-listeners/
Top comments (0)