“Can we just add a setting for this?”
It sounds harmless.
Suppose an office should be able to choose its default appointment duration. The first implementation is obvious:
@Entity
public class Office {
private int appointmentDuration;
}Set it to 30, expose it through the API, add an input in the frontend, and move on.
But now consider two offices that both currently use 30 minutes.
For the first office, 30 is explicitly configured. For the second, there is no configured value at all; it simply inherits the platform default of 30.
Those offices behave identically today.
They may behave differently tomorrow.
If the platform default changes to 20 minutes, the first office should probably stay at 30 while the second should inherit 20.
That distinction — explicit value versus effective value — is where a seemingly simple setting starts becoming a modeling problem.
It leads directly to another subtle question:
What should “Reset to default” actually do?
It should usually remove the override, not write the current default into it. Writing 30 and inheriting 30 happen to produce the same result today, but they represent different intent.
That is the kind of detail most generic settings-table designs miss.
This article follows one setting, APPOINTMENT_DURATION, from a field on an entity to a scoped, resolved, validated, audited, and cached configuration model — and looks at the design decisions that appear along the way.
A simple field is often the right answer
Putting configuration directly on an entity is not inherently bad.
@Entity
public class Office {
private boolean onlineBookingEnabled;
private int appointmentDuration;
private String timezone;
}For stable properties with obvious ownership, this design is excellent. It gives you strong typing, database constraints, simple queries, straightforward refactoring, and very little infrastructure.
The problem starts when those values stop being ordinary properties and acquire behavior: platform defaults, tenant overrides, office overrides, runtime modification, authorization rules, dynamic frontend rendering, validation metadata, audit requirements, or lifecycle rules.
At that point, configuration has concepts of its own, and those concepts need somewhere to live.
Definition and value are different concepts
Once settings become dynamic, I find it useful to separate two ideas.
A setting definition identifies a setting and records the persisted metadata the application needs to manage its lifecycle and presentation.
A setting value records an explicit override for a particular owner.
For example:
@Entity
public class SettingDefinition {
@Column(unique = true, nullable = false)
private String code;
private String label;
private String description;
@Enumerated(EnumType.STRING)
private SettingDataType dataType;
@Enumerated(EnumType.STRING)
private SettingStatus status;
}With:
public enum SettingDataType {
STRING,
BOOLEAN,
INTEGER,
DECIMAL,
ENUM,
JSON
}And:
public enum SettingStatus {
ACTIVE,
DEPRECATED,
DISABLED
}That entity is deliberately not the complete behavioral specification of the setting.
In the model I prefer, code owns behavioral metadata such as defaults, validation constraints, and permitted override scopes. The database owns persistent identity, lifecycle status, and presentation metadata such as labels or localizable descriptions.
So the canonical code-level specification might be:
public static final SettingSpec<Integer> APPOINTMENT_DURATION =
SettingSpec.integer("APPOINTMENT_DURATION")
.defaultValue(30)
.minimum(15)
.maximum(120)
.allowOverrideAt(
SettingScope.TENANT,
SettingScope.OFFICE
)
.build();This gives the application one authoritative place for the rules that affect execution.
The persisted definition still matters. It gives values and audit records a stable database identity, supports lifecycle management, and can carry presentation metadata without turning database rows into executable business rules.
At startup, the application can validate that the persisted definition is compatible with the code specification. If code says INTEGER while the database says STRING, deployment should fail before the application starts serving requests.
That separation avoids an awkward situation where the same rule exists independently in Java and in a mutable database row.
Ownership is not one field
A common generic model puts this on the definition:
private SettingOwnerType ownerType;That is coherent only if a setting belongs to exactly one level.
If APPOINTMENT_DURATION can exist only at office level, ownerType = OFFICE is fine.
But it does not describe a hierarchy such as:
user
↓
office
↓
organization
↓
tenant
↓
platform defaultIf the same setting can be overridden at several levels, its specification needs to express where overrides are permitted, not pretend it has one owner.
For example:
public enum SettingScope {
TENANT,
ORGANIZATION,
OFFICE,
USER
}Our appointment-duration setting permits:
TENANT
OFFICEThe platform default comes from the canonical specification. A tenant may override it. An office may override the tenant. A user may not.
Another setting might allow only tenant overrides. A security-sensitive setting might allow no business-level overrides at all.
That distinction becomes important once authorization enters the picture.
Resolution needs a context, not a growing method signature
Once hierarchy exists, the resolver needs to know which tenant, organization, office, or user is involved.
I would avoid this:
resolve(
String code,
UUID tenantId,
UUID officeId
);It bakes today's hierarchy into the public API. Add organizations later and every consumer changes.
A better boundary is a context object:
public record SettingContext(
UUID tenantId,
UUID organizationId,
UUID officeId,
UUID userId
) {}The internal resolver can then operate on that context:
public interface SettingResolver {
<T> ResolvedSetting<T> resolve(
SettingSpec<T> spec,
SettingContext context
);
}Business code should normally depend on a small Settings facade rather than directly on the resolver:
public interface Settings {
int getInteger(
SettingSpec<Integer> spec,
SettingContext context
);
boolean getBoolean(
SettingSpec<Boolean> spec,
SettingContext context
);
}The layering is intentional:
Business services
↓
Settings facade
↓
SettingResolver
↓
Persistence / cache / hierarchyThe facade gives consumers a convenient typed API. The resolver owns precedence, source tracking, caching, and persistence details.
Business services should never reimplement inheritance themselves.
Explicit value and effective value are different data
Suppose the platform default is 30 and an office has no override.
Returning this:
{
"value": 30
}throws away important information.
The frontend cannot know whether 30 was explicitly configured or inherited.
A better resolved model is:
public record ResolvedSetting<T>(
String code,
T explicitValue,
T effectiveValue,
SettingSource source,
boolean overridden
) {}An office with no override might resolve to:
{
"code": "APPOINTMENT_DURATION",
"explicitValue": null,
"effectiveValue": 30,
"source": "PLATFORM_DEFAULT",
"overridden": false
}If its tenant defines 20:
{
"code": "APPOINTMENT_DURATION",
"explicitValue": null,
"effectiveValue": 20,
"source": "TENANT",
"overridden": false
}And after the office chooses 45:
{
"code": "APPOINTMENT_DURATION",
"explicitValue": 45,
"effectiveValue": 45,
"source": "OFFICE",
"overridden": true
}This is not just frontend convenience. It exposes the actual semantics of the configuration model.
Reset is a first-class domain operation
Suppose the platform default is currently 30 and an office clicks Reset to default.
A naive implementation might do this:
settingValue.setValue("30");That does not reset anything.
It creates an explicit office override equal to today's default.
If the platform changes the default to 20 next month, that office stays on 30 because its value is now pinned.
If reset means “inherit again,” the operation must remove the explicit override.
But that does not mean the UI or business service should call the repository directly.
Reset has the same concerns as any other configuration mutation:
- authorization;
- auditing;
- cache invalidation;
- lifecycle rules;
- events or downstream side effects.
So it deserves an application operation of its own:
@Transactional
public void removeOverride(
SettingSpec<?> spec,
SettingContext context
) {
authorization.checkCanOverride(
spec,
context
);
SettingValue existing =
valueRepository.findOverride(
spec.code(),
context
).orElse(null);
if (existing == null) {
return;
}
valueRepository.delete(existing);
auditService.recordOverrideRemoved(
spec,
context,
existing.value()
);
cacheVersions.bump(context);
eventPublisher.publish(
new SettingChangedEvent(
spec.code(),
context
)
);
}After that operation, normal resolution takes over:
office override: none
tenant override: none
platform default: 30If the platform later changes to 20, the office automatically sees 20.
This is why absence is not merely missing data.
Absence can represent inheritance.
And reset is not just “delete instead of write.”
It is a first-class domain operation that restores inheritance.
Persisting polymorphic owners has real trade-offs
The domain model may be conceptually simple:
definition + scope + owner + valuebut the relational model still needs to represent that safely.
The generic approach is attractive:
setting_values
-------------------------
definition_id
owner_type
owner_id
valuewith:
UNIQUE(definition_id, owner_type, owner_id)It is flexible, but owner_id is polymorphic. The database cannot naturally enforce that an OFFICE value references offices.id while a TENANT value references tenants.id.
There are three common approaches.
A generic discriminator table keeps the schema compact and extensible but moves referential integrity into application logic.
Separate tables per scope, such as tenant_setting_values and office_setting_values, are more repetitive but give each owner a real foreign key and straightforward uniqueness constraints.
A third option uses nullable foreign keys:
definition_id
tenant_id
organization_id
office_id
user_id
valuewith a check constraint requiring exactly one owner column. That preserves relational integrity but becomes more cumbersome as scopes expand.
There is no universally correct answer. If ownership levels are few and stable, I value database-enforced referential integrity highly. If scopes really are extensible, a discriminator may be worth the weaker schema guarantees.
The important point is that a bare UUID ownerId is not free abstraction. It exchanges schema rigidity for weaker relational guarantees.
Not every setting should be overridable
APPOINTMENT_DURATION is a reasonable office-level setting.
AUDIT_LOGGING_ENABLED may not be.
The configuration model therefore needs to distinguish between visibility and editability.
Our canonical specs could look conceptually like this:
APPOINTMENT_DURATION
allowed overrides: TENANT, OFFICE
SECURITY_POLICY_ENABLED
allowed overrides: noneThe system must answer two separate questions:
What is the effective value?
and:
Is this actor allowed to create or remove an override at this scope?
The first belongs to resolution.
The second belongs to authorization.
A setting being visible does not imply it should be editable.
Validation belongs near the specification
Without a shared model, validation tends to spread through service code:
if ("APPOINTMENT_DURATION".equals(code)) {
int duration = Integer.parseInt(value);
if (duration < 15 || duration > 120) {
throw new InvalidSettingValueException();
}
}Then another setting adds another branch.
Then another.
The canonical specification gives structural validation a proper home:
SettingSpec.integer("APPOINTMENT_DURATION")
.defaultValue(30)
.minimum(15)
.maximum(120)
.build();Simple constraints such as type, minimum, maximum, allowed enum values, or string patterns can be enforced generically.
A setting also does not necessarily need a default. If the absence of a value is itself meaningful, the specification can explicitly model that instead of inventing a synthetic default just to satisfy the framework.
But not every rule belongs in the specification.
If appointment duration must be compatible with a scheduling algorithm, office opening hours, or another setting, that is business logic. A settings framework should centralize structural validation without turning into a homemade rules engine.
Where do definitions come from?
This is one of the most important questions in the entire design.
Who creates APPOINTMENT_DURATION in the first place?
Database-first
One option is to seed definitions through Liquibase, Flyway, or another migration system:
INSERT INTO setting_definitions (
code,
data_type,
status
)
VALUES (
'APPOINTMENT_DURATION',
'INTEGER',
'ACTIVE'
);This gives you explicit, reviewable deployment changes. Renames, additions, and lifecycle transitions are visible in migrations.
The downside is that application code has weaker compile-time knowledge of the available settings unless you create a second representation.
Code-first
Another approach is to declare the full catalog in source code:
public static final SettingSpec<Integer> APPOINTMENT_DURATION =
SettingSpec.integer("APPOINTMENT_DURATION")
.defaultValue(30)
.minimum(15)
.maximum(120)
.allowOverrideAt(TENANT, OFFICE)
.build();This is strongly typed, easy to discover, and easy to test.
But now you have to decide how database state follows code. Should startup silently insert missing definitions? Delete unknown ones? Modify metadata automatically?
That can make application startup unexpectedly destructive.
Hybrid: code owns behavior, migrations own lifecycle
For product-defined settings, I prefer a hybrid.
Code owns the behavioral contract:
- data type;
- default value;
- validation constraints;
- allowed scopes.
Database migrations own persistence and lifecycle:
- creation of the persisted definition;
- stable database identity;
- lifecycle state;
- labels or localization references;
- explicit renames and migrations.
Startup then performs reconciliation as validation rather than mutation.
For example:
Code expects:
APPOINTMENT_DURATION / INTEGER
Database contains:
APPOINTMENT_DURATION / STRING
→ deployment failsThe same principle applies to scope or other compatibility-critical metadata.
If code permits TENANT and OFFICE, but persisted metadata implies something incompatible, that should be detected deliberately rather than allowed to drift.
This avoids having two competing sources of truth: runtime behavior comes from code, while persistence evolution remains an explicit deployment concern.
The catalog joins the two halves
Once definitions are split between code and persistence, the application needs one place that can enumerate them.
That is the role of a SettingCatalog.
Conceptually:
public interface SettingCatalog {
Collection<SettingSpec<?>> all();
Optional<SettingSpec<?>> findByCode(
String code
);
}The catalog contains or discovers the registered code-level specifications:
APPOINTMENT_DURATION
ONLINE_BOOKING_ENABLED
REMINDER_DELAY
...When the application needs a settings page, startup validation, or bulk resolution, it joins those specs with their persisted SettingDefinition rows.
That joined view answers questions such as:
- Is this spec
ACTIVE,DEPRECATED, orDISABLED? - What label should the UI display?
- Does the persisted type still match the canonical spec?
- Which registered settings should appear in this context?
For example, the settings page can start from:
SettingCatalog
+
Persisted SettingDefinition metadata
+
Resolved values
+
Authorization
↓
SettingViewA DISABLED definition can therefore remain in the database for historical values and audit records without appearing in the normal configuration UI.
The catalog is also what makes bulk APIs such as resolveAll(...) practical: the application has a single enumerable registry of known specs rather than scattered constants with no discovery mechanism.
Setting codes are persistent identifiers
Suppose version one introduces:
SHOW_PATIENT_PHONE
Later, the feature becomes more nuanced and you replace it with:
PATIENT_CONTACT_VISIBILITY
That is not a normal rename.
The original code may already be referenced by stored values, audit records, frontend contracts, support documentation, tests, migration scripts, and external integrations.
Treating setting codes as API-like identifiers changes how lifecycle should work.
A safer migration might be:
1. introduce the new definition
2. migrate existing values
3. mark the old definition DEPRECATED
4. migrate consumers
5. eventually mark it DISABLEDDeleting the old row immediately is often the wrong operation.
Settings evolve much more like public contracts than ordinary labels.
Audit the decision, not just the current value
Configuration bugs often appear as behavior changes rather than exceptions.
A support ticket says:
Appointments were 30 minutes yesterday. Why are they 45 today?
Looking at the current setting only tells you that the value is 45. It does not tell you why the system changed.
For behaviorally important settings, an audit trail should be able to answer:
setting: APPOINTMENT_DURATION
owner: office-123
old value: inherited 30
new value: 45
changed by: user-456
changed at: 2026-09-09T10:42A reset should be equally visible:
setting: APPOINTMENT_DURATION
owner: office-123
old value: explicit 45
new value: inherited 20
operation: OVERRIDE_REMOVED
changed by: user-456For some settings, the historical trail is more valuable than the current row. This is especially true when configuration affects security, pricing, workflow, notifications, or user-visible behavior.
That is why a setting update or reset can become a real application operation rather than a simple repository mutation.
Updating a setting may have side effects
A service might eventually coordinate several concerns:
@Transactional
public <T> void setOverride(
SettingSpec<T> spec,
SettingContext context,
T value
) {
authorization.checkCanOverride(
spec,
context
);
validator.validate(
spec,
value
);
valueRepository.upsert(
spec.code(),
context,
value
);
auditService.recordChange(
spec,
context,
value
);
cacheVersions.bump(context);
eventPublisher.publish(
new SettingChangedEvent(
spec.code(),
context
)
);
}The generic type matters.
SettingSpec<Integer> should accept an Integer, not an arbitrary Object. If the specification is meant to provide type safety, the mutation API should preserve it all the way through the write path.
The matching reset operation follows the same lifecycle but removes the explicit value instead of storing a new one.
Not every setting needs events or cache invalidation. But the architecture should have a natural place for those concerns once they appear.
Changing DATE_FORMAT may affect only presentation. Changing ONLINE_BOOKING_ENABLED may alter behavior immediately. Changing APPOINTMENT_DURATION may affect future scheduling.
“Settings” are not necessarily passive data.
Bulk resolution matters
The typed facade is convenient for business logic:
int duration = settings.getInteger(
APPOINTMENT_DURATION,
context
);But it would be a poor implementation strategy for rendering an entire settings page.
Imagine 200 definitions across tenant, organization, office, and user scopes. Resolving each one independently can easily create an N+1 query problem.
The resolver therefore needs a bulk path too:
Map<String, ResolvedSetting<?>> resolveAll(
Collection<SettingSpec<?>> specs,
SettingContext context
);The caller can obtain those specs from the catalog, filtered by the persisted lifecycle metadata relevant to that operation.
Internally, the resolver can load each relevant scope in batches:
1 query → persisted definitions
1 query → tenant overrides
1 query → organization overrides
1 query → office overrides
1 query → user overridesand resolve precedence in memory.
This is an important distinction between a good consumer API and a good persistence strategy. The former may look like individual getters while the latter should remain batch-aware.
Configuration metadata can drive the frontend
Once definitions and resolved values are available, the backend can assemble a dedicated response DTO.
The constraint portion is intentionally type-dependent:
public record SettingView<T>(
String code,
String label,
String description,
SettingDataType type,
T explicitValue,
T effectiveValue,
SettingSource source,
boolean editable,
Map<String, Object> constraints
) {}For an integer setting, constraints might contain:
{
"minimum": 15,
"maximum": 120
}For a string:
{
"pattern": "^[A-Z0-9_-]+$",
"maxLength": 50
}For an enum:
{
"allowedValues": [
"EMAIL",
"SMS",
"NONE"
]
}So an APPOINTMENT_DURATION response could look like:
{
"code": "APPOINTMENT_DURATION",
"label": "Appointment duration",
"description": "Default duration for appointments",
"type": "INTEGER",
"explicitValue": null,
"effectiveValue": 30,
"source": "TENANT",
"editable": true,
"constraints": {
"minimum": 15,
"maximum": 120
}
}This DTO is intentionally different from ResolvedSetting<T>.
ResolvedSetting<T> represents resolution semantics.
SettingView<T> joins:
- canonical specification metadata;
- persisted definition metadata;
- resolved values;
- authorization state;
- presentation-specific constraints.
The frontend can now render the correct control, apply basic validation, indicate inheritance, and decide whether a reset action should be available.
Dynamic UI is useful, but it also means the setting catalog has become part of the backend/frontend contract.
Make testing cheap
A configuration abstraction becomes painful if every unit test has to create database rows.
Business code should depend on the small Settings facade:
public interface Settings {
int getInteger(
SettingSpec<Integer> spec,
SettingContext context
);
boolean getBoolean(
SettingSpec<Boolean> spec,
SettingContext context
);
}A test can then replace it with a simple in-memory implementation:
Settings settings = new InMemorySettings()
.with(
APPOINTMENT_DURATION,
45
);The scheduling service should not care whether production obtained 45 from PostgreSQL, Redis, an office override, or a tenant fallback.
Resolver behavior can be tested separately with focused integration tests covering precedence, reset semantics, disabled definitions, invalid values, and unauthorized overrides.
A settings framework should reduce coupling, not make configuration a prerequisite for every test fixture.
Caching inherited settings is harder than caching rows
Settings are typically read much more often than they are changed, so caching is attractive.
Hierarchy complicates invalidation.
Suppose office A inherits APPOINTMENT_DURATION = 30 from tenant T. The resolved result is cached. Tenant T then changes its value to 20.
Nobody changed office A, but its cache entry is now stale.
One practical strategy is to maintain configuration generations per owner:
platform version: 12
tenant T version: 7
office A version: 3A cache key for the resolved value can include the relevant generation vector:
APPOINTMENT_DURATION
tenant=T:v7
office=A:v3When the tenant changes, its generation becomes v8. Old entries no longer match without having to enumerate every descendant office immediately.
There are other valid cache strategies, but they all need to understand the same inheritance model as the resolver.
If caching is designed independently from ownership, stale configuration will eventually become a correctness bug.
Settings are not feature flags
A settings system can look similar to a feature-flag system, but the product semantics are usually different.
A feature flag is primarily about rollout and targeting:
Enable the new booking flow for 10% of users.
Enable feature X only in staging.
Enable the redesigned page for internal accounts.Flags are often engineer-owned and intentionally temporary.
A business setting represents persistent configuration:
This office uses 45-minute appointments.
This tenant sends reminders 24 hours before a visit.
This organization allows online booking.Settings are usually ownership-driven and long-lived.
The underlying infrastructure can overlap, but treating permanent business configuration as disposable feature flags — or temporary rollout flags as permanent settings — creates lifecycle problems later.
Not all configuration belongs in the settings system
A generic settings subsystem should not become the dumping ground for every configurable value.
Deployment configuration includes database URLs, service addresses, OAuth credentials, and infrastructure secrets. These generally belong in deployment tooling and secret-management systems.
Technical application configuration includes cache TTLs, scheduler intervals, HTTP limits, or thread-pool sizing. These often belong in application configuration or environment variables.
Business configuration includes appointment duration, booking policy, office preferences, and reminder behavior. These are the strongest candidates for runtime settings because a business actor owns the decision.
There is also sensitive business configuration. A tenant-specific SMS sender name may fit naturally into the settings model, while the tenant's SMS provider API key should not be exposed or persisted like an ordinary string. A settings system may store an encrypted value or, preferably where appropriate, a reference to a secret-management system.
Ownership and sensitivity are separate dimensions. “The tenant controls it” does not mean “store it as plaintext in setting_values.”
The final APPOINTMENT_DURATION model
Our original field was:
private int appointmentDuration;The final design looks very different.
Code declares the behavioral contract:
public static final SettingSpec<Integer> APPOINTMENT_DURATION =
SettingSpec.integer("APPOINTMENT_DURATION")
.defaultValue(30)
.minimum(15)
.maximum(120)
.allowOverrideAt(
SettingScope.TENANT,
SettingScope.OFFICE
)
.build();A SettingCatalog registers that spec and makes it enumerable.
A migration creates the persisted definition and lifecycle metadata.
Startup reconciliation verifies that the persisted definition is compatible with the spec.
An office may store an explicit override:
definition: APPOINTMENT_DURATION
scope: OFFICE
owner: office-123
value: 45Business code asks the facade for the effective value:
int duration = settings.getInteger(
APPOINTMENT_DURATION,
context
);Internally, the resolver might produce:
explicitValue = 45
effectiveValue = 45
source = OFFICEIf the office resets its configuration and the tenant has 20:
explicitValue = null
effectiveValue = 20
source = TENANTIf neither scope defines an override:
explicitValue = null
effectiveValue = 30
source = PLATFORM_DEFAULTThe write path validates the candidate value, checks whether the requested scope is permitted, verifies authorization, persists the override, records the change, updates cache generations, and emits domain events where needed.
The reset path goes through the same lifecycle but removes the override, restoring inheritance instead of persisting the current effective value.
The read path resolves hierarchy without exposing that hierarchy to business services.
The bulk read path performs the same resolution without generating hundreds of database calls.
The catalog joins code-owned specifications with database-owned lifecycle and presentation metadata.
That is no longer “a key and a value in a table.”
It is a configuration domain.
When should you build this?
Probably later than you think.
If your application has a few stable properties, keep explicit fields. They are easier to understand and harder to misuse.
A dedicated settings subsystem starts making sense when several concerns appear together: runtime modification, ownership hierarchy, inheritance, overrides, explicit versus effective values, authorization differences, dynamic interfaces, frequent definition changes, validation metadata, auditing, or lifecycle requirements.
The warning sign is not that you have many settings.
It is that every new setting requires another special rule, and those rules are spreading across unrelated services.
When that happens, configuration has developed behavior of its own.
It needs a boundary.
Conclusion
The hard part of a setting is rarely storing true, 30, or "Africa/Casablanca".
The hard part is defining what that value means when it is absent, inherited, overridden, reset, validated, secured, cached, changed, or retired.
That is the hidden cost behind:
“Just add a setting.”
Once those rules exist, the setting is no longer just configuration.
It is part of your domain.
