Design Decisions
Why Gluonic is built the way it is - the philosophy behind key architectural choices.
Core Philosophy
“Offline-first, batteries-included, database-agnostic, self-hosted”
Every design decision flows from these principles.
Why Deferred Persistence?
Decision: Separate optimistic updates (memory) from confirmed data (storage).
Alternatives considered:
- Optimistic writes to storage - Most sync engines do this
- No persistence - Keep everything in memory
- Deferred persistence - Gluonic’s choice ✓
Why deferred wins:
| Aspect | Optimistic Storage | Deferred Persistence |
|---|---|---|
| Rollback complexity | ⚠️ High (rollback DB) | ✅ Low (replace memory) |
| Queue replay | ❌ Broken | ✅ Perfect |
| Storage consistency | ⚠️ Can be inconsistent | ✅ Always authoritative |
| Implementation | ⚠️ Complex | ✅ Simple |
Key insight: Queue replay requires storage to contain only confirmed data. Otherwise replaying queue can reapply failed mutations.
Why MobX over Redux?
Decision: Use MobX for reactive state management.
Alternatives considered:
- Redux - Most popular
- Zustand - Simpler than Redux
- MobX - Gluonic’s choice ✓
- Jotai/Recoil - Atomic state
Why MobX wins:
// Redux approach
const [post, setPost] = useState()
useEffect(() => {
const unsubscribe = store.subscribe(() => {
const newPost = store.getState().posts[postId]
setPost(newPost)
})
return unsubscribe
}, [postId])
// Manual updates everywhere 😔
// MobX approach
const post = useModel<Post>('post', postId)
// Automatic updates ✓Benefits:
- ✅ Zero boilerplate (no actions, reducers, selectors)
- ✅ Automatic dependency tracking
- ✅ Precise re-renders (field-level)
- ✅ Works with Proxy pattern perfectly
Tradeoff: Requires understanding MobX (learning curve).
Why Adapter Pattern?
Decision: Database-agnostic via adapter interface.
Alternatives considered:
- Prisma-only - Simpler but locked-in
- Multiple implementations - Hard to maintain
- Adapter pattern - Gluonic’s choice ✓
Why adapters win:
- ✅ Works with any database (Postgres, MySQL, MongoDB, etc.)
- ✅ Works with any ORM (Prisma, TypeORM, Kysely, raw SQL)
- ✅ Clean separation of concerns
- ✅ Easy to test (mock adapter)
- ✅ Community can contribute adapters
Tradeoff: Extra abstraction layer (minimal cost).
Why TypeScript Decorators?
Decision: Use TC39 Stage 3 decorators for model definitions.
Alternatives considered:
- Schema objects - Like Drizzle, TypeORM
- Builder pattern - Fluent API
- Decorators - Gluonic’s primary choice ✓
- Code generation - Complementary approach ✓
Why decorators win:
// Decorator approach
@ClientModel('user')
export class User extends Model {
@Property() email: string = ''
@ManyToOne('posts', 'authorId') author: LazyReference<User>
}
// vs Schema object approach
const User = model({
name: 'user',
fields: {
email: { type: 'string' }
},
relationships: {
author: { type: 'manyToOne', foreignKey: 'authorId' }
}
})Benefits:
- ✅ Clean, declarative syntax
- ✅ Type-safe (TypeScript validates)
- ✅ Standard approach (TC39 Stage 3)
- ✅ Familiar to developers (used in TypeORM, NestJS, etc.)
Tradeoff: Build configuration needed (Vite plugin, tsconfig).
Solution: Also provide decorator-free API for flexibility.
Why Identity Mapping?
Decision: Same ID always returns same instance.
Alternatives considered:
- New instance per access - Simpler but wasteful
- Identity mapping - Gluonic’s choice ✓
Why identity mapping wins:
// Without identity mapping
const a = getModel('post', '123')
const b = getModel('post', '123')
a !== b // Different instances
// React memoization breaks
useMemo(() => expensive(post), [post])
// New instance every render → recalculates every time 😔
// With identity mapping
const a = getModel('post', '123')
const b = getModel('post', '123')
a === b // Same instance ✓
// React memoization works
useMemo(() => expensive(post), [post])
// Same instance → memoization effective ✓Benefits:
- ✅ Efficient React rendering
- ✅ Predictable behavior
- ✅ Less memory usage
- ✅ Memoization works
Why Lazy Loading?
Decision: Relationships load on-demand, not eagerly.
Alternatives considered:
- Eager loading - Load everything upfront
- Manual loading - Developer calls load()
- Lazy with auto-kick - Gluonic’s choice ✓
Why lazy auto-kick wins:
// Eager (loads too much)
const post = await loadPost('123', {
include: {
author: true,
comments: {
include: {
author: true
}
}
}
})
// Loaded: post + author + comments + comment authors
// Even if you don't use them! 😔
// Lazy auto-kick (loads what you need)
const post = useModel<Post>('post', '123')
// Loaded: just the post
console.log(post.author.value?.name)
// Kicked off: author load (background)
// Next render: shows author name ✓
// Never access comments → they don't load ✓Benefits:
- ✅ Performance (only load what’s needed)
- ✅ Feels synchronous (no await)
- ✅ Transparent (developer doesn’t manage loading)
Tradeoff: First access returns undefined (handle with optional chaining).
Why Last-Write-Wins?
Decision: Server version wins on conflicts (default).
Alternatives considered:
- Client-wins - Client changes never discarded
- Manual resolution - Developer decides
- Last-write-wins (server) - Gluonic’s default ✓
- Operational Transformation - Advanced (optional)
Why server-wins is default:
- ✅ Simple to understand
- ✅ No surprise behavior
- ✅ Server is authoritative
- ✅ Works for 90% of use cases
When it’s not enough: Collaborative editing → use OT or custom resolver (documented in Advanced).
Why WebSocket + Delta?
Decision: Use both WebSocket (real-time) and delta sync (catch-up).
Alternatives considered:
- WebSocket only - Real-time but fragile
- Polling only - Reliable but slow
- Both - Gluonic’s choice ✓
Why both wins:
WebSocket Connected:
→ Instant updates (real-time)
WebSocket Disconnects:
→ Delta sync catches up (reliable)
→ No data loss ✓
Best of both worlds!Why Batteries-Included /tx?
Decision: Provide generic mutation endpoint out of box.
Alternatives considered:
- Manual implementation - Replicache approach (200+ lines)
- Generic endpoint - Gluonic’s choice ✓
Why generic wins:
- ✅ 5 lines of config vs 200 lines of code
- ✅ Handles ownership, versioning, conflicts automatically
- ✅ Consistent behavior across projects
- ✅ Still customizable (validation, transformation)
Competitive advantage: Only self-hosted solution with this simplicity.
Why Three-Layer Architecture?
Decision: Separate Storage → Models → UI layers.
Why three layers:
| Layer | Responsibility | Why Separate? |
|---|---|---|
| Storage | Persistent data, sync | Drizzle adapter (RN/Web) |
| Models | Business logic, reactivity | Testable without storage or UI |
| UI | Components, rendering | Testable without data layer |
Benefit: Clean separation, testability, flexibility.
Tradeoffs Made
Chose Simplicity Over Flexibility
Example: Last-write-wins default
- Simple for 90% of users
- Advanced users can customize
Chose Performance Over Memory
Example: Identity mapping cache
- Uses memory for instance cache
- But eliminates duplicate objects
- Net: Less memory overall
Chose Developer Experience Over Code Size
Example: Decorators + Proxy + MobX
- More dependencies than minimal solution
- But much better DX (no boilerplate)
Philosophy Summary
Offline-first: Local data is primary, server is backup
Reactive: UI updates automatically, no manual state
Optimistic: Apply now, confirm later
Type-safe: TypeScript everywhere
Batteries-included: Works out of box, customize later
Database-agnostic: Use what you want
Self-hosted: Full control, no vendor lock-in
Next Steps
- Roadmap - Future direction
- Architecture - How it all fits together
- Comparison - vs other solutions