Data Models

    NOTE: This page is a description on how to create data models in the backend using Elide. For more information on interacting with an Elide API, please see our API usage documentation.

    The library provides a set of annotations for describing relationships between entities. Elide makes use of the following JPA annotations: , @OneToOne, , @ManyToOne, and . Any JPA property or field that is exposed via Elide and is not a relationship is considered an attribute of the entity.

    If you need more information about JPA, please review their documentation or see our examples below.

    Exposing a Model as an Elide Endpoint

    After creating a proper data model, exposing it through Elide requires you configure include it in Elide. Elide generates its API as a graph; this graph can only be traversed starting at a root node. Rootable entities are denoted by applying @Include(rootLevel=true) to the top-level of the class. Non-rootable entities can be accessed only as relationships through the graph.

    1. @Include
    2. @Entity
    3. public class Book {
    4. private Long id;
    5. private String title;
    6. private Set<Author> authors;
    7. @Id
    8. @GeneratedValue(strategy=GenerationType.AUTO)
    9. public Long getId() {
    10. return id;
    11. }
    12. public void setId(Long id) {
    13. this.id = id;
    14. }
    15. public String getTitle() {
    16. return title;
    17. }
    18. public void setTitle(String title) {
    19. this.title = title;
    20. }
    21. @ManyToMany
    22. public Set<Author> getAuthors() {
    23. return authors;
    24. }
    25. public void setAuthors(Set<Author> authors) {
    26. this.authors = authors;
    27. }
    28. }

    Considering the example above, we have a full data model that exposes a specific graph. Namely, a root node of the type Author and a bi-directional relationship from Author to Book. That is, one can access all Author objects directly, but must go through an author to see information about any specific Book object.

    All public getters and setters are exposed through the Elide API if they are not explicitly marked or @Exclude. allows a field to be ignored by both Elide and an underlying persistence store while @Exclude allows a field to exist in the underlying JPA persistence layer without exposing it through the Elide API.

    Much of the Elide per-model configuration is done via annotations. For a description of all Elide-supported annotations, please check out the .

    A computed attribute is an entity attribute whose value is computed in code rather than fetched from a data store.

    Elide supports computed properties by way of the @ComputedAttribute and annotations. These are useful if your datastore is also tied to your Elide view data model. For instance, if you mark a field @Transient, a datastore such as Hibernate will ignore it. In the absence of the * attributes, Elide will too. However, when applying a computed property attribute, Elide will expose this field anyway.

    A computed attribute can perform arbitrary computation and is exposed through Elide as a typical attribute. In the case below, this will create an attribute called myComputedAttribute.

    1. @Include
    2. @Entity
    3. public class Book {
    4. ...
    5. @Transient
    6. public String getMyComputedAttribute(RequestScope requestScope) {
    7. return "My special string stored only in the JVM!";
    8. }
    9. ...
    10. }

    The same principles are analogous to @ComputedRelationships.

    Lifecycle Hooks

    • Pre Commit - Executed immediately prior to transaction commit but after all security checks have been evaluated.
    • Post Commit - Executed immediately after transaction commit.
      There are two mechanisms to enable lifecycle hooks:

    • The simplest mechanism embeds the lifecycle hook as methods within the entity bean itself. The methods are marked with … annotations (see below).

    • Lifecycle hook functions can also be registered with the EntityDictionary when initializing Elide.

    There are separate annotations for each CRUD operation (read, update, create, and delete) and also each life cycle phase of the current transaction:

    1. @Entity
    2. class Book {
    3. @Column
    4. public String title;
    5. @OnReadPreSecurity("title")
    6. public void onReadTitle() {
    7. // title attribute about to be read but 'commit' security checks not yet executed.
    8. }
    9. @OnUpdatePreSecurity("title")
    10. public void onUpdateTitle() {
    11. // title attribute updated but 'commit' security checks not yet executed.
    12. }
    13. @OnUpdatePostCommit("title")
    14. public void onCommitTitle() {
    15. // title attribute updated & committed
    16. }
    17. @OnCreatePostCommit
    18. public void onCommitBook() {
    19. // book entity created & committed
    20. }
    21. /**
    22. * Trigger functions can optionally accept a RequestScope to access the user principal.
    23. */
    24. @OnDeletePreCommit
    25. public void onDeleteBook(RequestScope scope) {
    26. // book entity deleted but not yet committed
    27. }
    28. }

    All trigger functions can either take zero parameters or a single RequestScope parameter.

    The RequestScope can be used to access the user principal object that initiated the request:

    Update trigger functions on fields can also take both a RequestScope parameter and a ChangeSpec parameter.The ChangeSpec can be used to access the before & after values for a given field change:

    1. @OnUpdatePreSecurity("title")
    2. public void onUpdateTitle(RequestScope scope, ChangeSpec changeSpec) {
    3. //Do something with changeSpec.getModified or changeSpec.getOriginal
    4. }

    Specifying an annotation without a value executes the denoted method on every instance of that action (i.e. every update, read, etc.). However, if a value is specified in the annotation, then that particular method is only executed when the specific operation occurs to the particular field. Below is a description of each of these annotations and their function:

    • @OnCreatePreSecurity This annotation executes immediately when the object is created, with fields populated, on the server-side after User checks but before commit security checks execute and before it is committed/persisted in the backend. Any non-user inline and operation CreatePermission checks are effectively commit security checks.
    • This annotation executes after the object is created and all security checks are evaluated on the server-side but before it is committed/persisted in the backend.
    • @OnCreatePostCommit This annotation executes after the object is created and committed/persisted on the backend.
    • This annotation executes immediately when the object is deleted on the server-side but before commit security checks execute and before it is committed/persisted in the backend.
    • @OnDeletePreCommit This annotation executes after the object is deleted and all security checks are evaluated on the server-side but before it is committed/persisted in the backend.
    • This annotation executes after the object is deleted and committed/persisted on the backend.
    • @OnUpdatePreSecurity(value) If value is not specified, then this annotation executes on every update action to the object. However, if value is set, then the annotated method only executes when the field corresponding to the name in value is updated. This annotation executes immediately when the field is updated on the server-side but before commit security checks execute and before it is committed/persisted in the backend.
    • (value) If value is not specified, then this annotation executes on every update action to the object. However, if value is set, then the annotated method only executes when the field corresponding to the name in value is updated. This annotation executes after the object is updated and all security checks are evaluated on the server-side but before it is committed/persisted in the backend.
    • @OnUpdatePostCommit(value) If value is not specified, then this annotation executes on every update action to the object. However, if value is set, then the annotated method only executes when the field corresponding to the name in value is updated. This annotation executes after the object is updated and committed/persisted on the backend.
    • (value) If value is not specified, then this annotation executes every time an object field is read from the datastore. However, if value is set, then the annotated method only executes when the field corresponding to the name in value is read. This annotation executes immediately when the object is read on the server-side but before commit security checks execute and before the transaction commits.
    • @OnReadPreCommit(value) If value is not specified, then this annotation executes every time an object field is read from the datastore. However, if value is set, then the annotated method only executes when the field corresponding to the name in value is read. This annotation executes after the object is read and all security checks are evaluated on the server-side but before the transaction commits.
    • (value) If value is not specified, then this annotation executes every time an object field is read from the datastore. However, if value is set, then the annotated method only executes when the field corresponding to the name in value is read. This annotation executes after the object is read and the transaction commits.

      Registered Function Hooks

    To keep complex business logic separated from the data model, it is also possible to register LifeCycleHook functions during Elide initialization (since Elide 4.1.0):

    1. /**
    2. * @param <T> The elide entity type associated with this callback.
    3. */
    4. @FunctionalInterface
    5. public interface LifeCycleHook<T> {
    6. /**
    7. * Run for a lifecycle event
    8. * @param requestScope The request scope
    9. * @param changes Optionally, the changes that were made to the entity
    10. */
    11. public abstract void execute(T elideEntity,
    12. RequestScope requestScope,
    13. Optional<ChangeSpec> changes);

    The hook functions are registered with the EntityDictionary by specifying the corresponding life cycle annotation (which defines when the hook triggers) alongwith the entity model class and callback function:

    1. //Register a lifecycle hook for deletes on the model Book
    2. dictionary.bindTrigger(Book.class, OnDeletePreSecurity.class, callback);
    3. //Register a lifecycle hook for updates on the Book model's title attribute
    4. dictionary.bindTrigger(Book.class, OnUpdatePostCommit.class, "title", callback);

    Sometimes models require additional information from the surrounding system to be useful. Since all model objects in Elide are ultimately constructed by the DataStore, and because Elide does not directly depend on any specific dependency injection framework (though you can still use your own ), Elide provides an alternate way to initialize a model.

    Elide can be configured with an Initializer implementation for a particular model class. An Initializer is any class which implements the following interface:

    1. public void populateEntityDictionary(EntityDictionary dictionary) {
    2. /* Assuming this DataStore extends another... */
    3. super.populateEntityDictionary(dictionary);
    4. /*
    5. * Create an initializer for model Foobar, passing any runtime configuration to
    6. * the constructor of the initializer.
    7. */
    8. ...
    9. /* Bind the initializer to Foobar.class */
    10. dictionary.bindInitializer(foobarInitializer, Foobar.class);
    11. }

    Dependency Injection

    Dependency injection in Elide can be achieved by using . To do so, implement your own store (or extend an existing store) and implement something like the following:

    1. public class MyStore extends HibernateStore {
    2. private final Injector injector;
    3. public MyStore(Injector injector, ...) {
    4. super(...);
    5. this.injector = injector;
    6. }
    7. @Override
    8. public void populateEntityDictionary(EntityDictionary) {
    9. /* bind your entities */
    10. for (Class<?> entityClass : yourEntityList) {
    11. dictionary.bindInitializer(injector::inject, entityClass);
    12. }
    13. }
    14. }

    Ultimately, each time an object of entityClass type is instantiated by Elide, Elide will run an initializer that allows the injection framework to inject into the new object.

    If you’re using the elide-standalone artifact, then this is already done by default.

    Data models can be validated using . This requiresJSR303 data model annotations and wiring in a bean validator in the DataStore.

    Philosophy

    Data models are intended to be a view on top of the or the set of datastores which support your Elide-based service. While other JPA-based workflows often encourage writing data models that exactly match the underlying schema of the datastore, we propose a strategy of isolation on per-service basis. Namely, we recommend creating a data model that only supports precisely the bits of data you need from your underlying schema. Often times there will be no distinction when first building your systems. However, as your systems scale and you develop multiple services with overlapping datastore requirements, isolation often serves as an effective tool to reduce interdependency among services and maximize the separation of concern. Overall, while models can correspond to your underlying datastore schema as a one-to-one representation, it’s not always strictly necessary and sometimes even undesireable.

    As an example, let’s consider a situation where you have two Elide-based microservices: one for your application backend and another for authentication (suppose account creation is performed out-of-band for this example). Assuming both of these rely on a common datastore, they’ll both likely want to recognize the same underlying User table. However, it’s quite likely that the authentication service will only ever require information about user credentials and the application service will likely only ever need user metadata. More concretely, you could have a system that looks like the following:

    Table schema:

    1. id
    2. userName
    3. password
    4. firstName
    5. lastName

    Authentication schema:

    Application schema:

    1. id
    2. userName

    While you could certainly just use the raw table schema directly (represented as a JPA-annotated data model) and reuse it across services, the point is that you may be over-exposing information in areas where you may not want to. In the case of the User object, it’s quite apparent that the application service should never be capable of accidentally exposing a user’s private credentials. By creating isolated views per-service on top of common datastores, you sacrifice a small bit of DRY principles for much better isolation and a more targeted service. Likewise, if the underlying table schema is updated with a new field that neither one of these services needs, neither service requires a rebuild and redeploy since the change is irrelevant to their function.

    A note about microservices: Another common technique to building microservices is for each service to have its own set of datastores entirely independent from other services (i.e. no shared overlap); these datastores are then synced by other services as necessary through a messaging bus. If your system architecture calls for such a model, it’s quite likely you will follow the same pattern we have outlined here with one key difference: the underlying table schema for your individual service’s datastore will likely be exactly the same as your service’s model representing it. However, overall, the net effect is the same since only the relevant information delivered over the bus is stored in your service’s schema. In fact, this model is arguably more robust in the sense that if one datastore fails not all services necessarily fail.