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)
-
A change arrives over REST and is validated + persisted to the relational database (MySQL or Oracle).
-
After the transaction commits, the service rebuilds a read-optimized snapshot and publishes it to a Hazelcast distributed list.
-
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 |
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 |
|
Repository |
|
DTO |
|
Mapper |
|
Service |
|
Cache |
|
Controller |
|
Import/Export |
|
OpenAPI examples |
|
Migration |
|
Naming conventions (enforced by review, see CLAUDE.md):
| Suffix | Meaning |
|---|---|
|
Business logic (facade) |
|
REST endpoints |
|
Spring Data JPA |
|
JPA entity |
|
DTOs |
|
Hazelcast publisher |
|
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 |
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 |
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 |
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.
-
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> -
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 |
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 |
|
|
Controller HTTP |
|
MockMvc / RestAssured, mocked service |
Integration |
|
extend |
End-to-end |
|
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
@EntityGraphfor collections) -
Immutable request/response DTOs with Bean Validation — never the
@Entity -
Manual mapper producing the API snapshot and a
shared-dtosnapshot -
Service: constructor injection,
@Transactional,@Auditableon 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
labelKeyadded to the i18n bundle
Adding a Field to an Existing Domain
-
Entity — add the column field.
-
Liquibase — add a new changeset (
<addColumn>); never modify a merged one. For aNOT NULLcolumn on existing data, add it nullable + backfill, then enforce, in separate changesets. -
DTOs — add the field to the request/response (and patch DTO if present); add validation.
-
Mapper — map the new field both to the snapshot DTO and to the shared-dto type.
-
shared-dto — if the field must reach consumers, add it to the
com.friendly:shared-dtotype (coordinate — it is a published contract). -
Tests — extend unit + IT assertions; keep coverage above the gate.
-
OpenAPI examples — update
*OpenApiExamplesso 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.
Path A — Hazelcast Client (push-based, recommended for runtime consumers)
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
}
}
|
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.