Developer Guide: Working with Configurations

This guide is for any Friendly Tech developer, on any project, who needs to either:

  • Store configuration here — add a new configuration domain to ft-configs-service so it is persisted, versioned, audited, and published to the shared cache; or

  • Consume configuration from here — read a configuration snapshot from another service (ACS, northbound-api, angular backend, or your own).

You do not need to be a maintainer of this repository to do either. The patterns below are uniform across every domain, so once you have seen one, you have seen them all.

Mental Model

ft-configs-service is the single source of truth for device/portal configuration. It follows a cache-first distribution model:

   write path                                    read path
   ----------                                    ---------
HTTP (REST)  ->  Service (@Transactional)        Consumer service
                 -> DB (Liquibase tables)         -> Hazelcast client
                 -> after commit:                     -> IList "cache-name"  (fast, push-based)
                    refresh Hazelcast snapshot      OR
                                                   -> GET /configs-service/...  (HTTP pull)
  1. A change arrives over REST and is validated + persisted to the relational database (MySQL or Oracle).

  2. After the transaction commits, the service rebuilds a read-optimized snapshot and publishes it to a Hazelcast distributed list.

  3. Runtime consumers read that snapshot — either by joining the Hazelcast cluster (push-based, low latency) or by calling the REST API (pull-based).

Every change is audited (who changed what, before/after) and the database carries an optimistic-lock version to prevent concurrent-edit conflicts.

The global servlet context path is /configs-service (see src/main/resources/application.yml). Every endpoint in this guide is relative to it — e.g. the controller mapping /provision-portal/configuration is reachable at http://<host>:8080/configs-service/provision-portal/configuration.

Anatomy of a Configuration Domain

Each domain lives in its own package and follows the same layout. Using Provision Portal Configuration (com.friendly.ftconfigsservice.provisionportal.configuration) as the reference:

Layer Files (real example)

Entity

ProvisionPortalConfiguration.java, ProvisionPortalDeviceInfoField.java — JPA entities

Repository

repository/ProvisionPortalConfigurationRepository.java — Spring Data JPA interfaces

DTO

