Companion Models and Extensions – Enhancing Core Functionality

Beyond Base Models: The Extension Challenge

KOS provides powerful base models that handle fundamental device operations—availability tracking, configuration management, and status monitoring. But real-world applications often need domain-specific functionality that goes beyond these generic capabilities. Consider an AvailabilityModel that tracks whether beverages are available for dispensing. While this model handles core availability logic, your application might need brand-specific features like determining free beverage options, or beverage-specific capabilities like nutritional information lookup.

Companion models solve this challenge by implementing the decorator pattern for KOS models. They allow you to enhance existing models with domain-specific functionality without modifying the core model structure, keeping business logic cleanly separated while maintaining all the reactive and lifecycle benefits of the KOS architecture.

The Companion Model Pattern

Companion models extend parent models by providing additional functionality while preserving the parent’s interface and behavior. They’re automatically created and managed by the KOS framework based on configurable factory functions that determine when companions are needed and which type to create.

import { kosModel, kosCompanion, IKosDataModel, IKosIdentifiable, KosCompanionDecorator } from "@kosdev-code/kos-ui-sdk";
import { AvailabilityModel, AvailabilityModelOptions } from "@kosdev-code/kos-dispense-sdk";
// Interface merging for decorator type safety
interface BrandCompanionImpl extends KosCompanionDecorator<AvailabilityModel> {}
@kosModel({ modelTypeId: "brand-companion", singleton: false })
@kosCompanion() // Decorator mode - transparently proxies all parent properties
export class BrandCompanionImpl implements IKosDataModel, IKosIdentifiable {
  id: string;
  private logger: KosContextLogger;
  constructor(
    modelId: string,
    options: any,
    context: KosCreationContext
  ) {
    this.id = modelId;
    this.logger = context.logger;
  }
  async init(): Promise<void> {
    this.logger.debug(`Initializing brand companion for ${this.name}`); // Direct access to parent properties
  }
  async load(context: KosContext): Promise<void> {
    // Can access other models through context if needed
    // Parent properties are automatically available
  }
  // Enhanced functionality using parent data (accessed directly)
  get brandName(): string {
    return this.name; // Direct access to parent's name property
  }
  get isAvailable(): boolean {
    return this.available; // Direct access to parent's available property
  }
  get beverageCount(): number {
    return this.getRelatedModels()?.length || 0; // Direct access to parent methods
  }
  get hasFreeOptions(): boolean {
    const beverages = this.getRelatedModels() || [];
    return beverages.some(beverage => beverage.properties?.["free"]);
  }
  get marketingMessage(): string {
    return this.properties?.["marketing"] || "Try our delicious beverages!"; // Direct access to parent properties
  }
  // Complex operations combining parent data with companion logic
  async fetchDetailedBrandInfo(): Promise<DetailedBrandInfo> {
    const baseInfo = {
      name: this.name,
      available: this.available,
      properties: this.properties
    };
    const additionalData = await this.loadAdditionalBrandData();
    return { ...baseInfo, ...additionalData };
  }
  private async loadAdditionalBrandData(): Promise<Partial<DetailedBrandInfo>> {
    // Companion-specific data loading
    return await brandDataService.getExtendedInfo(this.id);
  }
}
export type BrandCompanion = PublicModelInterface<BrandCompanionImpl>;

This companion provides brand-specific enhancements to the base AvailabilityModel while maintaining clean separation between generic availability logic and domain-specific brand features.

CLI-Generated Companions

The KOS CLI provides dedicated support for creating companion models with proper structure and conventions:

# Create brand-specific companion for AvailabilityModel
kosui model:companion --modelName BrandCompanion --modelProject ui-models --companionParent AvailabilityModel
# Create beverage-specific companion
kosui model:companion --modelName BeverageCompanion --modelProject ui-models --companionParent AvailabilityModel
# Create nutritional information companion
kosui model:companion --modelName NutritionalCompanion --modelProject health-models --companionParent AvailabilityModel

The CLI generates the complete companion structure including proper TypeScript interfaces, lifecycle methods, and parent model integration patterns that follow KOS architectural conventions.

Factory Functions and Dynamic Creation

Companion models are created dynamically based on factory functions that analyze parent model properties and determine which type of companion to create. This allows different companions for the same parent model type based on runtime data.

