Broadleaf Microservices
  • v1.0.0-latest-prod

Adding a New Import Implementation

This guide discusses key steps for adding a new import implementation for a particular data type.

This tutorial will demonstrate each step by introducing a highly simplified example custom product import. It’ll use 'external ID' as the main mechanism to determine whether or not a product already exists in the datastore (and whether it should be created or updated).

There is already a significantly more functional out-of-box product import implementation available. This tutorial merely uses product as an arbitrary domain to exemplify import concepts.

Import Service Configuration


In import services, introduce a new import specification bean instance.

Typically, extending DefaultSpecification is a good idea.

Examine each of the methods available in ImportSpecification (and their Javadocs) and determine what defaults you’ll need to override for the behavior you need.

Example Tutorial Product Specification
import org.apache.commons.lang3.StringUtils;

import com.broadleafcommerce.dataimport.domain.ImportFieldConfig;
import com.broadleafcommerce.dataimport.service.normalizer.ImportDataNormalizer;
import com.broadleafcommerce.dataimport.service.validation.BooleanValidator;
import com.broadleafcommerce.dataimport.service.validation.MoneyAmountValidator;

import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

import lombok.Getter;

public class TutorialProductSpecification extends DefaultSpecification
        implements GlobalImportSpecification {

    private static String TUTORIAL_PRODUCT_SPECIFICATION_NAME = "Tutorial Product Specification";
    private static String PRODUCT_ROW_TYPE = "PRODUCT";

    @Getter(onMethod_ = @Override)
    private final List<ImportDataNormalizer> importDataNormalizers;

    public TutorialProductSpecification(List<ImportDataNormalizer> normalizers,
            List<String> requiredAuthorities,
            List<String> requiredScopes) {
        this.importDataNormalizers = normalizers;

    public boolean canHandle(String importType) {
        return StringUtils.equals(importType, TUTORIAL_PRODUCT_IMPORT_TYPE);

    public String getMainRecordType() {
         * This defaults to match the import type, but in our case, the row type is different from
         * the import type, so we must override this value.
        return PRODUCT_ROW_TYPE;

    public boolean isCatalogDiscriminated() {
         * The entities we're dealing with are catalog-discriminated, so we indicate this to ensure
         * correct context information is available.
        return true;

    public boolean isSandboxDiscriminated() {
         * The entities we're dealing with are sandbox-discriminated, so we indicate this to ensure
         * correct context information is available.
        return true;

    public boolean shouldAutoGenerateOperationTypeForEachRecord(String rowType) {
         * Since we're requiring an external ID to be provided, the resource tier handler will be
         * able to check for each record's existence in the datastore and determine whether it needs
         * to be created or updated. The import service doesn't have to do anything eagerly.
        return false;

    public boolean shouldAutoGenerateResourceTierIdForEachRecord(String rowType) {
         * Since we're requiring an external ID to be provided, 'resource tier ID' becomes
         * irrelevant and unnecessary to deal with. We already have a mechanism to uniquely identify
         * a record, so the import service does not need to eagerly generate or deal with resource
         * tier IDs.
        return false;

    public boolean shouldAllowUnmappedHeaders(String rowType) {
         * We only want to honor headers that we've explicitly defined mappings for, and ignore any
         * columns we don't recognize.
        return false;

    protected void populateHeaderFieldConfigsByRowType(
            Map<String, Map<String, ImportFieldConfig>> headerFieldConfigsByRowType) {
         * Since we don't have more than one row type in this import, we just place all field
         * configurations under the 'main' record type, which in this case is product. This is
         * particularly important in the case where no row type column is provided in the input
         * file.
        headerFieldConfigsByRowType.put(getMainRecordType(), fieldConfigurationsForProductRow());

    private Map<String, ImportFieldConfig> fieldConfigurationsForProductRow() {
         * Using a LinkedHashMap provides some consistency in ordering semantics, which can be
         * convenient in some cases.
        Map<String, ImportFieldConfig> fieldConfigurationByHeader = new LinkedHashMap<>();
        fieldConfigurationByHeader.put("External ID", new ImportFieldConfig("externalId", true));
        fieldConfigurationByHeader.put("Name", new ImportFieldConfig("name", true));
        fieldConfigurationByHeader.put("SKU", new ImportFieldConfig("sku", true));
        fieldConfigurationByHeader.put("Default Price", new ImportFieldConfig("defaultPrice", true)
                .withValidator(new MoneyAmountValidator()));
        fieldConfigurationByHeader.put("Is Online", new ImportFieldConfig("online", true)
                .withValidator(new BooleanValidator()));
        return fieldConfigurationByHeader;

This needs to be registered as a Spring bean, so ensure your Spring configuration does so.

Example Spring Configuration
public TutorialProductSpecification tutorialProductSpecification(
        ImportDataNormalizer productBooleanNormalizer) {
    return new TutorialProductSpecification(
            // Inject the BooleanNormalizer since we have boolean fields in the specification
            // Will require ALL_IMPORT_PRODUCT permissions from the requester
            // Will require IMPORT_PRODUCT scope from the requester
Defining required scopes and authorities is critically important for security as discussed in this documentation.
You may need to enable your auto-configuration class via spring.factories as discussed in this documentation.

(optional) Parent Entity

If your import can benefit from the 'parent entity' concept, ensure to add the additional configuration discussed in this documentation.

In this example, our imported entities don’t share a common parent entity, and thus there’s no need to configure anything special.

Example Import File

In import services, introduce an example import file for users to reference when using the new implementation.

As discussed in the above link, the easiest way to add an example is to add a file to src/main/resources/examples with the filename matching {lowercase-import-type}.{file-extension}.

In our example, since the import type is TUTORIAL_PRODUCT, we introduce a file called tutorial_product.csv.

Table 1. Example File
External ID Name SKU Default Price Is Online


Product 1





Product 2




Raw CSV of Example File
External ID,Name,SKU,Default Price,Is Online
prodExtId1,Product 1,PROD-1,1.99,true
prodExtId2,Product 2,PROD-2,2.99,false

Resource Tier Configuration

For resource tier services that come with out-of-box import implementations, much of the basic configuration described below will already be in-place. In such scenarios, it’s typically okay to skip to the import batch handler step.

Import Consumer Common Library Dependency

In the resource tier microservice containing the entities that are being imported, add a dependency on the import consumer common library:


Messaging Configuration

Spring Configuration

Add an @EnableDataImportMessagingConfiguration onto a @Configuration class (or directly on your @SpringBootApplication class).

This does the following:

  • Initialize the data bindings within Spring Cloud Stream for consuming BatchRequest messages from the import service and for producing BatchCompletion messages to send to the import service.

  • Register the BatchListener, which will automatically look for ImportBatchHandler beans and invoke them upon batch receipt. Completion data returned by the handler is sent to the message broker.

public class DemoApplication {

	public static void main(String[] args) {, args);
You may need to enable your auto-configuration class via spring.factories as discussed in this documentation.

Message Bindings

Add the batchRequestInput and batchCompletionOutput binding properties:

Consumer group configuration may need to be adjusted depending on flex package composition.
# Receives batches from the Import service

# Emits completions back for the import service to consume

Then, add a Spring Cloud Stream binder implementation to connect to the same broker as import services. This is an example of the Kafka binder:


Import Batch Handler

Add an implementation of ImportBatchHandler that can handle a batch of your new import type. Ultimately, the batch record structure will match what you have defined in ImportSpecification, so your handler should be implemented accordingly.

Key Considerations

  • Ensure the handler returns exactly 1 BatchCompletionRecord for every BatchRecord it receives - this 1:1 mapping is extremely important to make sure errors are correctly surfaced to the end-user and import completion progress is accurately tracked.

  • The handler should carefully account for all scenarios where it may experience errors. For example, errors can occur at the 'conversion' step from the raw input row into a Java POJO. Errors can also occur at the 'persistence' step where an actual create/update is attempted for an entity. Ensure to capture and report all errors in BatchCompletionRecord instances.

    • This can be particularly tricky in situations with dependents.

      • Ex: if parent fails conversion or persistence, then typically that should mean all dependents will fail immediately.

      • Ex: if parent successfully persists but dependents fail, you can consider partial success semantics.

      • In some cases, you may not use a dependent row, but rather have a related entity defined via a column on the parent record (ex: asset column on a product record). In this case, be extra careful about bubbling errors, since the import service only tracks success/failure at the row level. This might mean that even if the parent instance successfully persists, you have to mark the row as errored because the related entity defined in the column failed to convert/persist. Otherwise, the user will never know about the failed operation.

  • Keep in mind that the handler may need to process import retries where previously failed rows are modified and resubmitted.

  • If your imported entities are Indexable, consider how re-indexing behavior should be engaged for them.


Example Import Batch Handler
import static com.broadleafcommerce.catalog.provider.RouteConstants.Persistence.CATALOG_ROUTE_KEY;

import org.apache.commons.collections4.ListUtils;
import org.apache.commons.lang3.StringUtils;

import com.broadleafcommerce.catalog.domain.product.Product;
import com.broadleafcommerce.catalog.service.product.ProductService;
import com.broadleafcommerce.common.dataimport.AbstractImportBatchHandler;
import com.broadleafcommerce.common.dataimport.messaging.BatchCompletion;
import com.broadleafcommerce.common.dataimport.messaging.BatchCompletionRecord;
import com.broadleafcommerce.common.dataimport.messaging.BatchRequest;
import com.broadleafcommerce.common.dataimport.util.ConversionUtils;
import com.broadleafcommerce.common.dataimport.util.PersistenceRequest;

import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;

@DataRouteByKey(CATALOG_ROUTE_KEY) // Necessary for DataRouting
public class TutorialProductImportBatchHandler
         * AbstractImportBatchHandler provides a starting point with functionality that is typically
         * useful for import processing.
        extends AbstractImportBatchHandler {

    private final ProductService<Product> productService;

    private final TutorialProductRowConverter productConverter;

    public TutorialProductImportBatchHandler(ContextRequestHydrator hydrator,
            ProductService<Product> productService,
            TutorialProductRowConverter productConverter) {
        this.productService = productService;
        this.productConverter = productConverter;

    // Necessary for Data Routing
    public String getDataRouteKey() {
        return CATALOG_ROUTE_KEY;

    public boolean canHandle(BatchRequest batch) {
        return StringUtils.equals("TUTORIAL_PRODUCT", batch.getType());

    public BatchCompletion handle(BatchRequest batch) {
        List<PersistenceRequest<Product>> productsToPersist = new ArrayList<>();
        List<BatchCompletionRecord> allCompletions = new ArrayList<>();
        convertRecords(batch, productsToPersist, allCompletions);
        persistProducts(batch, productsToPersist, allCompletions);
        return new BatchCompletion(batch.getId(), batch.getImportId(), allCompletions);

    private void preFetchProductsIntoBatchContext(BatchRequest batchRequest) {
        Set<String> productExternalIdsToQuery = ListUtils.emptyIfNull(batchRequest.getRecords())
                .filter(requestedRecord -> TutorialProductRowConverter.PRODUCT_ROW_TYPE
                .map(productRecord -> productRecord.getRow().get("externalId"))

        // Method from AbstractImportBatchHandler that builds a fully hydrated context info
        ContextInfo readContext = buildReadContextInfo(batchRequest.getContext());

        final List<Product> productsFoundByExternalId =
                productService.readAllByExternalIds(productExternalIdsToQuery, readContext);

        // Populate the BatchContext in a way the TutorialProductRowConverter expects
        final Map<String, Product> prefetchedProductsByExternalId =
                (Map<String, Product>) batchRequest.getContext().getAdditionalContextMap()
                                s -> new HashMap<>());
        productsFoundByExternalId.forEach(foundProduct -> prefetchedProductsByExternalId
                .put(foundProduct.getExternalId(), foundProduct));

     * Converts the input product records and builds persistence requests for successful
     * conversions, and failure completions for the rest.
     * @param batchRequest the input batch request
     * @param products the destination to add successful conversions to
     * @param completions the destination to add any early failure completions to
    private void convertRecords(BatchRequest batchRequest,
            List<PersistenceRequest<Product>> products,
            List<BatchCompletionRecord> completions) {
                .filter(requestedRecord -> TutorialProductRowConverter.PRODUCT_ROW_TYPE
                .forEach(productRecord -> {
                    ConversionUtils.ConversionResponse<Product> productConversionResponse =
                    if (productConversionResponse.isSuccessful()) {
                                new PersistenceRequest<>(productConversionResponse.getConverted(),
                                        // This import doesn't have the concept of 'embedded items'
                    } else {
                        // Comes from AbstractImportBatchHandler

    private void persistProducts(BatchRequest batch,
            List<PersistenceRequest<Product>> productsToPersist,
            List<BatchCompletionRecord> completions) {
        // persist() comes from AbstractImportBatchHandler

Generally it’s recommended to create a dedicated 'converter' component that can convert a particular row from a BatchRecord into a target entity POJO. See AbstractRowConverter.

Example Converter
import org.apache.commons.lang3.StringUtils;
import org.springframework.lang.Nullable;

import com.broadleafcommerce.catalog.domain.product.DefaultProductType;
import com.broadleafcommerce.catalog.domain.product.Product;
import com.broadleafcommerce.common.dataimport.conversion.AbstractRowConverter;
import com.broadleafcommerce.common.dataimport.messaging.BatchRecord;
import com.broadleafcommerce.common.dataimport.messaging.BatchRequest;
import com.broadleafcommerce.common.dataimport.util.ConversionUtils;
import com.broadleafcommerce.common.dataimport.util.IdResolver;
import com.broadleafcommerce.common.dataimport.util.RowUtils;
import com.broadleafcommerce.common.extension.TypeFactory;
import com.fasterxml.jackson.databind.ObjectMapper;

import java.time.Instant;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;

public class TutorialProductRowConverter
        extends AbstractRowConverter<ConversionUtils.ConversionResponse<Product>> {

    public static final String PRODUCT_ROW_TYPE = "PRODUCT";
     * In {@link BatchRequest.BatchContext#getAdditionalContextMap()}, we expect a nested map under
     * this key to contain prefetched products by their external ID.

    private final TypeFactory typeFactory;
    private final ObjectMapper objectMapper;
    private final IdResolver idResolver;

    public TutorialProductRowConverter(TypeFactory typeFactory,
            ObjectMapper objectMapper,
            IdResolver idResolver) {
        this.typeFactory = typeFactory;
        this.objectMapper = objectMapper;
        this.idResolver = idResolver;

    public boolean canConvert(BatchRecord record, @Nullable BatchRequest.BatchContext context) {
        return PRODUCT_ROW_TYPE.equals(record.getRecordType());

    public ConversionUtils.ConversionResponse<Product> convert(
            // Parent is irrelevant in this context
            @Nullable Object parent,
            BatchRecord record,
            @Nullable BatchRequest.BatchContext context) {
        try {
            if (!canConvert(record, context)) {
                throw new IllegalArgumentException(String.format(
                        "Could not convert a record with row type %s", record.getRecordType()));

            Pair<Boolean, Product> productResolutionResult =
                    instantiateOrGetPrefetchedProduct(record, context);
            boolean productAlreadyExistsInDatastore = productResolutionResult.getFirst();
            Product product = productResolutionResult.getSecond();

            final OperationType operation = determineOperationType(product,
                    productAlreadyExistsInDatastore, record, context);
            initializeData(product, record, operation, context);
            return ConversionUtils.ConversionResponse.success(product, operation);
        } catch (Exception e) {
            return ConversionUtils.ConversionResponse

     * We expect the {@link TutorialProductImportBatchHandler} to have pre-fetched products by
     * identifier fields in the original rows. Any products that were found in the datastore should
     * have been populated into {@link BatchRequest.BatchContext#getAdditionalContextMap()}.
     * <p>
     * If a product is present in this map, we will use this existing instance.
     * <p>
     * If a product is not present in this map, we will simply instantiate a new instance.
     * @param batchRecord the original {@link BatchRecord}
     * @param context the {@link BatchRequest.BatchContext}, which should have any pre-fetched
     *        products in {@link BatchRequest.BatchContext#getAdditionalContextMap()}
     * @return a {@link Pair} containing a boolean describing whether the product was found in the
     *         pre-fetched map, as well as the product instance itself
    protected Pair<Boolean, Product> instantiateOrGetPrefetchedProduct(
            @lombok.NonNull BatchRecord batchRecord,
            @Nullable BatchRequest.BatchContext context) {

        Product preFetchedProduct = getPrefetchedProduct(batchRecord, context);
        if (preFetchedProduct != null) {
            return Pair.of(true, preFetchedProduct);

        Product newlyInstantiatedProduct = typeFactory.get(Product.class);
        return Pair.of(false, newlyInstantiatedProduct);

     * Checks the {@link BatchRequest.BatchContext#getAdditionalContextMap()} to see if there is
     * already a pre-fetched product matching the {@code batchRecord}.
     * @param batchRecord the batch record for which to find the corresponding pre-fetched product
     *        instance (if exists)
     * @param context the batch context which should contain pre-fetched products set by
     *        {@link TutorialProductImportBatchHandler}
     * @return the pre-fetched product instance if found, or {@code null}
    protected Product getPrefetchedProduct(BatchRecord batchRecord,
            @Nullable BatchRequest.BatchContext context) {
        Map<String, Object> additionalContextMap = Optional.ofNullable(context)
        if (additionalContextMap == null) {
            return null;

        return Optional.ofNullable(batchRecord.getRow().get("externalId"))
                .flatMap(providedExternalId -> Optional.ofNullable(
                        .map(prefetchedProductsMap -> (Map<String, Product>) prefetchedProductsMap)
                        .map(prefetchedProductsMap -> prefetchedProductsMap

     * Determine the effective operation type for the given batch record and product instance. This
     * can help inform decisions on how to instantiate/map fields.
     * @param product the resolved product instance
     * @param productAlreadyExistsInDatastore whether the product was determined to already exist in
     *        the datastore. See
     *        {@link #instantiateOrGetPrefetchedProduct(BatchRecord, BatchRequest.BatchContext)}
     * @param record the original batch record
     * @param context the batch context
     * @return the effective operation type for the given batch record
    protected OperationType determineOperationType(@lombok.NonNull Product product,
            boolean productAlreadyExistsInDatastore,
            @lombok.NonNull BatchRecord record,
            @Nullable BatchRequest.BatchContext context) {
        com.broadleafcommerce.common.dataimport.messaging.OperationType requestedOperation =
        if (requestedOperation == null) {
            // Nothing was explicitly requested, so determine what to do based on entity existence
            if (productAlreadyExistsInDatastore) {
                return OperationType.UPDATE;
            } else {
                return OperationType.CREATE;

        if (productAlreadyExistsInDatastore &&
                        .equals(requestedOperation)) {
             * The file requested a create, but we found that this entity already exists in the data
             * store. We will implicitly understand this to actually be an update operation.
             * For example, this helps us gracefully handle situations like import retries, where
             * the same file is imported multiple times. The first attempt may be partially
             * successful in creating new records, and we don't want to force users to manually edit
             * the file to change the operation type on those rows before submitting their next
             * attempt.
            return OperationType.UPDATE;

        return requestedOperation.toTrackingOperation();

     * Initialize the data from the row onto the product. Set additional data as needed. For
     * example, set ID and activeStartDate.
     * <p>
     * This uses {@link RowUtils#copyRecordToEntity(Map, ObjectMapper, Object, boolean)}, which
     * reflectively sets values on the target instance.
     * @param product the target product onto which we set data.
     * @param record the original BatchRecord
     * @param operation the operation; typically CREATE or UPDATE
     * @param context the BatchContext
    protected void initializeData(@lombok.NonNull final Product product,
            @lombok.NonNull final BatchRecord record,
            @lombok.NonNull OperationType operation,
            @Nullable final BatchRequest.BatchContext context) {
        if (OperationType.CREATE.equals(operation)) {
             * If a value for a particular field is not provided in the row, it will be 'null'.
             * Since this is a 'create', we don't want the mapping process to overwrite defaulted
             * values to 'null'. Thus, we force null values to be ignored when mapping.
            RowUtils.copyRecordToEntity(record.getRow(), objectMapper, product, true);
            // Set values for items we don't expect in input but are required for creation
            if (product.getActiveStartDate() == null) {
        } else {
             * If a value for a particular header is not provided in the row, it will be 'null'.
             * Since this is an 'update', the final update semantics will skip updating fields that
             * are explicitly provided as 'null' on the instance. Thus, we want null values in the
             * row to be force-set to null on the instance as well, which will ensure they're
             * ignored.
             * This ultimately doesn't have strong consequences, since in some cases certain headers
             * may be omitted from the file altogether, and in that situation not even the row has a
             * 'null' value for it. When this happens, the instance will just retain whatever
             * existing value it had.
            RowUtils.copyRecordToEntity(record.getRow(), objectMapper, product, false);
        final String id = resolveProductId(product, record, operation, context);

    protected String resolveProductId(@lombok.NonNull Product product,
            @lombok.NonNull BatchRecord record,
            @lombok.NonNull OperationType operation,
            @Nullable BatchRequest.BatchContext context) {
        final String suppliedId;
        if (StringUtils.isNotBlank(product.getId())) {
            suppliedId = product.getId();
        } else {
            suppliedId = record.getResourceTierIdentifier();

        return Objects.requireNonNull(idResolver.resolveId(record.getRecordType(),
                suppliedId, record.getRow()));
Example Spring Configuration
public TutorialProductImportBatchHandler tutorialProductImportBatchHandler(
        ContextRequestHydrator hydrator,
        ProductService<Product> productService,
        TutorialProductRowConverter productRowConverter) {
    return new TutorialProductImportBatchHandler(hydrator, productService, productRowConverter);

public TutorialProductRowConverter tutorialProductRowConverter(TypeFactory typeFactory,
        ObjectMapper objectMapper) {
    return new TutorialProductRowConverter(typeFactory,
            // from the import consumer library
            new DefaultUlidIdGenerator());
You may need to enable your auto-configuration class via spring.factories as discussed in this documentation.

Testing in the Broadleaf Admin

Let’s test out our new import in the admin.

  • Log into the admin and go to Processes > Imports

  • Click on 'Import'

  • Since the specification implemented GlobalImportSpecification, you should see the new import type in the dropdown of options

    New Import Type in Admin Dropdown
  • Select the new import type, and choose a Catalog and Sandbox you want to import the products into

  • Use the 'Download an Example' button to download the example you created earlier

    Download New Import Example
  • Upload the example and initiate the import process

  • Once complete, you should be able to go to Catalog > Products to see your new products in the appropriate Sandbox

    View Created Products

And that marks the end of this tutorial!

With the products now created, try to update them by modifying the input file with new values and submitting another import into the same catalog/sandbox.