Events in distributed architecture: Are you sure you know what you're sending?
Events - those small but powerful information packets that are the lifeblood of many modern distributed systems. Understanding what an event really is and what it is used for can save us from many sleepless nights and considerable refactoring. Let's forget about generalities for a moment and take a look at the specific types of events that we most often encounter in our systems.
Event versus command: A quick reminder
Let's start with the basics so that we all have the same frame of reference. We often confuse events with commands, and this is a mistake that can lead to architectural chaos.
A command is an order that you send with the expectation that it will be executed by a specific recipient. For example: PerformPaymentCommand.new(amount: 100, user_id: 123). An event, on the other hand, is information about a fact that has already happened. We do not expect a specific action from a single recipient; we simply announce to the world that something has happened. There can be many recipients, and each of them may react differently. For example: PaymentPerformedEvent.new(payment_id: “xyz123”, status: “completed”).
Why do we need typology?
You're probably wondering why we need another classification. Isn't it enough to just send events? Well, no! In a distributed architecture, where each microservice is an independent entity, proper event differentiation allows us to consciously manage dependencies and scaling. Martin Fowler, a guru in the world of architecture, proposed a sensible division that has become a point of reference for many of us.
Domain event: Your private matter
The first type is Domain Event. As the name suggests, it concerns changes within a single, limited context (Bounded Context), i.e., basically within your microservice. It is an internal message that says, “Hey, something has changed in our little world!” It is not intended for external communication. It is used to record status changes (e.g., in Event Sourcing) or to coordinate internal processes within a single service.
Imagine you have a project management service. When the status of a project changes from “open” to “on hold,” you generate a ProjectStatusChangedEvent within that service.
# domain event example
class ProjectStatusChangedEvent
attr_reader :project_id, :old_status, :new_status
def initialize(project_id:, old_status:, new_status:)
@project_id = project_id
@old_status = old_status
@new_status = new_status
end
# This event is typically handled by internal domain logic
def handle_internal_logic
puts "Internal logic for project #{project_id}: Status changed from #{old_status} to #{new_status}"
# e.g., update an internal audit log, trigger other internal domain events
end
end
# then inside the project management service:
event = ProjectStatusChangedEvent.new(project_id: "proj-123", old_status: "open", new_status: "paused")
event.handle_internal_logicThis event is fully controlled by your context. Changing its structure does not affect other services because they simply cannot see it.
Event Carried State Transfer (ECST): The entire state on a plate
Event Carried State Transfer (ECST) is an integration event. This means that it leaves your Bounded Context and goes to others. Its purpose is to transfer the entire state of an object to the consumer. Instead of just sending the information that “something has changed”, you send a snapshot of the object after the change.
When is this useful? Mainly in data replication scenarios, e.g., for building read models in CQRS (Command Query Responsibility Segregation) architecture or creating local caches. If you have a panel that displays project details and you want that data to always be up to date, even when the main project service is temporarily unavailable, ECST is a good choice.
- Pros: Independence from the temporary unavailability of the producer.
- Con: Increased coupling. Changing the structure of an object in ECST often requires updates for all consumers.
Notification Event: Minimalism and action
Notification Event is also an integration event, but of a completely different nature. It contains a minimum of information – usually just enough for another service to know that something has happened and to be able to react. The goal is to trigger a specific action on the consumer side, not to replicate data.
Example: A project service changes the status of a project to “on hold.” The finance module must respond by freezing the budget. It does not need the entire project status – it only needs to know that the project has been put on hold.
We focus on minimizing dependencies. The consumer does not need to know anything about the internal structure of the project, only that it has to perform a specific action.
Claim Check Pattern: A smart solution for big data
What if you have very large data to transfer, or sensitive data that you don't want to push through a message broker? That's where the Claim Check Pattern comes in handy. Instead of sending the entire payload in an event, you only send an identifier (claim check) to the resource, and the consumer, after receiving the event, fetches the full data from a secure API.
# Example of Claim Check Pattern
# instead of sending whole data, we send only the identifier of the data
class HugeProjectUpdatedNotification
attr_reader :project_id, :version, :api_endpoint # reference to data, and minimal context
def initialize(project_id:, version:, api_endpoint:)
@project_id = project_id
@version = version
@api_endpoint = api_endpoint
end
def publish_to_message_broker
payload = {
project_id: @project_id,
version: @version,
data_source: @api_endpoint # URL to fetch the full data
}
puts "Publishing Claim Check Notification: #{payload.inspect}"
# message_broker.publish("large_project_updates", payload.to_json)
end
end
# in service project after updating project
api_for_project_data = "https://api.mycompany.com/projects/proj-123/full-details"
event = HugeProjectUpdatedNotification.new(project_id: "proj-123", version: 5, api_endpoint: api_for_project_data)
event.publish_to_message_broker
# in consumer when getting HugeProjectUpdatedNotification:
# require 'net/http'
# uri = URI(event.api_endpoint)
# response = Net::HTTP.get_response(uri)
# full_project_data = JSON.parse(response.body) if response.is_a?(Net::HTTPSuccess)
# puts "Consumer fetched full project data: #{full_project_data.inspect}"This is an elegant solution that allows for effective resource and security management.
In summary
A conscious approach to event types is not just a matter of theory, but above all of practice. Distinguishing between Domain Events, ECSTs, and Notification Events allows you to design systems that are more flexible, less coupled, and easier to maintain. Remember that every integration event is a contract—and every contract requires attention. Versioning them is essential so that you don't wake up with your hand in the potty when you change one field and five other services stop working.
I hope this overview has helped you organize your knowledge about events and inspired you to think more deeply about what and how your systems communicate.
Happy emitting events!