// Register companions during application initialization
KosModelRegistry.dispense
  .models()
  .model(Core)
  .model(BrandCompanionImpl)
  .model(BeverageCompanionImpl) 
  .model(NutritionalCompanionImpl)
  // Factory function determines companion type based on parent data
  .companion(AvailabilityModel.type, (availability: AvailabilityModel) => {
    switch (availability.groupId) {
      case "brands":
        return "brand-companion";
      case "beverages":
        return "beverage-companion";
      default:
        return null; // No companion needed
    }
  });
// Property-based factory functions
.companion(AvailabilityModel.type, (availability) => {
  if (availability.properties?.["premium"]) {
    return "premium-companion";
  }
  if (availability.properties?.["seasonal"]) {
    return "seasonal-companion";
  }
  return "standard-companion";
});
// Complex business logic factory
.companion(AvailabilityModel.type, (availability) => {
  const region = getCurrentRegion();
  const isHighEnd = availability.properties?.["tier"] === "premium";
  if (region === "US" && isHighEnd) {
    return "us-premium-companion";
  }
  if (region === "EU" && availability.groupId === "beverages") {
    return "eu-beverage-companion";
  }
  return null; // Use base model only
});

Factory functions provide flexibility in companion creation, allowing the same parent model type to receive different companions based on runtime conditions, properties, or business logic requirements.

Lifecycle-Aware Companion Creation

By default, companion models are created during the parent model’s INIT phase (immediately when the parent is instantiated). However, some companions need access to data that’s only available after the parent completes its load() method—such as feature flags set during loading or data fetched from APIs.

The @kosCompanion decorator supports a lifecycle parameter that defers companion creation until a specific lifecycle phase:

import { kosModel, kosCompanion, DependencyLifecycle, IKosDataModel, KosCompanionComposition } from "@kosdev-code/kos-ui-sdk";
import type { AssemblyModel } from "@kosdev-code/kos-dispense-sdk";
interface CarbAgitatorModelImpl extends KosCompanionComposition<AssemblyModel> {}
@kosModel({ modelTypeId: "carb-agitator-model", singleton: false })
@kosCompanion({
  mode: "composition",
  lifecycle: DependencyLifecycle.LOAD  // Wait until parent completes load()
})
export class CarbAgitatorModelImpl implements IKosDataModel {
  id: string;
  constructor(modelId: string, options: CarbAgitatorOptions) {
    this.id = modelId;
    // Constructor runs during LOAD phase, after parent.load() completes
  }
  get holder() {
    // Safe to access data loaded during parent's load() method
    const parent = this.getCompanionParent();
    return parent.getHoldersByGroup("carbTank")[0];
  }
}

When Factory Functions Need Lifecycle Control

Factory functions are called during each lifecycle phase to determine if a companion should be created. This is particularly useful when the factory needs data that’s loaded during the parent’s lifecycle methods:

// Register companion with lifecycle-aware factory
KosModelRegistry.freestyle
  .models()
  .model(CarbAgitator)
  .companion(Assembly.type, (assembly: AssemblyModel) => {
    // This factory runs at each lifecycle phase
    // Feature flag is set during Assembly.load()
    if (KosFeatureFlags.isFeatureEnabled("carbTank")) {
      return CarbAgitator.type;  // Returns "carb-agitator-model"
    }
    return undefined;  // No companion needed
  });

How it works:

  1. INIT phase: Factory runs → feature flag not set yet → returns undefined → companion skipped
  2. Parent’s load() runs: Sets the carbTank feature flag
  3. LOAD phase: Factory runs again → feature flag is now set → returns CarbAgitator.type
  4. Lifecycle extracted: Framework checks CarbAgitator’s @kosCompanion decorator → sees lifecycle: LOAD
  5. Companion created: CarbAgitator is instantiated during LOAD phase with access to loaded data

Available Lifecycle Phases

import { DependencyLifecycle } from "@kosdev-code/kos-ui-sdk";
@kosCompanion({ lifecycle: DependencyLifecycle.INIT })     // Default - created immediately
@kosCompanion({ lifecycle: DependencyLifecycle.LOAD })     // After parent.load() completes
@kosCompanion({ lifecycle: DependencyLifecycle.READY })    // After parent.ready() completes
@kosCompanion({ lifecycle: DependencyLifecycle.ACTIVATE }) // After parent.activate() completes
@kosCompanion({ lifecycle: DependencyLifecycle.ONLINE })   // After parent.online() completes

