DEV Community

Cover image for Lifting CQRS and DDD into the Client: A Shared App-Domain for Web and Mobile
Champ of Greatness
Champ of Greatness

Posted on

Lifting CQRS and DDD into the Client: A Shared App-Domain for Web and Mobile

Summary: This post describes why we moved from business rules scattered across a web app in a monorepo to a single app-domain package shared by both web and mobile. We combined CQRS (Command Query Responsibility Segregation) and DDD (Domain-Driven Design)—patterns usually associated with server-side architecture—and ran them inside the client. Here we outline the transformation, how the shared domain works, and the pros and cons of this design.


The Problem: Business Rules Everywhere

Originally, the product lived as a web app in a monorepo. Business logic was spread across:

  • UI components making direct Supabase calls
  • Ad-hoc services with validation and persistence mixed together
  • Duplicate or inconsistent rules as features were copied from web to mobile

That led to:

  • No single source of truth for “what is a valid entity?”, “who can rename a record?”, or “how do we create a reusable template?”
  • Divergence between web and mobile behavior when each client implemented its own checks
  • Hard-to-test logic tied to React and Supabase, with no clear boundary between “domain” and “infrastructure”
  • Risky changes—touching a rule in one place might miss another client or code path

We needed one place where all business rules live, and both web and mobile could reuse it.


The Direction: One Domain, Two Clients

The target was clear: extract a shared domain and application layer that:

  1. Holds all business rules (validation, invariants, lifecycle).
  2. Exposes a stable API (commands and queries) so UI never talks to the database directly.
  3. Is client-agnostic—the same package runs in the browser (web) and in React Native (mobile).

That shared layer is app-domain: a single package consumed by both the web app and the mobile app, with Supabase (or any persistence) injected at runtime.

flowchart LR
  subgraph clients["Clients"]
    WEB[Web App]
    MOB[Mobile App]
  end

  subgraph shared["Shared (single source of truth)"]
    AD[app-domain]
  end

  subgraph infra["Infrastructure"]
    SUP[(Supabase)]
  end

  WEB --> AD
  MOB --> AD
  AD --> SUP
Enter fullscreen mode Exit fullscreen mode

The monorepo keeps app-domain, web, and mobile together today; the repo separation plan moves toward three repos, with web and mobile depending on a published @champcbg/atlas-app-domain package.


CQRS and DDD—Usually Server-Side, Now in the Client

CQRS (Command Query Responsibility Segregation) separates writes (commands) from reads (queries). Handlers execute commands that change state or queries that return data—no mixing of the two at the API boundary.

DDD (Domain-Driven Design) puts entities, invariants, and domain events at the centre. Persistence is hidden behind repository interfaces; the domain stays pure and testable.

These patterns are typically used on the server: API layer, domain service, repositories, database. We lifted them into the client:

  • Commands (e.g. CreateEntity, UpdateEntity, DeleteEntity) and queries (e.g. GetEntitiesForContext, GetEntityById) are defined and registered in app-domain.
  • Domain entities (aggregate roots and value objects) enforce rules and emit domain events; they live in domain/entities/.
  • Repositories are interfaces in the domain; Supabase implementations live in the app layer (app/infrastructure/supabase/). The UI never imports Supabase—only executeCommand and executeQuery.
  • Correlation context (e.g. userId, sessionId, correlationId) is created at the UI boundary and passed into every command/query for logging and tracing.

So: same mental model as a server-side CQRS/DDD app, but the “server” is the client process (browser or React Native), and the “database” is Supabase called from that process.

flowchart TB
  subgraph ui["Web or Mobile UI"]
    Page[Page / Screen]
  end

  subgraph app["App Layer (in client)"]
    Dispatch["executeCommand / executeQuery"]
    CmdHandlers[Command Handlers]
    QueryHandlers[Query Handlers]
    EventBus[Event Bus]
    Repos[Repository Implementations]
  end

  subgraph domain["Domain Layer"]
    Entities[Entities + Invariants]
    Events[Domain Events]
    RepoIfaces[Repository Interfaces]
  end

  subgraph persistence["Persistence"]
    Supabase[(Supabase)]
  end

  Page --> Dispatch
  Dispatch --> CmdHandlers
  Dispatch --> QueryHandlers
  CmdHandlers --> Entities
  QueryHandlers --> RepoIfaces
  CmdHandlers --> Repos
  QueryHandlers --> Repos
  Repos --> RepoIfaces
  Repos --> Supabase
  Entities --> Events
  CmdHandlers --> EventBus
