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
- Standardization: Consistent structure across all domain modules
- Type Safety: Full TypeScript support for cross-module dependencies
- Dependency Management: Automatic dependency resolution and validation
- Developer Experience: Clean imports, hot reloading, debugging tools
- Testing: Isolated module testing with dependency injection
- Configuration: Environment-aware configuration with validation
- Lifecycle Management: Proper module installation and cleanup
- 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.