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.
Elide generates its API entirely based on the concept of data models. Data models are Java classes that represent both a concept to your application and also the schema of an exposed web service endpoint. Data models are intended to be a view on top of the data store or the set of data stores which support your Elide-based service.
All Elide models have an identifier field that identifies a unique instance of the model. Models are also composed of optional attributes and relationships. Attribute are properties of the model. Relationships are simply links to other related Elide models. Annotations are used to declare that a class is an Elide model, that a relationship exists between two models, to denote which field is the identifier field, and to secure the model.
Elide has first class support for JPA (Java Persistence API) annotations. These annotations serve double duty by both:
Elide makes use of the following JPA annotations: @OneToOne
, @OneToMany
, @ManyToOne
, @ManyToMany
, @Id
, and @GeneratedValue
.
If you need more information about JPA, please review their documentation or see our examples below.
However, JPA is not required and Elide supports its own set of annotations for describing models:
Annotation Purpose | JPA | Non-JPA |
---|---|---|
Expose a model in elide | @Include |
|
To One Relationship | @OneToOne , @ManyToOne |
@ToOne |
To Many Relationship | @OneToMany , @ManyToMany |
@ToMany |
Mark an identifier field | @Id |
Much of the Elide per-model configuration is done via annotations. For a full description of all Elide-supported annotations, please check out the annotation overview.
After creating a proper data model, you can expose it through Elide by marking with with @Include
. 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.
@Include(rootLevel=true)
@Entity
public class Author {
@Id
@GeneratedValue(strategy=GenerationType.AUTO)
private Long id;
private String name;
@ManyToMany
private Set<Book> books;
}
@Include
@Entity
public class Book {
@Id
@GeneratedValue(strategy=GenerationType.AUTO)
private Long id;
private String title;
@ManyToMany
private Set<Author> authors;
}
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.
Every model in Elide must have an ID. This is a requirement of both the JSON-API specification and Elide’s GraphQL API. Identifiers can be assigned by the persistence layer automatically or the client. Elide must know two things:
@Id
annotation.@GeneratedValue
annotation.Identifier fields in Elide are typically integers, longs, strings, or UUIDs. It is also possible to have composite/compound ID fields composed of multiple fields. For example, the following identifier type includes three fields that together create a primary key:
@Embeddable
@Data
@AllArgsConstructor
@NoArgsConstructor
public class Address implements Serializable {
private long number;
private String street;
private long zipCode;
}
This new compound ID type can then be referenced in an Elide model identifier like this:
@Include(rootLevel = true)
@Data
@Entity
public class Building {
@Id
@Embedded
private Address address;
}
Because JSON-API requires all ID fields to be Strings, composite/compound IDs require the developer to register an Elide Serde
to serialize and deserialize the ID type to a String. For example, the following Serde
will encode/decode an Address
as a base64 encoded string:
@ElideTypeConverter(type = Address.class, name = "Address")
public class AddressSerde implements Serde<String, Address> {
private static final Pattern ADDRESS_PATTERN =
Pattern.compile("Address\\(number=(\\d+), street=([a-zA-Z0-9 ]+), zipCode=(\\d+)\\)");
@Override
public Address deserialize(String val) {
byte[] decodedBytes = Base64.getDecoder().decode(val);
String decodedString = new String(decodedBytes);
Matcher matcher = ADDRESS_PATTERN.matcher(decodedString);
if (! matcher.matches()) {
throw new InvalidValueException(decodedString);
}
long number = Long.valueOf(matcher.group(1));
String street = matcher.group(2);
long zipCode = Long.valueOf(matcher.group(3));
Address address = new Address(number, street, zipCode);
return address;
}
@Override
public String serialize(Address val) {
return Base64.getEncoder().encodeToString(val.toString().getBytes());
}
}
More information about Serde
and user defined types can be found here.
Elide distinguishes between attributes and relationships in a data model:
@ToMany
) in the model. Relationships can be bidirectional or unidirectional.An elide model can be described using properties (getter and setter functions) or fields (class member variables) but not both on the same entity. For any given entity, Elide looks at whether @Id
is a property or field to determine the access mode (property or field) for that entity. All public properties and all fields are exposed through the Elide API if they are not explicitly marked @Transient
or @Exclude
. @Transient
allows a field to be ignored by both Elide and an underlying persistence store while @Exclude
allows a field to exist in the underlying persistence layer without exposing it through the Elide API.
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 @ComputedRelationship
annotations. These are useful if your data store is also tied to your Elide view data model. For instance, if you mark a field @Transient
, a data store such as Hibernate will ignore it. In the absence of the @Computed*
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
.
@Include
@Entity
public class Book {
...
@Transient
@ComputedAttribute
public String getMyComputedAttribute(RequestScope requestScope) {
return "My special string stored only in the JVM!";
}
...
}
The same principles are analogous to @ComputedRelationship
s.
Lifecycle event triggers allow custom business logic (defined in functions) to be invoked during CRUD operations at three distinct phases:
There are two mechanisms to enable lifecycle hooks:
@On...
annotations (see below).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:
@Entity
class Book {
@Column
public String title;
@OnReadPreSecurity("title")
public void onReadTitle() {
// title attribute about to be read but 'commit' security checks not yet executed.
}
@OnUpdatePreSecurity("title")
public void onUpdateTitle() {
// title attribute updated but 'commit' security checks not yet executed.
}
@OnUpdatePostCommit("title")
public void onCommitTitle() {
// title attribute updated & committed
}
@OnCreatePostCommit
public void onCommitBook() {
// book entity created & committed
}
/**
* Trigger functions can optionally accept a RequestScope to access the user principal.
*/
@OnDeletePreCommit
public void onDeleteBook(RequestScope scope) {
// book entity deleted but not yet committed
}
}
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:
@OnReadPostCommit("title")
public void onReadTitle(RequestScope scope) {
User principal = scope.getUser();
//Do something with the principal object...
}
Update and Create 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:
@OnUpdatePreSecurity("title")
public void onUpdateTitle(RequestScope scope, ChangeSpec changeSpec) {
//Do something with changeSpec.getModified or changeSpec.getOriginal
}
Lifecycle triggers can evaluate for actions on a specific field in a class, for any field in a class, or for the entire class. The behavior is determined by the value passed in the annotation:
*
denotes that the trigger should be called once per action on all fields or properties in the class that were referenced in the request.Below is a description of each of these annotations and their function:
@OnCreatePreSecurity(value)
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.@OnCreatePreCommit(value)
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(value)
This annotation executes after the object is created and committed/persisted on the backend.@OnDeletePreSecurity
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.@OnDeletePostCommit
This annotation executes after the object is deleted and committed/persisted on the backend.@OnUpdatePreSecurity(value)
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.@OnUpdatePreCommit(value)
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)
This annotation executes after the object is updated and committed/persisted on the backend.@OnReadPreSecurity(value)
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)
This annotation executes after the object is read and all security checks are evaluated on the server-side but before the transaction commits.@OnReadPostCommit(value)
This annotation executes after the object is read and the transaction commits.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):
/**
* Function which will be invoked for Elide lifecycle triggers
* @param <T> The elide entity type associated with this callback.
*/
@FunctionalInterface
public interface LifeCycleHook<T> {
/**
* Run for a lifecycle event
* @param elideEntity The entity that triggered the event
* @param requestScope The request scope
* @param changes Optionally, the changes that were made to the entity
*/
public abstract void execute(T elideEntity,
RequestScope requestScope,
Optional<ChangeSpec> changes);
The hook functions are registered with the EntityDictionary
by specifying the corresponding life cycle annotation (which defines when the hook triggers) along
with the entity model class and callback function:
//Register a lifecycle hook for deletes on the model Book
dictionary.bindTrigger(Book.class, OnDeletePreSecurity.class, callback);
//Register a lifecycle hook for updates on the Book model's title attribute
dictionary.bindTrigger(Book.class, OnUpdatePostCommit.class, "title", callback);
//Register a lifecycle hook for updates on _any_ of the Book model's attributes
dictionary.bindTrigger(Book.class, OnUpdatePostCommit.class, callback, true);
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 dependency injection frameworks), 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:
@FunctionalInterface
public interface Initializer<T> {
/**
* Initialize an entity bean
*
* @param entity Entity bean to initialize
*/
public void initialize(T entity);
}
Initializers can be configured in the EntityDictionary
using the following bind method:
/**
* Bind a particular initializer to a class.
*
* @param <T> the type parameter
* @param initializer Initializer to use for class
* @param cls Class to bind initialization
*/
public <T> void bindInitializer(Initializer<T> initializer, Class<T> cls) {
bindIfUnbound(cls);
getEntityBinding(cls).setInitializer(initializer);
}
Elide does not depend on a specific dependency injection framework. However, Elide can inject entity models during their construction (to implement life cycle hooks or other functionality).
Elide provides a framework agnostic, functional interface to inject entity models:
/**
* Used to inject all beans at time of construction.
*/
@FunctionalInterface
public interface Injector {
/**
* Inject an entity bean.
*
* @param entity Entity bean to inject
*/
void inject(Object entity);
}
An implementation of this interface can be passed to the EntityDictionary
during its construction:
EntityDictionary dictionary = new EntityDictionary(PermissionExpressions.getExpressions(),
(obj) -> injector.inject(obj));
If you’re using the elide-spring-boot*
artifacts, dependency injection is already setup using Spring.
If you’re using the elide-standalone
artifact, dependency injection is already setup using Jetty’s ServiceLocator
.
Data models can be validated using bean validation. This requires
JSR303 data model annotations and wiring in a bean validator in the DataStore
.
Type coercion between the API and underlying data model has common support across JSON-API and GraphQL and is covered here.
Elide supports two kinds of inheritance:
@MappedSuperclass
.@Inheritance
.Entity inheritance has a few caveats:
InheritanceType.JOINED
and InheritanceType.SINGLE_TABLE
strategies are supported.Data models are intended to be a view on top of the data store or the set of data stores which support your Elide-based service. While other JPA-based workflows often encourage writing data models that exactly match the underlying schema of the data store, 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 data store 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 data store schema as a one-to-one representation, it’s not always strictly necessary and sometimes even undesirable.
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 data store, 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:
id
userName
password
firstName
lastName
Authentication schema:
id
userName
password
Application schema:
id
userName
firstName
lastName
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 data stores, 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 data stores entirely independent from other services (i.e. no shared overlap); these data stores 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 data store 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 data store fails not all services necessarily fail.