Savvi Studio

Domain Module Contract Specification

Overview

The Domain Module Contract provides a standardized way to organize, register, and manage domain-specific functionality within the Savvi Studio application. It builds on our existing dependency injection container system to create self-contained, reusable modules with explicit interfaces and dependencies.

Goals

  • Modular Architecture: Enable domain-specific functionality to be packaged as discrete modules
  • Type Safety: Provide full TypeScript support across module boundaries
  • Dependency Management: Leverage existing DI container for clean dependency resolution
  • Developer Experience: Standardize imports/exports and provide clear contracts
  • Lifecycle Management: Support module installation, configuration, and cleanup
  • Container Integration: Seamlessly integrate with existing container topology

Core Interfaces

DomainModule Interface

interface DomainModule<TConfig = void> {
  // Module Identity
  readonly name: string;
  readonly version: string;
  readonly description?: string;
  
  // Dependency Injection Integration
  providers: readonly Provider<any>[];
  tokens: Record<string, Token<any>>;
  
  // Domain Assets
  procedures?: DomainProcedures;
  components?: DomainComponents;
  hooks?: DomainHooks;
  routers?: DomainRouters;
  mappers?: DomainMappers;
  
  // Configuration
  configSchema?: z.ZodType<TConfig>;
  defaultConfig?: TConfig;
  config?: TConfig;
  
  // Environment-specific behavior
  environments?: {
    development?: Partial<TConfig>;
    production?: Partial<TConfig>;
    test?: Partial<TConfig>;
  };
  
  // Feature flags
  features?: Record<string, boolean>;
  
  // Module Dependencies
  dependencies?: readonly string[]; // Other module names
  
  // Lifecycle Hooks
  install?(container: Container, config?: TConfig): Promise<void>;
  configure?(config: TConfig): this;
  uninstall?(container: Container): Promise<void>;
  
  // Container Level (where this module should be registered)
  containerLevel?: ContainerKey;
}

Supporting Types

// Domain asset collections
interface DomainProcedures {
  [key: string]: (...args: any[]) => any;
}

interface DomainComponents {
  [key: string]: React.ComponentType<any>;
}

interface DomainHooks {
  [key: string]: (...args: any[]) => any;
}

interface DomainRouters {
  [key: string]: any; // tRPC router or similar
}

interface DomainMappers {
  [key: string]: ResourceMapper<any> | StatementMapper<any>;
}

// Module metadata for registry
interface ModuleMeta {
  module: DomainModule;
  loadOrder: number;
  dependencies: string[];
  dependents: string[];
}

Module Structure Convention

Each domain module follows a standardized file structure:

src/lib/{domain}/
├── index.ts           # Main module export
├── module.ts          # Module definition
├── model.ts           # Domain models & Zod schemas
├── types.ts           # TypeScript interfaces
├── tokens.ts          # DI tokens
├── providers.ts       # DI providers
├── service.ts         # Core business logic services
├── mappers.ts         # Resource/Statement mappers
├── router.ts          # API router (tRPC)
├── procedures/        # Individual API procedures
├── membership/        # Sub-domains (if applicable)
└── README.md         # Domain documentation

Standard Module Export Pattern

// src/lib/teams/index.ts
// 1. Re-export all domain assets
export * from './model';       // Team, TeamMembership, TeamRole
export * from './tokens';      // teamServiceToken, etc.
export * from './types';       // API request/response types
export * from './providers';   // teamsProviders array
export * from './mappers';     // teamMapper, teamMembershipMapper
export * from './service';     // TeamService, TeamMembershipService

// 2. Export module contract
export { teamsModule } from './module';

// 3. Convenience type exports for cross-module usage
export type {
  // Service interfaces
  TeamService,
  TeamMembershipService,
  
  // Domain models
  Team,
  TeamMembership,
  TeamRole,
  
  // API types
  CreateTeamRequest,
  UpdateTeamRequest,
  ListTeamsResponse,
} from './types';

// 4. Token collection for easy cross-module import
export const TeamsTokens = {
  teamService: teamServiceToken,
  teamMembershipService: teamMembershipServiceToken,
} as const;

// 5. Type-safe token type exports
export type TeamServiceToken = typeof teamServiceToken;
export type TeamMembershipServiceToken = typeof teamMembershipServiceToken;

Module Definition

