I have following code specification sample that is modelling image inside Photoshop.
Image is given with PhotoshopImage
. Every image has Layers
which is an object that holds all layers that an image is made of and in my case it contains only two layers - first is solid layer (instance of DefaultLayer
) and second one is transparent layer (instance of NotifiableLayer
). Whenever DefaultLayer
is updated, we have also to update NotifiableLayer
that is listening to changes on DefaultLayer
(that is below), so that it can update itself (like when you update some black pixel on layer below, then transparent layer with 50% opacity that is on top of that lower layer will show that pixel in gray color).
Implementation of this is given as:
public class ES2 {
public static void main(String[] args) {
PhotoshopImage image = new PhotoshopImage();
//draw ine black pixel at position 1,1 in layer 1 (top transparent layer)
DrawOneBlackPixelCommand command1 = new DrawOneBlackPixelCommand(1,1,new Coordinates(1,1));
image.drawOneBlackPixel(command1);
//draw one black pixel at position 0,0 in layer 0 (bottom solid layer)
//this command will also affect transparent layer 1 via callback
DrawOneBlackPixelCommand command2 = new DrawOneBlackPixelCommand(1,0,new Coordinates(0,0));
image.drawOneBlackPixel(command2);
int[][] imagePixels = image.getImagePixels();
//[2, 0]
//[0, 1]
System.out.println(Arrays.toString(imagePixels[0]));
System.out.println(Arrays.toString(imagePixels[1]));
}
}
record DrawOneBlackPixelCommand(
int imageId,
int layerType,
Coordinates pixelCoordinates
){}
record Coordinates(int x, int y){}
class PhotoshopImage{
Integer imageId = 1;
String imageName = "someName";
LocalDateTime dateTime = LocalDateTime.now();
Layers layers;
PhotoshopImage(){
layers = new Layers();
}
void drawOneBlackPixel(DrawOneBlackPixelCommand command){
if(LocalDateTime.now().isBefore(dateTime)){
throw new DrawingPixelTimeExpiredException();
}
layers.drawOneBlackPixel(command.layerType(), command.pixelCoordinates());
}
int[][] getImagePixels(){
return layers.getVisibleLayerPixels();
}
class DrawingPixelTimeExpiredException extends RuntimeException{}
}
class Layers{
Set<NotifiableLayer> notifiableLayerObservers = new HashSet<>();
NavigableMap<Integer, Layer> layers = new TreeMap<>();
Layers(){
DefaultLayer solid = new DefaultLayer();
NotifiableLayer transparent = new NotifiableLayer();
layers.put(0, solid);
layers.put(1, transparent);
notifiableLayerObservers.add(transparent);
}
void drawOneBlackPixel(int layerType, Coordinates pixelCoordinates){
if(!layers.containsKey(layerType)){
throw new LayerDoesNotExistException();
}
Layer change = layers.get(layerType);
change.drawOneBlackPixel(pixelCoordinates);
notifiableLayerObservers.forEach(l -> l.notifyLayer(change, pixelCoordinates));
}
public int[][] getVisibleLayerPixels() {
return layers.lastEntry().getValue().getLayerPixels();
}
class LayerDoesNotExistException extends RuntimeException{}
}
interface Layer{
void drawOneBlackPixel(Coordinates coordinates);
int[][] getLayerPixels();
}
class DefaultLayer implements Layer{
int[][] pixels = new int[][]{{0,0},{0,0}};
@Override
public void drawOneBlackPixel(Coordinates c) {
pixels[c.x()][c.y()] = 1;
}
@Override
public int[][] getLayerPixels() {
return pixels;
}
}
class NotifiableLayer implements Layer{
int[][] pixels = new int[][]{{0,0},{0,0}};
void notifyLayer(Layer changed, Coordinates c){
//if it is not this layer, then it is layer below (solid layer)
if(changed!=this){
int pixelInLayerBelow = changed.getLayerPixels()[c.x()][c.y()];
syncPixelWithLayerBelow(pixelInLayerBelow, c);
}
}
private void syncPixelWithLayerBelow(int pixelBelow, Coordinates c){
pixels[c.x()][c.y()] = pixelBelow + 1;
}
@Override
public void drawOneBlackPixel(Coordinates c) {
pixels[c.x()][c.y()] = 1;
}
@Override
public int[][] getLayerPixels() {
return pixels;
}
}
Now, this is implemented as mutable state objects (that is - it is not using event sourcing). Whatever manual about event sourcing that I read, it is based only on some super-simple examples.
In my case - I do not know how to create events OneBlackPixelDrawnEvent
(one way is in updated answer below, but it looks too complex for the benefits ES brings) - that should be result of these 2 operations in the code, and how to apply those events - should it be applied in PhotoshopImage
, or should each layer be in charge of updating part of its state? How to forward those events from PhotoshopImage
aggregate to Layers
and further down?
UPDATE - Example of one way to implement using event sourcing
import java.time.LocalDateTime;
import java.util.*;
import java.util.stream.Collectors;
public class ES2 {
public static void main(String[] args) {
PhotoshopImage image = new PhotoshopImage();
//draw ine black pixel at position 1,1 in layer 1 (top transparent layer)
DrawOneBlackPixelCommand command1 = new DrawOneBlackPixelCommand(1,1,new Coordinates(1,1));
List<Event> events1 = image.drawOneBlackPixel(command1);
//[OneBlackPixelDrawnEvent[layerType=1, pixelCoordinates=Coordinates[x=1, y=1], pixelValue=1]]
System.out.println(events1);
//draw one black pixel at position 0,0 in layer 0 (bottom solid layer)
//this command will also affect transparent layer 1 via callback
DrawOneBlackPixelCommand command2 = new DrawOneBlackPixelCommand(1,0,new Coordinates(0,0));
List<Event> events2 = image.drawOneBlackPixel(command2);
//[OneBlackPixelDrawnEvent[layerType=0, pixelCoordinates=Coordinates[x=0, y=0], pixelValue=1], LayerSyncedEvent[layerType=1, pixelCoordinates=Coordinates[x=0, y=0], pixelValue=2]]
System.out.println(events2);
int[][] imagePixels = image.getImagePixels();
//[2, 0]
//[0, 1]
System.out.println(Arrays.toString(imagePixels[0]));
System.out.println(Arrays.toString(imagePixels[1]));
}
}
interface Event{}
record DrawOneBlackPixelCommand(
int imageId,
int layerType,
Coordinates pixelCoordinates
){}
record Coordinates(int x, int y){}
record OneBlackPixelDrawnEvent(
Integer layerType,
Coordinates pixelCoordinates,
Integer pixelValue
) implements Event{}
class PhotoshopImage{
Integer imageId = 1;
String imageName = "someName";
LocalDateTime dateTime = LocalDateTime.now();
Layers layers;
PhotoshopImage(){
layers = new Layers();
}
List<Event> drawOneBlackPixel(DrawOneBlackPixelCommand command){
if(LocalDateTime.now().isBefore(dateTime)){
throw new DrawingPixelTimeExpiredException();
}
List<Event> events = layers.drawOneBlackPixel(command.layerType(), command.pixelCoordinates());
apply(events); //Only here we can update state of this aggregate, so it is not updated twice
return events;
}
void apply(List<Event> events){
layers.apply(events);
}
int[][] getImagePixels(){
return layers.getVisibleLayerPixels();
}
class DrawingPixelTimeExpiredException extends RuntimeException{}
}
class Layers{
Map<Integer, NotifiableLayer> notifiableLayerObservers = new HashMap<>();
NavigableMap<Integer, Layer> layers = new TreeMap<>();
Layers(){
DefaultLayer solid = new DefaultLayer();
NotifiableLayer transparent = new NotifiableLayer();
layers.put(0, solid);
layers.put(1, transparent);
notifiableLayerObservers.put(1, transparent);
}
List<Event> drawOneBlackPixel(int layerType, Coordinates pixelCoordinates){
if(!layers.containsKey(layerType)){
throw new LayerDoesNotExistException();
}
Layer change = layers.get(layerType);
OneBlackPixelDrawnEvent event = change.drawOneBlackPixel(pixelCoordinates);
//Here, I have to add layerType, since it is a missing info on event!
OneBlackPixelDrawnEvent updatedEvent = new OneBlackPixelDrawnEvent(layerType, event.pixelCoordinates(), event.pixelValue());
List<LayerSyncedEvent> syncedEvents = notifiableLayerObservers.entrySet().stream()
.map(en ->
en.getValue()
.notifyLayer(change, updatedEvent)
//Here we have to re-pack event, since it is missing some info that can be
//filled only on this level
.map(e -> new LayerSyncedEvent(en.getKey(), e.pixelCoordinates(), e.pixelValue()))
)
.flatMap(Optional::stream)
.collect(Collectors.toList());
List<Event> results = new ArrayList<>();
results.add(updatedEvent);
results.addAll(syncedEvents);
//apply(results); we still cannot apply here, since applying in aggregate root would apply twice!
return results;
}
public void apply(List<Event> events){
for(Event e : events){
if(e instanceof LayerSyncedEvent ev){
layers.get(ev.layerType()).apply(ev);
}
if(e instanceof OneBlackPixelDrawnEvent ev){
layers.get(ev.layerType()).apply(ev);
}
}
}
public int[][] getVisibleLayerPixels() {
return layers.lastEntry().getValue().getLayerPixels();
}
class LayerDoesNotExistException extends RuntimeException{}
}
interface Layer{
OneBlackPixelDrawnEvent drawOneBlackPixel(Coordinates coordinates);
int[][] getLayerPixels();
<T extends Event> void apply(T e);
}
class DefaultLayer implements Layer{
int[][] pixels = new int[][]{{0,0},{0,0}};
@Override
public OneBlackPixelDrawnEvent drawOneBlackPixel(Coordinates c) {
OneBlackPixelDrawnEvent event = new OneBlackPixelDrawnEvent(null, c, 1);
//apply(event); ! Since applying in aggregate root - cannot apply here!
return event;
}
@Override
public int[][] getLayerPixels() {
return pixels;
}
@Override
public <T extends Event> void apply(T e) {
if(e instanceof OneBlackPixelDrawnEvent ev){
Coordinates c = ev.pixelCoordinates();
pixels[c.x()][c.y()] = ev.pixelValue();
}
}
}
record LayerSyncedEvent(
Integer layerType,
Coordinates pixelCoordinates,
Integer pixelValue
) implements Event{}
class NotifiableLayer implements Layer{
int[][] pixels = new int[][]{{0,0},{0,0}};
Optional<LayerSyncedEvent> notifyLayer(Layer changed, OneBlackPixelDrawnEvent event){
//if it is not this layer, then it is layer below (solid layer)
if(changed!=this){
Coordinates c = event.pixelCoordinates();
//Since layer is not updated anymore in-place, we have to take changes from event!
//int pixelInLayerBelow = changed.getLayerPixels()[c.x()][c.y()];
int pixelInLayerBelow = event.pixelValue();
return Optional.of(syncPixelWithLayerBelow(pixelInLayerBelow, c));
}
return Optional.empty();
}
private LayerSyncedEvent syncPixelWithLayerBelow(int pixelBelow, Coordinates c){
LayerSyncedEvent event = new LayerSyncedEvent(null, c, pixelBelow + 1);
//apply(event); ! Since applying in aggregate root - cannot apply here!
return event;
}
@Override
public OneBlackPixelDrawnEvent drawOneBlackPixel(Coordinates c) {
OneBlackPixelDrawnEvent event = new OneBlackPixelDrawnEvent(null, c, 1);
//apply(event); ! Since applying in aggregate root - cannot apply here!
return event;
}
@Override
public int[][] getLayerPixels() {
return pixels;
}
@Override
public <T extends Event> void apply(T e) {
if(e instanceof LayerSyncedEvent ev){
Coordinates c = ev.pixelCoordinates();
pixels[c.x()][c.y()] = ev.pixelValue();
}
if(e instanceof OneBlackPixelDrawnEvent ev){
Coordinates c = ev.pixelCoordinates();
pixels[c.x()][c.y()] = ev.pixelValue();
}
}
}
I just updated example here with one way to implement Aggregate root, with methods that return events. I guess this is one possible implementation - but look how much complex this is now; even this simple example - increased 2x in complexity. Am I doing something wrong, or is this just not that easy to do in event-sourced system?
Event Sourcing is based on the assumption that the system records events happening on Aggregate roots. In your case, when a Layer is updated, the Image containing it will append an event to an internal collection. Something like
LayerUpdated
... although it's considered good practise to give meaningful names to events.When all the operations (aka
Commands
) have been executed, the system starts persisting those events and for each one, it will also broadcast a notification.Now you could either have each
NotifiableLayer
listening to specific notifications, or you can have a separate service who can do that and update all theNotifiableLayer
instances accordingly. I would go for the service: I don't really like the idea of Domain Entities listening to notifications.