Broadleaf Microservices

Automatic Domain creation

When defining a new domain or extending an existing Broadleaf domain, there can be a fair bit of boilerplate code involved. Fortunately, Broadleaf has a feature to support automatically handling new domains and domain extensions with just a small amount of configuration.

This feature allows us to automatically configure the following with some simple configuration via annotations, including:

  • Domain projections

  • Basic service/repositories with CRUD operations

  • Endpoints with CRUD operations

This is not an all or nothing proposition, either. For example, we may want to define our own service class implementation and use the auto generated endpoints, or vice-versa: define our own controller and use the auto generated service.

For this article, we’ll cover this feature and how to configure and use it in various scenarios.

Setting up Automatic Projection on an existing domain

First, we’ll discuss how we can handle automatic projection when extending an existing domain. The most common extension happens on the Product domain, so we’ll use that as our example.

Out of the box, there are two java classes representing a product:

  1. com.broadleafcommerce.catalog.domain.product.Product

  2. com.broadleafcommerce.catalog.provider.jpa.domain.product.JpaProduct

One of these, Product, we call the projection domain (It may also be referred to as the business type/domain). The other, JpaProduct, we call the repository domain.

To add a new field, we’ll start by extending JpaProduct.

package com.mycompany.catalog.provider.jpa

@Entity
@Table(name = "COMPANY_PRODUCT")
@Data
public class JpaCompanyProduct extends JpaProduct {

    private static final long serialVersionUID = 1L;

    @Column(name = "MY_COMPANY_PROPERTY")
    private String myCompanyProperty;

}

Now, we simply need to configure this class as a JPA entity:

@Configuration
@JpaEntityScan(basePackages = "com.mycompany.catalog.provider.jpa",
routePackage = RouteConstants.Persistence.CATALOG_ROUTE_PACKAGE)
public class MyCompanyCatalogJpaAutoConfiguration {
}
Note
At this point, we would generally run UtilitiesIT to generate the necessary schema changes. Refer to the docs on extending the out of box domain for information on automatically generating schema changes in Liquibase.

And that’s it as far as configuration goes, but what if we want to actually use this change and set the value myCompanyProperty manually? That’s when we would use the Projection interface.

For the purposes of this tutorial, let’s assume we want to manually create and persist this entity.

public void createNewProduct(ContextInfo context) {
  Projection<JpaCompanyProduct> product = Projection.get(JpaCompanyProduct.class, "productId"); // (1)
  product.expose().setMyCompanyProperty("setting the property");  // (2)
  Projection<JpaCompanyProduct> persisted = productService.create(product, context); // (3)
}
  1. We create a new instance via Projection.get with the entity extension and its database ID (optional) as arguments.

  2. We interact with the underlying object via the expose method. We can use any defined getters and setters via expose

  3. When calling the create method, a Projection object is returned.

Note
We could interact directly with ProductRepository as well, but that is generally not recommended as that bypasses service class logic and validation flow. Instead, we should still interact directly with ProductService.

Setting up Automatic Projection for a new entity

As we mentioned earlier, we can use the automatic projection feature to quickly set up a brand new entity with a basic API, service, and repository. In this section, we’ll set up a new domain from scratch and explore the various options. As in the previous example, we’ll assume we’re setting up this new entity in the catalog service.

We’ll start by defining our new repository domain:

package com.mycompany.catalog.provider.jpa.domain;


@Entity
@Table(name = "COMPANY_ENTITY")
@Data
@EqualsAndHashCode(exclude = "id")
@EntityListeners(TrackingListener.class)
@TrackableExtension(TrackableBehavior.APPLICATION)
public class MyCompanyEntity implements Serializable, ApplicationTrackable<ApplicationJpaTracking> {

    @Id
    @GeneratedValue(generator = "blcid")
    @GenericGenerator(name = "blcid", strategy = "blcid")
    @Type(type = "com.broadleafcommerce.data.tracking.jpa.hibernate.ULidType")
    @Column(name = "ID", nullable = false)
    private String id;