// src/lib/teams/module.ts
import { z } from 'zod';
import { ContainerKey } from '@/lib/studio/types';
import type { DomainModule } from '@/lib/modules/types';
import { teamsProviders } from './providers';
import { TeamsTokens } from './tokens';
import { teamsProcedures } from './procedures';
import { teamsComponents } from '../../../components/teams';
import { teamsHooks } from '../../../hooks/teams';

const TeamsConfigSchema = z.object({
  maxTeamSize: z.number().default(50),
  allowPublicTeams: z.boolean().default(false),
  invitationExpiry: z.number().default(7 * 24 * 60 * 60 * 1000), // 7 days
});

export const teamsModule: DomainModule<z.infer<typeof TeamsConfigSchema>> = {
  name: 'teams',
  version: '1.0.0',
  description: 'Team management and membership functionality',
  
  // DI Integration
  providers: teamsProviders,
  tokens: TeamsTokens,
  
  // Domain Assets
  procedures: teamsProcedures,
  components: teamsComponents,
  hooks: teamsHooks,
  
  // Configuration
  configSchema: TeamsConfigSchema,
  defaultConfig: {
    maxTeamSize: 50,
    allowPublicTeams: false,
    invitationExpiry: 7 * 24 * 60 * 60 * 1000,
  },
  
  // Environment overrides
  environments: {
    development: {
      maxTeamSize: 10,
      allowPublicTeams: true,
    },
    test: {
      maxTeamSize: 5,
      invitationExpiry: 1000, // 1 second for fast tests
    },
  },
  
  // Dependencies
  dependencies: ['auth', 'graph'],
  
  // Container registration
  containerLevel: ContainerKey.App,
  
  // Lifecycle
  async install(container, config) {
    console.log(`Installing teams module with config:`, config);
    
    // Perform any setup needed
    const graphClient = await container.get(GraphTokens.userGraph);
    // ... setup logic
  },
  
  configure(config) {
    this.config = {
      ...this.defaultConfig,
      ...config,
    };
    return this;
  },
};

Module Registry System

ModuleRegistry Class

// src/lib/modules/registry.ts
export class ModuleRegistry {
  private modules = new Map<string, ModuleMeta>();
  private loadOrder: string[] = [];
  private installed = new Set<string>();

  /**
   * Register one or more modules
   */
  register(...modules: DomainModule[]): this {
    // Add modules to registry
    modules.forEach(module => {
      if (this.modules.has(module.name)) {
        throw new Error(`Module '${module.name}' is already registered`);
      }
      
      this.modules.set(module.name, {
        module,
        loadOrder: 0, // Will be calculated
        dependencies: module.dependencies || [],
        dependents: [],
      });
    });
    
    // Validate all dependencies exist
    this.validateDependencies();
    
    // Calculate load order
    this.calculateLoadOrder();
    
    return this;
  }

  /**
   * Install all modules in dependency order
   */
  async installAll(container: Container): Promise<void> {
    for (const moduleName of this.loadOrder) {
      await this.installModule(moduleName, container);
    }
  }

  /**
   * Install a specific module (and its dependencies)
   */
  async installModule(moduleName: string, container: Container): Promise<void> {
    if (this.installed.has(moduleName)) {
      return; // Already installed
    }

    const meta = this.modules.get(moduleName);
    if (!meta) {
      throw new Error(`Module '${moduleName}' not registered`);
    }

    // Install dependencies first
    for (const depName of meta.dependencies) {
      await this.installModule(depName, container);
    }

    const { module } = meta;
    
    // Apply environment-specific configuration
    const config = this.resolveConfig(module);
    
    // Register providers with container
    container.register(module.providers);
    
    // Run module installation hook
    await module.install?.(container, config);
    
    this.installed.add(moduleName);
    
    console.log(`✅ Installed module: ${moduleName}`);
  }

  /**
   * Get modules by container level
   */
  getModulesForContainer(level: ContainerKey): DomainModule[] {
    return Array.from(this.modules.values())
      .map(meta => meta.module)
      .filter(module => module.containerLevel === level);
  }

  private validateDependencies(): void {
    for (const [moduleName, meta] of this.modules) {
      for (const depName of meta.dependencies) {
        if (!this.modules.has(depName)) {
          throw new Error(
            `Module '${moduleName}' depends on '${depName}' which is not registered`
          );
        }
        
        // Add to dependents list
        const depMeta = this.modules.get(depName)!;
        depMeta.dependents.push(moduleName);
      }
    }
  }