dto/*RequestDto.java, dto/*SnapshotDto.java, dto/*PatchRequest.java — immutable request/response records

Mapper

service/ProvisionPortalConfigurationMapper.java — entity ↔ DTO ↔ shared-dto (manual, not MapStruct)

Service

service/ProvisionPortalConfigurationService.java — @Transactional business logic + @Auditable

Cache

cache/ProvisionPortalConfigurationCacheService.java — publishes the snapshot to Hazelcast

Controller

controller/ProvisionPortalConfigurationController.java — REST endpoints + OpenAPI

Import/Export

importexport/ProvisionPortalConfigurationXmlService.java — XML parse/render for file import/export

OpenAPI examples

ProvisionPortalConfigurationOpenApiExamples.java — example payloads for Swagger

Migration

src/main/resources/db/changelog/provision/…​xml — Liquibase table definitions

Naming conventions (enforced by review, see CLAUDE.md):

Suffix Meaning

*Service

Business logic (facade)

*Controller

REST endpoints

*Repository

Spring Data JPA

*Entity / domain noun

JPA entity

*Dto / *Request / *Response

DTOs

*CacheService

Hazelcast publisher

*OpenApiExamples

Swagger example payloads

Adding a New Configuration Domain

The example below adds a hypothetical mqtt-broker domain. Replace names accordingly. The order matters: each step builds on the previous one.

Step 1 — Entity

Use the custom @SmartIdGeneration (handles MySQL AUTO_INCREMENT and Oracle sequence {table}_0 automatically), Spring Data auditing fields, and an @Version column.

@Entity
@Table(name = "mqtt_broker_configuration")
@EntityListeners(AuditingEntityListener.class)   // populates created/updated audit fields
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class MqttBrokerConfiguration {

    @Id
    @SmartIdGeneration                            // MySQL identity / Oracle sequence, auto-detected
    @Column(name = "id")
    private Long id;

    @Column(name = "host", nullable = false, length = 255)
    private String host;

    @Column(name = "port", nullable = false)
    private Integer port;

    @CreatedDate    @Column(name = "created_at", updatable = false) private LocalDateTime createdAt;
    @CreatedBy      @Column(name = "created_by", updatable = false) private String createdBy;
    @LastModifiedDate @Column(name = "updated_at")                  private LocalDateTime updatedAt;
    @LastModifiedBy   @Column(name = "updated_by")                  private String updatedBy;

    @Version
    @Column(name = "entity_version", nullable = false)
    private Integer version;
}

For singleton configuration (one row per install — the common case), there is no natural business key. Load it with a deterministic query (see Step 2) and use a constant audit id (Step 5). For localized text, follow the *_i18n table convention used by ACS (a child table keyed by (entry_id, locale)); resolve with English fallback at the service layer.

Step 2 — Repository

public interface MqttBrokerConfigurationRepository
        extends JpaRepository<MqttBrokerConfiguration, Long> {

    // Singleton accessor -- deterministic ordering, never relies on a hardcoded id
    Optional<MqttBrokerConfiguration> findTopByOrderByIdAsc();
}

For collection domains that load nested data, prefer @EntityGraph or JOIN FETCH to avoid N+1 queries. When a domain has multiple collections, use separate queries rather than one multi-collection @EntityGraph (a single graph produces a cartesian product).

Step 3 — DTOs

Request/response DTOs are immutable (@Value + @Builder) with Bean Validation. @Valid cascades to nested objects.

@Value
@Builder
@Schema(description = "MQTT broker configuration payload")
public class MqttBrokerConfigurationRequestDto {

    @NotBlank
    @Schema(description = "Broker host")
    String host;

    @NotNull
    @Min(1) @Max(65535)
    @Schema(description = "Broker port")
    Integer port;
}

Keep a separate *SnapshotDto for responses (it can carry a generatedAt timestamp and computed fields). Never expose the JPA @Entity directly.

Step 4 — Mapper

Mapping is manual (deliberate choice — no MapStruct/ModelMapper). Produce two outputs: the API snapshot DTO, and the shared-dto type that external consumers read from the cache.

@Component
@RequiredArgsConstructor
public class MqttBrokerConfigurationMapper {

    public MqttBrokerConfigurationSnapshotDto toSnapshot(MqttBrokerConfiguration entity) {
        return MqttBrokerConfigurationSnapshotDto.builder()
                .host(entity.getHost())
                .port(entity.getPort())
                .generatedAt(Instant.now())
                .build();
    }

    // The shape that consumers receive from Hazelcast / REST.
    // Reuse an existing type from com.friendly:shared-dto when one fits.
    public MqttBrokerConfig toSharedConfiguration(MqttBrokerConfiguration entity) {
        return new MqttBrokerConfig(entity.getHost(), entity.getPort());
    }
}

The snapshot type published to the cache should come from the shared com.friendly:shared-dto artifact so consuming services can deserialize it without depending on ft-configs-service internals. If no suitable type exists, add one to shared-dto (coordinate with consumer teams) rather than publishing an internal DTO.

Step 5 — Service (with auditing and cache refresh)

Constructor injection (@RequiredArgsConstructor, final fields), @Transactional, and @Auditable on every mutating method. After the transaction commits, trigger the cache refresh via AfterCommitExecutor so Hazelcast is updated only when the DB write is durable.

@Service
@RequiredArgsConstructor
@Slf4j
public class MqttBrokerConfigurationService {

    private final MqttBrokerConfigurationRepository repository;
    private final MqttBrokerConfigurationMapper mapper;
    private final MqttBrokerConfigurationCacheService cacheService;

    @Transactional(readOnly = true)
    public MqttBrokerConfigurationSnapshotDto getConfiguration() {
        return mapper.toSnapshot(loadSingleton());
    }

    @Transactional
    @Auditable(
            operation = AuditOperation.UPDATE,
            context   = "MqttBrokerConfiguration",
            id        = "'mqtt-broker'",                       // SpEL; constant for a singleton
            entity    = MqttBrokerConfiguration.class,
            group     = "Provisioning",                        // UI tree grouping
            labelKey  = "audit.entity.mqtt_broker_configuration",
            icon      = "settings")
    public MqttBrokerConfigurationSnapshotDto updateConfiguration(MqttBrokerConfigurationRequestDto request) {
        MqttBrokerConfiguration entity = loadSingleton();
        entity.setHost(request.getHost());
        entity.setPort(request.getPort());
        repository.save(entity);
        scheduleCacheRefresh();
        return mapper.toSnapshot(entity);
    }

    private MqttBrokerConfiguration loadSingleton() {
        return repository.findTopByOrderByIdAsc()
                .orElseThrow(() -> FriendlyException.errorWithDescription(
                        ConfigsErrorCode.MQTT_BROKER_CONFIGURATION_NOT_FOUND, "singleton configuration"));
    }

    private void scheduleCacheRefresh() {
        AfterCommitExecutor.executeAfterCommit(
                cacheService::refreshCache, "MqttBrokerConfiguration cache refresh");
    }
}

Audit id rules. The id SpEL must resolve to the entity’s actual primary key, or the before-snapshot fails to load and the audit degenerates to a single $entity row. For singletons, a constant string ("'mqtt-broker'") is the accepted convention. For multi-record operations where one id cannot describe the change (batch updates, imports), set customPayload = true and populate AuditPayloadContextHolder inside the method.

Step 6 — Cache Service

Extend AbstractIListCacheService<S> where S is the shared-dto snapshot type. Provide both the production constructor (HazelcastInstance) and the test constructor (IList), implement cacheName() and refreshCache().

@Service
@Slf4j
public class MqttBrokerConfigurationCacheService
        extends AbstractIListCacheService<MqttBrokerConfig> {

    private final MqttBrokerConfigurationRepository repository;
    private final MqttBrokerConfigurationMapper mapper;

    @Autowired
    public MqttBrokerConfigurationCacheService(HazelcastInstance hazelcastInstance,
                                               MqttBrokerConfigurationRepository repository,
                                               MqttBrokerConfigurationMapper mapper) {
        super(hazelcastInstance);
        this.repository = repository;
        this.mapper = mapper;
    }

    // Test constructor: inject a mock IList directly
    public MqttBrokerConfigurationCacheService(IList<MqttBrokerConfig> cache,
                                               MqttBrokerConfigurationRepository repository,
                                               MqttBrokerConfigurationMapper mapper) {
        super(cache);
        this.repository = repository;
        this.mapper = mapper;
    }

    @Override
    protected String cacheName() {
        return CacheNames.MQTT_BROKER_CONFIGURATION;     // see Step 7
    }

    @Override
    @Transactional(readOnly = true)
    public MqttBrokerConfig refreshCache() {
        MqttBrokerConfig snapshot = mapper.toSharedConfiguration(
                repository.findTopByOrderByIdAsc()
                        .orElseThrow(() -> FriendlyException.errorWithDescription(
                                ConfigsErrorCode.MQTT_BROKER_CONFIGURATION_NOT_FOUND, "singleton configuration")));
        IListCacheWriter.replaceSnapshot(cache, snapshot);   // atomic set(0,..) — never empty
        return snapshot;
    }

    public MqttBrokerConfig getConfiguration() {
        return IListCacheReader.getFirstOrNull(cache);
    }
}

IListCacheWriter.replaceSnapshot uses set(0, snapshot) for in-place replacement so consumers never observe an empty list during an update (it only falls back to add() on first population). For multi-entry caches, use IListCacheWriter.replaceAll(cache, entries) (adds new entries first, then removes old ones).

Step 7 — Register the Cache Name

Add a constant to the central registry utils/cache/CacheNames.java (grouped by domain). This is the name consumers will look up.

// in CacheNames.java
public static final String MQTT_BROKER_CONFIGURATION = "mqtt-broker-configuration";

Step 8 — Controller

Plural-noun, kebab-case path; full OpenAPI annotations; DTOs in/out; @Valid on the body. Standard endpoint set: GET (read), PUT (full replace), optional POST /{section} patches, GET /export, POST /import/dry-run, POST /import.

@RestController
@RequestMapping("/mqtt-broker/configuration")
@RequiredArgsConstructor
@Validated
@Tag(name = "MQTT Broker Configuration", description = "CRUD and import/export")
public class MqttBrokerConfigurationController {

    private final MqttBrokerConfigurationService service;

    @GetMapping(produces = MediaType.APPLICATION_JSON_VALUE)
    @Operation(summary = "Get MQTT broker configuration")
    @ApiResponse(responseCode = "200", description = "Configuration snapshot")
    public MqttBrokerConfigurationSnapshotDto getConfiguration() {
        return service.getConfiguration();
    }

    @PutMapping(consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE)
    @Operation(summary = "Update MQTT broker configuration")
    public MqttBrokerConfigurationSnapshotDto update(
            @Valid @RequestBody MqttBrokerConfigurationRequestDto request) {
        return service.updateConfiguration(request);
    }
}

Step 9 — Liquibase Migration

Add a changelog under the matching subdirectory and wire it into the master file. Never edit a changeset that has already been merged — add a new one.

  1. Create src/main/resources/db/changelog/provision/2026-06-10-mqtt-broker-configuration.xml:

    <?xml version="1.0" encoding="UTF-8"?>
    <databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
                       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
                       xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
                                           http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.31.xsd"
                       objectQuotingStrategy="QUOTE_ONLY_RESERVED_WORDS">
    
        <changeSet id="mqtt-broker-configuration-create-table" author="your-name" context="ddl">
            <comment>Create table for MQTT broker configuration</comment>
            <createTable tableName="mqtt_broker_configuration">
                <column name="id" type="BIGINT" autoIncrement="true">
                    <constraints primaryKey="true" nullable="false"/>
                </column>
                <column name="host" type="VARCHAR(255)"><constraints nullable="false"/></column>
                <column name="port" type="INT"><constraints nullable="false"/></column>
                <column name="created_at" type="TIMESTAMP" defaultValueComputed="CURRENT_TIMESTAMP">
                    <constraints nullable="false"/>
                </column>
                <column name="created_by"  type="VARCHAR(255)"/>
                <column name="updated_at"  type="TIMESTAMP"/>
                <column name="updated_by"  type="VARCHAR(255)"/>
                <column name="entity_version" type="INT" defaultValueNumeric="0">
                    <constraints nullable="false"/>
                </column>
            </createTable>
        </changeSet>
    </databaseChangeLog>
  2. Register it in src/main/resources/db/changelog/db.changelog-master.yaml:

      - include:
          file: db/changelog/provision/2026-06-10-mqtt-broker-configuration.xml

The same changelog runs on both MySQL and Oracle — Liquibase auto-detects the dialect, and @SmartIdGeneration adapts the ID strategy at runtime (Oracle expects a sequence named {table}_0). Use the context="ddl" attribute for schema changes; seed data goes in separate changesets.

Step 10 — Tests

Coverage gate is 90% line / 85% branch (JaCoCo), and the gate aggregates unit + integration runs — so controllers need at least a *MockMvcTest and the domain needs MySQL + Oracle integration tests.

Kind Naming Base / style

Unit

*Test.java

@ExtendWith(MockitoExtension.class), mock repos, real service under test

Controller HTTP

*MockMvcTest.java

MockMvc / RestAssured, mocked service

Integration

*IT.java (or *MySqlIT / *OracleIT)

extend AbstractMySqlIntegrationTest / AbstractOracleIntegrationTest (Testcontainers)

End-to-end

*E2E.java

full context

Use TestDataFactory for seeding (users, JWTs, entities). Never use reflection in tests; never mock the subject under test. See Build & Test for the full testing reference.

Step 11 — i18n Audit Label

Add the labelKey you referenced in @Auditable to the audit message bundle (e.g. audit.entity.mqtt_broker_configuration) so the audit-log UI renders a localized type name. Without it, the entity simple name is used.

New-Domain Checklist

  • Entity with @SmartIdGeneration, auditing fields, @Version

  • Repository with a deterministic singleton accessor (or @EntityGraph for collections)

  • Immutable request/response DTOs with Bean Validation — never the @Entity

  • Manual mapper producing the API snapshot and a shared-dto snapshot

  • Service: constructor injection, @Transactional, @Auditable on mutations, after-commit cache refresh

  • Cache service extends AbstractIListCacheService, both constructors, cacheName() + refreshCache()

  • Cache name constant added to CacheNames.java

  • Controller: kebab-case path, OpenAPI annotations, @Valid

  • Liquibase changelog created and included in the master file (new changeset, never edit merged ones)

  • Unit + *MockMvcTest + MySQL IT + Oracle IT, coverage ≥ 90/85

  • Audit labelKey added to the i18n bundle

Adding a Field to an Existing Domain

  1. Entity — add the column field.

  2. Liquibase — add a new changeset (<addColumn>); never modify a merged one. For a NOT NULL column on existing data, add it nullable + backfill, then enforce, in separate changesets.

  3. DTOs — add the field to the request/response (and patch DTO if present); add validation.

  4. Mapper — map the new field both to the snapshot DTO and to the shared-dto type.

  5. shared-dto — if the field must reach consumers, add it to the com.friendly:shared-dto type (coordinate — it is a published contract).

  6. Tests — extend unit + IT assertions; keep coverage above the gate.

  7. OpenAPI examples — update *OpenApiExamples so Swagger stays accurate.

Consuming Configurations from Another Service

You have two integration paths. Pick cache for low-latency, push-based reads inside the cluster; pick REST for occasional pulls or services outside the cluster.

Join the cluster as a Hazelcast client and read the distributed list by its registered name. The element type is the com.friendly:shared-dto snapshot the publisher stores.

# consumer service config
hazelcast-client:
  cluster-name: dev
  network:
    cluster-members:
      - configs-service-host:5701
@Component
@RequiredArgsConstructor
public class ProvisionPortalConfigConsumer {

    private final HazelcastInstance client;

    public DeviceConfiguration current() {
        IList<DeviceConfiguration> cache =
                client.getList("provision-portal-configuration");   // CacheNames constant value
        return cache.isEmpty() ? null : cache.get(0);               // snapshot is always at index 0
    }
}
  • Add the com.friendly:shared-dto dependency to your consumer so the snapshot type deserializes.

  • Read index 0 — the publisher keeps a single snapshot there and replaces it atomically (set(0,..)), so you never see an empty list mid-update.

  • Treat the snapshot as read-only. Writes go through the REST API of ft-configs-service, not by mutating the cache.

Path B — REST API (pull-based)

curl -s http://configs-service-host:8080/configs-service/provision-portal/configuration \
     -H "Authorization: Bearer <jwt>"

Endpoints are documented in Swagger UI at http://<host>:8080/configs-service/swagger-ui/index.html. Authentication is JWT (see Security).

Which cache name do I read?

All names are constants in utils/cache/CacheNames.java, grouped by domain (e.g. PROVISION_PORTAL_CONFIGURATION = "provision-portal-configuration", NORTHBOUND_CONFIG = "northbound-config", COLUMN_DEFINITIONS = "column-definitions"). Use the string value as the getList(…​) argument.

Build, Run, Test

# Build
./gradlew build

# Run locally (embedded H2)
SPRING_PROFILES_ACTIVE=local ./gradlew bootRun

# Unit tests (fast, no DB)
./gradlew test

# Integration tests (Testcontainers)
./gradlew integrationTestMySql     # profile it-mysql
./gradlew integrationTestOracle    # profile it-oracle

# End-to-end
./gradlew e2eTest

# Single test
./gradlew test --tests "com.friendly.ftconfigsservice.SomeTest.methodName"

Spring profiles: local (H2 dev), mysql / oracle (runtime DB selection), it-mysql / it-oracle (integration tests). Key shared dependencies: com.friendly:shared-dto (consumer DTO contracts), com.friendly:ft-cache (Hazelcast wiring), com.hazelcast:hazelcast.