rest-domain-state-manager
A pure ES Module library that wraps REST API response data in a Proxy for automatic
change tracking, then dispatches the optimal HTTP method (POST / PUT / PATCH) at save time.
No bundler · No npm · No dependencies
Quick Navigator
1-1. Installation
No bundler, npm, or CDN required. Copy the folder and import.
your-project/
├── rest-domain-state-manager/
└── index.html
<script type="module">
import { ApiHandler, DomainState }
from './rest-domain-state-manager/rest-domain-state-manager.js';
</script>
1-2. Creating an ApiHandler
ApiHandler handles HTTP transport. You create the instance — this is where you set the server address.
const api = new ApiHandler({
host: 'localhost:8080',
debug: true,
});
// Multiple backends: create one instance per server
const userApi = new ApiHandler({ host: 'user-service.com' });
const orderApi = new ApiHandler({ host: 'order-service.com' });
1-3. GET → Modify → Save
const user = await api.get('/api/users/user_001');
console.log(user.data.name); // read
user.data.name = 'Davi'; // tracked → op: replace
user.data.address.city = 'Seoul'; // nested → tracked
await user.save('/api/users/user_001'); // PATCH (changes) or PUT (no changes)
await user.remove('/api/users/user_001');
1-4. Creating New Data → POST
fromVO()
class UserVO extends DomainVO {
static baseURL = 'localhost:8080/api/users';
static fields = {
user_id: { default: '' },
name: { default: '' },
role: { default: 'USER' },
address: { default: { city: '', zip: '' } },
};
}
const newUser = DomainState.fromVO(new UserVO(), api);
newUser.data.user_id = 'user_' + Date.now();
newUser.data.name = 'Davi';
await newUser.save(); // POST to static baseURL
fromForm()
<form id="user-form">
<input name="name" />
<input name="address.city" />
<select name="role">...</select>
</form>
const formState = DomainState.fromForm('user-form', api);
await formState.save('/api/users');
1-5. Multiple APIs at Once (DomainPipeline)
const result = await DomainState.all({
roles: api.get('/api/roles'),
user: api.get('/api/users/user_001'),
})
.after('roles', async (roles) => { console.log(roles.data); })
.after('user', async (user) => { console.log(user.data); })
.run();
if (result._errors?.length) console.warn(result._errors);
1-6. Rendering Lists as UI Elements (DomainRenderer)
DomainState.use(DomainRenderer); // register once
const roles = await api.get('/api/roles');
roles.renderTo('#role-select', {
type: 'select', valueField: 'roleId', labelField: 'roleName',
class: 'form-select', placeholder: 'Select a role',
events: { change: (e) => console.log(e.target.value) },
});
roles.renderTo('#role-radio', {
type: 'radio', valueField: 'roleId', labelField: 'roleName',
containerClass: 'form-check', class: 'form-check-input', labelClass: 'form-check-label',
});
2-1. ApiHandler Full Options
| Option | Type | Description |
host | string | Host without protocol (e.g. api.example.com) |
basePath | string | Common path prefix (e.g. /app/api) |
baseURL | string | Combined host + basePath string |
protocol | string | 'HTTP'|'HTTPS'|'FILE'|'SSH' |
env | string | 'development'|'production' |
debug | boolean | When true, treated as development environment |
⚠️host and baseURL are mutually exclusive. Conflict auto-resolution is attempted; if impossible an Error is thrown.
// Style 1 — decomposed
const api = new ApiHandler({ host: 'api.example.com', basePath: '/app/api', env: 'production' });
// Style 2 — unified
const api = new ApiHandler({ baseURL: 'localhost:8080/app/api', debug: true });
// Per-request override
const user = await api.get('/api/users/1', { urlConfig: { host: 'other-server.com' } });
Protocol resolution priority
1. Explicit protocol option
2. env → development: HTTP, production: HTTPS
3. No env + debug: true → HTTP
4. No env + debug: false → HTTPS
2-2. DomainVO Field Declaration
class ProductVO extends DomainVO {
static baseURL = 'localhost:8080/api/products';
static fields = {
productId: { default: '' },
name: { default: '', validate: (v) => v.trim().length > 0 },
price: { default: 0, validate: (v) => v >= 0, transform: Number },
tags: { default: [] },
meta: { default: { createdAt: '', updatedAt: '' } },
};
}
const product = DomainState.fromVO(new ProductVO(), api);
await product.save(); // POST to static baseURL
// Schema validation on GET response:
const p2 = DomainState.fromJSON(jsonText, api, { vo: new ProductVO() });
2-3. DomainState Internals
const user = await api.get('/api/users/1');
user.data.name = 'Davi'; // changeLog: [{ op:'replace', path:'/name', ... }]
user.data.address.city = 'Seoul'; // deep proxy → path '/address/city'
delete user.data.phone; // op: 'remove'
console.log(user._getChangeLog());
| Factory | isNew | save() result |
api.get() / fromJSON() | false | PATCH if changes, PUT if none |
fromVO() | true | POST |
fromForm() | true | POST |
2-4. Factory Methods in Detail
fromJSON options
| Option | Type | Description |
urlConfig | UrlConfig | URL config override |
debug | boolean | Debug mode |
label | string | Debug popup display name |
vo | DomainVO | Schema validation + inject validators/transformers |
form | string|HTMLFormElement | Auto-populate form with response data |
fromForm event tracking
| Element | Event | Reason |
input[type=text], textarea | blur | Avoids spurious set traps while typing |
select, radio, checkbox | change | Value finalized immediately on selection |
2-5. save() Branching Strategy
isNew === true → POST (toPayload)
isNew === false
changeLog.length > 0 → PATCH (RFC 6902 JSON Patch)
changeLog.length === 0 → PUT (toPayload)
await user.save('/api/users/1'); // explicit path
await user.save(); // uses urlConfig or static baseURL
| After successful request | Action |
| PATCH / PUT | changeLog cleared |
| POST | isNew → false + changeLog cleared |
2-6. DomainPipeline strict Mode / _errors Handling
| strict | Behavior | Returns |
false (default) | On failure: record to _errors, continue | resolve + _errors |
true | Immediately reject on first failure | reject |
// strict: false
const result = await DomainState.all({ roles: api.get('/api/roles'), user: api.get('/api/users/BAD') })
.after('roles', async r => { /* ... */ })
.run();
result._errors?.forEach(({ key, error }) => console.warn(key, error));
// strict: true
try {
await DomainState.all({ ... }, { strict: true }).after('roles', r => {}).run();
} catch (err) { console.error('Pipeline aborted:', err); }
2-7. renderTo() Full Config
Common (all types)
| Option | Type | Description |
type | string | 'select'|'radio'|'checkbox'|'button' |
valueField | string | Field name used as each item's value |
labelField | string | Field name used as display text |
class | string | Element className |
css | object | Inline style (camelCase keys) |
events | object | { eventName: handler } |
select extras
| Option | Description |
placeholder | First disabled option text |
multiple | Enable multi-select |
radio / checkbox extras
| Option | Description |
name | input[name]. Defaults to valueField (MyBatis auto-match). |
containerClass / containerCss | Wrapper div className / inline style |
labelClass / labelCss | Label element className / inline style |
button
valueField value is injected as button[data-value]. Read from e.target.dataset.value.
2-8. Plugin System (use())
const MyPlugin = {
install(DomainState) {
DomainState.prototype.toCSV = function () {
return Object.values(this._getTarget()).join(',');
};
},
};
DomainState.use(MyPlugin).use(AnotherPlugin); // chainable
const user = await api.get('/api/users/1');
console.log(user.toCSV());
ℹ️Duplicate registration is silently ignored. install() runs at most once per plugin object. If the plugin has no install method, a TypeError is thrown.
2-9. Debug Popup
const user = DomainState.fromVO(new UserVO(), api, { debug: true, label: 'UserVO' });
user.openDebugger();
The popup shows all tabs on the same origin in real time: DomainState label, current data, changeLog entries, and _errors array.
⚠️Allow browser popup blocking for this to work. DomainState created via fromForm() auto-refreshes the popup on form events.
Layer Overview
┌──────────────────────────────────────────────────────────────────────┐
│ Entry Point │
│ rest-domain-state-manager.js │
│ { ApiHandler, DomainState, DomainVO, │
│ DomainPipeline, DomainRenderer } │
└───────────────┬──────────────────────────────┬───────────────────────┘
│ │
┌───────────────▼──────────────┐ ┌────────────▼─────────────────────┐
│ model/ │ │ plugin/ │
│ DomainState │ │ DomainRenderer │
│ DomainVO │ │ renderers/ │
│ DomainPipeline │ └──────────────────────────────────┘
└───────────────┬──────────────┘
│ internal calls
┌───────────────▼──────────────────────────────────────────────────────┐
│ src/ │
│ handler/ core/ constants/ debug/ │
│ ApiHandler api-proxy.js error.messages debug-channel.js │
│ api-mapper.js op.const (BroadcastChannel │
│ url-resolver protocol.const + popup HTML) │
└──────────────────────────────────────────────────────────────────────┘
Drawbridge Pattern
createProxy() returns four closures that DomainState stores internally.
This is called the Drawbridge Pattern — the library controls all four gates into the closure world,
and only exposes the proxy gate to the outside.
// What createProxy() returns
{
proxy: object, // The only public-facing gate
getChangeLog: () => [], // Internal gate: read change history
getTarget: () => {}, // Internal gate: read the raw object
clearChangeLog: () => void, // Internal gate: reset after sync
}
💡Consumers only ever touch domainState.data (the proxy). The other three closures are called exclusively by DomainState internals. This boundary guarantees changeLog integrity.
Proxy Trap Design
Path accumulation
proxy.address.city = 'Seoul'
// get trap: prop='address', basePath='' → returns new Proxy(basePath='/address')
// set trap: prop='city', basePath='/address' → records path '/address/city'
| Trap | Trigger | op | Note |
get | Property read | — | Returns deep proxy if value is Object/Array |
set (new key) | Assign to absent key | add | Also reflects on target |
set (replace) | Change existing value | replace | Records oldValue |
deleteProperty | delete proxy.field | remove | Records oldValue |
Bypass list
These are returned via Reflect.get without deep proxy wrapping: Symbol properties, toJSON, then, valueOf.
Data Flow
GET → Display
Server (JSON response)
↓ fetch()
api-handler._fetch() — common headers, response.ok check
↓ text (JSON string)
api-mapper.toDomain() — JSON.parse → plain object
↓
api-proxy.createProxy() — wrap in Proxy, create drawbridge set
↓
DomainState constructor — store metadata, broadcast to debug channel
↓
consumer code — domainState.data.xxx read/write
save()
domainState.save(path)
↓ isNew check
├─ true → toPayload() → POST
└─ false → changeLog check
├─ length > 0 → toPatch() → PATCH (RFC 6902)
└─ length = 0 → toPayload() → PUT
↓ on success
clearChangeLog() // isNew = false if POST
File Structure
rest-domain-state-manager/
├── rest-domain-state-manager.js Single entry point
├── proxy.test.html Integration tests (7 cases)
├── model/
│ ├── DomainState.js
│ ├── DomainVO.js
│ └── DomainPipeline.js
├── src/
│ ├── constants/
│ │ ├── protocol.const.js
│ │ ├── op.const.js
│ │ ├── channel.const.js
│ │ ├── error.messages.js
│ │ └── log.messages.js
│ ├── common/js-object-util.js
│ ├── core/
│ │ ├── api-proxy.js
│ │ ├── url-resolver.js
│ │ └── api-mapper.js
│ ├── handler/api-handler.js
│ └── debug/debug-channel.js
└── plugin/
└── domain-renderer/
├── DomainRenderer.js
├── renderer.const.js
└── renderers/
├── select.renderer.js
├── radio-checkbox.renderer.js
└── button.renderer.js
ApiHandler
| Method | Signature | Returns | Description |
get() | get(path, opts?) | Promise<DomainState> | GET → DomainState (isNew: false) |
_fetch() | _fetch(url, opts?) | Promise<string|null> | Internal. Called by save/remove. |
getUrlConfig() | — | object | Returns normalized URL config |
isDebug() | — | boolean | Returns debug flag |
Error structure
{ status: number, statusText: string, body: string }
// thrown when response.ok is false
DomainState
Static methods
| Method | Signature | Returns |
fromJSON() | fromJSON(text, handler, opts?) | DomainState (isNew: false) |
fromForm() | fromForm(form, handler, opts?) | DomainState (isNew: true) |
fromVO() | fromVO(vo, handler, opts?) | DomainState (isNew: true) |
all() | all(resourceMap, opts?) | DomainPipeline |
use() | use(plugin) | DomainState (chainable) |
Instance
| Member | Type | Description |
.data | Proxy (getter) | The only public entry point for domain data |
.save(path?) | Promise<void> | POST / PATCH / PUT auto-dispatch |
.remove(path?) | Promise<void> | DELETE |
.log() | void | Log changeLog to console (debug: true only) |
.openDebugger() | void | Open debug popup (debug: true only) |
._getChangeLog() | array | Internal. Shallow copy of change history. |
._getTarget() | object | Internal. Raw underlying object. |
DomainVO
| Method | Returns | Description |
toSkeleton() | object | Build initial object from fields.default |
getValidators() | object | { field: fn } map |
getTransformers() | object | { field: fn } map |
getBaseURL() | string|null | Returns static baseURL |
checkSchema(data) | { valid, missingKeys, extraKeys } | Compare response with schema |
DomainPipeline
| Method | Signature | Returns |
after() | after(key, handler) | DomainPipeline (chainable) |
run() | run() | Promise<{ [key]: DomainState, _errors? }> |
DomainRenderer
After DomainState.use(DomainRenderer), renderTo(selector, config) is available on all DomainState instances. Requires the instance to hold array data.
Library Identity
This library is not an HTTP client. It uses fetch() internally, but its core value is not "how to send requests" — it is "tracking what changed and synchronizing correctly at save time".
| HTTP client | rest-domain-state-manager |
| Focus | Request/response transport | Domain state change tracking + sync |
| Usage | fetch('/api', { method, body }) | data.name = '...' → auto-tracked |
| HTTP method | Developer decides | Library decides automatically |
| Change scope | Full payload | Changed paths only (RFC 6902 Patch) |
Single Source of Truth
While a screen is active, one DomainState instance holds the single truth for that resource. All changes — DOM elements, form input, direct assignment — flow exclusively through domainState.data (the Proxy).
// Anti-pattern — reading DOM directly
const payload = { name: document.getElementById('name').value };
// Correct — Proxy is the single source
const user = DomainState.fromForm('userForm', api);
await user.save('/api/users/1');
changeLog Structure
The changeLog is an ordered record of every mutation since the last sync. Each entry follows an internal format compatible with RFC 6902 JSON Patch.
// Internal changeLog entry
{ op: 'add'|'replace'|'remove', path: '/address/city', value: 'Seoul', oldValue: 'Busan' }
// Converted to RFC 6902 when PATCH is dispatched
[{ "op": "replace", "path": "/address/city", "value": "Seoul" }]
isNew Flag
isNew indicates whether this instance represents a resource not yet persisted to the server. It is set at factory call time and flipped to false automatically after a successful POST.
const newUser = DomainState.fromVO(new UserVO(), api); // isNew: true
await newUser.save(); // → POST
// After success: isNew is now false
newUser.data.name = 'Davi';
await newUser.save(); // → PATCH (now treated as existing resource)
Locality of Behavior
This library treats Locality of Behavior as a core design principle. Related behavior should live together so reading the code reveals the full picture. One well-commented 50-line function is preferred over ten 5-line functions.
// Design intent:
// "What happens after save() should be predictable
// without reading the source."
await user.save('/api/users/1');
// isNew false + changes → PATCH
// isNew false + no changes → PUT
// isNew true → POST
// One interface. Three predictable behaviors.
Each decision was made after evaluating alternatives and trade-offs. This is the record of "why it was designed this way."
DD-1. Proxy Scope
| Option | Pros | Cons |
| A. Service/domain layer only | No conflict with React immutability rules. Clear debug boundary. | View layer cannot enjoy "assignment = sync." |
| B. All the way to view/state layer | model.name = '...' triggers sync directly. | Conflicts with React shallow comparison. Array type-check bugs. Harder to debug. |
✅Adopted: A — Service/domain layer only for the first implementation. React integration is deferred to a separate experimental branch.
DD-2. HTTP Method Strategy
| Option | Pros | Cons |
| PUT only | Simple. No change tracking needed. | Misses the core goal of path-level tracking. Wasteful for large objects. |
| PATCH only | Send changed fields only. | Sends empty array when nothing changed. POST case unresolved. |
| POST / PATCH / PUT mixed | Semantically correct per scenario. PUT ensures idempotency when nothing changed. | Requires managing both isNew and changeLog. |
✅Adopted: Mixed (A+C) — isNew flag + changeLog length drive automatic three-way dispatch.
DD-3. deep proxy Scope
✅Adopted: Object + 1D Array, incremental implementation — Design includes Array; implementation order is Object → Array index access → nested structures. Array mutating methods (push, splice) precision improvement is a future task.
DD-4. changeLog Format
✅Adopted: Internal format + conversion layer — Internal entries keep oldValue and extra metadata. api-mapper.toPatch() converts to RFC 6902 at send time.
DD-5. Form Binding
✅Adopted: Path-based binding — input[name] dot notation encodes nested paths. fromForm() alone handles initialization + binding + tracking as a complete unit.
DD-6. strict Default
✅Adopted: strict: false — HTTP requests are already spent cost. Post-processing handler failures are independent concerns; record them in _errors and continue. Use strict: true only when hard stopping is explicitly needed.
DD-7. Debug Channel
✅Adopted: BroadcastChannel — Browser-native API, no dependencies. TAB_PING is broadcast by the popup itself (not the tab) to work around the self-message restriction. Popup HTML is generated as a string via _buildPopupHTML() — no separate HTML file needed.
DD-8. radio / checkbox name Default
✅Adopted: valueField as name default — Matches MyBatis ResultMap field names automatically for SI project JSP form submit patterns. Override with explicit name option only when a different name is needed.