DEV Community

Cover image for SmartOrder — Part 5: The Commons Layer — Shared Infrastructure Done Right
Francesco Portus
Francesco Portus

Posted on

SmartOrder — Part 5: The Commons Layer — Shared Infrastructure Done Right

How a carefully designed shared library eliminates boilerplate across microservices — generics, AOP logging, custom Jackson modules, and a test framework you'll wish you had years ago.

Part 4 explored the Inventory Service. Before moving forward, there's a layer that quietly powers everything — a layer most tutorials skip entirely. This post dives into services/commons: the shared infrastructure that both order-service and inventory-service depend on.

Commons is not a dumping ground. It's a deliberate, multi-module library that solves real problems: generic CRUD, consistent serialization, AOP-based observability, OpenAPI cleanup, and test data lifecycle management. Let's take it apart.

👉 services/commons


The three sub-modules at a glance

services/commons
├── commons-business   ← domain-layer abstractions (no web dependency)
├── commons-service    ← web-layer infrastructure (HATEOAS, Jackson, AOP)
└── service-test       ← test utilities with transaction control
Enter fullscreen mode Exit fullscreen mode

The split is intentional. commons-business has no Spring MVC dependency — it can be pulled into any service without dragging in a web container. commons-service assumes a web context and brings in HATEOAS, OpenAPI, and AOP support.


commons-business: Generic CRUD without the ceremony

The Entity contract

Every domain object in SmartOrder implements a single interface:

public interface Entity<I> {
  I getId();
  void setId(I id);
}
Enter fullscreen mode Exit fullscreen mode

Simple. But it's the foundation for the entire generic CRUD stack. Inventory uses UUID, Order uses ObjectId — the abstraction doesn't care.

AbstractCrudService: one class to rule them all

public abstract class AbstractCrudService<T extends Entity<I>, I, R extends CrudRepository<T, I>>
    implements CrudService<T, I> {

  protected final R repository;

  @Override
  public <S extends T> Optional<S> update(I id, S entity) {
    return findById(id)
        .map(existing -> {
          BeanUtils.copyProperties(entity, existing, ServiceUtils.getImmutableFields(existing));
          return (S) save(existing);
        });
  }
}
Enter fullscreen mode Exit fullscreen mode

The update method is the interesting part. It uses BeanUtils.copyProperties but excludes immutable fields automatically. No manually listing "id", "createdDate", "lastModifiedDate" in every service.

ServiceUtils: reflection-based field protection

public static <S extends Entity<?>> String[] getImmutableFields(S entity) {
  Set<String> ignored = new HashSet<>();
  ignored.add("id");

  ignored.addAll(
      Arrays.stream(entity.getClass().getDeclaredFields())
          .filter(ServiceUtils::isAuditingField)
          .map(Field::getName)
          .toList());

  return ignored.toArray(new String[0]);
}

private static boolean isAuditingField(Field field) {
  return field.isAnnotationPresent(CreatedDate.class)
      || field.isAnnotationPresent(LastModifiedDate.class);
}
Enter fullscreen mode Exit fullscreen mode

Any field annotated with @CreatedDate or @LastModifiedDate is automatically excluded from updates. This convention-over-configuration approach means services never accidentally overwrite audit timestamps — and it works without touching a single business class.

JPA vs Mongo — two concrete services, zero duplication

// For JPA (Inventory Service)
public abstract class JpaCrudService<T extends Entity<I>, I>
    extends AbstractCrudService<T, I, JpaRepository<T, I>> {

  @Override
  public Page<T> findAll(Pageable pageable) {
    return repository.findAll(pageable);
  }
}

// For MongoDB (Order Service)
public abstract class MongoCrudService<T extends Entity<I>, I>
    extends AbstractCrudService<T, I, MongoRepository<T, I>> {

  @Override
  public Page<T> findAll(Pageable pageable) {
    return repository.findAll(pageable);
  }
}
Enter fullscreen mode Exit fullscreen mode

InventoryServiceImpl extends JpaCrudService and OrderServiceImpl extends MongoCrudService. Polyglot persistence — one generic layer.


commons-service: Where the real magic lives

Custom Jackson deserializers for Spring types

This is one of the most underrated pieces of the codebase. Spring's Page<T>, Pageable, PagedModel<T>, and EntityModel<T> don't serialize/deserialize symmetrically out of the box. Commons solves this with a set of custom Jackson modules that auto-register via JacksonAutoConfiguration.

The module architecture is elegant:

public class AbstractDeserializerModule<T> extends SimpleModule {

  private final Class<? super T> rawType;
  private final Function<JavaType, JsonDeserializer<? extends T>> deserializerFactory;

