Skip to Content
CommunityDesign Decisions

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:

  1. Optimistic writes to storage - Most sync engines do this
  2. No persistence - Keep everything in memory
  3. Deferred persistence - Gluonic’s choice ✓

Why deferred wins:

AspectOptimistic StorageDeferred 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:

  1. Redux - Most popular
  2. Zustand - Simpler than Redux
  3. MobX - Gluonic’s choice ✓
  4. 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:

  1. Prisma-only - Simpler but locked-in
  2. Multiple implementations - Hard to maintain
  3. 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:

  1. Schema objects - Like Drizzle, TypeORM
  2. Builder pattern - Fluent API
  3. Decorators - Gluonic’s primary choice ✓
  4. 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:

  1. New instance per access - Simpler but wasteful
  2. 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:

  1. Eager loading - Load everything upfront
  2. Manual loading - Developer calls load()
  3. 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:

  1. Client-wins - Client changes never discarded
  2. Manual resolution - Developer decides
  3. Last-write-wins (server) - Gluonic’s default ✓
  4. 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:

  1. WebSocket only - Real-time but fragile
  2. Polling only - Reliable but slow
  3. 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:

  1. Manual implementation - Replicache approach (200+ lines)
  2. 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:

LayerResponsibilityWhy Separate?
StoragePersistent data, syncDrizzle adapter (RN/Web)
ModelsBusiness logic, reactivityTestable without storage or UI
UIComponents, renderingTestable 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

Last updated on