Use the Menu on the left hand side to learn about the different flavors of projections.
This the multi-page printable view of this section. Click here to print.
Projection Types
- 1: Snapshot
- 2: Aggregate
- 3: Managed
- 4: Managed (local)
- 5: Subscribed
- 6: Subscribed (local)
1 - Snapshot
Now that we know how snapshotting works and what a projection is, it is quite easy to put things together:
A SnapshotProjection is a Projection (read EventHandler) that can be stored into/created from a Snapshot. Let’s go back to the example we had before:
/**
* maintains a map of UserId->UserName
**/
public class UserNames implements SnapshotProjection {
private final Map<UUID, String> existingNames = new HashMap<>();
@Handler
void apply(UserCreated created) {
existingNames.put(created.aggregateId(), created.userName());
}
@Handler
void apply(UserDeleted deleted) {
existingNames.remove(deleted.aggregateId());
}
// ...
This projection is interested in UserCreated
and UserDeleted
EventObjects and can be serialized by
the SnapshotSerializer
.
If you have worked with FactCast before, you’ll know what needs to be done (if you haven’t, just skip this section and be happy not to be bothered by this anymore):
- create an instance of the projection class, or get a Snapshot from somewhere
- create a list of FactSpecs (FactSpecifications) including the Specifications from
UserCreated
andUserDeleted
- create a FactObserver that implements an
void onNext(Fact fact)
method, that- looks at the fact’s namespace/type/version
- deserializes the payload of the fact into the right EventObject’s instance
- calls a method to actually process that EventObject
- keeps track of facts being successfully processed
- subscribe to a fact stream according to the FactSpecs from above (either from Scratch or from the last factId processed by the instance from the snapshot)
- await the completion of the subscription to be sure to receive all EventObjects currently in the EventLog
- maybe create a snapshot manually and store it somewhere, so that you do not have to start from scratch next time
… and this is just the “happy-path”.
With Factus however, all you need to do is to use the following method:
/**
* If there is a matching snapshot already, it is deserialized and the
* matching events, which are not yet applied, will be as well. Afterwards, a new
* snapshot is created and stored.
* <p>
* If there is no existing snapshot yet, or they are not matching (see
* serialVersionUID), an initial one will be created.
*
* @return an instance of the projectionClass in at least initial state, and
* (if there are any) with all currently published facts applied.
*/
@NonNull
<P extends SnapshotProjection> P fetch(@NonNull Class<P> projectionClass);
like
UserNames currentUserNames=factus.fetch(UserNames.class);
Easy, uh? As the instance is created from either a Snapshot or the class, the instance is private to the caller here.
This is the reason why there is no ConcurrentHashMap or any other kind of synchronization necessary within UserNames
.
Lifecycle hooks
There are plenty of methods that you can override in order to hook into the lifecycle of a SnapshotProjection.
- onCatchup() - will be called when the catchup signal is received from the server.
- onComplete() - will be called when the FactStream is at its end (only valid for catchup projections)
- onError() - whenever an error occurs on the server side or on the client side before applying a fact
- onBeforeSnapshot() - will be called whenever factus is about to take a snapshot of the projection. Might be an opportunity to clean up.
- onAfterRestore() - will be called whenever factus deserializes a projection from a snapshot. Might be an opportunity to initialize things.
- executeUpdate(Runnable) - will be called to update the state of a projection. The runnable includes applying the Fact/Event and also updating the state of the projection, in case you want to do something like introduce transactionality here.
This is not meant to be an exhaustive list. Look at the interfaces/classes you implement/extend and their javadoc.
2 - Aggregate
Another special flavor of a Snapshot Projection is an Aggregate. An Aggregate extends the notion on Snapshot Projection by bringing in an aggregate Id. This is the one of the UserNames
example. It does not make sense to maintain two different UserNames Projections, because by definition, the UserNames projection should contain all UserNames in the system.
When you think of User
however, you have different users in the System that differ in Id and (probably) UserName.
So calling factus.fetch(User.class)
would not make any sense. Here Factus offers two different methods for access:
/**
* Same as fetching on a snapshot projection, but limited to one
* aggregateId. If no fact was found, Optional.empty will be returned
*/
@NonNull
<A extends Aggregate> Optional<A> find(
@NonNull Class<A> aggregateClass,
@NonNull UUID aggregateId);
/**
* shortcut to find, but returns the aggregate unwrapped. throws
* {@link IllegalStateException} if the aggregate does not exist yet.
*/
@NonNull
default <A extends Aggregate> A fetch(
@NonNull Class<A> aggregateClass,
@NonNull UUID aggregateId) {
return find(aggregateClass, aggregateId)
.orElseThrow(() -> new IllegalStateException("Aggregate of type " + aggregateClass
.getSimpleName() + " for id " + aggregateId + " does not exist."));
}
As you can see, find
returns the user as an Optional (being empty if there never was any EventObject published regarding that User), whereas fetch
returns the User unwrapped and fails if there is no Fact for that user found.
All the rules from SnapshotProjections apply: The User instance is (together with its id) stored as a snapshot at the end of the operation. You also have the beforeSnapshot() and afterRestore() in case you want to hook into the lifecycle (see SnapshotProjection)
3 - Managed
As we have learnt, SnapshotProjections are created from scratch or from Snapshots, whenever you fetch them. If you look at it from another angle, you could call them unmanaged in a sense, that the application has no control over their lifecycle. There are use cases where this is less attractive. Consider a query model that powers a high-traffic REST API. Recreating an instance of a SnapshotProjection for every query might be too much of an overhead caused by the network transfer of the snapshot and the deserialization involved.
Considering this kind of use, it would be good if the lifecycle of the Model would be managed by the application. It also means, there must be a way to ‘update’ the model when needed (technically, to process all the Facts that have not yet been applied to the projection). However, if the Projection is application managed (so that it can be shared between threads) but needs to be updated by catching up with the Fact-Stream, there is a problem we did not have with SnapshotProjections, which is locking.
Definition
A ManagedProjection is a projection that is managed by the Application. Factus can be used to lock/update/release a Managed Projection in order to make sure it processes Facts in the correct order and uniquely.
Factus needs to make sure only one thread will change the Projection by catching up with the latest Facts. Also, when Factus has no control over the Projection, the Projection implementation itself needs to ensure that proper concurrency handling is implemented in the place the Projection is being queried from, while being updated. Depending on the implementation strategy used by you, this might be something you don’t need to worry about (for instance when using a transactional datastore).
ManagedProjections are StateAware
(they know their position in the FactStream) and WriterTokenAware
, so that
they provide a way for Factus to coordinate updates.
flexible update
One of the most important qualities of ManagedProjections is that they can be updated at any point. This makes them viable candidates for a variety of use cases. A default one certainly is a “strictly consistent” model, which can be used to provide consistent reads over different nodes that always show the latest state from the fact stream. In order to achieve this, you’d just update the model before reading from it.
// let's consider userCount is a spring-bean
UserCount userCount = new UserCount();
// now catchup with the published events
factus.update(userCount);
Obviously, this makes the application dependent on the event store for availability (and possibly latency). The good part however is, that if FactCast was unavailable, you’d still have (a potentially) stale model you can fall back to.
In cases where consistency with the fact-stream is not that important, you might just want to occasionally update the model. An example would be to call update for logged-in users (to make sure, they see their potential writes) but not updating for public users, as they don’t need to see the very latest changes. One way to manage the extends of “staleness” of a ManagedProjection could be just a scheduled update call, once every 5 minutes or whatever your requirements are for public users.
private final UserCount userCount;
private final Factus factus;
@Scheduled(cron = "*/5 * * * *")
public void updateUserCountRegularly(){
factus.update(userCount);
}
If the projection is externalized and shared, keep in mind that your users still get a consistent view of the system, because all nodes share the same state.
Typical implementations
ManagedProjections are often used where the state of the projection is externalized and potentially shared between nodes. Think of JPA Repositories or a Redis database.
The ManagedProjection instance in the application should provide access to the externalized data and implement the locking facility.
Over time, there will be some examples added here with exemplary implementations using different technologies.
However, ManagedProjections do not have to work with externalized state. Depending on the size of the Projection and consistency requirements between nodes, it might also be a good idea to just have an in-process (local) representation of the state. That makes at least locking much easier.
Let’s move on to LocalManagedProjections…
4 - Managed (local)
As a specialization of ManagedProjection, a LocalManagedProjection lives within the application
process and does not use shared external Databases to maintain its state.
Relying on the locality, locking and keeping track of the state (position in the eventstream) is
just a matter of synchronization and an additional field, all being implemented in the abstract
class LocalManagedProjection
that you are expected to extend.
public class UserCount extends LocalManagedProjection {
private int users = 0;
@Handler
void apply(UserCreated created) {
users++;
}
@Handler
void apply(UserDeleted deleted) {
users--;
}
int count() {
return users;
}
}
As you can see, the WriterTokenBusiness and the state management are taken care of for you, so that you can just focus on the implementation of the projection.
Due to the simplicity of use, this kind of implementation would be attractive for starting with for non-aggregates, assuming the data held by the Projection is not huge.
5 - Subscribed
The SnapshotProjection
and ManagedProjection
have one thing in common:
The application actively controls the frequency and time of updates by actively calling a method. While this gives the
user a maximum of control, it also requires synchronicity. Especially when building query models, this is not
necessarily a good thing. This is where the SubscribedProjection
comes into play.
Definition
A SubscribedProjection
is subscribed once to a Fact-stream and is asynchronously updated as soon as the application
receives relevant facts.
Subscribed projections are created by the application and subscribed (once) to factus. As soon as Factus receives matching Facts from the FactCast Server, it updates the projection. The expected latency is obviously dependent on a variety of parameters, but under normal circumstances it is expected to be <100ms, sometimes <10ms.
However, its strength (being updated in the background) is also its weakness: the application never knows what state the projection is in (eventual consistency).
While this is a perfect projection type for occasionally connected operations or public query models, the inherent eventual consistency might be confusing to users, for instance in a read-after-write scenario, where the user does not see his own write. This can lead to suboptimal UX und thus should be used cautiously after carefully considering the trade-offs.
A SubscribedProjection
is also StateAware
and WriterTokenAware
. However, the token will not be released as frequently
as with a ManagedProjection
. This may lead to “starving” models, if the process keeping the lock is non-responsive.
Please keep that in mind when implementing the locking facility.
Read-After-Write Consistency
Factus updates subscribed projections automatically in the background. Therefore a manual update with
``
factus.update(projection)
is not possible. In some cases however it might still be necessary to make sure a subscribed
projection has processed a fact before continuing.
One such use-case might be read-after-write consistency. Imagine a projection powering a table shown to a user. This
table shows information collected from facts A
and B
, where B
gets published by the current application, but
A
is published by another service, which means we need to use a subscribed projection. With the push of a button a user can publish a new B
fact, creating another row
in the table. If your frontend then immediately reloads the table, it might not yet show the new row, as the subscribed
projection has not yet processed the new fact.
In this case you can use the factus.waitFor
method to wait until the projection has consumed a certain fact. This
method will block until the fact is either processed or the timeout is exceeded.
// publish a fact we need to wait on and extract its ID
final var factId = factus.publish(new BFact(), Fact::id);
factus.waitFor(subscribedProjection, factId, Duration.ofSeconds(5));
With this, the waiting thread will block for up to 5 seconds or until the projection has processed the fact stream up to or beyond the specified fact. If you use this, make sure that the projection you are waiting for will actually process the fact you are waiting on. Otherwise a timeout is basically guaranteed, as the fact will never be processed by this projection.
6 - Subscribed (local)
As a specialization of the SubscribedProjection
, a LocalSubscribedProjection
is local to one VM (just like a LocalManagedProjection
).
This leads to the same problem already discussed in relation to LocalManagedProjection
: A possible inconsistency between nodes.
A LocalSubscribedProjection
is providing locking (trivial) and state awareness, so it is very easy to use/extend.