Introduction
This article is a continuation of "[Event Sourcing] Trying out Sekiban DCB: Introduction". In the previous post, we covered setting up the DCB Native environment and its processing flow.
Initially, features like creating a Student, fetching individual and list data, and enrolling/dropping classes are already implemented. The student's state is built from recorded events. The information a student holds includes Student ID, Name, Max Class Count, and a list of currently enrolled classes.
In this post, we will add a feature to update a student's name and their maximum class limit. The Student ID and enrolled classes will remain unchanged. Decreasing the max limit below the current number of enrolled classes will be prohibited.
When updating, we don't directly overwrite the current data. Instead, we record a StudentProfileUpdated event. A Projector then processes this event to build the updated student state.
Update Feature Specifications
| Item | Specification |
|---|---|
StudentId |
Specifies the student to update. It is not changed. |
Name |
Required, 1 to 100 characters |
MaxClassCount |
1 to 10. Changing it to a value lower than the current enrollment count is not allowed. |
| Enrollment list | Keeps the contents before the update. |
| Non-existing student | The update is rejected. |
The API receives JSON containing the student ID.
POST /api/students/update
Content-Type: application/json
{
"studentId": "9912a519-a6f6-41fe-92bf-07b99045efff",
"name": "John",
"maxClassCount": 3
}
We will add the implementation in the order of Event, Decider, Command, Projector, and API.
The sample code is available on GitHub.
1. Add an Event That Represents the Update
We define the fact that the student information was updated as StudentProfileUpdated event.
using Dcb.ImmutableModels.Tags;
using Sekiban.Dcb.Events;
namespace Dcb.ImmutableModels.Events.Student;
public record StudentProfileUpdated(
Guid StudentId,
string Name,
int MaxClassCount) : IEventPayload
{
public EventPayloadWithTags GetEventWithTags() =>
new(this, new StudentTag(StudentId));
}
The event records the target student ID, the updated name, and the updated maximum number of classes. By adding StudentTag, this event is associated with the target student.
The enrollment list is not included in the event because it is not part of this update.
2. Implement Business Rules and State Updates in Deciders
In Deciders, we implement validation based on the current state and the processing that applies the event to the state.
using Dcb.ImmutableModels.Events.Student;
namespace Dcb.ImmutableModels.States.Student.Deciders;
public static class StudentProfileUpdatedDecider
{
public static void Validate(StudentState state, int maxClassCount)
{
if (maxClassCount < state.EnrolledClassRoomIds.Count)
{
throw new ApplicationException(
"MaxClassCount cannot be less than the current enrollment count.");
}
}
public static StudentState Evolve(
this StudentState state,
StudentProfileUpdated updated) =>
state with
{
Name = updated.Name,
MaxClassCount = updated.MaxClassCount
};
}
In Validate, the updated maximum class count is compared with the current number of enrolled classes. For example, if a student is enrolled in three classes, the maximum class count cannot be changed to two.
In Evolve, the state with the contents of the event applied is returned. Only the name and maximum class count can be changed, while the student ID and enrollment list are kept unchanged.
3. Add a Command to Handle Update Requests
UpdateStudent is responsible for defining the input values and handling the processing from retrieving the state to generating the event.
using System.ComponentModel.DataAnnotations;
using Dcb.ImmutableModels.Events.Student;
using Dcb.ImmutableModels.States.Student;
using Dcb.ImmutableModels.States.Student.Deciders;
using Dcb.ImmutableModels.Tags;
using Sekiban.Dcb.Commands;
using Sekiban.Dcb.Events;
namespace Dcb.EventSource.Student;
public record UpdateStudent : ICommandWithHandler<UpdateStudent>
{
public Guid StudentId { get; init; }
[Required(ErrorMessage = "Name is required")]
[StringLength(
100,
MinimumLength = 1,
ErrorMessage = "Name must be between 1 and 100 characters")]
public string Name { get; init; }
[Range(1, 10,
ErrorMessage = "MaxClassCount must be between 1 and 10")]
public int MaxClassCount { get; init; }
public UpdateStudent(
Guid studentId,
string name,
int maxClassCount)
{
StudentId = studentId;
Name = name;
MaxClassCount = maxClassCount;
}
public static async Task<EventOrNone> HandleAsync(
UpdateStudent command,
ICommandContext context)
{
var tag = new StudentTag(command.StudentId);
var tagState =
await context.GetStateAsync<StudentProjector>(tag);
if (tagState.Payload is not StudentState state)
{
throw new ApplicationException("Student does not exist.");
}
StudentProfileUpdatedDecider.Validate(
state,
command.MaxClassCount);
return new StudentProfileUpdated(
command.StudentId,
command.Name,
command.MaxClassCount)
.GetEventWithTags();
}
}
| Validation | Implementation location |
|---|---|
| Name is required and has a valid length | Command attributes |
| Maximum class count is between 1 and 10 | Command attributes |
| The student to update exists | Command handler |
| Maximum class count is greater than or equal to the current enrollment count | Decider |
After Sekiban validates the Command attributes, the handler retrieves the current state of the student. If the business rule validation passes, it generates and returns StudentProfileUpdated.
Sekiban handles event persistence, so there is no need to write database persistence logic in the handler.
4. Add Update Event Handling to the Projector
Saving the event alone does not reflect the update in the student state. We add handling for the update event and Evolve to the Projector that builds the state.
For StudentProjector, which handles the state of a single student, add the following branch.
(StudentState state, StudentProfileUpdated updated)
=> state.Evolve(updated),
The processing for each event becomes as follows.
public static ITagStatePayload Project(
ITagStatePayload current,
Event ev) =>
(current, ev.Payload) switch
{
(EmptyTagStatePayload, StudentCreated created)
=> StudentCreatedDecider.Create(created),
(StudentState state, StudentProfileUpdated updated)
=> state.Evolve(updated),
(StudentState state, StudentEnrolledInClassRoom enrolled)
=> state.Evolve(enrolled),
(StudentState state, StudentDroppedFromClassRoom dropped)
=> state.Evolve(dropped),
_ => current
};
We also add handling for the same event to StudentListProjection, which builds the student list.
var newState = ev.Payload switch
{
StudentCreated created
=> StudentCreatedDecider.Create(created),
StudentProfileUpdated updated
=> currentState.Evolve(updated),
StudentEnrolledInClassRoom enrolled
=> currentState.Evolve(enrolled),
StudentDroppedFromClassRoom dropped
=> currentState.Evolve(dropped),
_ => currentState
};
With this, the updated name and maximum class count are reflected in both the state of a single student and the student list.
5. Add the Update API
Add an endpoint to StudentEndpoints that receives the update Command.
group.MapPost("/update", UpdateStudentAsync)
.WithName("UpdateStudent");
private static async Task<IResult> UpdateStudentAsync(
[FromBody] UpdateStudent command,
[FromServices] ISekibanExecutor executor)
{
var execution = await executor.ExecuteAsync(command);
return Results.Ok(new
{
studentId = command.StudentId,
eventId = execution.EventId,
sortableUniqueId = execution.SortableUniqueId,
message = "Student updated successfully"
});
}
The API directly receives UpdateStudent and asks Sekiban to execute it. The check for whether the student exists and the validation of the maximum class count are handled through the Command and Decider processing.
The flow from the update request to event persistence is as follows.
POST /api/students/update
↓
UpdateStudent
↓
Validate input values
↓
Retrieve the current student state
↓
Check the existence of the student
and the consistency of the maximum class count
↓
Generate StudentProfileUpdated
↓
Sekiban stores the event
When building or updating the state from the stored event, the Projector calls the corresponding Evolve.
Events Stored in PostgreSQL
Let's check the events actually stored in the database. In dcb_events, the following creation event and update event are stored. The data below was retrieved in ascending order of SortableUniqueId.
[
{
"ServiceId": "default",
"Id": "01a0840a-df7f-7c43-9665-0564c61c7718",
"SortableUniqueId": "063924518500223460000021724853",
"EventType": "StudentCreated",
"Payload": {
"studentId": "9912a519-a6f6-41fe-92bf-07b99045efff",
"name": "Student update persistence test",
"maxClassCount": 5
},
"Tags": ["Student:9912a519-a6f6-41fe-92bf-07b99045efff"],
"Timestamp": "2026-09-09T02:41:40.235475+00:00",
"CausationId": "01a0840a-df7f-7c43-9665-0564c61c7718",
"CorrelationId": "CreateStudent",
"ExecutedUser": "GeneralSekibanExecutor"
},
{
"ServiceId": "default",
"Id": "01a0840a-e817-7b00-8c14-4f869aa9a78d",
"SortableUniqueId": "063924518502423536500632865263",
"EventType": "StudentProfileUpdated",
"Payload": {
"studentId": "9912a519-a6f6-41fe-92bf-07b99045efff",
"name": "Student update persistence test updated",
"maxClassCount": 3
},
"Tags": ["Student:9912a519-a6f6-41fe-92bf-07b99045efff"],
"Timestamp": "2026-09-09T02:41:42.43232+00:00",
"CausationId": "01a0840a-e817-7b00-8c14-4f869aa9a78d",
"CorrelationId": "UpdateStudent",
"ExecutedUser": "GeneralSekibanExecutor"
}
]
For the same student ID, the name and maximum class count at the time of creation and the updated name and maximum class count remain as separate events.
When StudentProfileUpdated is applied to the state created by StudentCreated, the name becomes Student update persistence test updated, and the maximum class count becomes 3. The Projector branch and the Decider's Evolve added this time reproduce this state change.
Conclusion
By implementing the student information update step by step, I felt that I gained a deeper understanding of the event sourcing flow of "validating a change, recording it as an event, and building the state from that history," as well as the role of each process. I also thought it was very useful that persistence can be left to Sekiban, allowing us to focus on business rules and state changes.
This time, I used Codex for the implementation. Instead of asking it to implement the entire feature at once, I proceeded while checking the purpose and code of each step. Since the processing in this structure is separated by responsibility, it worked well with this kind of implementation through dialogue, and I was able to implement the feature while building up my understanding step by step.
Top comments (0)