  private calculateLoadOrder(): void {
    const visited = new Set<string>();
    const visiting = new Set<string>();
    const order: string[] = [];

    const visit = (moduleName: string) => {
      if (visiting.has(moduleName)) {
        throw new Error(`Circular dependency detected involving module '${moduleName}'`);
      }
      if (visited.has(moduleName)) {
        return;
      }

      visiting.add(moduleName);
      
      const meta = this.modules.get(moduleName)!;
      for (const depName of meta.dependencies) {
        visit(depName);
      }
      
      visiting.delete(moduleName);
      visited.add(moduleName);
      order.push(moduleName);
    };

    for (const moduleName of this.modules.keys()) {
      visit(moduleName);
    }

    this.loadOrder = order;
    
    // Update load order in metadata
    order.forEach((moduleName, index) => {
      this.modules.get(moduleName)!.loadOrder = index;
    });
  }

  private resolveConfig<T>(module: DomainModule<T>): T | undefined {
    if (!module.configSchema || !module.defaultConfig) {
      return undefined;
    }

    const env = process.env.NODE_ENV as 'development' | 'production' | 'test';
    const envConfig = module.environments?.[env] || {};
    
    const resolved = {
      ...module.defaultConfig,
      ...envConfig,
      ...module.config, // Explicit config overrides everything
    };

    return module.configSchema.parse(resolved);
  }
}

Container Integration

Enhanced Container Builders

// src/lib/studio/containers.ts (enhanced)
import { domainModules } from './modules';

// Module registry instance
const moduleRegistry = new ModuleRegistry().register(...domainModules);

export const enhancedAppContainerBuilder = () => {
  const base = appContainerBuilder();
  
  // Get modules that should be registered at App level
  const appModules = moduleRegistry.getModulesForContainer(ContainerKey.App);
  
  // Register all app-level modules
  appModules.forEach(module => {
    base.register(module.providers);
  });
  
  return base;
};

export const enhancedCoreContainerBuilder = () => {
  const base = coreContainerBuilder();
  
  // Get modules that should be registered at Core level
  const coreModules = moduleRegistry.getModulesForContainer(ContainerKey.Core);
  
  coreModules.forEach(module => {
    base.register(module.providers);
  });
  
  return base;
};

// Update topology to use enhanced builders
export const EnhancedTopology: Record<ContainerToken, ContainerMeta> = {
  [rootContainerToken]: {
    key: ContainerKey.Root,
    builder: rootContainerBuilder // No modules at root level
  },
  [coreContainerToken]: {
    key: ContainerKey.Core,
    parent: [rootContainerToken],
    builder: enhancedCoreContainerBuilder
  },
  [appContainerToken]: {
    key: ContainerKey.App,
    parent: [coreContainerToken],
    builder: enhancedAppContainerBuilder
  },
  // ... admin and webhook containers
} as const;

Module Collection

// src/lib/studio/modules.ts
import { authModule } from '@/lib/auth';
import { graphModule } from '@/lib/graph';
import { teamsModule } from '@/lib/teams';
import { tasksModule } from '@/lib/tasks';
import { adminModule } from '@/lib/admin';

export const domainModules = [
  authModule,
  graphModule,
  teamsModule, 
  tasksModule,
  adminModule,
] as const;

export type DomainModuleName = typeof domainModules[number]['name'];

// Type-safe module lookup
export const getModule = <T extends DomainModuleName>(
  name: T
): Extract<typeof domainModules[number], { name: T }> => {
  const module = domainModules.find(m => m.name === name);
  if (!module) {
    throw new Error(`Module '${name}' not found`);
  }
  return module as any;
};

Cross-Module Dependencies

Type-Safe Token Import

// src/lib/tasks/providers.ts
import { deps, provider, Scope } from '@/lib/container';
import { TeamsTokens, type TeamService } from '@/lib/teams'; // Clean import!
import { GraphTokens, type GraphClient } from '@/lib/graph';

export const taskServiceProvider = provider(taskServiceToken)
  .withDependencies(
    GraphTokens.userGraph,
    TeamsTokens.teamService  // Type-safe cross-module dependency
  )
  .onCreate((deps) => {
    const graphClient: GraphClient = deps[GraphTokens.userGraph];
    const teamService: TeamService = deps[TeamsTokens.teamService]; // Fully typed!
    
    return new TaskService(graphClient, teamService);
  })
  .withScope(Scope.Scoped)
  .build();