    @Column(name = "CONTEXT_ID")
    @Convert(converter = UlidConverter.class)
    @FilterAndSortAlias("id")
    private String contextId;

    @Column(name = "NAME")
    private String name;

    @Column(name = "DESCRIPTION", length = JpaConstants.MEDIUM_TEXT_LENGTH)
    private String description;

    @Embedded
    private ApplicationJpaTracking tracking;

}

In a typical scenario, we’d also need to implement the ModelMapperMappable and BusinessTypeAware interfaces on our entity class, as well as a business domain. Here, we’ll let automatic projection take care of that, but we need to provide some configuration. This will let the system know that we want it to automatically manage this entity’s projection, endpoints, and service.

@Configuration
@JpaEntityScan(basePackages = "com.mycompany.provider.jpa.domain",
routePackage = RouteConstants.Persistence.CATALOG_ROUTE_PACKAGE)
@EnableJpaTrackableFlow(entityClass = MyCompanyEntity.class, routeKey = RouteConstants.Persistence.CATALOG_ROUTE_KEY,
permissionRoots = {"MY_ENTITY"}, rootPath = "/my-entity", projectionName = "MyCompanyProjection")
public class MyCompanyJpaAutoConfiguration {}

The interesting bit here is the annotation @EnableJpaTrackableFlow. Let’s talk about each of the arguments individually:

  1. entityClass - This is the new entity class we just defined.

  2. routeKey - The data route that applies to this entity. Since we’re operating in the catalog service, this is the catalog route key.

  3. permissionRoots - One or more permission roots. In this case, we would need the permission ALL_MY_ENTITY to have full access to the auto-generated endpoints, or individual permissions such as READ_MY_ENTITY, CREATE_MY_ENTITY, DELETE_MY_ENTITY, etc.

  4. rootPath - This is the path to the endpoint that will be automatically created.

  5. projectionName - Represents the simple class name that will be generated for our projection (Optional). This name may be used in the Broadleaf admin, so it is recommended to set this to a human-readable value.

Be aware that the EnableJpaTrackableFlow annotation is repeatable. If we have multiple entities we want the system to automatically handle, we’ll need an annotation for each of those entity classes.

Automatically Created Endpoints

With the previous configuration, several endpoints will be automatically created without having to define a controller class.

Table 1. Automatically Generated Endpoints
Method Header Path Permission Description

GET

/my-entity

READ_MY_ENTITY

Read all entities

GET

/my-entity/{id}

READ_MY_ENTITY

Read an entity by ID

PUT

/my-entity/{id}

UPDATE_MY_ENTITY

Replace an entity by ID

PATCH

/my-entity/{id}

UPDATE_MY_ENTITY

Update an entity by ID

DELETE

/my-entity/{id}

DELETE_MY_ENTITY

Delete an entity by ID

Explicitly Declaring Endpoints on Automatic Projections

It is possible to explicitly create endpoints as well. Let’s assume we want to use the automatically generated service implementation, but define our own controller.

To do so, we’ll create a controller class implementing the ProjectionReferencedApi interface:

@RestController
@RequiredArgsConstructor
@DataRouteByExample(Product.class) // We are in the catalog service, so we need to use the Catalog route
public class MyEntityEndpoint implements ProjectionReferencedApi<Projection<MyCompanyEntity>> {

    private final RsqlCrudEntityService<Projection<MyCompanyEntity>> service;

    @GetMapping("/my-entity")
    @Policy(permissionRoots = {"MY_ENTITY"})
    @Override
    public Page<Projection<MyCompanyEntity>> readAll(HttpServletRequest request,
            @ContextOperation ContextInfo context,
            @PageableDefault(size = 50) Pageable page,
            Node filters) {
        return service.readAll(filters, page, context);
    }

