ADR-005: Entity Immutability
Status
Accepted
Context
Entities wrap database records and are returned from repository/service methods. Without immutability constraints, several problems arise:
Unintended Mutations:
const account = await accountService.getById(123);
account.balance = 9999; // Accidental mutation
// Balance changed in memory but not in database
// Inconsistent state
Hidden Side Effects:
function processOrder(order) {
order.status = 'completed'; // Mutates caller's object
// Caller doesn't expect their object to change
}
Testing Challenges:
const user = await userService.getById(1);
someFunction(user);
// Did someFunction mutate user? Can't tell without inspecting internals
Debugging Difficulty:
// Which function changed account.balance?
const account = await accountService.getById(123);
doSomething(account);
doSomethingElse(account);
doAnotherThing(account);
// Balance changed somewhere, but where?
Requirements:
- Prevent accidental mutations of entity data
- Make data flow explicit and traceable
- Ensure entity state matches database state
- Enable confident parallel operations
- Simplify debugging and testing
Decision
Freeze all entity instances after construction, making them immutable.
Implementation
Entities are frozen via Object.freeze() in constructor:
// src/common/BaseEntity.js
class BaseEntity {
constructor(data = {}, entityOptions = {}) {
// ... filter hidden fields, assign data ...
Object.assign(this, filteredData);
Object.freeze(this); // Make immutable
}
}
Update Pattern
Updates require explicit cloning:
// Create new instance with updated values
const updated = entity.cloneWith({ balance: 100 });
// Original entity unchanged
console.log(entity.balance); // 0
console.log(updated.balance); // 100
Service Layer Pattern
Services convert entities to plain objects before database updates:
// src/common/BaseService.js
async update(id, data, { trx } = {}) {
// Convert entity to POJO if needed
const plainData = this.isEntity(data) ? data.toJSON() : data;
return await this.repository.update(id, plainData, { trx });
}
This allows:
const account = await accountService.getById(123);
const updated = account.cloneWith({ balance: 100 });
await accountService.update(123, updated); // Accepts entity or POJO
Consequences
Positive
No Unintended Mutations:
const account = await accountService.getById(123);
account.balance = 9999; // TypeError: Cannot assign to read only property
Bugs from accidental mutations eliminated at runtime.
Explicit Data Flow:
// Clear that new instance created
const updated = account.cloneWith({ status: 'active' });
await accountService.update(account.id, updated);
Intent explicit in code, easier to reason about.
Deterministic Testing:
const account = new AccountEntity({ balance: 100 });
processOrder(account);
expect(account.balance).toBe(100); // Always true, no hidden mutations
Tests more reliable, no need to deep clone test data.
Safe Concurrent Operations:
const account = await accountService.getById(123);
await Promise.all([
operation1(account),
operation2(account),
operation3(account)
]);
// No race conditions from mutations
Simplified Debugging:
// Entity state can't change after creation
const user = await userService.getById(1);
// user.email is same value throughout function
// No need to suspect it changed
State Consistency:
const account = await accountService.getById(123);
// account.balance matches database value
// Can't drift out of sync with DB
Negative
Performance Overhead:
// Each update creates new object
let account = await accountService.getById(123);
account = account.cloneWith({ balance: 100 }); // Allocates new object
account = account.cloneWith({ status: 'active' }); // Allocates another
Mitigation:
- Negligible for typical web API workloads (< 1ms per allocation)
- Object pooling possible if needed (not implemented)
- Update operations batch multiple changes:
cloneWith({ balance: 100, status: 'active' })
Learning Curve:
// Developers must learn immutable pattern
const account = await accountService.getById(123);
// This doesn't work:
account.balance = 100;
// Must use this instead:
const updated = account.cloneWith({ balance: 100 });
Mitigation:
- Clear error messages guide developers
- Pattern documented in ARCHITECTURE.md
- Consistent across all entities (only one pattern to learn)
Deep Updates More Verbose:
// Updating nested object requires reconstruction
const user = await userService.getById(1);
const updatedAddress = { ...user.address, city: 'Richmond' };
const updated = user.cloneWith({ address: updatedAddress });
Mitigation:
- Entities rarely have deeply nested structures
- Most updates are flat field changes
- Helper methods can simplify common patterns if needed
Alternatives Considered
Mutable Entities
Approach: Allow direct property assignment, track changes separately.
const account = await accountService.getById(123);
account.balance = 100;
await accountService.update(account.id, account);
Rejected Because:
- No protection against accidental mutations
- Unclear whether entity has unsaved changes
- Testing harder (must clone objects)
- State can drift from database
Copy-on-Write with Change Tracking
Approach: Track which fields changed, only save those.
const account = await accountService.getById(123);
account.balance = 100; // Records change
await account.save(); // Saves only changed fields
Rejected Because:
- Complex change tracking logic
- Entities coupled to persistence layer
- Unclear when changes applied
- Violates Entity-Repository separation
Proxy-Based Immutability
Approach: Use ES6 Proxy to intercept mutations.
const account = new Proxy(data, {
set() { throw new Error('Immutable'); }
});
Rejected Because:
- Performance overhead (proxy trap on every access)
- Complicated debugging (harder to inspect)
- Less browser/runtime compatibility
Object.freeze()achieves same goal more simply
Immutable.js Library
Approach: Use Immutable.js data structures.
const account = Immutable.Map({ balance: 100 });
const updated = account.set('balance', 200);
Rejected Because:
- Additional dependency
- Different API from plain objects
- Harder to serialize to JSON
- More learning curve for developers
Object.freeze()sufficient for current needs
Implementation Notes
Deep Freeze
Repositories use deep freeze for nested objects:
// src/common/BaseRepository.js
const deepFreeze = (value, seen = new WeakSet()) => {
if (value == null || typeof value !== 'object' || seen.has(value)) {
return value;
}
seen.add(value);
Object.getOwnPropertyNames(value).forEach((key) => {
const v = value[key];
if (v && typeof v === 'object') deepFreeze(v, seen);
});
return Object.freeze(value);
};
This prevents mutations at any depth:
const user = await userService.getById(1);
user.address.city = 'Richmond'; // TypeError
Relations Materialized as Immutable
Related entities are also frozen:
const account = await accountService.getOneWithRelations(
{ id: 123 },
'user',
{ trx }
);
// Both frozen
account.user.email = 'new@example.com'; // TypeError
account.balance = 100; // TypeError
Clone Implementation
// src/common/BaseEntity.js
cloneWith(updates = {}) {
return new this.constructor({ ...this, ...updates });
}
Spreads current properties, applies updates, creates new instance.
Serialization
Frozen objects serialize normally:
const account = new AccountEntity({ balance: 100 });
JSON.stringify(account); // Works fine
// { "balance": 100 }
toJSON() method handles serialization:
toJSON() {
const json = {};
for (const key of Object.getOwnPropertyNames(this)) {
if (key === 'hiddenFields' || key === 'constructor') continue;
json[key] = this[key];
}
return json;
}
Related Decisions
- ADR-001: Service-Oriented Architecture - Entity layer design
- ADR-004: Contract-Based Testing - Tests validate immutability
References
- Base entity implementation:
src/common/BaseEntity.js - Deep freeze implementation:
src/common/BaseRepository.js - Service normalization:
src/common/BaseService.js#toPlain()
Date: 2025-10-17 Author: Scott Lewis