Development Process
Building a solution based on Spine Event Engine framework is an iterative process which consists of the stages described in this document.
Getting domain knowledge
The purpose of this step is to find out what we’re going to build and why. Consider using EventStorming or another domain discovery approach for grasping the knowledge from the experts.
Most likely that the solution would have several Bounded Contexts. For each context developers need to define:
- Signals
- Entities
It is likely that some of the bits of this picture would change during the process. But the whole team, including domain experts, need to have complete understanding of how the business works to avoid “surprises” down the road.
We return to learning the domain when we discover inconsistencies in the model, or we need more information about how the business works, or the business wants to develop further and we need to update the model.
Once we got enough domain knowledge we proceed to the implementation.
Implementing a Bounded Context
At this stage we select one of the Bounded Contexts for the implementation. Each context is developed separately. In this sense it can be seen as a microservice. It would be natural to start implementing the context which initiates the business flow.
Defining data types
Implementation starts from defining data types of the selected context as Protobuf messages.
The first step is to define entity IDs. For example:
// The identifier for a task.
message TaskId {
string uuid = 1;
}
Then commands, events, rejections are defined:
// A command to create a new task.
message CreateTask {
TaskId id = 1;
string name = 2 [(required) = true];
string description = 3;
}
// A new task has been created.
message TaskCreated {
TaskId task = 1;
string name = 2 [(required) = true];
string description = 3;
UserId who_created = 4 [(required) = true];
}
Then we define states of entities.
message Task {
option (entity).kind = AGGREGATE;
TaskId id = 1;
string name = 2 [(required) = true];
string description = 3;
UserId owner = 4 [(required) = true];
DeveloperId assignee = 5;
}
Value Objects are added when they are needed to describe entities or messages like commands or events.
Adding business logic
The business logic of a Bounded Context is based on Entities.
They handle messages updating the state in response. Entities like Aggregate
s and
ProcessManager
s can generate events. ProcessManager
s can also generate new commands.
Projection
s only consume events.
Updating the state of the domain model in response to messages and generating new messages is the “life” of the domain model. Messages are delivered to entities by Repositories.
Entities
During this step we create entity classes and add message handling methods to them.
Code snippets below show Aggregate
and Projection
classes with their handler methods.
final class TaskAggregate
extends Aggregate<TaskId, Task, Task.Builder> {
@Assign
TaskCreated handle(CreateTask cmd, CommandContext ctx) {
return TaskCreated
.newBuilder()
.setTask(cmd.getId())
.setName(cmd.getName())
.setDescription(cmd.getDescription())
.setWhoCreated(ctx.getActor())
.vBuild();
}
@Apply
void event(TaskCreated e) {
builder().setName(e.getName())
.setDescription(e.getDescription())
.setOwner(e.getWhoCreated());
}
}
final class TaskItemProjection
extends Projection<TaskId, TaskItem, TaskItem.Builder> {
@Subscribe
void on(TaskCreated e) {
builder().setTask(e.getTask())
.setName(e.getName())
}
@Subscribe
void on(TaskCompleted e, EventContext ctx) {
builder().setWhenDone(ctx.getTimestamp());
}
}
Repositories
The framework provides default implementations for repositories.
A custom Repository
class may be needed for:
- Dispatching messages to entities in a non-standard way. By default, a command is dispatched using the first field of the command message as an ID of the target entity. An event is dispatched by the ID of the entity which emitted the event.
- Domain-specific operations on entities of this kind.
Repositories are added to the Bounded Context they belong when it is created:
BoundedContext tasksContext = BoundedContext.multiTenant("Tasks")
.add(TaskAggregate.class) // use default repository impl.
.add(new TaskItemProjectionRepository())
.build();
This wires repositories into the message delivery mechanism of the corresponding Buses.
Testing
Implementation of the Bounded Context is tested using the messaging paradigm.
The following code snippet asserts that handling a command CreateTask
produces one
TaskCreated
event with expected arguments.
// Given
BlackBoxBoundedContext context = BlackBoxBoundedContext.from(tasksContext);
// When
context.receivesCommand(createTask());
// Then
TaskCreated expected = TaskCreated.newBuilder()
.setTask(id)
.setName(name)
.build();
EventSubject assertEvents =
context.assertEvents()
.withType(TaskCreated.class)
assertEvents.hasSize(1);
assertEvents.message(0)
.comparingExpectedFieldsOnly()
.isEqualTo(expected);
Modification of entities is also tested. The following code snippet asserts that the state
of the TaskAggregate
was also updated with expected arguments.
EntitySubject assertEntity =
context.assertEntityWithState(Task.class, id);
Task expectedState = Task.newBuilder()
.setId(id)
.setName(name)
.build();
assertEntity.hasStateThat()
.comparingExpectedFieldsOnly()
.isEqualTo(expectedState);
Deployment
Configuring Server Environment
For information on configuring server environment of a Spine-based application, please
see the reference documentation of the ServerEnvironment
class.
Assembling Application
The server-side application is composed with its Bounded Contexts.
Server server = Server.atPort(portNumber)
.add(tasksContext)
.add(usersContext)
.add(commentsContext)
.build();
server.start();
This exposes CommandService
,
QueryService
, and
SubscriptionService
to client-side connections.
Repeating the cycle
The stages described above are repeated as another Bounded Context is added to the implementation, or as some changes or extensions to the existing contexts are required.
Client application development
Development of client applications may start after the data types are defined. Once this is done, developers run the Spine Model Compiler to generate the code for all supported client platforms.
Since entity classes like Projection
use composition with state types, you don’t have to deal
with DTOs. You can start writing the code which subscribes or queries these types right after
they are defied in the .proto
files, and the Model Compiler finishes its job.
Client applications can also subscribe to events generated by Bounded Contexts at the backend.
For more information on the client-side development, please refer to the Client Libraries section.