    @PostMapping(value = "/my-entity", consumes = MediaType.APPLICATION_JSON_VALUE)
    @Policy(permissionRoots = {"MY_ENTITY"})
    @Override
    public Projection<MyCompanyEntity> create(HttpServletRequest request,
            @ContextOperation(OperationType.CREATE) ContextInfo context,
            @RequestBody Projection<MyCompanyEntity> req) {
        return service.create(req, context);
    }

    @GetMapping("/my-entity/{id}")
    @Policy(permissionRoots = {"MY_ENTITY"})
    @Override
    public Projection<MyCompanyEntity> readById(HttpServletRequest request,
            @ContextOperation ContextInfo context,
            @PathVariable("id") String id) {
        return service.readByContextId(id, context);
    }

    @PatchMapping(value = "/my-entity/{id}", consumes = MediaType.APPLICATION_JSON_VALUE)
    @Policy(permissionRoots = {"MY_ENTITY"})
    @Override
    public Projection<MyCompanyEntity> update(HttpServletRequest request,
            @ContextOperation(OperationType.UPDATE) ContextInfo context,
            @PathVariable("id") String id,
            @JsonView(RequestView.class) @RequestBody Projection<MyCompanyEntity> req) {
        return service.update(id, req, context);
    }

    @PutMapping(value = "/my-entity/{id}", consumes = MediaType.APPLICATION_JSON_VALUE)
    @Policy(permissionRoots = {"MY_ENTITY"})
    @Override
    public Projection<MyCompanyEntity> replace(HttpServletRequest request,
            @ContextOperation(OperationType.UPDATE) ContextInfo context,
            @PathVariable("id") String id,
            @RequestBody Projection<MyCompanyEntity> req) {
        return service.replace(id, req, context);
    }

    @DeleteMapping("/my-entity/{id}")
    @Policy(permissionRoots = {"MY_ENTITY"})
    @Override
    public void delete(HttpServletRequest request,
            @ContextOperation(OperationType.DELETE) ContextInfo context,
            @PathVariable("id") String id) {
        service.delete(id, context);
    }
}

There are a few key notes here:

  1. We’ve omitted the standard Spring configuration for brevity, but we would just configure this the same as any other Spring @RestController: it’s just a Spring managed bean.

  2. Notice that we’re operating on Projection<MyCompanyEntity> and not MyCompanyEntity directly. Remember, as was mentioned earlier, that in order to access the properties of MyCompanyEntity, we use the Projection#expose method.

  3. We implement the ProjectionReferencedApi<Projection<MyCompanyEntity>> marker interface. This tells the system to back off and not automatically create endpoints for MyCompanyEntity.

  4. Our service class is defined as RsqlCrudEntityService<Projection<MyCompanyEntity>>. This implementation includes most standard CRUD operations. More advanced service functionality requires defining our own service implementation.

Custom Service Implementation

Finally, we can define our own service implementation. We can do this alongside our own controller, like the one we created above, or we can use the pre-defined endpoints and simply create our own service implementation.

To do so, we simply define a service like the one below and register it as a Spring bean:

public class SecondService extends BaseRsqlCrudEntityService<Projection<MyCompanyEntity>> {

    public SecondService(TrackableRepository<MyCompanyEntity> repository,
            RsqlCrudEntityHelper helper) {
        super(repository, helper);
    }

    @Override
    Projection<MyCompanyEntity> create(Projection<MyCompany> domain, ContextInfo context) {
       //... do stuff on creation
    }
}

Custom Projection Domain

Using our own projection domain is also an option. We don’t have to rely on the Projection interface.

Why would we want to do this?

  1. Working with a concrete class is simpler, especially if we need to perform more advanced logic.

  2. Direct control of getters/setters.

  3. More advanced mapping capabilities

First, we define our projection, or business domain, class. This implementation should have the same fields that we want mapped over from the entity class:

@Data
@JsonInclude(Include.NON_NULL)
public class MyCompanyProjection implements ContextStateAware {

    private String id;
    private String name;
    private String description;
    private ContextState contextState;

}

To use our own projection domain, we just need to implement the BusinessTypeAware interface on our entity class.