  @Override
  public void setupModule(SetupContext context) {
    super.setupModule(context);
    context.addDeserializers(new Deserializers.Base() {
      @Override
      public JsonDeserializer<?> findBeanDeserializer(
          JavaType javaType, DeserializationConfig config, BeanDescription beanDesc) {
        if (rawType.equals(javaType.getRawClass())) {
          return deserializerFactory.apply(javaType);
        }
        return null;
      }
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

Each module is just a one-liner that extends this base:

public class PageModule extends AbstractDeserializerModule<Page<?>> {
  public PageModule() {
    super(Page.class, javaType -> new PageDeserializer<>(javaType.containedTypeOrUnknown(0)));
  }
}
Enter fullscreen mode Exit fullscreen mode

PageDeserializer handles missing fields gracefully — no content array? Returns an empty list. No pageable? Returns Pageable.unpaged(). No totalElements? Falls back to content size.

@Override
public Page<T> deserialize(JsonParser parser, DeserializationContext ctxt) throws IOException {
  ObjectNode node = parser.readValueAsTree();

  List<T> content = extractContent(node, ctxt);
  Pageable pageable = extractPageable(node, ctxt);
  long totalElements = extractTotalElements(node, content);

  return new PageImpl<>(content, pageable, totalElements);
}
Enter fullscreen mode Exit fullscreen mode

The same pattern applies to PagedModel, EntityModel, and Pageable. Any service that pulls commons-service gets symmetric serialization for free.

AOP logging with zero configuration

LoggingAspect intercepts every method in your business packages and logs start, result, duration, and errors — with a correlation ID automatically managed via MDC.

@Around("execution(* it.portus..*(..))" + " && !within(it.portus.ms.commons..*)")
public Object logMethod(ProceedingJoinPoint joinPoint) throws Throwable {
  // ...
  String correlationId = getOrGenerateCorrelationId();
  long start = System.currentTimeMillis();

  if (targetLogger.isDebugEnabled()) {
    targetLogger.debug("[correlationId: {}] {} - START with args: {}",
        correlationId, methodName, formatArgs(joinPoint.getArgs()));
  }

  try {
    Object result = joinPoint.proceed();
    long duration = System.currentTimeMillis() - start;
    targetLogger.info("[correlationId: {}] {} - SUCCESS in {} ms, result: {}",
        correlationId, methodName, duration, result);
    return result;
  } catch (Exception ex) {
    targetLogger.error("[correlationId: {}] {} - ERROR in {} ms: {}",
        correlationId, methodName, duration, ex.getMessage(), ex);
    throw ex;
  } finally {
    MDC.remove(CORRELATION_ID);
  }
}
Enter fullscreen mode Exit fullscreen mode

The configuration is smart: it scans for @SpringBootApplication and @ComponentScan packages at startup, excluding the commons package itself. You can also extend it:

logging:
  aspect:
    extra-packages:
      - com.mycompany.special
Enter fullscreen mode Exit fullscreen mode

No @EnableAspectJAutoProxy annotation required. No bean declaration. Just add commons-service and your methods are observed.

HATEOAS: pluralizing link relations

A subtle but important detail. Spring HATEOAS by default uses class names for link relations. Commons registers a custom PluralizingRelProvider that makes collection links grammatically correct:

@Override
public LinkRelation getCollectionResourceRelFor(Class<?> type) {
  return LinkRelation.of(English.plural(type.getSimpleName().toLowerCase()));
}
Enter fullscreen mode Exit fullscreen mode

Inventory"inventory" for item, "inventories" for collection. Order"order" / "orders". Uses the evo-inflector library to handle English plural rules (companycompanies, boxboxes).

HATEOASLinkUtils: resolving placeholder base paths

When services run behind a gateway, their self-links need to reflect the forwarded prefix — not the internal path. HATEOASLinkUtils handles this transparently by reading ${property.name:/default} placeholders from @RequestMapping annotations:

// Controller mapping:
@RequestMapping("${openapi.order-service.base-path:/api/v1}")

// At runtime, HATEOASLinkUtils resolves the placeholder from environment or ForwardedHeader:
public static Link buildLink(Class<?> controllerClazz, Environment env, Link rawLink) {
  return applyPlaceholder(controllerClazz, env, rawLink);
}
Enter fullscreen mode Exit fullscreen mode

The same helper resolves affordances — so HAL-Forms metadata also gets correctly rewritten when accessed through the gateway. Clients never see internal URLs.

OpenAPI schema cleanup

Both services generate OpenAPI specs from code. Without intervention, Spring adds internal Spring types (RepresentationModel, Links, Link, PagedModel, etc.) to the schema — cluttering the API docs.

DefaultSchemaProcessorImpl solves this with a post-processing pipeline:

  1. Replace certain schemas with custom definitions (e.g., PageMetadata gets a cleaner DTO)
  2. Reorder schemas — business schemas first, utility schemas last
  3. Inline internal schemas (like Links) wherever they're referenced
  4. Remove internal schemas from the final components section

HateoasProcessorImpl extends this to handle HATEOAS-specific types and generates a correct Links schema as additionalProperties: { $ref: Link }.

@Override
protected <T> Optional<Schema<T>> resolveSchemaFromClass(Class<?> clazz) {
  if (Links.class.equals(clazz)) {
    Schema linkSchema = resolved.referencedSchemas.get("Link");
    ObjectSchema linksSchema = new ObjectSchema();
    linksSchema.additionalProperties(linkSchema);
    linksSchema.setDescription("HATEOAS Links");
    return Optional.of(linksSchema);
  }
  return super.resolveSchemaFromClass(clazz);
}
Enter fullscreen mode Exit fullscreen mode

service-test: A test framework in 3 files

Most test frameworks force you to choose between loading all test data upfront or managing state manually. service-test introduces a cleaner model with @WithTestData.

The annotation

@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
public @interface WithTestData {
  Class<? extends TestDataLoader>[] value();
  boolean transactional() default true;
  boolean rollback() default true;
}
Enter fullscreen mode Exit fullscreen mode

You provide a TestDataLoader implementation:

@TestComponent
@RequiredArgsConstructor
public class InventoryTestDataLoader implements TestDataLoader {

  private final InventoryRepository inventoryRepository;

  @Override
  public void load() {
    inventoryRepository.saveAll(
        Instancio.ofList(Inventory.class)
            .size(5)
            .ignore(Select.field(Inventory::getId))
            .ignore(Select.field(Inventory::getCreatedDate))
            .ignore(Select.field(Inventory::getLastModifiedDate))
            .create());
  }
}
Enter fullscreen mode Exit fullscreen mode

And annotate your test:

@DataJpaTest
@WithTestData(InventoryTestDataLoader.class)
@ImportAutoConfiguration(JpaAutoConfiguration.class)
@Import({InventoryServiceImpl.class})
class InventoryServiceTest {
  // each test runs with 5 random inventories pre-loaded
  // automatically rolled back after each test
}
Enter fullscreen mode Exit fullscreen mode

How it works: WithTestDataListener

WithTestDataListener is registered as a TestExecutionListener via spring.factories. It hooks into beforeTestMethod and afterTestMethod:

  • If transactional = true (default): loads data within the existing test transaction — Spring rolls it back automatically
  • If transactional = false: opens a new PROPAGATION_REQUIRES_NEW transaction, commits or rolls back after the test based on the rollback flag
@Override
public void beforeTestMethod(TestContext testContext) {
  findAnnotation(testContext).ifPresent(annotation -> {
    Runnable loadData = () -> loadTestData(ctx, annotation);
    if (annotation.transactional()) {
      loadData.run();
      return;
    }
    // Start a new transaction for non-transactional tests
    TransactionStatus status = txManager.getTransaction(def);
    testContext.setAttribute(TX_STATUS_KEY, status);
    loadData.run();
  });
}
Enter fullscreen mode Exit fullscreen mode

This gives you full control over test data lifecycle without reaching for @Sql scripts or Liquibase migrations in tests.


Auto-configuration: zero-config by design

All commons configuration registers itself via Spring Boot's AutoConfiguration.imports:

it.portus.ms.commons.config.CommonsAutoConfiguration
it.portus.ms.commons.config.HateoasConfiguration
it.portus.ms.commons.config.JacksonAutoConfiguration
it.portus.ms.commons.config.LoggingAspectAutoConfiguration
it.portus.ms.commons.config.MongoMappersConfiguration
it.portus.ms.commons.config.OpenApiCustomizerConfiguration
Enter fullscreen mode Exit fullscreen mode

Every configuration class uses @ConditionalOnClass guards:

  • HateoasConfiguration only activates if HateoasConfiguration (Spring HATEOAS) is on the classpath
  • MongoMappersConfiguration only activates if ObjectId (MongoDB) is present
  • LoggingAspectAutoConfiguration only activates if AspectJ is present

This means services only pay for what they use. The Inventory Service doesn't get Mongo mappers. The gateway doesn't get JPA auditing utilities.


What makes this commons layer unusual

Most "commons" in enterprise codebases accumulate over years and become a mess of utilities nobody owns. SmartOrder's commons avoids this with three rules:

  1. No circular dependenciescommons-business knows nothing about commons-service
  2. Conditional everything — configs guard themselves with @ConditionalOnClass
  3. Convention over configuration — auditing exclusions, package scanning, and pluralization are automatic

The result: onboarding a new service means adding one Maven dependency. CRUD, HATEOAS, logging, OpenAPI cleanup, and test data management come along for free.


What's next

With commons fully unpacked, the complete picture of SmartOrder's service architecture is clear. The next article will zoom out and look at the commons-service OpenAPI template customization — how Mustache templates are layered and overridden to produce clean, HATEOAS-aware generated code across both services.

Repo: → github.com/portus84/smartorder-ms

Top comments (0)