Each subsection in this document contains a common workflow for developers of the CARP Core Framework. But first, let's introduce some overarching basic concepts.
This codebase follows domain-driven design (DDD). In the remainder of this document, familiarity with DDD terminology (such as "aggregate root" and "integration event") is assumed.
Some domain models extend from AggregateRoot
. They (1) implement the snapshot pattern, and (2) keep track of domain events.
- By calling
getSnapshot()
aSnapshot
of the object at that specific time can be retrieved. Snapshots should be immutable and serializable, which allows them to be used as data transfer objects (DTOs) in application services and to be persisted in a database. - Domain events keep track of changes to the aggregate root.
Currently, they are stored as a simple list of
events
. These can be handled by callingconsumeEvents
, after which they are erased. Note: depending on the chosen persistence model in an infrastructure, events may go unused, e.g., in case a document-oriented database is used and any update is written by updating the full document.
APIs in subsystems are exposed through interfaces extending from ApplicationService
.
They have an associated IntegrationEvent
which is used to support eventual consistency across application services.
Integration events should be immutable and serializable, which allows them to be sent over implementations of EventBus
.
- Add or update a domain model field to capture new state (e.g.,
Study.name
). - If the field is mutable, broadcast a matching
DomainEvent
on changes:- Add/update a corresponding
Event
(e.g.,Study.Event.InternalDescriptionChanged
containsname
). - Call
event()
with the correspondingEvent
whenever the matching field changes.
- Add/update a corresponding
- Include/edit the corresponding field in the
Snapshot
for this class (e.g.,StudySnapshot.name
). Make sure the field, and all its containing fields, are immutable. - Update the
fromXYZ
function in the snapshot to correctly initialize the domain model (e.g.,StudySnapshot.fromStudy()
). - Update the
fromSnapshot
function in the domain model to correctly initialize the snapshot (e.g.,Study.fromSnapshot()
). - Update the
..._fromSnapshot_obtained_by_getSnapshot_is_the_same
unit test to verify whether all fields from/to domain model and snapshot are copied over correctly. - Identify affected application services: any service which takes the modified snapshot as input or output, either directly or as a nested object, is affected (e.g.,
StudyProtocolSnapshot
). - If the snapshot is used in application services, update the corresponding TypeScript declaration in
typescript-declarations
. You may also need to update serialized JSON in unit tests to make tests pass. - For each of the affected application services, upgrade the application service API version.
To allow implementing infrastructures to be backwards compatible for callers expecting an older API, CARP uses versioned APIs.
Each application service interface should contain a static API_VERSION
field with a major and minor version.
Only minor version are backwards compatible.
When an incoming request contains the same major version as the backend but a different minor version, the infrastructure can migrate incoming requests and responses.
This migration is straightforward to wire into the infrastructure when using CARP's recommended infrastructure helpers,
and relies on the migration being implemented in CARP Core as a JSON transformation between old and new versions of IntegrationEvent
, RPC RequestObject
's, and their return types.
- Increment the minor version in the
ApplicationService
interface by 1 (e.g.,StudyService.API_VERSION.minor
). - Extend from
ApiMigrator
, specifying the old and new minor version in the constructor, and override the missing methods to implement the migration. Don't forget to migrate nested objects (e.g.,StudyDetails.protocolSnapshot
needs to be migrated ifStudyProtocolSnapshot
changes). - Add the migration to the list of migrations in the corresponding
ApplicationServiceApiMigrator
constructor (e.g.,StudyServiceApiMigrator
). - Copy the output of the
OutputTestRequests
unit test (build\test-requests
) of the relevant application service to the corresponding test resources as indicated by the failingversioned_test_requests_for_current_api_version_available
test. These generated test resources will be used to verify the migrations (step 3) of any subsequent API version upgrades. - Update the affected JSON schemas. At a minimum you will need to change the request object's API version (e.g.,
StudyServiceRequest.json
). These schemas are useful for non-Kotlin clients. If you forget to do this,JsonSchemasTest
will fail; this test validates generated JSON output, known to be correct, using the schemas.
Keep in mind that CARP data types should be device-agnostic.
Therefore, don't include device-specific information in new SensorData
types.
Failing tests and static code analysis (detektPasses
) will guide you to make sure newly introduced data types are immutable, serializable, registered, and tested.
But, below are the necessary steps to follow:
- Add data type meta data to
CarpDataTypes
, following the template of existing data types. - Add a new class extending from
SensorData
(or object in case the measure contains no data) to thedk.cachet.carp.common.application.data
namespace in thecarp.common
subsystem (e.g.,AngularVelocity
).- Make sure to name the class after the collected data, and not the sensor which collects the data (e.g.,
AngularVelocity
vsGyro
). - Add clear KDoc documentation on how the data should be interpreted.
For data fields, use/document SI units wherever appropriate, and choose sufficiently precise units so that no data is lost when unit conversions from raw data to the
Data
are done. - Ensure that the class is immutable (contains no mutable fields) and is a
data class
orobject
. - Make the class serializable using
kotlinx.serialization
. For basic types, this should be as easy as marking it as@Serializable
. - Specify
@SerialName
using the data type specified in step 1.
- Make sure to name the class after the collected data, and not the sensor which collects the data (e.g.,
- Register the new data type for polymorphic serialization in
COMMON_SERIAL_MODULE
. - Add a test instance of the new
Data
type tocommonInstances
. - Include the data type in the README.
- Update JSON schemas for the new type:
- Add a new schema in
rpc/schemas/common/data
corresponding to the class name (e.g.,AngularVelocity.json
). - Add the new schema as a subtype in
Data.json
. The existing examples should guide you, but double-check you specified the right data type constant. - Warning: the presence or validity of this schema is not yet tested.
It is recommended to serialize an instance of the new data type (e.g., by running a slightly modified polymorphic serialization test in
DataSerializationTest
) and validate the output manually for now.
- Add a new schema in
Implementations of DeviceConfiguration
define and provide access to a couple of associated classes, located under the dk.cachet.carp.common.application.devices
namespace in the carp.common
subsystem:
DeviceRegistration
uniquely identifies a physical device and includes specifications about it (e.g., OS version, model number, ...) which can be retrieved programmatically.DeviceRegistrationBuilder
provides an API to construct instances of a specificDeviceRegistration
type. By linking concrete implementations ofDeviceConfiguration
to a correspondingDeviceRegistrationBuilder
, a typesafeDeviceConfiguration.createRegistration()
DSL is made available which helps to construct validDeviceRegistration
's expected by the device.
Failing tests and static code analysis (detektPasses
) will guide you to make sure newly introduced DeviceConfiguration
and DeviceRegistration
types are
immutable, serializable, registered, contain the expected fields and defaults, and are tested.
But, below are the necessary steps to follow:
- Determine how to uniquely identify the device (e.g., serial number, MAC address, ...)
and create a class (or reuse an existing one) extending from
DeviceRegistration
(see note below). Whether to reuse an existing registration type or create a device-specific one should be decided based on how meaningful it is to log device-specific specifications, i.e., whether it carries important information which helps interpret the device's behavior or data. - Add a new class extending from
DeviceConfiguration
(see note below), e.g.,BLEHeartRateDevice
. - Implement the default values
isOptional = false
anddefaultSamplingConfiguration = emptyMap()
. This can be done in the constructor or class body, depending on whether the user should be able to change the default. - Add
Sensors
andTasks
as nested objects (even if empty):- Include the available data streams and corresponding sampling schemes to
object Sensors : DataTypeSamplingSchemeMap()
. Consider sensible default sampling configurations for each of the data stream sampling schemes. - Add task builders for the available tasks to
object Tasks : TaskConfigurationList()
. All devices supportBackgroundTask
, for which a builder is already added toTaskConfigurationList
.
- Include the available data streams and corresponding sampling schemes to
- For the new
DeviceRegistration
(if added) andDeviceConfiguration
types:- Register type for polymorphic serialization in
COMMON_SERIAL_MODULE
. - Add a test instance to
commonInstances
. - Update JSON schemas for the new type:
- Add a new schema in
rpc/schemas/common/devices
corresponding to the class name (e.g.,BLEHeartRateDevice.json
). - Add the new schema as a subtype in
DeviceRegistration.json
orDeviceConfiguration.json
. The existing examples should guide you, but double-check you specified the right type constant. - Warning: the presence or validity of this schema is not yet tested. It is recommended to serialize an instance of the new data type and validate the output manually for now.
- Add a new schema in
- Register type for polymorphic serialization in
- Include the
DeviceConfiguration
in the README.
Note: When extending DeviceConfiguration
or DeviceRegistration
, do the following:
- Ensure that the class is immutable (contains no mutable fields) and is a
data class
. - Make the class serializable using
kotlinx.serialization
. For basic types, this should be as easy as marking it as@Serializable
.