Skip to main content

Module System

The Ever Works platform is organized as a NestJS monorepo with a layered module architecture. This guide covers how feature modules, shared modules, dynamic modules, and global modules are structured, and how to avoid common pitfalls like circular dependencies.

Module Hierarchy​

ApiModule (Root)
|
+-- AuthModule Feature module (controllers + services)
| +-- PassportModule
| +-- JwtModule.registerAsync()
| +-- DatabaseModule
|
+-- WorksModule Feature module
| +-- DatabaseModule
| +-- FacadesModule
| +-- MailModule
|
+-- MonitoringModule.forRoot() Global dynamic module
| +-- SentryModule.forRoot()
| +-- PostHogModule.forRoot()
|
+-- ThrottlerModule.forRoot() Global (from @nestjs/throttler)
+-- ScheduleModule.forRoot() Global (from @nestjs/schedule)
+-- EventEmitterModule.forRoot() Global (from @nestjs/event-emitter)
+-- CacheFactory.TypeORM() Global cache
+-- AgentPluginsModule.forRoot() Global plugin system
|
+-- SubscriptionsModule
+-- NotificationsModule
+-- ScreenshotModule
+-- PluginsModule
+-- GitProviderModule
+-- OAuthModule
+-- DeployModule
+-- AiConversationModule
+-- TriggerInternalModule
+-- TwentyCrmModule.forRoot()

Module Categories​

Feature Modules​

Feature modules encapsulate a domain area with its controllers, services, and DTOs:

// apps/api/src/auth/auth.module.ts
@Module({
imports: [
PassportModule,
DatabaseModule,
HttpModule,
JwtModule.registerAsync({
useFactory: () => ({
secret: jwtConstants.secret(),
signOptions: { expiresIn: jwtConstants.accessTokenExpiration() }
})
})
],
providers: [
AuthService,
LocalStrategy,
JwtStrategy,
GithubAuthStrategy,
GoogleAuthStrategy,
TokenCleanupService,
OAuthUrlService
],
controllers: [OAuthController, AuthController],
exports: [AuthService]
})
export class AuthModule {}

Key characteristics:

  • Declares its own controllers
  • Imports only the modules it needs
  • Exports only the services other modules need

Shared Modules​

Shared modules provide reusable services with no controllers. They are imported by multiple feature modules:

// packages/agent/src/database/database.module.ts
@Module({
imports: [
ConfigModule.forFeature(databaseConfig),
TypeOrmModule.forRootAsync({
/* ... */
}),
TypeOrmModule.forFeature(ENTITIES)
],
providers: [
WorkRepository,
UserRepository,
RefreshTokenRepository
// ... all repositories
],
exports: [
TypeOrmModule,
WorkRepository,
UserRepository,
RefreshTokenRepository
// ... all repositories
]
})
export class DatabaseModule {}

DatabaseModule is imported by AuthModule, WorksModule, FacadesModule, and others. TypeORM handles deduplication so the database connection is created only once.

Global Modules​

Modules decorated with @Global() are available everywhere without explicit imports:

// packages/monitoring/src/monitoring.module.ts
@Global()
@Module({})
export class MonitoringModule {
static forRoot(config?: MonitoringConfig) {
return {
module: MonitoringModule,
global: true,
imports: [SentryModule.forRoot(config?.sentry), PostHogModule.forRoot(config?.posthog)],
providers: [AnalyticsService, SentryService],
exports: [AnalyticsService, SentryService]
};
}
}

After MonitoringModule.forRoot() is imported in ApiModule, AnalyticsService and SentryService are injectable in any module without additional imports.

Dynamic Modules​

Dynamic modules use static methods (forRoot, forRootAsync, forFeature) to accept configuration:

// Usage in ApiModule
ThrottlerModule.forRoot(throttlerConfig),

MonitoringModule.forRoot({
sentry: { dsn: process.env.SENTRY_DSN },
posthog: { apiKey: process.env.POSTHOG_API_KEY },
}),

CacheFactory.TypeORM({ isGlobal: true }),

TwentyCrmModule.forRoot(),

AgentPluginsModule.forRoot(),