Service Interface Dependencies

// Services can depend on other module's service interfaces
export class TaskService {
  constructor(
    private graph: GraphClient,
    private teamService: TeamService  // Cross-module service dependency
  ) {}

  async createTaskForTeam(teamId: bigint, taskData: CreateTaskData) {
    // Validate team exists using teams module
    const team = await this.teamService.getTeam(teamId);
    
    // Create task with team context
    return this.createTask({
      ...taskData,
      teamId: team.id,
    });
  }
}

Development Experience

Hot Module Replacement (Development)

// src/lib/modules/dev.ts
if (process.env.NODE_ENV === 'development') {
  export class DevModuleRegistry extends ModuleRegistry {
    private watchers = new Map<string, FSWatcher>();

    withHotReload(): this {
      // Watch module files for changes
      for (const [moduleName, meta] of this.modules) {
        const modulePath = `src/lib/${moduleName}`;
        const watcher = fs.watch(modulePath, { recursive: true }, (eventType, filename) => {
          if (filename?.endsWith('.ts') && !filename.endsWith('.d.ts')) {
            this.reloadModule(moduleName);
          }
        });
        this.watchers.set(moduleName, watcher);
      }
      return this;
    }

    private async reloadModule(moduleName: string): Promise<void> {
      console.log(`🔄 Hot reloading module: ${moduleName}`);
      // Implementation for hot module replacement
    }
  }
}

Module Debugging

// Development tools for module introspection
export interface ModuleDebugInfo {
  name: string;
  version: string;
  loadOrder: number;
  dependencies: string[];
  dependents: string[];
  providers: Array<{
    token: string;
    scope: Scope;
  }>;
  config: any;
}

export const getModuleDebugInfo = (registry: ModuleRegistry): ModuleDebugInfo[] => {
  // Return debug information for all registered modules
};

Testing Support

Module Testing Utilities

// src/lib/modules/testing.ts
export class TestModuleRegistry extends ModuleRegistry {
  /**
   * Create isolated test registry with minimal dependencies
   */
  static forTesting(...modules: DomainModule[]): TestModuleRegistry {
    const registry = new TestModuleRegistry();
    
    // Add required core modules automatically
    const coreModules = [authModule, graphModule]; // Always needed
    
    return registry.register(...coreModules, ...modules);
  }

  /**
   * Override module configuration for testing
   */
  withTestConfig<T>(moduleName: string, config: Partial<T>): this {
    const meta = this.modules.get(moduleName);
    if (meta) {
      meta.module.config = { ...meta.module.config, ...config };
    }
    return this;
  }
}

// Usage in tests
describe('Teams Module', () => {
  let container: Container;
  
  beforeEach(async () => {
    const registry = TestModuleRegistry
      .forTesting(teamsModule)
      .withTestConfig('teams', { maxTeamSize: 2 });
    
    container = new Container();
    await registry.installAll(container);
  });
});

Implementation Phases

Phase 1: Foundation

  • Create base module interfaces and types
  • Implement ModuleRegistry class
  • Create module testing utilities
  • Document migration guide for existing domains

Phase 2: Core Modules

  • Migrate auth module to new contract
  • Migrate graph module to new contract
  • Update container builders for enhanced registration

Phase 3: Business Domains

  • Migrate teams module to new contract
  • Migrate tasks module to new contract
  • Add cross-module dependency examples

Phase 4: Enhancement

  • Add hot module replacement for development
  • Create module debugging tools
  • Add configuration validation and environment handling
  • Performance optimization and caching

Benefits

  1. Standardization: Consistent structure across all domain modules
  2. Type Safety: Full TypeScript support for cross-module dependencies
  3. Dependency Management: Automatic dependency resolution and validation
  4. Developer Experience: Clean imports, hot reloading, debugging tools
  5. Testing: Isolated module testing with dependency injection
  6. Configuration: Environment-aware configuration with validation
  7. Lifecycle Management: Proper module installation and cleanup
  8. Container Integration: Seamless integration with existing DI system

Migration Guide

See Domain Module Migration Guide for step-by-step instructions on migrating existing domains to the new contract system.