Internal Architecture
While each region connector could implement a completely different architecture, since they are isolated from another, it is better to use the same architecture. That way it is possible to reuse existing shared functionality, where only one bean instance has to be created for a shared implementation in the region connector context.
The permission process model has to be implemented by each region connector. When a permission request changes status, it has to be emitted to the EP.
The region connectors are implemented using event sourcing. Event sourcing uses events to build an aggregate. An event is a change in a certain system. All events contain an aggregate ID, which identifies all events that are related to each other. Using the aggregate ID, it is possible to create an aggregate. This aggregate is the domain entity. In this case, a permission request is built from permission events. All permission events with the same permission ID represent the current status of the permission request. For more detailed information on event sourcing see this blog post.
Event Store
An event store is the database that persists events and allows applications to load the aggregate. There are two ways to rebuild the domain entity:
- Load all events from the store and build the aggregate in the code
- Utilize the event stores capabilities to create the aggregate.
In EDDIE's case the second approach was chosen. PostgreSQL is the event store. This is done by using append-only tables, which just have read-write enabled. The mapping from events to the event table is done by Hibernate. An abstract base event is defined, which contains all the necessary Hibernate configuration to persist multiple classes inheriting the base event. All events in a region connector inherited from this base event. The different implementations of the base event can vary, some might only contain the same information as the base event, others may add many additional fields. For example, the created event often contains a metering point ID.
WARNING
Since an append-only table is used, don't save any credentials, such as access tokens in events. This should be saved in their own tabel, that allows delete operations.
The aggregate is created in PostgreSQL as a view. That way it is possible to retrieve permission requests just as if they are persisted to a normal table via Hibernate. No special configuration is needed for that. PostgresQL provides window functions, which allows operating on related rows. The aggregate is created by executing an aggregate functions on column of for all related rows. In many cases, the aggregate function that's used is the firstval_agg function. It gets the latest non-null value for a column. Of course, any other aggregate function can be used if needed. The following is an example of the event table and the permission request view that recreates the aggregate.
CREATE TABLE foo_bar.permission_event
(
dtype varchar(31) NOT NULL, -- the type of event, needed by Hibernate to persist the event
id bigserial NOT NULL, -- unique ID for just this event
event_created timestamp(6) WITH TIME ZONE, -- timestamp of when the event was created
-- The following have to be present in each permission request, but not in each event
permission_id varchar(36), -- the aggregate ID
status text, -- the current status of the permission request
data_start date,
data_end date,
granularity text,
-- here any other fields needed by events
PRIMARY KEY (id)
);
-- Create the get latest non null value aggregate function
CREATE FUNCTION foo_bar.coalesce2(anyelement, anyelement) RETURNS anyelement
LANGUAGE sql AS
'SELECT COALESCE($1, $2)';
CREATE AGGREGATE foo_bar.firstval_agg(anyelement)
(SFUNC = foo_bar.coalesce2, STYPE =anyelement);
--
-- Create the permission request view from the events, by aggregating the fields
CREATE VIEW foo_bar.permission_request AS
SELECT DISTINCT ON (permission_id) permission_id,
foo_bar.firstval_agg(connection_id) OVER w AS connection_id,
MIN(event_created) OVER w AS created, -- here the min aggregate function is used, to get the smallest value
foo_bar.firstval_agg(data_need_id) OVER w AS data_need_id,
foo_bar.firstval_agg(granularity) OVER w AS granularity,
foo_bar.firstval_agg(permission_start) OVER w AS permission_start,
foo_bar.firstval_agg(permission_end) OVER w AS permission_end,
foo_bar.firstval_agg(status) OVER w AS status,
FROM foo_bar.permission_event
WINDOW w AS (PARTITION BY permission_id ORDER BY event_created DESC) -- Order by event_created to get the newest events first
ORDER BY permission_id, event_created;In order for Hibernate to pick up the view as table, the permission request implementation needs a few modifications.
import energy.eddie.api.agnostic.process.model.PermissionRequest;
import jakarta.persistence.*;
@Entity
@Table(name = "permission_request", schema = "foo_bar")
public class FluviusPermissionRequest implements PermissionRequest {
// Omitted...
}Schema Migration
Since the integration of the permission event table and permission request view are solely done via SQL, it is necessary to provide schema migration. This is done via Flyway. The core runs schema migrations for all region connectors. The schemas have to be provided in the resource directory under db/migration/<region-connector-name>/V<major-version>_<minor-version>__<name>.sql The migrations are automatically executed on startup.
Event Bus
Event sourcing utilizes an event bus to send events to event handlers. The event bus can be an external component, but it also can be provided via a library. In EDDIE's case, Project Reactor is used to provide the event bus. The event bus is an internal component. Event handlers can subscribe to specific events via the event bus. There are two kinds of event handlers.
The integration event handler is used to integrate the events to external systems such as Kafka. There are already two implementations for connection status messages and permission market documents. They integrate events to the outbound connectors.
The second kind is the domain event handler. Domain event handlers react to event and execute business logic. For example, when a validated event is emitted, a domain event handler can subscribe to those and send the permission request to the PA.
Outbox
While it is possible to implement an integration event handler to persist events, this approach has been shown to be brittle in combination with Hibernate, since it could be that events are emitted faster than they are persisted. Instead the outbox pattern has been chosen. The outbox first persists the permission event and then emits the event to the event bus. That way only persisted events are ever sent to the event bus. The implementation can be found here.
Implementing the Events
For each permission process the mapping to the permission process model can be different. Some permission processes of PAs assume more interaction with the EP than others. The mapping has to be done on a case per case basis. The following should give a rough unterstanding on how to map the different statuses to the permission process of the PA. Furthermore, some implementation details are given on how to implement the events.
Persistable Event
The root of all events of a region connector is going to be the persistable event. The following is an example of a peristable event.
import energy.eddie.api.agnostic.process.model.events.PermissionEvent;
import energy.eddie.api.v0.PermissionProcessStatus;
import jakarta.persistence.*;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
@Entity(name = "FoobarPersistablePermissionEvent")
// Inheritance type is important in order for the view of the permission request to work
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
// Point Hibernate to the correct schema
@Table(schema = "foo_bar", name = "permission_event")
// Supress warnings for all entities, since Hibernate does not play nice with non-null fields.
@SuppressWarnings({"NullAway", "unused"})
public abstract class PersistablePermissionEvent implements PermissionEvent {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private final Long id;
// Aggregate ID
@Column(length = 36, name = "permission_id")
// permissionIDs are usually UUIDs, but that depends on the implementation of the region connector
private final String permissionId;
private final ZonedDateTime eventCreated;
@Enumerated(EnumType.STRING)
@Column(columnDefinition = "text")
// Persist enums as text instead of as numbers to make debugging and integration with other systems easier
private final PermissionProcessStatus status;
protected PersistablePermissionEvent(
String permissionId,
PermissionProcessStatus status,
ZonedDateTime created
) {
this.id = null;
this.permissionId = permissionId;
this.eventCreated = created;
this.status = status;
}
// Required by Hibernate
protected PersistablePermissionEvent() {
this.id = null;
permissionId = null;
eventCreated = null;
status = null;
}
@Override
public String permissionId() {
return permissionId;
}
@Override
public PermissionProcessStatus status() {
return status;
}
@Override
public ZonedDateTime eventCreated() {
return eventCreated;
}
}While the permission request view has to be created by hand, it is possible to let Hibernate generate the DDL for the permission event table. See Spring docs on how to let Hibernate generate the DDL. In order to see the SQL printed to the console set the following property: spring.jpa.show-sql=true.
INFO
Enable just your region connector, otherwise Hibernate will fail during startup, because it will try to generate the schema for other region connectors as well.
Here a full example that can be pasted in the application.properties file:
spring.jpa.show-sql=true
spring.jpa.generate-ddl=trueCreated Event
Once a permission request needs to be created the data is sent to the REST endpoint for creation of permission requests. For more info on the REST endpoints see dispatcher servlet. Once the data is received a created event has to be emitted via the outbox containing all the data that does not need any validation, such as connection ID, permission ID, which is created by EDDIE, data need ID, etc.
@Entity(name = "CreatedEvent")
public class CreatedEvent extends PersistablePermissionEvent {
@Column(length = 36)
private final String dataNeedId;
@Column(columnDefinition = "text")
private final String connectionId;
public CreatedEvent(String permissionId, String dataNeedId, String connectionId) {
super(permissionId, PermissionProcessStatus.CREATED);
this.dataNeedId = dataNeedId;
this.connectionId = connectionId;
}
protected CreatedEvent() {
this.dataNeedId = null;
this.connectionId = null;
}
}Afterwards the permission request can be validated. The data need has to be checked for the following:
- Does it exist
- Can the region connector support this data need
This can be done using the DataNeedCalculationService. The other values specific for the region connector have to be validated as well. Creation and validation is usually done synchronously to allow for immediate feedback to the EDDIE button via REST response.
Validated Event
Once the validation is finished successfully, the validated event is emitted. It should contain all the fields that needed to be validated. After the validation, the other operations can be async done via the event bus and event handlers, since the sending the permission request to the PA might take some time, as well as the PA's response.
After the permission request is validated, it has to be sent to the PA. For OAuth, the redirect would be the sending of the permission request, but it is impossible to know if the redirect was successful. Therefore, the sent to PA event is emitted after the PA redirected back to EDDIE.
Malformed Event
If the validations fail, a malformed event has to be emitted. The validation errors should be included as an AttributeError. This contains the erroneous field and the error message. To be able to persist a list of AttributeErrors, the AttributeErrorListConverter can be used. This converter converts the list into a JSON structure. Of course, other converters can be used, or even a dedicated table referenced by the malformed event.
@Convert(converter = AttributeErrorListConverter.class)
@Column(name = "errors", columnDefinition = "text")
private final List<AttributeError> errors;The malformed state is final, and there should be no other state coming afterward.
Sent to PA Event
Once the permission request was sent successful to the PA this event can be emitted.
Unable to Send Event
If sending the event was not successful, this event is emitted. This is a retry loop, so it is possible to periodically re-emit the validated event, which should trigger the sending process. For OAuth flows, this event is unnecessary, since it is possible to redirect a final customer again.
Timed Out Event
If the PA never sends an answer regarding the acceptance or rejection of the permission request, this event is emitted. There is a default implementation that checks periodically for stale permission requests and times them out. This is a final state, which cannot be recovered.
Invalid Event
PAs will validate the permission requests themselves again. If the permission request is not valid, they respond with some kind of validation error. In this case the permission request is invalid and the invalid event is emitted. This is a final state, which cannot be recovered.
Rejected Event
If the final customer rejects a permission request, the PA will reject the permission request. In this case the rejected event is emitted. This is a final state, which cannot be recovered.
Accepted Event
If the final customer approves the permission request, the accepted event is emitted. After this event data can be polled from the MDA.
Unfulfillable Event
If after acceptance it turns out the data received from the MDA does not fit the data need, an unfulfillable event is emitted. If the PA supports termination, the permission request should be externally terminated, by emitting the requires external termination event.
Fulfilled
This event is emitted once all data is received from the MDA. Can be a final event, but if the PA supports external termination, the requires external termination event needs to be emitted.
Terminated
If the permission request was accepted, the EP can decide to terminate the permission request anytime for any reason. This is done by using a termination document The termination document is received by the RegionConnector implementation. It can be a final event, but if the PA supports external termination, the external termination event needs to be emitted.
Requires External Termination Event
This event indicates that a permission request should be terminated with the PA. It is an optional event, since not all PAs support terminations. After receiving this event, the termination needs to be sent to the PA. If the termination is successful, the externally terminated event is emitted, otherwise the failed to terminate event is emitted.
Externally Terminated Event
This event indicates that the permission request was terminated with the PA. This is a final state, which cannot be recovered.
Failed to Terminate Event
This event is similar to the unable to send event in that sense that it allows to trigger the external termination again in case it was not possible the first time. This is done by emitting the requires external termination event.
Internal Events
For events that should not be propagated to outbound connectors the InternalPermissionEvent marker interface can be used in addition. This events will be persisted and sent to the event bus, but integration event handlers have to ignore them. Use them to if you want to send an event that does not change the status of a permission request. For example, to trigger periodical polling.