- JDK 17 or higher must be installed.
- Docker 19.03.0 or higher must be installed and Docker daemon must be running locally.
Build the application as an OCI container to the local Docker registry:
./gradlew build jibDockerBuild
Run the application locally along with the database using Docker Compose:
cd local
cp sample.env .env
docker-compose up -d
You may edit the file .env to change the following runtime environment variable:
REST_API_PORT
-
HTTP port of the application's REST API; default: 8080
Use curl
or the integrated Swagger UI to make requests to the API:
http://localhost:8080/swagger-ui.html
Use the REST API HTTP port configured above.
An office rental company owns multiple coworking spaces providing their guests with devices like computers, displays, coffee machines etc. Each device has an id, a type and a name. Additionally, a device can have configuration properties specific to its type. A computer, for instance, can have a username, a password and an ip address.
The application is a backend service that stores configurations of differant devices. It provides an API endpoint that allows clients to:
- create a configuration for a new device
- update the configuration of an existing device
- retrieve the configuration of a device by its id
- list all device configurations
-
The application only models elements specifically mentioned in the requirements. For instance, coworking spaces holding the devices are not represented.
-
All configuration values are mandatory.
-
A device id is a semantic value (like a serial number), a UUID or any other unique string provided to this application by the application client, it is not a synthetic surrogate key generated by this application.
-
Listing all device configurations does not require any specific order.
-
Once a device is stored in the database, its ID and type cannot be modified.
-
Device passwords can be stored and retrieved in plain text. In a real-world application, they would be either hashed and salted or encrypted at rest, depending on the purpose for which they are stored. In both cases, they would be stored in a separate part of the database with stricter access control and also have dedicated, additionally protected endpoints.
-
Access control to the API and other security concerns are outside the scope.
-
Concurrency control on API level is not required. When updating device information, a client may rely on previously requested data that may have become stale by the time it sends an update.
The solution follows the hexagonal architecture concepts. That is, the periphery components (persistence and API) reference the application logic, while the logic is self-contained. Wherever possible, objects are kept immutable.
The logic in this CRU(D) application is trivial. Nevertheless, this approach was chosen in order to present the general development style.
Every device type, such as Computer
is represented by its own class, extending the abstract Device
class.
For the data store, two types of databases would have been suitable:
- an relational database like PostgreSQL
- a document store like MongoDB
Both would allow all four required operations to be performant at scale. As for ACID guarantees, they are offered by both, traditional SQL databases and latest versions of modern document stores.
The device configurations do not share any relationship with each other, as of the current requirements, hence a document store would serve well.
However, it might be a safer choice to remain flexible for future requirements. An example could be the ability to select those devices that have a certain type or belong to a certain set of coworking spaces, like all computers in German offices. Such relationships are more easily represented in a relational database. Therefore, for this application, PostgreSQL has been chosen as the persistence solution
The application's persistence layer leverages JPA/Hibernate as the ORM, together with Spring Data.
To represent the data model in the relational database, out of JPA's four common inheritance mapping strategies, table per class has been chosen. Each concrete device type, such as computer, has its own database table, avoiding nullable columns for values only available for that device type and also name clashes for semantically different values.
No join will be required for the queries, while the polymorphic retrieval of all devices is supported through a union query. The inheritance hierarchy of the entities at the persistence layer mirrors the data model. The device id is used as the primary key.
The application provides a REST API to its clients with the following endpoints:
POST /devices
creates a new configuration for a devicePUT /devices/<device id>
replaces a device configurationPATCH /devices/<device id>
updates values in a device configurationGET /devices/<device id>
retrieves a single device configurationGET /devices
lists the configurations of all devices
Hypermedia (HATEOAS) is not supported.
Should Spring Boot annotations be allowed in core application package?
Current solution:
The application package is fully framework-agnostic.
Instead of having the @Service
annotation on the service classes,
the service beans are created through a @Configuration
in the boot package.
The annotation @Transaction
would make sense on the service classes as well.
However, it is put on the method ´transactionalin the JPA implementation of the
DeviceRepository` interface.
The API allows updates to device configurations through an HTTP PATCH query to
the URL path /devices/{device id}
.
The simplest format for a PATCH request body is JSON Merge Patch, specified by RFC7386. It allows the client to send only the values to modify. The goal here is to avoid unnecessary payload.
An example is a PATCH query to /devices/lenovo-XR1823
with a body as follows:
{
"name": "pc-123",
"ipAddress": "1.1.1.1",
}
A field with a null
value would mean that not only the value should be removed,
but even the field should structurally disappear.
This application enforces a structure on the objects. The restriction in the specification would mean that either the content type cannot be used or, at least, deleted fields should not re-appear when requested by a subsequent GET.
In the current model, all fields are mandatory,
so specifying a null
value is forbidden to begin with.
Should the model change, however,
then this API would not be fully compliant with the specification of the content type.
After all, a pragmatic interpretation should not be an impediment in practice.
Since the type of the device with the specified ID is already known to the system, it doesn't need to be specified by the client. This also means that the backend needs to look up the type before parsing the request.
Whose responsibility is it to know how to perform the update?
- Should the REST controller request the original data for the item and replace the specified items?
- Should the application layer be responsible for performing these two operations?
- Should all be left to the persistence layer that might perform an optimized database update?
The decision was taken to move those operations that might yield technical optimizations in the future to the persistence layer, even though they might be composed of two operations at the moment. This includes all operations that verify the existence of an entity before updating it.
On the other hand, operations for which the backend requires the knowledge of the persisted device type, are making two calls to the persistence layer. To ensure their atomicity, however, they need to be performed within the boundaries of a transaction. For that reason, the application layer, which is responsible for defining those boundaries, has control over these operations.
For the update operation, the REST controller needs to know the type to be able to parse the received update document. In order to preserve the separation of responsibilities between the controller and the service, the controller sends a closure to the service. That function knows how to parse either type of document. The service executes the closure with the device type that it retrieves from the database before the execution.
Creating new devices and updating existing ones could have been designed in two different ways:
- A device resource is both created and updated using a PUT request.
- A POST request is used for creating a device resource, a PUT request for updating it.
Since the ID is specified by the API client instead of being generated by the service, a PUT request would have been well suited for both operations, as idempotency would be preserved.
However, this API aims to provide the ability to its consumer to differentiate between both operations. Also, one thing to avoid is data being overridden accidentally amid an attempt to create a device with the ID of an existing one. For these reasons, the decision was taken in favor of solution 2. The client should be aware of whether a device should be created or overridden upfront and applying the wrong operation should result in an error.
Another question deals with updating a resource. Should the full resource be sent at all times in order to be updated or should the client send only the values to be updated?
In order to allow single values to be updated without the need to send the full resource data, an incremental update has been allowed through the PATCH method.
A full update to a device using the PUT method is also possible. Having the PUT endpoint alongside PATCH allows a client to send equal structured device representations for both creation (POST) and modification (PUT), whereas a document update sent as PATCH cannot include non-modifiable fields, such as the ID.