Chapter 18: Domain-Driven Design for Frontend
You've built ViewModels that manage presentation logic. You've created Models that handle data and validation. You've connected them to Views across multiple frameworks. But there's a deeper question lurking beneath all this architecture: How do you organize your domain logic itself?
Domain-Driven Design (DDD) provides answers. Originally conceived for backend systems, DDD's principles translate remarkably well to frontend applications—especially when combined with MVVM's separation of concerns. This chapter explores how to apply DDD concepts to frontend architecture using the GreenWatch greenhouse monitoring system as our case study.
Why DDD Matters for Frontend
Traditional frontend development often treats the UI as a thin layer over backend APIs. Components fetch data, display it, and send updates back. Business logic? That lives on the server.
But modern frontend applications are more sophisticated. They:
- Manage complex domain models (greenhouses, sensors, readings, alerts)
- Enforce business rules (threshold validation, sensor calibration)
- Coordinate workflows (alert configuration, data aggregation)
- Handle offline scenarios (local state, sync strategies)
When your frontend has genuine domain complexity, DDD provides the vocabulary and patterns to manage it. The key insight: your frontend has its own domain model, distinct from (but related to) the backend's domain model.
Core DDD Concepts for Frontend
Ubiquitous Language
DDD starts with language. The terms you use in code should match the terms domain experts use. In GreenWatch, we don't have "data points"—we have sensor readings. We don't have "notification rules"—we have threshold alerts.
This linguistic precision shows up everywhere in our codebase:
// packages/models/src/schemas/sensor.schema.ts
export const SensorTypeEnum = z.enum([
'temperature',
'humidity',
'soilMoisture',
'lightIntensity'
]);
export const SensorStatusEnum = z.enum(['active', 'inactive']);These aren't arbitrary technical names. They're the exact terms greenhouse operators use. When a developer reads SensorTypeEnum, they immediately understand what it represents. When a domain expert reviews the code, they recognize their own vocabulary.
Entities and Value Objects
Entities have identity that persists over time. A greenhouse with ID gh-123 remains the same greenhouse even if its name or location changes. Value Objects are defined by their attributes—two sensor readings with identical values are interchangeable.
In GreenWatch, our entities are clear:
// packages/models/src/schemas/greenhouse.schema.ts
export const CreateGreenhouseSchema = z.object({
id: z.string().uuid().optional(),
name: z.string().min(1),
location: z.string().min(1),
size: z.string().min(1),
cropType: z.string().optional(),
createdAt: z.string().datetime().optional(),
updatedAt: z.string().datetime().optional(),
});
export type GreenhouseData = z.infer<typeof CreateGreenhouseSchema>;The id field signals entity status—this greenhouse has persistent identity. Compare with a sensor reading:
// packages/models/src/schemas/sensor-reading.schema.ts
export const CreateSensorReadingSchema = z.object({
id: z.string().uuid().optional(),
sensorId: z.number().int().positive(),
timestamp: z.string().refine((val) => !isNaN(Date.parse(val)), {
message: 'Invalid timestamp format',
}),
value: z.number(),
});While readings have IDs for database purposes, conceptually they're value objects. A reading of "22.5°C at 2024-01-15 10:30" is defined entirely by those attributes. Two readings with identical values are functionally equivalent.
Aggregates and Aggregate Roots
An aggregate is a cluster of entities and value objects treated as a single unit for data changes. The aggregate root is the only entity that external code can reference directly.
In GreenWatch, Greenhouse is an aggregate root:
// Conceptual aggregate structure (not explicit in code, but enforced by design)
Greenhouse (Aggregate Root)
├── Sensors (Entities within aggregate)
│ └── SensorReadings (Value Objects)
└── ThresholdAlerts (Entities within aggregate)The RestfulApiModel pattern enforces aggregate boundaries through its API design:
// packages/mvvm-core/src/models/RestfulApiModel.ts
export class RestfulApiModel<TData, TSchema extends ZodSchema<TData>> extends BaseModel<TData, TSchema> {
public async fetch(id?: string | string[]): Promise<void> {
let url = this.getUrl();
let expectedType: 'single' | 'collection' = 'collection';
if (id) {
if (Array.isArray(id)) {
url = `${this.getUrl()}?ids=${id.join(',')}`;
expectedType = 'collection';
} else {
url = this.getUrl(id);
expectedType = 'single';
}
}
const fetchedData = await this.executeApiRequest(url, { method: 'GET' }, expectedType);
this.setData(fetchedData);
}
public async create(
payload: Partial<ExtractItemType<TData>> | Partial<ExtractItemType<TData>>[]
): Promise<ExtractItemType<TData> | ExtractItemType<TData>[] | undefined> {
// Optimistic update implementation...
}
public async update(
id: string,
payload: Partial<ExtractItemType<TData>>
): Promise<ExtractItemType<TData> | undefined> {
// Optimistic update with aggregate consistency...
}
public async delete(id: string): Promise<void> {
// Aggregate deletion...
}
}Notice how RestfulApiModel provides CRUD operations at the aggregate level. You don't update individual sensor readings directly—you work through the sensor aggregate. This maintains consistency boundaries.
The optimistic update pattern in create() and update() methods demonstrates aggregate thinking:
// From RestfulApiModel.create()
public async create(
payload: Partial<ExtractItemType<TData>> | Partial<ExtractItemType<TData>>[]
): Promise<ExtractItemType<TData> | ExtractItemType<TData>[] | undefined> {
const originalData = this.getCurrentData();
// Generate temporary ID for optimistic update
const tempId = generateTempId();
const tempItem = { ...payload, id: tempId, tempId: tempId };
// Optimistically update the aggregate
if (Array.isArray(originalData)) {
optimisticData = [...originalData, tempItem];
}
this.setData(optimisticData);
try {
// Persist to server
const createdItem = await this.executeApiRequest(
this.getUrl(),
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
},
'single'
);
// Replace temporary item with server response
this.setData(
currentData.map((item: any) =>
item.id === tempId ? createdItem : item
) as TData
);
return createdItem;
} catch (error) {
// Revert optimistic update on failure
this.setData(originalData);
throw error;
}
}The aggregate (the collection of items) is updated atomically. Either the entire operation succeeds, or it's rolled back. This maintains aggregate consistency even with optimistic updates.
Bounded Contexts in GreenWatch
A bounded context is an explicit boundary within which a domain model is defined and applicable. Different contexts can have different models of the same concepts.
GreenWatch has three primary bounded contexts:
1. Monitoring Context
Purpose: Real-time sensor data visualization and greenhouse status
Core Entities:
- Greenhouse (aggregate root)
- Sensor (entity)
- SensorReading (value object)
Key Operations:
- Fetch current sensor readings
- Display greenhouse status
- Visualize trends
ViewModel:
// packages/view-models/src/SensorViewModel.ts
import { RestfulApiViewModel } from '@web-loom/mvvm-core';
import { SensorListSchema, type SensorListData, SensorModel } from '@repo/models';
export class SensorViewModel extends RestfulApiViewModel<
SensorListData,
typeof SensorListSchema
> {
constructor(model: SensorModel) {
super(model);
}
}
const sensorModel = new SensorModel();
export const sensorViewModel = new SensorViewModel(sensorModel);The SensorViewModel focuses purely on presentation concerns within the Monitoring context. It doesn't know about alert configuration or historical analytics—those belong to other contexts.
2. Alerting Context
Purpose: Threshold management and alert notifications
Core Entities:
- ThresholdAlert (aggregate root)
- AlertRule (value object)
- AlertNotification (value object)
Key Operations:
- Configure alert thresholds
- Trigger alerts when thresholds exceeded
- Manage notification preferences
Model:
// packages/models/src/ThresholdAlertModel.ts
import { RestfulApiModel } from '@web-loom/mvvm-core';
import { ThresholdAlertListSchema, type ThresholdAlertListData } from './schemas/alert.schema';
import { fetchWithCache } from './utils/fetcher';
import { apiRegistry } from './services/services';
import { API_BASE_URL } from './config';
const { path } = apiRegistry.alert.list;
const CONFIG = {
baseUrl: API_BASE_URL,
endpoint: path,
fetcher: fetchWithCache,
schema: ThresholdAlertListSchema,
initialData: [],
validateSchema: false,
};
export class ThresholdAlertModel extends RestfulApiModel<
ThresholdAlertListData,
typeof ThresholdAlertListSchema
> {
constructor() {
super(CONFIG);
}
}The Alerting context has its own model of what matters. It cares about thresholds and notifications, not about real-time sensor visualization. This separation allows each context to evolve independently.
3. Configuration Context
Purpose: Greenhouse and sensor setup and management
Core Entities:
- Greenhouse (aggregate root)
- Sensor (entity)
- SensorConfiguration (value object)
Key Operations:
- Create/update greenhouses
- Add/remove sensors
- Configure sensor parameters
ViewModel:
// packages/view-models/src/GreenHouseViewModel.ts
import { createReactiveViewModel, type ViewModelFactoryConfig } from '@web-loom/mvvm-core';
import { greenHouseConfig } from '@repo/models';
import { type GreenhouseListData, GreenhouseListSchema, type GreenhouseData } from '@repo/models';
type TConfig = ViewModelFactoryConfig<GreenhouseListData, typeof GreenhouseListSchema>;
const config: TConfig = {
modelConfig: greenHouseConfig,
schema: GreenhouseListSchema,
};
export const greenHouseViewModel = createReactiveViewModel(config);The Configuration context treats greenhouses as mutable entities that can be created, updated, and deleted. The Monitoring context, by contrast, treats them as relatively static containers for sensor data.
Context Mapping
Different bounded contexts need to communicate. Context mapping defines how contexts relate and integrate.
Shared Kernel
The packages/models/src/schemas/ directory represents a shared kernel—domain concepts shared across contexts:
// Shared across all contexts
packages/models/src/schemas/
├── greenhouse.schema.ts // Shared greenhouse definition
├── sensor.schema.ts // Shared sensor definition
├── sensor-reading.schema.ts // Shared reading definition
└── alert.schema.ts // Shared alert definitionAll contexts agree on these core schemas. When Monitoring context fetches a greenhouse, it gets the same structure that Configuration context uses to create greenhouses.
The shared kernel is kept minimal. Only truly universal concepts belong here. Context-specific concerns (like alert notification preferences) live within their respective contexts.
Customer-Supplier
The backend API is the supplier, and our frontend contexts are customers. The API defines the contract, and we adapt to it:
// packages/models/src/utils/fetcher.ts
// Adapter layer between our domain and the API
export async function fetchWithCache<T>(
url: string,
options?: RequestInit
): Promise<T> {
// Caching logic, error handling, retries...
const response = await fetch(url, options);
if (!response.ok) {
throw new Error(`API request failed: ${response.statusText}`);
}
return response.json();
}The fetcher function is an anti-corruption layer. It translates between the API's representation and our domain model. If the API changes, we update the adapter, not our entire domain model.
Published Language
Zod schemas serve as our published language—the formal contract between contexts:
// packages/models/src/schemas/sensor.schema.ts
export const SensorTypeEnum = z.enum([
'temperature',
'humidity',
'soilMoisture',
'lightIntensity'
]);
export const SensorStatusEnum = z.enum(['active', 'inactive']);
export const CreateSensorSchema = z.object({
id: z.string().uuid().optional(),
type: SensorTypeEnum,
status: SensorStatusEnum,
greenhouseId: z.number().int().positive(),
createdAt: z.string().datetime().optional(),
updatedAt: z.string().datetime().optional(),
greenhouse: CreateGreenhouseSchema,
});
export type SensorData = z.infer<typeof CreateSensorSchema>;These schemas are executable documentation. They define exactly what a valid sensor looks like, and they enforce that definition at runtime. Any context that works with sensors uses this published language.
Domain Events
Domain events represent significant occurrences in the domain. In frontend applications, domain events enable loose coupling between bounded contexts.
While GreenWatch doesn't currently implement explicit domain events, the architecture supports them through the event bus pattern. Here's how you would implement domain events:
// Hypothetical domain events for GreenWatch
import { createEventBus, EventMap } from '@web-loom/event-bus-core';
// Define domain events as a type-safe event map
interface GreenWatchDomainEvents extends EventMap {
'sensor.reading.received': [reading: SensorReadingData];
'threshold.alert.triggered': [alert: ThresholdAlertData];
'greenhouse.created': [greenhouse: GreenhouseData];
'sensor.status.changed': [sensorId: string, status: 'active' | 'inactive'];
}
// Create a domain event bus
export const domainEventBus = createEventBus<GreenWatchDomainEvents>();
// Monitoring context publishes events
export class SensorReadingService {
async recordReading(reading: SensorReadingData): Promise<void> {
// Persist the reading
await this.sensorReadingModel.create(reading);
// Publish domain event
domainEventBus.emit('sensor.reading.received', reading);
}
}
// Alerting context subscribes to events
export class AlertingService {
constructor() {
// Listen for sensor readings
domainEventBus.on('sensor.reading.received', (reading) => {
this.checkThresholds(reading);
});
}
private async checkThresholds(reading: SensorReadingData): Promise<void> {
const alerts = await this.findTriggeredAlerts(reading);
for (const alert of alerts) {
// Publish alert event
domainEventBus.emit('threshold.alert.triggered', alert);
}
}
}Domain events provide several benefits:
-
Decoupling: Monitoring context doesn't need to know about Alerting context. It just publishes events.
-
Extensibility: New contexts can subscribe to existing events without modifying publishers.
-
Audit Trail: Events create a natural log of what happened in the system.
-
Temporal Decoupling: Publishers and subscribers don't need to be active simultaneously.
The event bus implementation from @web-loom/event-bus-core provides type-safe event handling:
// packages/event-bus-core/src/eventBus.ts
class EventBusImpl<M extends EventMap> implements EventBus<M> {
private emitter = new EventEmitter<M>();
on<K extends keyof M>(event: K | K[], listener: Listener<K, M>): void {
const eventNames = Array.isArray(event) ? event : [event];
eventNames.forEach((eventName) => {
this.emitter.on(eventName, listener as GenericListener);
});
}
emit<K extends keyof M>(event: K, ...args: M[K] extends any[] ? M[K] : []): void {
(this.emitter.emit as any)(event, ...args);
}
}Type safety ensures that event payloads match their definitions. You can't emit a sensor.reading.received event with the wrong payload type—TypeScript catches it at compile time.
Repositories and Data Access
In DDD, repositories provide collection-like interfaces for accessing aggregates. They abstract away data storage details.
The RestfulApiModel pattern serves as our repository implementation:
// RestfulApiModel acts as a repository
export class SensorModel extends RestfulApiModel<SensorListData, typeof SensorListSchema> {
// Repository interface:
// - fetch(id?) - retrieve sensors
// - create(payload) - add new sensor
// - update(id, payload) - modify sensor
// - delete(id) - remove sensor
}
// Usage in ViewModel
export class SensorViewModel extends RestfulApiViewModel<
SensorListData,
typeof SensorListSchema
> {
constructor(model: SensorModel) {
super(model);
// ViewModel uses model as a repository
// It doesn't know about HTTP, caching, or storage
}
}The repository pattern provides several advantages:
-
Abstraction: ViewModels work with domain concepts, not HTTP endpoints.
-
Testability: Mock the repository in tests without mocking HTTP.
-
Flexibility: Swap implementations (REST API, GraphQL, local storage) without changing ViewModels.
-
Consistency: All data access goes through repositories, enforcing aggregate boundaries.
The RestfulApiModel base class implements common repository operations:
// From RestfulApiModel
public async fetch(id?: string | string[]): Promise<void> {
// Fetch aggregate(s) from repository
}
public async create(
payload: Partial<ExtractItemType<TData>> | Partial<ExtractItemType<TData>>[]
): Promise<ExtractItemType<TData> | ExtractItemType<TData>[] | undefined> {
// Add aggregate(s) to repository
}
public async update(
id: string,
payload: Partial<ExtractItemType<TData>>
): Promise<ExtractItemType<TData> | undefined> {
// Update aggregate in repository
}
public async delete(id: string): Promise<void> {
// Remove aggregate from repository
}Each method maintains aggregate consistency. Updates are atomic—either the entire aggregate changes, or nothing changes.
Domain Services
Some domain logic doesn't naturally belong to any entity. Domain services encapsulate this logic.
In GreenWatch, sensor calibration is a domain service:
// Hypothetical domain service
export class SensorCalibrationService {
/**
* Calibrates a sensor reading based on sensor type and environmental factors
*/
calibrateReading(
reading: SensorReadingData,
sensor: SensorData,
environmentalFactors: EnvironmentalFactors
): CalibratedReading {
// Domain logic for calibration
const baseValue = reading.value;
// Apply sensor-specific calibration
let calibratedValue = baseValue;
if (sensor.type === 'temperature') {
// Temperature sensors need altitude adjustment
calibratedValue = this.adjustForAltitude(
baseValue,
environmentalFactors.altitude
);
} else if (sensor.type === 'humidity') {
// Humidity sensors need temperature compensation
calibratedValue = this.compensateForTemperature(
baseValue,
environmentalFactors.temperature
);
}
return {
...reading,
value: calibratedValue,
calibrated: true,
calibrationFactors: environmentalFactors
};
}
private adjustForAltitude(value: number, altitude: number): number {
// Domain-specific calibration formula
const altitudeAdjustment = altitude * 0.0065; // °C per meter
return value + altitudeAdjustment;
}
private compensateForTemperature(humidity: number, temp: number): number {
// Relative humidity compensation
const compensationFactor = 1 + ((temp - 20) * 0.01);
return humidity * compensationFactor;
}
}This service encapsulates domain knowledge about sensor calibration. It doesn't belong in SensorReading (a value object) or Sensor (which represents the physical device). It's a standalone domain service.
Domain services are stateless and operate on domain objects passed as parameters. They're distinct from application services (which orchestrate use cases) and infrastructure services (which handle technical concerns).
Validation as Domain Logic
Validation is domain logic. The rules for what constitutes a valid greenhouse or sensor reading are domain concerns, not technical concerns.
Zod schemas encode domain validation rules:
// packages/models/src/schemas/sensor-reading.schema.ts
export const CreateSensorReadingSchema = z.object({
id: z.string().uuid().optional(),
sensorId: z.number().int().positive(),
timestamp: z.string().refine((val) => !isNaN(Date.parse(val)), {
message: 'Invalid timestamp format',
}),
value: z.number(),
});These aren't arbitrary technical constraints. They represent domain rules:
- Sensor ID must be positive: You can't have a sensor with ID -1 or 0. That's a domain rule.
- Timestamp must be valid: Sensor readings must have valid timestamps. That's a domain rule.
- Value must be numeric: Sensor readings are numeric measurements. That's a domain rule.
More complex domain rules can be encoded as custom refinements:
// Hypothetical domain validation
export const ThresholdAlertSchema = z.object({
id: z.string().uuid().optional(),
sensorId: z.number().int().positive(),
minThreshold: z.number(),
maxThreshold: z.number(),
alertType: z.enum(['warning', 'critical']),
}).refine(
(data) => data.minThreshold < data.maxThreshold,
{
message: 'Minimum threshold must be less than maximum threshold',
path: ['minThreshold'],
}
).refine(
(data) => {
// Critical alerts must have tighter thresholds
if (data.alertType === 'critical') {
const range = data.maxThreshold - data.minThreshold;
return range <= 10; // Max 10-degree range for critical alerts
}
return true;
},
{
message: 'Critical alerts must have threshold range ≤ 10',
path: ['alertType'],
}
);These refinements encode domain invariants—rules that must always hold true. The schema becomes executable domain knowledge.
Layered Architecture with DDD
DDD works best with a layered architecture. GreenWatch follows this structure:
┌─────────────────────────────────────────┐
│ Presentation Layer │
│ (Views - React, Vue, Angular, etc.) │
│ - Components │
│ - UI State │
│ - Framework-specific code │
└─────────────────────────────────────────┘
↓ observables
┌─────────────────────────────────────────┐
│ Application Layer │
│ (ViewModels) │
│ - Presentation logic │
│ - Use case orchestration │
│ - Framework-agnostic │
└─────────────────────────────────────────┘
↓ commands
┌─────────────────────────────────────────┐
│ Domain Layer │
│ (Models, Entities, Services) │
│ - Business logic │
│ - Domain rules │
│ - Aggregates │
│ - Domain events │
└─────────────────────────────────────────┘
↓ persistence
┌─────────────────────────────────────────┐
│ Infrastructure Layer │
│ (API clients, Storage, Event Bus) │
│ - HTTP communication │
│ - Caching │
│ - Event publishing │
└─────────────────────────────────────────┘
Each layer has clear responsibilities:
Presentation Layer (apps/mvvm-react/src/components/):
- Renders UI
- Handles user interactions
- Subscribes to ViewModel observables
- Framework-specific
Application Layer (packages/view-models/):
- Orchestrates use cases
- Manages presentation state
- Coordinates between domain and presentation
- Framework-agnostic
Domain Layer (packages/models/, packages/mvvm-core/src/models/):
- Encodes business rules
- Defines entities and aggregates
- Implements domain services
- Pure domain logic
Infrastructure Layer (packages/models/src/utils/, API clients):
- Handles technical concerns
- Communicates with backend
- Manages caching and storage
- Publishes events
Dependencies flow downward. Presentation depends on Application, Application depends on Domain, Domain depends on nothing (except Infrastructure for persistence, via dependency inversion).
Practical DDD Patterns in GreenWatch
Let's see how these concepts come together in actual code.
Pattern 1: Aggregate Root with Optimistic Updates
// packages/mvvm-core/src/models/RestfulApiModel.ts
export class RestfulApiModel<TData, TSchema extends ZodSchema<TData>>
extends BaseModel<TData, TSchema> {
public async update(
id: string,
payload: Partial<ExtractItemType<TData>>
): Promise<ExtractItemType<TData> | undefined> {
const originalData = this.getCurrentData();
let itemToUpdateOriginal: ExtractItemType<TData> | undefined;
let optimisticData: TData | null = null;
// Find the aggregate to update
if (Array.isArray(originalData)) {
itemToUpdateOriginal = originalData.find((item: any) => item.id === id);
if (!itemToUpdateOriginal) {
throw new Error(`Item with id ${id} not found for update`);
}
// Optimistically update the aggregate
const optimisticallyUpdatedItem = { ...itemToUpdateOriginal, ...payload };
optimisticData = originalData.map((item: any) =>
item.id === id ? optimisticallyUpdatedItem : item
) as TData;
}
// Apply optimistic update
this.setData(optimisticData);
try {
// Persist to repository
const updatedItemFromServer = await this.executeApiRequest(
this.getUrl(id),
{
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
},
'single'
);
// Replace optimistic update with server response
const currentData = this.getCurrentData();
if (Array.isArray(currentData)) {
this.setData(
currentData.map((item: any) =>
item.id === id ? updatedItemFromServer : item
) as TData
);
}
return updatedItemFromServer;
} catch (error) {
// Revert on failure - maintain aggregate consistency
this.setData(originalData);
throw error;
}
}
}This pattern maintains aggregate consistency even with optimistic updates. The aggregate is either fully updated or fully reverted—never in an inconsistent state.
Pattern 2: Repository with Type-Safe Schemas
// packages/models/src/ThresholdAlertModel.ts
import { RestfulApiModel } from '@web-loom/mvvm-core';
import {
ThresholdAlertListSchema,
type ThresholdAlertListData
} from './schemas/alert.schema';
import { fetchWithCache } from './utils/fetcher';
import { apiRegistry } from './services/services';
import { API_BASE_URL } from './config';
const { path } = apiRegistry.alert.list;
const CONFIG = {
baseUrl: API_BASE_URL,
endpoint: path,
fetcher: fetchWithCache,
schema: ThresholdAlertListSchema,
initialData: [],
validateSchema: false,
};
export class ThresholdAlertModel extends RestfulApiModel<
ThresholdAlertListData,
typeof ThresholdAlertListSchema
> {
constructor() {
super(CONFIG);
}
}The repository pattern abstracts data access. The ViewModel doesn't know about HTTP, endpoints, or caching. It just works with the repository interface.
Pattern 3: ViewModel as Application Service
// packages/view-models/src/SensorViewModel.ts
import { RestfulApiViewModel } from '@web-loom/mvvm-core';
import { SensorListSchema, type SensorListData, SensorModel } from '@repo/models';
export class SensorViewModel extends RestfulApiViewModel<
SensorListData,
typeof SensorListSchema
> {
constructor(model: SensorModel) {
super(model);
}
// Application service methods would go here
// For example:
// async activateSensor(sensorId: string): Promise<void>
// async deactivateSensor(sensorId: string): Promise<void>
// async calibrateSensor(sensorId: string, factors: CalibrationFactors): Promise<void>
}The ViewModel acts as an application service, orchestrating domain operations and managing presentation state. It's the bridge between the domain layer and the presentation layer.
Pattern 4: Shared Kernel with Published Language
// packages/models/src/schemas/sensor.schema.ts
import { z } from 'zod';
import { CreateGreenhouseSchema } from './greenhouse.schema';
// Published language - shared across all contexts
export const SensorTypeEnum = z.enum([
'temperature',
'humidity',
'soilMoisture',
'lightIntensity'
]);
export const SensorStatusEnum = z.enum(['active', 'inactive']);
export const CreateSensorSchema = z.object({
id: z.string().uuid().optional(),
type: SensorTypeEnum,
status: SensorStatusEnum,
greenhouseId: z.number().int().positive(),
createdAt: z.string().datetime().optional(),
updatedAt: z.string().datetime().optional(),
greenhouse: CreateGreenhouseSchema,
});
export type SensorData = z.infer<typeof CreateSensorSchema>;
export const SensorListSchema = z.array(CreateSensorSchema);
export type SensorListData = z.infer<typeof SensorListSchema>;These schemas form the shared kernel—the common language all contexts use. They're versioned, validated, and serve as executable contracts.
When to Use DDD in Frontend
DDD isn't always necessary. Use it when:
-
Domain complexity is high: Multiple entities, complex business rules, intricate workflows.
-
Multiple bounded contexts exist: Different parts of the application have different models of the same concepts.
-
Long-term evolution is expected: The domain will grow and change over time.
-
Team size justifies it: Multiple developers need shared vocabulary and clear boundaries.
Don't use DDD when:
-
Domain is simple: CRUD operations on a few entities don't need DDD.
-
Application is short-lived: Prototypes and experiments don't benefit from DDD overhead.
-
Team is small: Solo developers or tiny teams may find DDD too heavyweight.
-
Backend owns all domain logic: If your frontend is truly just a thin UI layer, DDD may be overkill.
GreenWatch benefits from DDD because it has genuine domain complexity: multiple entity types, business rules (threshold validation, sensor calibration), and distinct bounded contexts (monitoring, alerting, configuration).
Key Takeaways
-
Ubiquitous Language: Use domain terms consistently in code, conversations, and documentation.
SensorReading, notDataPoint. -
Entities vs Value Objects: Entities have persistent identity. Value objects are defined by their attributes.
-
Aggregates: Group related entities and value objects. Enforce consistency boundaries through aggregate roots.
-
Bounded Contexts: Different parts of the application can have different models. Monitoring, Alerting, and Configuration are separate contexts.
-
Context Mapping: Define how contexts relate. Use shared kernels, customer-supplier relationships, and published languages.
-
Domain Events: Represent significant occurrences. Enable loose coupling between contexts.
-
Repositories: Abstract data access. ViewModels work with domain concepts, not HTTP endpoints.
-
Domain Services: Encapsulate domain logic that doesn't belong to any entity.
-
Validation as Domain Logic: Encode business rules in schemas. Make invalid states unrepresentable.
-
Layered Architecture: Separate presentation, application, domain, and infrastructure concerns.
DDD provides the vocabulary and patterns to manage domain complexity in frontend applications. Combined with MVVM's separation of concerns, it creates a robust foundation for building maintainable, evolvable applications.
The GreenWatch system demonstrates these patterns in practice. The domain model is explicit, bounded contexts are clear, and the architecture supports long-term evolution. As the system grows—adding new sensor types, more sophisticated alerting rules, or advanced analytics—the DDD foundation makes that growth manageable.
Next, we'll explore testing strategies for MVVM applications, showing how the separation of concerns we've established makes comprehensive testing practical and effective.