Factory Function Considerations

Important: Factory functions are called multiple times (once per lifecycle phase) to determine the companion type and extract its lifecycle metadata. Keep factories:

  • Pure and deterministic: Return the same result for the same inputs
  • Lightweight: Avoid expensive operations (they run 5 times per parent)
  • Side-effect free: No mutations, API calls, or external state changes
// ✅ Good - lightweight feature flag check
.companion(Assembly.type, () => {
  return KosFeatureFlags.isFeatureEnabled("carbTank")
    ? CarbAgitator.type
    : undefined;
})
// ✅ Good - based on stable parent data
.companion(Pump.type, (pump) => {
  return pump.pumpType === "FCM" ? FcmCalibration.type : undefined;
})
// ❌ Bad - expensive operation
.companion(Assembly.type, async () => {
  const data = await fetchFromAPI();  // Called 5 times!
  return data.needsCompanion ? "companion" : undefined;
})
// ❌ Bad - non-deterministic
let counter = 0;
.companion(Assembly.type, () => {
  counter++;
  return counter % 2 === 0 ? "companion" : undefined;  // Different each call!
})

When to Use Lifecycle Control

Use lifecycle-deferred companions when:

  1. Factory depends on parent’s loaded data: Feature flags, API responses, or data mappers that run during load()
  2. Companion needs loaded parent data: Accessing properties populated during parent’s lifecycle methods
  3. Complex initialization order: Managing dependencies between multiple companions

For most companions, the default INIT lifecycle works fine since the system waits for all models to reach READY/ONLINE before becoming operational anyway.

Decorator vs Composition Patterns

The @kosCompanion decorator supports two patterns for different use cases:

Decorator Pattern (Default)

The decorator pattern provides transparent access to all parent properties, making the companion a drop-in replacement:

import { kosModel, kosCompanion, IKosDataModel, KosCompanionDecorator } from "@kosdev-code/kos-ui-sdk";
// Interface merging for decorator type safety
interface BeverageCompanionImpl extends KosCompanionDecorator<AvailabilityModel> {}
@kosModel({ modelTypeId: "beverage-companion", singleton: false })
@kosCompanion() // Defaults to decorator mode
export class BeverageCompanionImpl implements IKosDataModel {
  id: string;
  private nutritionService: NutritionService;
  constructor(
    modelId: string,
    options: any,
    context: KosCreationContext
  ) {
    this.id = modelId;
    this.nutritionService = context.get(NutritionService.type);
  }
  async init(): Promise<void> {
    this.logger.debug(`Initializing beverage companion for ${this.name}`); // Direct parent access
  }
  // Access parent properties directly (no .parent needed)
  get beverageName(): string {
    return this.name; // Direct access to parent's name
  }
  get isAvailable(): boolean {
    return this.available; // Direct access to parent's available
  }
  get baseProperties(): Record<string, any> {
    return this.properties || {}; // Direct access to parent's properties
  }
  // Companion-specific extensions
  get calories(): number {
    return this.baseProperties.calories || 0;
  }
  get allergens(): string[] {
    return this.baseProperties.allergens || [];
  }
  get isVegan(): boolean {
    return this.baseProperties.vegan === true;
  }
  get sizesAvailable(): string[] {
    return this.baseProperties.sizes || ["small", "medium", "large"];
  }
  // Complex companion functionality
  async getNutritionalInfo(): Promise<NutritionalInfo> {
    const basicInfo = {
      calories: this.calories,
      allergens: this.allergens,
      vegan: this.isVegan
    };
    // Enhance with detailed nutritional data
    const detailedInfo = await this.nutritionService.getDetailedNutrition(this.id);
    return { ...basicInfo, ...detailedInfo };
  }
  async getRecommendedPairing(): Promise<string[]> {
    return await this.nutritionService.getPairingSuggestions(
      this.id,
      this.allergens
    );
  }
  canConsumeWithDiet(dietType: string): boolean {
    switch (dietType.toLowerCase()) {
      case 'vegan':
        return this.isVegan;
      case 'low-calorie':
        return this.calories < 100;
      case 'nut-free':
        return !this.allergens.includes('nuts');
      default:
        return true;
    }
  }
}
### Composition Pattern
The composition pattern keeps the parent encapsulated for explicit access:
```typescript
import { kosModel, kosCompanion, IKosDataModel, KosCompanionComposition } from "@kosdev-code/kos-ui-sdk";
// Interface merging for composition type safety
interface ServiceCompanionImpl extends KosCompanionComposition<ServiceModel> {}
@kosModel({ modelTypeId: "service-companion", singleton: false })
@kosCompanion({ mode: 'composition' }) // Explicit composition mode
export class ServiceCompanionImpl implements IKosDataModel {
  id: string;
  private analyticsService: AnalyticsService;
  constructor(
    modelId: string,
    options: any,
    context: KosCreationContext
  ) {
    this.id = modelId;
    this.analyticsService = context.get(AnalyticsService.type);
  }
  async init(): Promise<void> {
    const parent = this.getCompanionParent();
    this.logger.debug(`Initializing service companion for ${parent.serviceName}`);
  }
  // Access parent through getCompanionParent() method
  get serviceInfo(): string {
    const parent = this.getCompanionParent();
    return `Service: ${parent.serviceName} (${parent.status})`;
  }
  get isHealthy(): boolean {
    const parent = this.getCompanionParent();
    return parent.status === 'healthy';
  }
  // Companion-specific functionality
  get analyticsData(): ServiceAnalytics {
    const parent = this.getCompanionParent();
    return {
      serviceName: parent.serviceName,
      uptime: this.calculateUptime(parent),
      requestCount: parent.properties?.requestCount || 0
    };
  }
  private calculateUptime(parent: ServiceModel): number {
    // Companion-specific calculation logic
    return parent.properties?.startTime 
      ? Date.now() - parent.properties.startTime 
      : 0;
  }
}

Both patterns provide strong TypeScript support and predictable behavior. Choose decorator mode for transparent parent replacement and composition mode for explicit parent encapsulation.

Cross-Model Relationships

Companions can establish relationships with other models in the system through the KOS context:

import { kosModel, kosCompanion, IKosDataModel, KosCompanionDecorator } from "@kosdev-code/kos-ui-sdk";
// Interface merging for decorator type safety
interface EnhancedCompanionImpl extends KosCompanionDecorator<AvailabilityModel> {}
@kosModel({ modelTypeId: "enhanced-companion", singleton: false })
@kosCompanion() // Decorator mode for transparent parent access
export class EnhancedCompanionImpl implements IKosDataModel {
  id: string;
  private relatedModel?: OtherModel;
  constructor(
    modelId: string,
    options: any,
    context: KosCreationContext
  ) {
    this.id = modelId;
  }
  async load(context: KosContext): Promise<void> {
    // Establish relationships with other models through context
    this.relatedModel = context.get(OtherModel.type);
  }
  get relatedItems(): AvailabilityModel[] {
    // Access methods from related models - direct access to parent methods
    return this.relatedModel?.getAvailabilityByChild<AvailabilityModel>(
      this.id
    ) || [];
  }
  get otherCompanion(): OtherCompanion | undefined {
    // Access another companion on the same parent
    const parent = this.getCompanionParent();
    return getKosCompanionModel<OtherCompanion>(
      parent,
      "other-companion"
    );
  }
  async fetchEnhancedData(): Promise<EnhancedData> {
    // Combine data from parent and related models
    const [companionData, relatedData] = await Promise.all([
      this.otherCompanion?.getData(),
      this.relatedModel?.getData()
    ]);
    return {
      parentData: {
        name: this.name,        // Direct access to parent properties
        available: this.available,
        properties: this.properties
      },
      companionData,
      relatedData
    };
  }
}

This pattern enables companions to integrate with other models while maintaining their specific domain focus.

State Management Integration

Companions can integrate with external services while leveraging KOS’s reactive architecture:

import { kosModel, kosCompanion, IKosDataModel, KosCompanionDecorator } from "@kosdev-code/kos-ui-sdk";
// Interface merging for decorator type safety
interface ServiceIntegrationCompanionImpl extends KosCompanionDecorator<AvailabilityModel> {}
@kosModel({ modelTypeId: "service-integration-companion", singleton: false })
@kosCompanion() // Decorator mode for transparent parent access
export class ServiceIntegrationCompanionImpl implements IKosDataModel {
  id: string;
  private externalService?: ExternalService;
  constructor(
    modelId: string,
    options: any,
    context: KosCreationContext
  ) {
    this.id = modelId;
  }
  async load(context: KosContext): Promise<void> {
    // Access external services through context
    this.externalService = context.get<ExternalService>("external-service");
  }
  get enhancedData(): EnhancedInfo {
    // Combine parent data with external service data
    const baseData = this.properties || {}; // Direct access to parent properties
    const serviceData = this.externalService?.getData(this.id);
    return {
      ...baseData,
      ...serviceData
    };
  }
  async updateExternalState(): Promise<void> {
    // Update external service based on parent state
    await this.externalService?.update(this.id, {
      name: this.name,           // Direct access to parent properties
      available: this.available
    });
    // Trigger reactive updates in parent model
    this.notifyChange();
  }
}

This integration pattern allows companions to bridge external services with KOS’s reactive architecture.

Accessing and Using Companions

KOS provides the getKosCompanionModel utility function for accessing companion models from their parent models, creating seamless integration between base functionality and enhanced features.

import { getKosCompanionModel } from "@kosdev-code/kos-ui-sdk";
function BrandSelectionCard({ availability }: { availability: AvailabilityModel }) {
  // Access companion for enhanced functionality
  const brandCompanion = getKosCompanionModel<BrandCompanion>(
    availability, 
    "brand-companion"
  );
  return (
    <div className="brand-selection-card">
      {/* Use parent for core functionality */}
      <h3>{availability.name}</h3>
      <div className="availability-status">
        {availability.available ? "Available" : "Not Available"}
      </div>
      {/* Use companion for enhanced features */}
      {brandCompanion && (
        <div className="brand-enhancements">
          <div className="beverage-count">
            {brandCompanion.beverageCount} beverages available
          </div>
          {brandCompanion.hasFreeOptions && (
            <div className="free-option-badge">
              Free Options Available!
            </div>
          )}
          <div className="marketing-message">
            {brandCompanion.marketingMessage}
          </div>
          <button 
            onClick={() => brandCompanion.fetchDetailedBrandInfo()}
            className="details-button"
          >
            View Details
          </button>
        </div>
      )}
    </div>
  );
}
// For complex scenarios with multiple companion types
function EnhancedBeverageDisplay({ availability }: { availability: AvailabilityModel }) {
  const beverageCompanion = getKosCompanionModel<BeverageCompanion>(
    availability, 
    "beverage-companion"
  );
  const nutritionalCompanion = getKosCompanionModel<NutritionalCompanion>(
    availability, 
    "nutritional-companion"
  );
  const personalizedCompanion = getKosCompanionModel<PersonalizedCompanion>(
    availability, 
    "personalized-companion"
  );
  return (
    <div className="enhanced-beverage-display">
      <div className="basic-info">
        <h3>{availability.name}</h3>
        <div>Status: {availability.available ? "Available" : "Unavailable"}</div>
      </div>
      {beverageCompanion && (
        <div className="beverage-details">
          <div>Sizes: {beverageCompanion.sizesAvailable.join(", ")}</div>
          {beverageCompanion.isVegan && <span className="vegan-badge">Vegan</span>}
        </div>
      )}
      {nutritionalCompanion && (
        <div className="nutritional-info">
          <div>Calories: {nutritionalCompanion.calories}</div>
          <div>Allergens: {nutritionalCompanion.allergens.join(", ")}</div>
        </div>
      )}
      {personalizedCompanion && (
        <div className="personalized-features">
          <div className="rating">
            Rating: {personalizedCompanion.personalizedRating}/5
          </div>
          {personalizedCompanion.isPreferred && (
            <span className="favorite-badge">❤️ Favorite</span>
          )}
          <button 
            onClick={() => personalizedCompanion.updatePreferences('add')}
            disabled={personalizedCompanion.isPreferred}
          >
            Add to Favorites
          </button>
        </div>
      )}
    </div>
  );
}

This usage pattern allows components to progressively enhance functionality based on available companions while gracefully handling scenarios where companions aren’t present.

Best Practices

When building companion models, follow these guidelines:

  • Keep companions focused: Each companion should have a single, clear purpose
  • Use composition pattern: Prefer exposing the parent as a member over duplicating its interface
  • Leverage reactive patterns: KOS’s observable system handles memoization automatically for computed properties
  • Use @kosModelEffect for async operations: When you need to fetch data based on parent changes, use effects instead of manual caching
  • Handle missing parents gracefully: Always check if companion exists before using it
  • Clean up resources: Use the unload() lifecycle method for cleanup when needed
import { kosModel, kosCompanion, kosModelEffect, IKosDataModel, KosCompanionDecorator } from "@kosdev-code/kos-ui-sdk";
// Interface merging for decorator type safety
interface TrackingCompanionImpl extends KosCompanionDecorator<AvailabilityModel> {}
@kosModel({ modelTypeId: "tracking-companion", singleton: false })
@kosCompanion() // Decorator mode for transparent parent access
export class TrackingCompanionImpl implements IKosDataModel {
  id: string;
  private trackingService: TrackingService;
  constructor(
    modelId: string,
    options: any,
    context: KosCreationContext
  ) {
    this.id = modelId;
    this.trackingService = context.get(TrackingService.type);
  }
  // Computed properties are automatically memoized by KOS
  get metadata(): Metadata {
    // This only re-runs when dependencies change - direct access to parent properties
    return {
      name: this.name,
      available: this.available,
      properties: this.properties
    };
  }
  // Use @kosModelEffect for async operations that react to parent changes
  @kosModelEffect({
    dependencies: (model: TrackingCompanion) => [
      model.available  // Direct access to parent's available property
    ],
  })
  async trackStateChange() {
    await this.trackingService.track('state-changed', {
      itemId: this.id,
      available: this.available,
      timestamp: Date.now()
    });
  }
  unload(): void {
    // Clean up any subscriptions or resources
    this.trackingService.cleanup();
  }
}

Lifecycle Binding vs Dependencies

A critical distinction between companions and regular model dependencies:

Model Dependencies (accessed via context):

  • Have independent lifecycles
  • Created and destroyed separately
  • Can exist before or after the models that use them
  • Referenced but not owned

Companion Models:

  • Lifecycle is bound to the parent model
  • Created automatically when parent is created
  • Destroyed automatically when parent is destroyed
  • Owned by and inseparable from their parent

This tight lifecycle coupling ensures companions don’t leak resources or become orphaned—they live and die with their parent. Key implications:

  • Automatic cleanup: When a parent model is destroyed, all its companions are automatically cleaned up
  • Shared lifecycle: Companions follow the same init → load → unload lifecycle as their parent
  • Readiness dependencies: Parent model readiness depends on companion readiness, just like any child model
  • Error propagation: If a companion fails to initialize, it affects the parent’s status

The Power of Reactive Companions

The key insight about companion models is that they’re full KOS models with complete access to the reactive system. They can:

  • Automatically react to parent changes through computed properties and effects
  • Stay synchronized with parent state without manual update logic
  • Trigger side effects when parent properties change using @kosModelEffect
  • Maintain derived state that updates automatically when dependencies change

This reactive nature, combined with lifecycle binding, means companions aren’t just passive extensions—they’re active participants in the model ecosystem that maintain their own state in perfect sync with their parents throughout their entire lifecycle.

Building Extensible Domain Models

Companion models provide a sophisticated extension mechanism that allows you to enhance core KOS functionality with domain-specific features while maintaining clean architecture and strong separation of concerns.

By leveraging KOS’s observable system, companions can react to parent changes automatically, keeping enhanced functionality synchronized without manual coordination. This reactive foundation, combined with factory functions for dynamic creation and proper TypeScript typing through IKosCompanionOptions, makes companion models the ideal pattern for extending base models with domain-specific capabilities.

Whether adding analytics tracking that responds to availability changes, nutritional information that updates with selections, or business logic that reacts to state transitions, companions provide the architectural foundation for building extensible, reactive systems that evolve with changing business requirements.


Ready to explore more advanced patterns? Continue with HTTP Integration and Routing to learn about building web-accessible KOS applications, or dive into Advanced State Management for complex reactive patterns.