Pathway for the UI
Inspired by the best practices in Domain-driven design, Spine encourages the developers to build user interfaces in top of Projections. Under well-known conditions, each Projection instance is being built asynchronously upon the stream of domain Events. And when time comes, it is available for a fast querying, skipping all the numerous JOINs and DISTINCTs.
However, in data-heavy applications, users deal with the increasing amount of displayed information. What was simple and comprehensible at first may require additional filtering and summarisation down the road. Therefore, as time passes by, client-side code of some apps may become overcomplicated.
The typical scenario for a certain UI element is as follows:
-
A view element is built on a client-side. It displays the data of a single Projection or a list of Projections by querying the server and transparently rendering the results.
-
Someday more complexity is introduced to the view element. More complex Projections have to be built on the server-side to keep the data available within a single query. While keeping up the Projections up-to-date eats more CPU time, the data can still be fetched from the server within a single query.
-
The amount of data increases even more. To display it conveniently on the UI, client adds even more criteria when fetching the data from server. Maybe, even introducing a faceted search or some grouping. Under these circumstances it becomes inefficient to build Projections for each combination of the parameters displayed in the UI. Therefore, the client code starts to send several queries and combine their results on-the-go.
In this scenario, the code at steps 1 and 2 is clearly testable: a state of Projections is tested via BlackBox, the client-side code is tested for proper UI rendering and interactions.
However, when moving to step 3 things become different. The business logic of building the UI is now spread between the client- and server-side code. It is no longer possible to test a single scenario without involving both sides into the test suite. Such integration tests are significantly more difficult to run and maintain. Also, the client-side code (especially, the one in JS) may not be as strongly typed as the server-side code, and thus more prone to errors.
A typical workaround here would to create a server-side layer between the Bounded Context and the client-server transport. In this way, the business logic stays on the server-side. However, there are issues with that, too.
- It lacks the clarity in terms of Ubiquitous Language — as it's difficult to tell which domain this intermediate layer belongs to.
- It's still hard to test in conjunction with sending signals and asserting the Entity states, since some infrastructure has to be created on_top of
BlackBox.
- Documenting and explaining the workaround is a piece of routine work that has to be done in each project over again.
Named Query
New use cases require better tooling. In scope of this issue a new concept is introduced: Named Queries.
They are designed to achieve the following goals:
- Provide a language extension for complex read-side views exposed by a Bounded Context.
- Code-generate the "boring" building blocks in favour of implementing the same things over and over on a client-side.
- Make building of the views testable via the
BlackBox.
- Make named queries served via the existing
QueryService.
How to use it
Declaration
Named Queries are declared as messages in Protobuf. Similar to commands, we introduce a convention to treat the files ending in queries.proto as such containing the definitions of Named Queries:
// com/acme/backlog_queries.proto:
// Queries for Issues which creation date is in the date range.
//
// The resulting set of issues is grouped by the milestone to which each issue belongs.
message IssuesPerRange {
// The first nested `message` is intepreted as a type of the inbound parameter.
message Param {
// The start of the date range, inclusive.
LocalDate start = 1;
// The end of the date range, exclusive.
LocalDate end = 2;
}
// The second nested `message` is counted as a type of the query result.
message Result {
// option (async) = true; // option to tell the results are fed asynchronously.
// option (repeated) = true; // tells there may be many results of this type.
repeated IssuesOfMilestone issues = 1;
}
}
Code generation
The framework's code generation processes it into an abstract query handler:
public abstract class IssuesPerRangeQueryHandlerBase<IssuesPerRange.Param, IssuesPerRange.Result>
extends QueryHandler {
/**
* Executes the query, returning a single {@code Result} by the given {@code Param}.
*/
public abstract Result perform(Param parameter, QueryContext context) {...}
///// Other possibilities:
// With the `option (repeated) = true;`
public abstract Iterator<Result> perform(Param parameter, QueryContext context) {...}
// With the `option (async) = true;`
public abstract void perform(Param parameter, AsyncResult<Result> observer, QueryContext context) {...}
// With the `option (async) = true; option (repeated) = true;`
public abstract void perform(Param parameter, StreamObserver<Result> observer, QueryContext context) {...}
}
where
QueryContext is a container for actor, zone ID etc which are propagated from the client side.
AsyncResult is a StreamObserver completing automatically after a single object has been sent to its onNext method.
The users of the framework then are able to extend the IssuesPerRangeQueryHandlerBase, filling the perform(..) methods with the actual query processing:
final class IssuesPerRangeQueryHandler extends IssuesPerRangeQueryHandlerBase {
@Override
public abstract Result perform(Param parameter, QueryContext context) {
//
return result;
}
}
From the conceptual perspective, such a handler is a Domain Service on a Query side of an application.
Executing intermediate EntityQuery
Spine also introduces a QueryHandler which is the base type for all handlers of Named Queries. Its API allows to execute Entity Queries, so that a concrete query handler could combine the output of intermediate Entity Queries into the final result:
public abstract class QueryHandler {
// ...
/**
* Executes the given Entity Query in scope of the enclosing Bounded Context
* and returns the iterator over the results.
*
* @param <S> the type of the entity state which is being queried
*/
protected final <S extends EntityState<?>> Iterator<S> execute(EntityQuery<?, S, ?> query) {...}
}
// Generated by Spine Compiler
public abstract class IssuesPerRangeQueryHandlerBase<IssuesPerRange.Param, IssuesPerRange.Result>
extends QueryHandler {..}
final class IssuesPerRangeQueryHandler extends IssuesPerRangeQueryHandlerBase {
@Override
public abstract Result perform(Param parameter, QueryContext context) {
IssueView.Query query =
IssueView.query()
.whenCreated().isGreaterOrEqualTo(parameter.getStart())
.whenCreated().isLessThan(parameter.getEnd())
.build();
Iterator<IssueView> iterator = execute(query);
Result result = groupByMilestones(iterator);
return result;
}
}
Registration in BoundedContext
Instances of Named Query handlers should be registered in the respective BoundedContext:
final class IssuesPerRangeQueryHandler extends IssuesPerRangeQueryHandlerBase {..}
// ...
QueryHandler issuesPerRangeHandler = new IssuesPerRangeQueryHandler();
BoundedContext
.singleTenant("Issues")
// ...
.register(issuesPerRangeHandler);
Exposure via QueryService
TODO: discuss this matter one more time.
At the moment we have a single endpoint in the QueryService:
// A service for querying the read-side from clients.
service QueryService {
// Reads a certain data from the read-side by setting the criteria via Query.
rpc Read(Query) returns (QueryResponse);
}
It's really difficult to re-use the current Query and QueryResponse types, as the resulting values may not be Entities. Therefore, we'll probably have to introduce one more endpoint:
// A service for querying the read-side from clients.
service QueryService {
// ... — this one we have already.
rpc Read(Query) returns (QueryResponse);
// A newly introduced endpoint.
rpc Read(NamedQuery) returns (NamedQueryResponse);
}
Still to discuss:
- Client API changes.
- BlackBox API changes.
- Changes to Spine codegen: new types are tightly related to the
BoundedContext and other server-side routines. Most likely, we'll need to be able to add new codegen modules at core-java level.
Pathway for the UI
Inspired by the best practices in Domain-driven design, Spine encourages the developers to build user interfaces in top of Projections. Under well-known conditions, each Projection instance is being built asynchronously upon the stream of domain Events. And when time comes, it is available for a fast querying, skipping all the numerous
JOINs andDISTINCTs.However, in data-heavy applications, users deal with the increasing amount of displayed information. What was simple and comprehensible at first may require additional filtering and summarisation down the road. Therefore, as time passes by, client-side code of some apps may become overcomplicated.
The typical scenario for a certain UI element is as follows:
A view element is built on a client-side. It displays the data of a single Projection or a list of Projections by querying the server and transparently rendering the results.
Someday more complexity is introduced to the view element. More complex Projections have to be built on the server-side to keep the data available within a single query. While keeping up the Projections up-to-date eats more CPU time, the data can still be fetched from the server within a single query.
The amount of data increases even more. To display it conveniently on the UI, client adds even more criteria when fetching the data from server. Maybe, even introducing a faceted search or some grouping. Under these circumstances it becomes inefficient to build Projections for each combination of the parameters displayed in the UI. Therefore, the client code starts to send several queries and combine their results on-the-go.
In this scenario, the code at steps 1 and 2 is clearly testable: a state of Projections is tested via
BlackBox, the client-side code is tested for proper UI rendering and interactions.However, when moving to step 3 things become different. The business logic of building the UI is now spread between the client- and server-side code. It is no longer possible to test a single scenario without involving both sides into the test suite. Such integration tests are significantly more difficult to run and maintain. Also, the client-side code (especially, the one in JS) may not be as strongly typed as the server-side code, and thus more prone to errors.
A typical workaround here would to create a server-side layer between the Bounded Context and the client-server transport. In this way, the business logic stays on the server-side. However, there are issues with that, too.
BlackBox.Named Query
New use cases require better tooling. In scope of this issue a new concept is introduced: Named Queries.
They are designed to achieve the following goals:
BlackBox.QueryService.How to use it
Declaration
Named Queries are declared as messages in Protobuf. Similar to commands, we introduce a convention to treat the files ending in
queries.protoas such containing the definitions of Named Queries:Code generation
The framework's code generation processes it into an abstract query handler:
where
QueryContextis a container for actor, zone ID etc which are propagated from the client side.AsyncResultis aStreamObservercompleting automatically after a single object has been sent to itsonNextmethod.The users of the framework then are able to extend the
IssuesPerRangeQueryHandlerBase, filling theperform(..)methods with the actual query processing:From the conceptual perspective, such a handler is a Domain Service on a Query side of an application.
Executing intermediate
EntityQuerySpine also introduces a
QueryHandlerwhich is the base type for all handlers of Named Queries. Its API allows to execute Entity Queries, so that a concrete query handler could combine the output of intermediate Entity Queries into the final result:Registration in
BoundedContextInstances of Named Query handlers should be registered in the respective
BoundedContext:Exposure via
QueryServiceTODO: discuss this matter one more time.
At the moment we have a single endpoint in the
QueryService:It's really difficult to re-use the current
QueryandQueryResponsetypes, as the resulting values may not be Entities. Therefore, we'll probably have to introduce one more endpoint:Still to discuss:
BoundedContextand other server-side routines. Most likely, we'll need to be able to add new codegen modules atcore-javalevel.