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.
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
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);
}
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);
});
}
}
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);
}
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);
}
}
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;
}
});
}
}
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)));
}
}
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);
}
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);
}
}
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
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()));
}
Inventory → "inventory" for item, "inventories" for collection. Order → "order" / "orders". Uses the evo-inflector library to handle English plural rules (company → companies, box → boxes).
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);
}
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:
-
Replace certain schemas with custom definitions (e.g.,
PageMetadatagets a cleaner DTO) - Reorder schemas — business schemas first, utility schemas last
-
Inline internal schemas (like
Links) wherever they're referenced - 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);
}
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;
}
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());
}
}
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
}
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 newPROPAGATION_REQUIRES_NEWtransaction, commits or rolls back after the test based on therollbackflag
@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();
});
}
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
Every configuration class uses @ConditionalOnClass guards:
-
HateoasConfigurationonly activates ifHateoasConfiguration(Spring HATEOAS) is on the classpath -
MongoMappersConfigurationonly activates ifObjectId(MongoDB) is present -
LoggingAspectAutoConfigurationonly 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:
-
No circular dependencies —
commons-businessknows nothing aboutcommons-service -
Conditional everything — configs guard themselves with
@ConditionalOnClass - 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.
Top comments (0)