The forRoot() / forRootAsync() pattern is used for one-time configuration at the application root. The forFeature() pattern is used for per-module configuration.

The Facades Module​

The FacadesModule demonstrates the pattern of bundling related services:

const FACADES = [
AiFacadeService,
SearchFacadeService,
ScreenshotFacadeService,
ContentExtractorFacadeService,
DataSourceFacadeService,
GitFacadeService,
OAuthFacadeService,
DeployFacadeService
];

@Module({
imports: [DatabaseModule],
providers: FACADES,
exports: FACADES
})
export class FacadesModule {}

Note that the FacadesModule relies on PluginsModule being registered globally -- it does not import it directly. This avoids a circular dependency since facades are consumed by pipeline modules that are also part of the plugin system.

Application Bootstrap​

The ApiModule uses OnApplicationBootstrap to run post-initialization logic:

@Module({
/* imports, providers */
})
export class ApiModule implements OnApplicationBootstrap {
constructor(private readonly pluginBootstrap: PluginBootstrapService) {}

async onApplicationBootstrap(): Promise<void> {
await this.pluginBootstrap.bootstrap();
}
}

This is the single entry point for plugin discovery and loading. It runs after all modules have been initialized, ensuring all dependencies are available.

Workspace Package Modules​

The monorepo structure maps to NestJS modules via workspace packages:

packages/agent/ --> DatabaseModule, FacadesModule, PluginsModule, ...
packages/monitoring/ --> MonitoringModule
packages/tasks/ --> TriggerModule, worker modules
packages/plugin/ --> Plugin contracts (no NestJS module, just types)
packages/contracts/ --> Shared TypeScript types

Import workspace packages using the @ever-works/* alias:

import { DatabaseModule, UserRepository } from '@ever-works/agent/database';
import { MonitoringModule } from '@ever-works/monitoring';
import { config } from '@ever-works/agent/config';

Avoiding Circular Dependencies​

Problem​

ModuleA imports ModuleB
ModuleB imports ModuleA
--> Circular dependency error

Solutions​

  1. Extract shared logic -- Move the shared service into a third module that both can import:
ModuleA --> SharedModule
ModuleB --> SharedModule
  1. Use forwardRef() (last resort):
@Module({
imports: [forwardRef(() => ModuleB)]
})
export class ModuleA {}
  1. Use events -- Instead of direct cross-module calls, use the EventEmitter:
// ModuleA emits an event
this.eventEmitter.emit('work.created', new WorkCreatedEvent(work));

// ModuleB listens for the event (no import of ModuleA needed)
@OnEvent('work.created')
handleWorkCreated(event: WorkCreatedEvent) { /* ... */ }

The platform uses EventEmitterModule extensively to decouple modules.

Module Registration Checklist​

When creating a new feature module:

1. [ ] Create the module class with @Module()
2. [ ] List all providers (services, repositories)
3. [ ] List controllers
4. [ ] Import required dependency modules
5. [ ] Export only the services consumed by other modules
6. [ ] Register the module in ApiModule imports
7. [ ] If globally needed, use @Global() or forRoot() with global: true

Best Practices​

  1. One module per domain -- Group related controllers, services, and repositories into a single feature module.

  2. Minimal exports -- Only export what other modules actually need. Internal helper services should remain private.

  3. Use forRoot for configuration -- Any module that needs runtime configuration should use the dynamic module pattern.

  4. Prefer @Global() sparingly -- Only monitoring, caching, and the plugin registry are truly global. Feature modules should be explicit about their imports.

  5. Events over imports -- When two modules need to communicate but should not depend on each other, use the event emitter.

Troubleshooting​

Module not found when importing​

Ensure the workspace package is built. Run pnpm build from the root, or build the specific package with turbo build --filter=@ever-works/agent.

Provider not available in module​

Check that the provider is listed in exports of the source module AND that the source module is listed in imports of the consuming module.

Duplicate database connections​

If DatabaseModule is imported in multiple places, TypeORM deduplicates the connection. If you see duplicate connection warnings, ensure TypeOrmModule.forRoot is called only once (in DatabaseModule), and other modules use TypeOrmModule.forFeature.