@Entity
@Table(name = "COMPANY_ENTITY")
@Data
@EqualsAndHashCode(exclude = "id")
@EntityListeners(TrackingListener.class)
@TrackableExtension(TrackableBehavior.APPLICATION)
public class MyCompanyEntity implements Serializable, ApplicationTrackable<ApplicationJpaTracking>,
    BusinessTypeAware {

    @Id
    @GeneratedValue(generator = "blcid")
    @GenericGenerator(name = "blcid", strategy = "blcid")
    @Type(type = "com.broadleafcommerce.data.tracking.jpa.hibernate.ULidType")
    @Column(name = "ID", nullable = false)
    private String id;

    @Column(name = "CONTEXT_ID")
    @Convert(converter = UlidConverter.class)
    @FilterAndSortAlias("id")
    private String contextId;

    @Column(name = "NAME")
    private String name;

    @Column(name = "DESCRIPTION", length = JpaConstants.MEDIUM_TEXT_LENGTH)
    private String description;

    @Embedded
    private ApplicationJpaTracking tracking;

    public Class<?> getBusinessDomainType() { return MyCompanyProjection.class; }
}

Now, when we want to define a custom endpoint or service implementation, we just use MyCompanyProjection as our domain type rather than Projection<MyCompanyEntity>.

Validation of a Projection class using the EntityValidator Interface

Validation on a Projection can be performed using the EntityValidator interface, but if advanced validation is needed it is recommended to define a concrete business class.

The roadblock here is determining whether a validator should execute on a particular projection class. This is achieved using the EntityValidator#supports(Class<?>, ContextInfo) method.

We can work around this issue using the following implementation:

public class MyCompanyDomainEntityValidator implements EntityValidator {

    // .... validation code

    public boolean supports(Class<?> serviceClass, ContextInfo contextInfo) {
        return Projection.get(MyCompanyEntity.class).getClass() == serviceClass;
    }
}

Controlling Request/Response data with ExplicitProjectionFieldConfiguration

In some cases, we may want to control the data that we expose via projections. For instance, maybe we want some sensitive customer information to be available on a request, but not a response. We can control what data is allowed on requests/responses, as well as custom serialization/deserialization via the @ExplicitProjectionFieldConfiguration annotation.

We’ll re-use our MyCompanyEntity example.

@Entity
@Table(name = "COMPANY_ENTITY")
@Data
@EqualsAndHashCode(exclude = "id")
@EntityListeners(TrackingListener.class)
@TrackableExtension(TrackableBehavior.APPLICATION)
public class MyCompanyEntity implements Serializable, ApplicationTrackable<ApplicationJpaTracking>, BusinessTypeAware {

    @Id
    @GeneratedValue(generator = "blcid")
    @GenericGenerator(name = "blcid", strategy = "blcid")
    @Type(type = "com.broadleafcommerce.data.tracking.jpa.hibernate.ULidType")
    @Column(name = "ID", nullable = false)
    @ExplicitProjectionFieldConfiguration(ignore = true) // Ignore this value in the request/response
    private String id;

    @Column(name = "CONTEXT_ID")
    @Convert(converter = UlidConverter.class)
    @FilterAndSortAlias("id")
    @ExplicitProjectionFieldConfiguration(responseOnly = true) // Only return this value in API response
    private String contextId;

    @Column(name = "NAME")
    @ExplicitProjectionFieldConfiguration(requestOnly = true) // Only allow this value on requests. Not included in API response.
    private String name;

    @Column(name = "DESCRIPTION", length = JpaConstants.MEDIUM_TEXT_LENGTH)
    private String description;

    @Column(name = "MONEY")
    @ExplicitProjectionFieldConfiguration(usingSerializer = CurrencyAwareBigDecimalSerializer.class,
                                          usingDeserializer = OptionalMonetaryAmountDeserializer.class)
                                          // Use a custom serializer/deserializer to handle money
    private BigDecimal money;

    @Embedded
    private ApplicationJpaTracking tracking;
}