Skip to content

Feature design considerations

Łukasz Dywicki edited this page Jul 11, 2022 · 2 revisions

Subsection of https://github.com/OpenNMS/horizon-stream/wiki/Design-Principles

Feature files which are developed as part of project need to follow a clear separation of concerns. This separation allows to keep isolation of issues and also dependency tree. Main point for having such strict separation is reliability and stability of features and their validation. The more things are being installed, the harder it is to keep stability of deployment.

Feature separation

Common feature layers:

  1. Horizon base distro used to host other builds (ie. configure maven repositories, logging, directory structure).
  2. Libraries - standalone set of ICMP, SNMP and other low level peripheries which can be installed alone.
  3. Core - definitions of system APIs and services.
  • api - just interfaces, model and other interaction related contracts.
  • core - implementation of core service or services.
  1. Subsystems - a subsystem is a complete feature which covers given functionality, for example a provisioning based on source X or Y.
  • api - definition of subsystem specific APIs which allow further extension development, this could be for example a SNMPTrapListener.
  • model - type definitions used to interact with API, could be seen as a 'dto' structures depending on the invocation context.
  • core - implementation of subsystem apis.
  • rest - service/interaction layer
    • api - definition of specific extensions (ie. JAR-RS service interfaces)
    • core - mapping of model structures and apis to/from json and other formats, also implementation of web->subsystem api invocations.
  • grpc - service/interaction layer .. (follows above schematics)

With above design the dependency path should be straight

core api <----------------- core services ------------------> infrastructure services (ie. metrics)
    ^                                    
    | (model + api)
    |
 subsystem ----> library
  ^      ^
  |      |
ext.A   ext.B

Dependency trees

Overall design outlined above should be followed both in features but also in Maven modules. This indeed multiplies amount of bundles, however it does keep clear boundary of ie. client and server interfaces, implementations and also dependencies. Quite often dependencies which are used by client side differ by ones used by a server or API/SPI interfaces offered by a server library.

Given above example of a module with rest interface we would have following constraints:

  1. rest.api module/package separated from api - this is because core or subsystem API should be free of invocation context in most of the cases. If context must be propagated then it shall become a part of an API or model.
  2. rest.api and rest.core - by keeping these two distinct the rest api can depend only of jaxrs api (a single jar) and jackson annotations while other part can depend on actual jaxrs implementation or pull in specific jaxrs implementation dependencies (ie. Jersey or CXF specific APIs). This makes it possible to consume rest.api module to create a jaxrs client stub with other frameworks outside of the project.
  3. rest.core depends on api, so it does not require a full disclosure of how core service is being implemented, also feature resolution and compilation do not require access to specific implementation classes. By this approach there is always a clear way from ie. web layer to the core layer.

Difficult parts

There are situations where direct dependency on a specific element or functionality is desired or necessary. This could be a metric collection or distributed tracing but also access context propagation. There is no clear answer how to handle these parts. From one hand making them first level citizens complicates and obfuscates API definitions, on other hand having them implicit (or woven in) complicates implementation and introduced additional mechanisms which needs to be developed and maintained. A very basic rule of thumb in such cases is mixture of composition and delegation. Given an example of tracing:

// infrastructure api
interface TraceId {} // model
interface TraceFactory { // api
  TraceId create(); 
  TraceId reclaim(String serializedId);
  TraceCall call(TraceId trace, Runnable operation);
}

// implementation of tracing infrastructure
class OpentracingId implements TraceId {} // specific 
class OpentracingTraceFactory {
 ...
}

// an imaginary subsystem api, omitting factories and other
interface StatusPoller {
  CompletableFuture<Status> query(String host, TraceId traceId);
}
class StatusPollerImpl implements StatusPoller {
  private TraceFactory traceFactory;
  CompletableFuture<Status> query(String host, TraceId traceId) {
    traceFactory.call(traceId, () -> ...);
  }
}

Above is a completely imaginary scenario but its main principle is showing up a key aspect - a implementation independent invocation of an infrastructure service such as tracing. Information which travel over is just a model (TraceId), later specific implementation services require only api at compile and rely on specific service implementation at runtime.

Clone this wiki locally