Get Started Free
‹ Back to courses
course: Practical Event Modeling

Domain Functions: Defining an Event Model in Software

9 min
Bobby Calderwood

Bobby Calderwood

Senior Principal Architect, Executive Director

Domain Functions: Defining an Event Model in Software

Overview

Now that we've completed our Event Model by visualizing the business narrative, user experience, state changes, and data flow for our business process, it's time to begin implementing the system that the event model describes.

Resources

Architecture for Dummies Functional Event Sourcing Decider

Use the promo code EVNTMODELING101 to get $25 of free Confluent Cloud usage

Be the first to get updates and new content

We will only share developer content and updates, including notifications when new content is added. We will never send you sales emails. 🙂 By subscribing, you understand we will process your personal information in accordance with our Privacy Statement.

Domain Functions: Defining an Event Model in Software

In the previous module, we completed our event model by visualizing the business narrative, user experience, state changes, and data flow for our business process. In this module, we'll begin implementing the system that the event model describes. There are many different approaches to choosing a software architecture for implementing our event model. The type of architecture that I find is the best match for implementing an event model is a domain-centric architecture named Domain Functions outlined in this diagram. Other domain-centric architectures you might be familiar with go by the names Hexagonal or Ports and Adapters or Clean or Onion. All domain-centric architectures share the goal of separating the pure business logic of the domain from the sometimes messy and side-effecting concerns of the surrounding infrastructure. In such an architecture, software components in outer layers can depend on and interact with layers closer to the center, but layers near the center cannot depend on or interact with layers further outward. What distinguishes the Domain Functions architecture from other domain-centric architectures is that the domain core exposes a functional interface comprised of three functions called Decide, React, and Evolve. We'll begin our implementation by modeling the transfer types identified in the event model as commands, events, and read models. We'll then implement our Decide, React, and Evolve Functions in terms of our domain types to fully encapsulate the state changes outlined in our model. In the next module, we'll learn how to adapt our domain core and transfer type schemas for use within the streaming data platform. For now, we'll just focus on implementing the state changes and business logic visualized in the event model. So what are transfer types and domain types and how are they different? Transfer types, sometimes called domain transfer objects and abbreviated as DTOs, are used outside of and in the application layer of our programs for information storage and transmission. As such, they should be accessible by all programming languages and run times used in your overall system. They're usually serialized and governed using schemas, but these schema constraints tend to be technically rather than business domain focused. The goal of transfer types is simplicity and efficiency. Transfer types are implemented extremely efficiently in the streaming data platform by way of Kafka's serialization/deserialization, or SerDes system, and the schema registry. All of the commands, events, and read models that populate our event model are transfer types since they describe the flow, transmission, and storage of information throughout the larger system regardless of the programming language. Domain types, on the other hand, are used inside of our domain to implement its core business logic and so are programming language-specific. They're constrained in their values and types to model the business domain rather than by technical considerations like transfer type schemas. We should be able to represent the events and read models as well as the data types comprising these model components as domain types in our programming language using domain-specific simple values like VIN or vehicle identification number as well as composite values like records, lists, sets, maps, et cetera. One powerful tool for expressing domain values as they change status during the course of the business process is algebraic data types or ADTs. For example, in our ride-sharing app Autonomo, a ride goes through several stages along its lifecycle. Requested, scheduled, in progress, canceled, and then completed. Rather than modeling this lifecycle using a status field and some data fields that may or may not be populated during the course of the ride's lifecycle, we can create a separate programmatic type for each stage. All of these types represent a ride, but are more specific. Most languages have a mechanism for representing such algebraic data types such as enumeration types, union types, or sealed interfaces. ADTs and other techniques for implementing domain types strive to make it impossible to represent invalid states in our business logic. Notice the use of algebraic data types and domain-specific simple types like VIN to represent a ride on the domain type side versus a much simpler representation for storage and transmission on the transfer type side. Within our implementation, we will need to translate back and forth between transfer types and domain types. Since the innermost domain core of our program shouldn't know anything about transfer types, the outer layer and adapter layers must know how to create a domain type from a transfer type for use in executing domain logic and a transfer type from a domain type for storage and transmission of results. The domain types must be implemented such that they cannot be constructed if the domain constraints aren't properly met. The application layer can then convey an error message as appropriate, but the program doesn't result in an invalid or corrupted state. This translation process from the transfer type to domain type is often called the anti-corruption layer in domain-driven design,. The translation in the other direction, from domain type to transfer type, is much more straightforward since we can guarantee that we have valid domain types and so our application layer can simply construct and populate the transfer type from the domain type. We'll convert domain types to transfer types both to transmit commands and read model responses and to publish events to the event stream. We'll walk through an example of this translating between transfer types and domain types in our next module when we wire up our application. For now, just being aware of this distinction is good enough. The streaming data platform supports several schema systems that automate much of the creation and use of transfer types and their serialization. Avro and Protocol Buffers, or Protobufs, both have tool chains in several languages that enable the definition of transfer types in a special interface definition language syntax, and then the generation of programmatic types for use in our applications. JSON Schema also has wide language support and can be used to validate JSON values stored or transmitted as strings. All three of these transfer type formats Avro, Protocol Buffers, and JSON Schema are supported by the Confluent Schema Registry as we'll discuss in a bit more detail in the next module. For a much more detailed exploration of schemas and the Schema Registry, I recommend Danica Fine's Schema Registry 101 course right here on developer.confluent.io. Once we've defined our types, we orchestrate the state changes in our domain as visualized in the event model using three functions. The two primary functions are called the Decide and Evolve Functions, which implement the primary business logic of a domain. The third function, react, is used only in certain cases to integrate between our domains or to chain outcomes together in a saga. The first of our domain functions is the Decide Function. The role of the Decide Function is to implement the state change slices in our event model. It does so by validating an incoming command based on the current state, both of which are inputs to this function. If the command is invalid or otherwise unacceptable, the Decide Function returns an error describing why the command was rejected. If the command is acceptable, then the Decide Function returns a sequence of events to be recorded onto an event stream. The recording of these events comprise a state change in our system. For example, here we have the ride stream's Decide Function, which calls through to the ride command's Decide Method. Here we see the implementation of the Decide Method for the request Ride Command. For this command, if the ride is currently in its initial, pre-existent state, then we'll return a list with a single Ride Requested event. If the ride is at any other stage, this command wouldn't make any sense and the Decide Function responds with an error. The Decide Function implements the state change slices identified by the event model and so must exhaustively handle all possible commands for a particular stream. So in this case, we'd also implement the same method on the Request Ride, Schedule Ride, Confirm Pickup, Cancel Ride, and Confirm Drop-off commands. The next of our domain functions is the Evolve Function. The Evolve Function implements the state view slices of a single stream of events. It takes, as input, the current state of a read model plus the next available event in the stream, incorporates that event into the read model according to the business logic, and returns the next value of the read model. For example, here is the Evolve Function for the ride stream, which calls through to the Evolve Method on the ride read model. Notice how we use different domain types to represent a ride at its various lifecycle stages. A specific lifecycle stage only needs to explicitly handle certain events. In this example, the Ride Canceled and Ride Scheduled events by returning a new model reflecting the change caused by the event. In this case, either canceling or scheduling this ride. For all other events, this model would remain unchanged and so simply returns itself. Of course, we would implement this Evolve Method appropriately on each of our specific implementations of the ride read model such as Scheduled Ride, In Progress Ride, Canceled Ride, and Completed Ride. Our final domain function is the React Function. The React Function facilitates integration among event streams. This function enables us to chain together actions into a saga by reacting to an event on a foreign stream to invoke commands on the local stream, usually for importing or translating foreign events into our local context. The input to this function is the foreign event and the output is a list of commands to issue. For example, here's the React Function that translates Ride Events that affect vehicle occupancy onto the vehicle's stream. That way, a vehicle won't get double booked. Now that we've created our transfer types and their associated schemas, our domain types, and the Decide, React, and Evolve Functions for the various streams in our model, we're ready to plug these pieces into the application and infrastructure layers of our system. Our next module covers how to use the code we've built within the various components of the streaming data platform. If you aren't already on Confluent Developer, head there now using the link in the video description to access other courses, hands-on exercises, and many other resources for continuing your learning journey.