Enter fullscreen mode Exit fullscreen mode

How the Shared App-Domain Is Used

Bootstrap (Web and Mobile)

Before any command or query runs, the host app initializes app-domain with its own dependencies:

import { initAppDomain } from '@champcbg/atlas-app-domain';
import { supabase } from '@/lib/supabase';
import { logger } from '@/services/base/logger';
import { captureError } from '@/services/base/errorHandler';

initAppDomain({
  supabase,
  createLogger: (ctx) => logger.withContext(ctx),
  captureError,
});
Enter fullscreen mode Exit fullscreen mode

So: Supabase, logging, and error reporting are injected; app-domain does not depend on a specific framework or environment.

Usage in UI (Web or Mobile)

UI creates a correlation context and calls the same API from either client:

import {
  createCorrelationContext,
  executeCommand,
  executeQuery,
  getEntitiesForContextQuery,
  createEntityCommand,
} from 'app-domain';

const context = createCorrelationContext({ userId: user?.id, sessionId });
const listResult = await executeQuery(
  getEntitiesForContextQuery(contextId, { includeArchived: false }),
  context
);
const createResult = await executeCommand(
  createEntityCommand({ name: 'Example item', contextId }),
  context
);
Enter fullscreen mode Exit fullscreen mode

All feature areas in the product go through this same pipeline. One contract, two platforms.


Pros of This Design

Benefit Description
Single source of truth All business rules live in domain entities and command/query handlers. No “web version” vs “mobile version” of the same rule.
Testability Handlers take repository interfaces and logger/captureError; unit tests mock repositories and assert on behavior without Supabase or React.
Consistent behavior Web and mobile share the same validation, normalization, and error codes (e.g. VALIDATION_ERROR, ENTITY_NOT_FOUND).
Clear boundaries UI only knows about commands, queries, and DTOs. Persistence and infrastructure stay behind the app layer.
Easier evolution New features or rule changes are implemented once in app-domain; both clients get them when they upgrade the package.
Correlation and observability Every operation carries correlation context, so logs and errors can be traced across the client and (where used) backend.
Domain events Entities record events (e.g. RoutineCreated, GoalCompleted); the app layer publishes them so the host can react (e.g. create profile, sync external systems) without coupling domain to UI.

Cons and Trade-offs

Trade-off Description
Client-side complexity CQRS/DDD on the client is more structure than a “call Supabase from the component” approach. New developers must learn the command/query/dispatch model and where to add logic (entity vs handler).
Bundle size The full app-domain (entities, handlers, DTOs, repository adapters) is included in each client. We accept this for correctness and consistency; tree-shaking and code-splitting can help where applicable.
No dedicated API server Commands and queries run in the client and call Supabase (or similar) directly. There is no mandatory “backend for all logic”—which fits our current design but means auth and RLS must be solid and any future server-side-only logic would need a different path (e.g. Edge Functions).
Initialization discipline initAppDomain() must be called before any executeCommand/executeQuery. If forgotten, the app fails at runtime; we mitigate with clear errors and docs.
Event bus is in-memory The event bus is process-local. Cross-tab or cross-device events would require a separate mechanism (e.g. Supabase Realtime, or server-driven events).

When This Approach Fits

This design works well when:

  • You have multiple clients (e.g. web and mobile) that must behave the same.
  • You want one place to enforce and evolve business rules.
  • You are comfortable with Supabase (or similar) from the client and strong RLS/auth rather than a thick API server for every operation.
  • You value testability and clear boundaries and are willing to invest in the command/query/entity structure.

It is less suitable when:

  • You need all logic behind a single API server (e.g. strict “no direct DB from client” policies).
  • The team prefers minimal structure and “quick UI → Supabase” wiring over a shared domain.

Summary

We transformed from business rules spread across the web app in a monorepo to a shared app-domain used by both web and mobile. By bringing CQRS and DDD into the client, we get a single source of truth for rules, consistent behavior across platforms, and a testable, boundary-conscious design—at the cost of more upfront structure and dependency on correct initialization and packaging. For a product that must stay consistent on web and mobile while evolving quickly, this trade-off has been worthwhile.

flowchart LR
  Before[Rules in Web Only] --> Extract[Extract app-domain]
  Extract --> Share[Share with Mobile]
  Share --> CQRS[CQRS + DDD in client]
  CQRS --> Result[One domain, two clients]
Enter fullscreen mode Exit fullscreen mode

Related documentation

Top comments (0)