flexdi
v0.6.1
Published
Framework-agnostic dependency injection that adapts to your favorite UI library
Readme
FlexDI
A flexible, efficient, and lightweight dependency injection library for React / React Native / Vue3 / Lit applications.
The library is inspired by the principles and architectural approach of NestJS and Angular, but adapted for frontend applications.
FlexDI allows you to organize a modular architecture with separation of concerns, component lifecycle management, and clear separation of business logic from presentation.
Note: This is the first version of the library. The project is open for contributors, and any help is welcome.
Usage Tips
- Code Organization: Group related services into modules.
- Naming: Use suffixes for different types of classes (Service, Repository, Presenter).
- Singleton modules: Use
@Singleton()for modules that should be available with explicit import without creating new instances. - Presenters: Use presenters to separate business logic from UI framework.
- Testing: The library makes testing easier by allowing real implementations to be replaced with mocks.
Philosophy and Principles
FlexDI is designed to support SOLID principles and clean architecture:
- Clear separation of business logic from presentation
- Separate testing of components
- High modularity and code reusability
- Reduction of bugs through strict typing and dependency inversion
The absence of global providers is a conscious design decision, not a limitation. This approach reduces the risk of implicit dependencies and increases code maintainability.
The module graph is meant to be a DAG, not just in practice but by design: a cycle between modules
(module A imports module B which imports module A, directly or through a longer chain) means two
modules aren't actually separate units of responsibility - they're one module artificially split in
two. FlexDI treats this as the architectural smell it is rather than something to accommodate: it's
rejected at load time with a Circular module dependency detected: ... error naming the full cycle,
instead of being left to silently misbehave. The fix is never a workaround in FlexDI itself - it's
extracting what both modules actually need into a third, genuinely shared module that they both
import.
Installation
Basic Installation
npm install flexdi reflect-metadata
# or
yarn add flexdi reflect-metadataReact/ReactNative/Vue3 useObservable under hood use RxJs.
Additional installation
npm install rxjs
# or
yarn add rxjsLit
Additional installation
npm install lit @lit/context
# or
yarn add lit @lit/contextProject configuration
Reflect Metadata
Add the reflect-metadata import at your application's entry point (before using any decorators):
// index.ts or app.ts or main.ts (your entry file)
import 'reflect-metadata'
// ... rest of your imports and codeTypeScript Configuration
Make sure your tsconfig.json includes:
{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
}Vue3 setup:
import { flexdiPlugin } from 'flexdi/vue3'
createApp(App)
.use(flexdiPlugin) // add flexdi directives
.mount('#app')ReactNative setup (expo)
npm install babel-plugin-transform-typescript-metadata
# or
yarn add babel-plugin-transform-typescript-metadata// babel.config.js
module.exports = function (api) {
api.cache(true)
return {
presets: ['babel-preset-expo'],
plugins: [
"babel-plugin-transform-typescript-metadata",
["@babel/plugin-proposal-decorators", { "legacy": true }],
["@babel/plugin-proposal-class-properties", { "loose": true }],
]
}
}
// metro.config.js
const {getDefaultConfig} = require('@expo/metro-config')
const config = getDefaultConfig(__dirname)
config.resolver.unstable_enablePackageExports = true;
config.resolver.sourceExts.unshift("mjs");
module.exports = configVite setup
By default, Vite transpiles TypeScript with esbuild, which does not emit design:paramtypes
metadata — so the optional @Inject() shorthand described above
won't activate. To enable it, swap esbuild's TypeScript/decorators handling for Babel's on
.ts/.tsx files:
npm install --save-dev vite-plugin-babel babel-plugin-transform-typescript-metadata @babel/plugin-proposal-decorators @babel/plugin-transform-class-properties @babel/preset-typescript// vite.config.ts
import babel from 'vite-plugin-babel'
import { defineConfig } from 'vite'
export default defineConfig({
plugins: [
// ...your other plugins (e.g. @vitejs/plugin-react)
babel({
include: /\.tsx?$/,
babelConfig: {
presets: ['@babel/preset-typescript'],
plugins: [
// must come BEFORE the decorators plugin, or no metadata is emitted
'babel-plugin-transform-typescript-metadata',
['@babel/plugin-proposal-decorators', {legacy: true}],
['@babel/plugin-transform-class-properties', {loose: true}],
],
},
}),
],
})Without this setup, FlexDI still works exactly as before — you just need an explicit @Inject()
on every constructor parameter.
Core Concepts
FlexDI is built on the following concepts:
- Modules - core building blocks of the application that encapsulate logic and services
- Providers - objects that describe how to create and provide dependencies
- Dependency Injection - automatic provision of dependencies to components
- Scopes - managing object lifetimes (Singleton, Transient)
Working Example
React A full-featured React project built using FlexDI can be found in the repository:
https://github.com/AndreyShashlovDev/scalpel-frontend
React Native Sample project
https://github.com/AndreyShashlovDev/flexdi-rn
Vue3 Sample project
https://github.com/AndreyShashlovDev/flexdi-vue3
Usage
Defining a Module
import { Module } from 'flexdi'
import { AuthService } from './services/auth.service'
import { AuthServiceImpl } from './services/auth.service.impl'
import { UserService } from './services/user.service'
import { UserServiceImpl } from './services/user.service.impl'
@Module({
providers: [
{ provide: AuthService, useClass: AuthServiceImpl },
{ provide: UserService, useClass: UserServiceImpl }
],
exports: [AuthService, UserService]
})
export class AppModule {}Defining a Service with Dependency Injection
import { Inject, Injectable } from 'flexdi'
import { UserRepository } from '../repositories/user.repository'
@Injectable()
export class UserServiceImpl {
constructor(
@Inject(UserRepository) private readonly userRepository: UserRepository
) {}
async getUsers() {
return this.userRepository.getAll()
}
}Different modules for React/ React Native / Vue3 / Lit
// react project
import { RootModuleLoader } from 'flexdi/react'
// react native project
import { RootModuleLoader } from 'flexdi/react-native'
// vue3 project
import { RootModuleLoader } from 'flexdi/vue3'
// lit project - registers <flexdi-root-module-loader> etc. as a side effect of the import
import 'flexdi/lit'Detailed Guide
Decorators
@Module
Defines a class as a module that can contain providers and import other modules.
Parameters:
providers- array of providers available within the moduleimports- array of modules that are imported by the current moduleexports- array of providers that will be available to modules importing the current module
@Module({
imports: [CommonModule, AuthModule],
providers: [
{ provide: UserService, useClass: UserServiceImpl },
{ provide: 'API_URL', useValue: 'https://api.example.com' }
],
exports: [UserService]
})
export class UserModule {}Important to note:
- All exports from the root module become available to all modules that are loaded after it.
- By default, each service has a Singleton scope and is visible in the current module and in modules that import it.
- Independent modules do not have access to exports of other modules unless these modules are imported into the current module.
@Injectable
Marks a class as available for dependency injection. Optionally, you can specify the scope.
@Injectable() // Default is Scope.SINGLETON
export class UserServiceImpl {}
@Injectable(Scope.TRANSIENT) // New instance on each request
export class LoggerServiceImpl {}Scope.TRANSIENT gets a fresh instance every time it's resolved — including through getService,
useInject, and usePresenter, not just when it's injected as a dependency of another provider.
The only requirement is that the whole dependency chain resolves synchronously (no useFactory
returning a Promise anywhere in it) — getService/useInject/usePresenter are synchronous
APIs, so an async factory in a TRANSIENT provider's chain throws rather than silently blocking.
@Inject
Specifies the token for dependency injection in the constructor. The token can be:
- An abstract class (Abstract)
- A string (string)
- A symbol (Symbol)
- A concrete class (Type)
constructor(
@Inject(UserService) private readonly userService: UserService, // Class
@Inject('API_URL') private readonly apiUrl: string, // String
@Inject(Symbol.for('Logger')) private readonly logger: Logger, // Symbol
@Inject(SomeRepository) private readonly repo: Repository // Abstract class
) {}@Inject is optional for class-typed parameters
@Inject is only required when the token can't be inferred from the constructor parameter's own
TypeScript type — a string/symbol token, or when binding to a different implementation than the
declared type. When the parameter type itself is a concrete or abstract class, FlexDI can resolve
it automatically from design:paramtypes (the same metadata NestJS/Angular rely on), so @Inject
becomes optional:
@Injectable()
export class UserServiceImpl {
constructor(
private readonly userRepository: UserRepository // no @Inject needed — resolved from the parameter's own type
) {}
}This only works when your build actually emits design:paramtypes metadata for decorated classes:
- Works out of the box with
tsc(emitDecoratorMetadata: true, as configured above). - Requires extra setup with esbuild-based bundlers (Vite, and by extension most modern
React/Vue web setups) — esbuild does not implement
emitDecoratorMetadata. See Vite setup below. - If the metadata isn't emitted, FlexDI simply falls back to requiring an explicit
@Inject()for that parameter — fully backward compatible, nothing breaks, you just don't get the shorthand.
Types that erase to Object/String/Number/Boolean/Array/Function at runtime (interfaces,
primitives, generics, any) can never be inferred this way and always require an explicit
@Inject(token).
@Singleton
Marks a module as a singleton that will be created only once and available to all modules with explicit import.
@Singleton()
@Module({
providers: [{ provide: SharedService, useClass: SharedServiceImpl }],
exports: [SharedService]
})
export class SharedModule {}A module marked as @Singleton() can be created at any time, and from that moment it will be available to everyone as a singleton, but only when explicitly imported into a module.
Provider Types
Class Provider
{
provide: UserService,
useClass: UserServiceImpl,
scope: Scope.SINGLETON // optional
}Value Provider
{
provide: 'API_KEY',
useValue: 'secret-api-key'
}Factory Provider (supports async)
{
provide: 'ApiClient',
deps: [ConfigService, LoggerService],
useFactory: async (configService, logger) => {
const config = await configService.getConfig()
return new ApiClientImpl(config.apiUrl, logger)
},
}Token Provider
{
provide: 'UserServiceAlias',
useToken: UserService
}Basic Presenters
Presenters must inherit from BasicPresenter and implement the ready and destroy methods:
export abstract class BasicPresenter<InitArgs> {
protected args?: InitArgs
public init(args?: InitArgs): void {
this.args = args
this.ready(args)
}
public abstract ready(args?: InitArgs): void
public abstract destroy(): void
}Example of a presenter with access to args:
export interface UserPresenterArgs {
userId: string
}
abstract class UserPresenter extends BasicPresenter<UserPresenterArgs> {
abstract getUsers(): Observable<User[]>
}
@Injectable()
export class UserPresenterImpl extends UserPresenter {
private users = new BehaviorSubject<User[]>([])
constructor(
@Inject(UserService) private readonly userService: UserService
) {
super()
}
public ready(args?: UserPresenterArgs): void {
// You can use arguments from args
const userId = args?.userId || 'default'
// Or through this.args
console.log(`Initializing for user: ${this.args?.userId}`)
this.loadUsers(userId)
}
public destroy(): void {
// Cleanup resources when component is destroyed
this.users.complete()
}
private async loadUsers(userId: string): Promise<void> {
const users = await this.userService.getUsersByManager(userId)
this.users.next(users)
}
public getUsers(): Observable<User[]> {
return this.users.asObservable()
}
}Lifecycle Management
Components can implement the OnDisposeInstance interface to perform resource cleanup when a service is unloaded from the DI container:
import { OnDisposeInstance } from 'flexdi'
export class DatabaseServiceImpl implements OnDisposeInstance {
private connection: Connection
constructor() {
this.connection = createConnection()
}
// Automatically called when the service is unloaded from the DI container
onDisposeInstance(): void {
this.connection.close()
}
}The destroy() method in presenters is called when the view part of the component is destroyed.
Working with Asynchronous Dependencies
FlexDI supports asynchronous initialization and the use of Promises:
@Module({
providers: [
{
provide: 'Config',
useFactory: async () => {
const response = await fetch('/api/config')
return await response.json()
}
}
],
exports: ['Config']
})
export class ConfigModule {}Integration
useInject
Hook for injecting dependencies into functional components:
const userService = useInject(UserService)usePresenter
Hook for working with presenters, automatically manages their lifecycle:
// Parameters are passed to the init(args?: InitArgs) -> ready(args?: InitArgs) method and are available through this.args
const presenter = usePresenter(UserPresenter, { userId: '123' })useObservable
Hook for subscribing to Observable with automatic unsubscription:
const users = useObservable(presenter.getUsers(), [])By default, the hook resubscribes whenever the Observable argument itself changes identity -
which happens on every render if it's called inline like above, since e.g. Subject#asObservable()
returns a new wrapper object each time it's called, not the same reference back. That's harmless for
a plain BehaviorSubject-backed stream (resubscribing just redelivers the current value, and React
skips the extra render), but it does mean real work re-runs on every render for an observable chain
that does something on subscribe (an HTTP call behind switchMap, a tap, opening a connection).
If that matters, pass an explicit third deps array (same idea as useEffect's) so it only
resubscribes when those actually change:
const users = useObservable(presenter.getUsers(), [], [presenter])React (basic usage same for ReactNative)
Setting Up the Root Module
Applications should always start with a root module:
import { RootModuleLoader, ErrorBoundary } from 'flexdi/react'
import { AppModule } from './modules/app.module'
import { App } from './App'
const root = createRoot(document.getElementById('root'))
root.render(
<RootModuleLoader
module={AppModule}
ErrorBoundary={ErrorBoundary} // is optional (or custom)
LoadingComponent={LoadingSpinner}
ErrorComponent={ErrorView}
enableStrictMode={false} // true ONLY if <StrictMode> is used and you are in dev mode
>
<App />
</RootModuleLoader>
)Vue3
Setting Up the Root Module
Applications should always start with a root module:
<script setup lang="ts">
import { RootModuleLoader } from 'flexdi/vue3'
import ErrorComponent from './common/app-ui/ErrorComponent.vue'
import LoadingComponent from './common/app-ui/LoadingComponent.vue'
import { RootModule } from './RootModule.ts'
const rootModule = RootModule
</script>
<template>
<RootModuleLoader
:module="rootModule"
:loading-component="LoadingComponent"
:error-component="ErrorComponent"
>
<router-view></router-view>
</RootModuleLoader>
</template>
Using Dependencies in React / React Native Components
import { usePresenter, useInject, useObservable } from 'flexdi/react'
import { UserService } from './services/user.service'
import { UserPresenter } from './presenters/user.presenter'
export const UserList = () => {
// Using presenter with automatic initialization and cleanup
const presenter = usePresenter(UserPresenter)
const users = useObservable(presenter.getUsers(), [])
// Using service injection
const userService = useInject(UserService)
return (
<div>
<h1>Users</h1>
<ul>
{users.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
</div>
)
}Using Dependencies in Vue3 Components
<script setup lang="ts">
import { useInject, useObservable, usePresenter } from 'flexdi/vue3'
import { ServiceA } from '../../../common/service/ServiceA.ts'
import { HomePagePresenter } from '../domain/HomePagePresenter.ts'
const presenter = usePresenter(HomePagePresenter, {userId: 123})
const user = useObservable(presenter.getUser(), null)
const error = useObservable(presenter.error(), null)
const isLoading = useObservable(presenter.isLoading(), true)
const serviceA = useInject(ServiceA)
function loadUser() {
presenter.onUserLoadClick()
}
function callServiceA() {
serviceA.doSomething()
}
</script>
<template>
<div>
<h1>User home page</h1>
<div v-if="isLoading">Loading...</div>
<div v-else-if="error">Error is: {{ error }}</div>
<div v-else-if="user">
<h2>{{ user.name }}</h2>
<p>Email: {{ user.email }}</p>
<button @click="loadUser()">Load user 123</button>
<button @click="callServiceA()">Call service A</button>
</div>
</div>
</template>Lit
Lit support uses @lit/context for module propagation and
Reactive Controllers instead of hooks/composables
- controllers are meant to be created once in your element's constructor, the same place Lit's own docs recommend creating any controller.
Setting Up the Root Module
// main.ts
import 'reflect-metadata'
import 'flexdi/lit' // registers <flexdi-root-module-loader>, <flexdi-module-loader>, <flexdi-module-provider>
import './app-root.element'
import { AppModule } from './app.module'
const loader = document.createElement('flexdi-root-module-loader') as any
loader.module = AppModule
const loading = document.createElement('p')
loading.slot = 'loading'
loading.textContent = 'Loading...'
loader.appendChild(loading)
loader.appendChild(document.createElement('app-root'))
document.body.appendChild(loader)Using Dependencies in a Lit Component
import { html, LitElement } from 'lit'
import { customElement } from 'lit/decorators.js'
import { InjectController, ObservableController, PresenterController } from 'flexdi/lit'
import { ServiceA } from '../../common/service/ServiceA'
import { HomePagePresenter } from '../domain/HomePagePresenter'
@customElement('home-page')
class HomePageElement extends LitElement {
// Controllers are created once, in the constructor (implicit here as field initializers) -
// never inside render(), same as Lit's own guidance for any ReactiveController.
private readonly presenterCtrl = new PresenterController(this, HomePagePresenter, {userId: 123})
private readonly userCtrl = new ObservableController(this, () => this.presenterCtrl.value.getUser(), null)
private readonly serviceACtrl = new InjectController(this, ServiceA)
protected render() {
// A component can connect to the DOM before its module finishes loading (there's no
// virtual-DOM reconciler deciding when to instantiate it) - check `.isReady` first, the same
// way you'd check a loading flag in the React/Vue3 examples above.
if (!this.presenterCtrl.isReady || !this.serviceACtrl.isReady) {
return html`<div>Loading...</div>`
}
const user = this.userCtrl.value
return html`
<h1>${user.name}</h1>
<p>Email: ${user.email}</p>
<button @click=${() => this.presenterCtrl.value.onUserLoadClick()}>Load user 123</button>
<button @click=${() => this.serviceACtrl.value.doSomething()}>Call service A</button>
`
}
}Note the property-access difference from hooks: usePresenter(...) returns the presenter
directly, while PresenterController is the controller object - the presenter itself is
presenterCtrl.value.
Root/ModuleLoader for Lit
<flexdi-root-module-loader>/<flexdi-module-loader> load a module and expose loading/error
state through named slots, since that's the native way to project conditional content in a
web component - there's no prop-based LoadingComponent/ErrorComponent reference to pass:
<flexdi-root-module-loader .module=${AppModule}>
<div slot="loading">Loading...</div>
<div slot="error">Something went wrong</div>
<app-root></app-root>
</flexdi-root-module-loader><app-root> (and anything under it) connects to the DOM as soon as the tree it belongs to does -
there's no virtual-DOM layer deferring its construction the way there is in React/Vue3, so it can't
be held back until the module is ready. Instead, PresenterController/InjectController/
ObservableController each expose .isReady and only resolve once the loader confirms the module
actually loaded (they're notified through the same @lit/context value the loader provides, which
it only sets after a successful load) - check it in render() before reading .value, as in the
example above.
<flexdi-module-provider> is the Lit equivalent of ModuleProvider - for a module that's typically
already re-exported by an ancestor module a Loader resolved, with no loading/error state machine
of its own. It resolves the same way (context is set once loading is confirmed), so it's safe
either way, whether or not the module happens to already be loaded elsewhere:
<flexdi-module-provider .module=${FeatureModule}>
<feature-root></feature-root>
</flexdi-module-provider>There's a working, tested example app in examples/lit in this repository.
RootModuleLoader React / ReactNative
Component for loading the root module of the application:
<RootModuleLoader
module={AppModule}
ErrorBoundary={ErrorBoundaryView}
LoadingComponent={LoadingView}
ErrorComponent={ErrorView}
enableStrictMode={false} // true ONLY if <StrictMode> is used and you are in dev mode
>
<App />
</RootModuleLoader>The enableStrictMode parameter should be set to true ONLY when <StrictMode> is used in the application and you are in development mode. Otherwise, be sure to set it to false, otherwise the presenters will not receive ready/destroy events and will not work correctly.
RootModuleLoader Vue3
Component for loading the root module of the application:
<script setup lang="ts">
import { RootModuleLoader } from 'flexdi/vue3'
import ErrorComponent from './common/app-ui/ErrorComponent.vue'
import LoadingComponent from './common/app-ui/LoadingComponent.vue'
import { RootModule } from './RootModule.ts'
const rootModule = RootModule
</script>
<template>
<RootModuleLoader
:module="rootModule"
:loading-component="LoadingComponent"
:error-component="ErrorComponent"
>
<router-view></router-view>
</RootModuleLoader>
</template>ModuleLoader React / React Native
Component for loading a module and its dependencies:
<ModuleLoader
module={FeatureModule}
// children?: ReactNode
// or
// Component?: ComponentType<any>
ErrorBoundary={ErrorBoundaryView}
LoadingComponent={LoadingView}
ErrorComponent={ErrorView}
>
{/*children*/}
<FeatureComponent />
</ModuleLoader>ModuleLoader Vue3
Component for loading the root module of the application:
<script setup lang="ts">
import { ModuleLoader } from 'flexdi/vue3'
import ErrorComponent from './common/app-ui/ErrorComponent.vue'
import LoadingComponent from './common/app-ui/LoadingComponent.vue'
import { FeatureModule } from './FeatureModule.ts'
const featureModule = FeatureModule
</script>
<template>
<ModuleLoader
:module="featureModule"
:loading-component="LoadingComponent"
:error-component="ErrorComponent"
>
<router-view></router-view>
</ModuleLoader>
</template>createModuleRoute React
Function for creating a React Router route with module support and lazy loading of components:
import { lazy } from 'react'
import { createModuleRoute } from 'flexdi/react'
// Lazy loading of component
const UserPage = lazy(() => import('./pages/UserPage'))
const route = createModuleRoute({
path: '/users',
module: UserPageModule,
Component: UserPage, // Lazily loaded component
ErrorBoundary: ErrorBoundary,
LoadingComponent: LoadingView,
ErrorComponent: ErrorView
})Example usage with multiple routes:
import { lazy } from 'react'
import { createBrowserRouter } from 'react-router-dom'
const HomePage = lazy(() => import('./pages/HomePage'))
const UserPage = lazy(() => import('./pages/UserPage'))
const createAppRoute = (
{
path,
feature,
module
}: { path: string, feature: LazyExoticComponent<ComponentType<unknown>>, module: ModuleType }
) => createModuleRoute({
path,
module: module,
Component: feature,
ErrorBoundary: ErrorBoundary,
LoadingComponent: LoadingView,
ErrorComponent: ErrorView,
})
const appRoutes = [
createAppRoute({
path: '/',
feature: HomePage,
module: HomePageModule,
}),
createAppRoute({
path: '/users',
feature: UserPage,
module: UserPageModule,
})
]
const router = createBrowserRouter(appRoutes)createModuleScreen / createModuleNavigator React Native
Function for creating a React native navigation with module support and lazy loading of components:
import { createStackNavigator } from '@react-navigation/stack'
import { createModuleNavigator, createModuleScreen, ErrorBoundary } from 'flexdi/react-native'
import React from 'react'
import ErrorScreen from '../common/app-ui/ErrorScreen'
import LoadingScreen from '../common/app-ui/LoadingScreen'
import { CounterScreenModule } from '../feature/counter/di/CounterScreenModule'
import CounterScreen from '../feature/counter/presentation/CounterScreen'
import { HomeScreenModule } from '../feature/home/di/HomeScreenModule'
import HomeScreen from '../feature/home/presentation/HomeScreen'
import { ProfileScreenModule } from '../feature/user/di/ProfileScreenModule'
import ProfileScreen from '../feature/user/presentation/ProfileScreen'
import { NavigationScreen } from './NavigationScreen'
const Stack = createStackNavigator()
// external screen creation
const CounterScreenWithModule = createModuleScreen({
module: CounterScreenModule,
Component: CounterScreen,
LoadingComponent: LoadingScreen,
ErrorComponent: ErrorScreen,
ErrorBoundary: ErrorBoundary,
navigationOptions: {
title: 'Counter'
}
})
const navigatorConfig = createModuleNavigator({
type: 'stack',
screens: [
{
name: NavigationScreen.HOME,
Component: HomeScreen,
module: HomeScreenModule,
options: {title: 'Main'}
},
{
name: NavigationScreen.PROFILE,
Component: ProfileScreen,
module: ProfileScreenModule,
options: {title: 'Profile'}
}
],
defaultScreenOptions: {
headerStyle: {
backgroundColor: '#4a90e2',
},
headerTintColor: '#fff',
headerTitleStyle: {
fontWeight: 'bold',
},
},
LoadingComponent: LoadingScreen,
ErrorComponent: ErrorScreen,
// ErrorBoundary: optional (used by default boundary)
})
const AppNavigator = () => {
return (
<Stack.Navigator
initialRouteName={NavigationScreen.HOME}
screenOptions={navigatorConfig.navigatorOptions}
>
{navigatorConfig.screens.map(screen => (
<Stack.Screen
key={screen.name}
name={screen.name}
component={screen.component}
options={screen.component.navigationOptions}
/>
))}
<Stack.Screen
name={NavigationScreen.COUNTER}
component={CounterScreenWithModule}
options={CounterScreenWithModule.navigationOptions}
/>
</Stack.Navigator>
)
}
export default AppNavigator
createModuleRoute Vue3
Function for creating a Vue Router route with module support and lazy loading of components:
import { createModuleRoute } from 'flexdi/vue3'
import { HomePageModule } from '../../feature/home/di/HomePageModule.ts'
import ErrorComponent from '../app-ui/ErrorComponent.vue'
import LoadingComponent from '../app-ui/LoadingComponent.vue'
export default [
createModuleRoute({
path: '/',
name: 'home', // optional
// meta?: Record<string, unknown> -- optional
module: HomePageModule,
component: () => import('../../feature/home/presentation/HomePageView.vue'),
loadingComponent: LoadingComponent,
errorComponent: ErrorComponent
}),
]Usage Examples
Basic Application Example with Abstract Service
import { BasicPresenter } from './BasicPresenter'
export interface User {
name: string
role: string
}
// Defining an abstract class and implementation
export abstract class AuthService {
abstract isAuthenticated(): boolean
abstract getUserInfo(): User
}
@Injectable()
class AuthServiceImpl extends AuthService {
isAuthenticated(): boolean {
return true
}
getUserInfo(): User {
return {name: 'Admin', role: 'admin'}
}
}
// Defining modules
@Module({
providers: [
// Binding abstract class to concrete implementation
{provide: AuthService, useClass: AuthServiceImpl}
],
exports: [AuthService]
})
class AuthModule {}
abstract class UserPresenter extends BasicPresenter<void> {
abstract isUserAuthenticated(): Observable<boolean>
abstract getUserInfo(): Observable<User>
}
// Presenter uses abstract class
@Injectable()
class UserPresenterImpl extends UserPresenter {
private isAuthenticated = new BehaviorSubject<boolean>(false)
private userInfo = new BehaviorSubject<User>({name: '', role: ''})
constructor(@Inject(AuthService) private authService: AuthService) {
super()
}
ready() {
console.log('UserPresenter initialized')
this.updateUserState()
}
destroy() {
console.log('UserPresenter destroyed')
this.isAuthenticated.complete()
this.userInfo.complete()
}
private updateUserState() {
this.isAuthenticated.next(this.authService.isAuthenticated())
this.userInfo.next(this.authService.getUserInfo())
}
isUserAuthenticated(): Observable<boolean> {
return this.isAuthenticated.asObservable()
}
getUserInfo(): Observable<User> {
return this.userInfo.asObservable()
}
}
@Module({
imports: [AuthModule],
providers: [{provide: UserPresenter, useClass: UserPresenterImpl}],
exports: [UserPresenter]
})
class UserModule {}
// React component
const App = () => {
const presenter = usePresenter(UserPresenter)
const userInfo = useObservable(presenter.getUserInfo(), {name: '', role: ''})
const isAuthenticated = useObservable(presenter.isUserAuthenticated(), false)
return (
<div>
<h1>Welcome, {userInfo.name}!</h1>
<p>Role: {userInfo.role}</p>
<p>Status: {isAuthenticated ? 'Authenticated' : 'Not authenticated'}</p>
</div>
)
}
// Application entry point
createRoot(document.getElementById('root')).render(
<RootModuleLoader
module={UserModule}
ErrorBoundary={ErrorBoundaryView}
LoadingComponent={LoadingSpinnerView}
ErrorComponent={ErrorViewView}
enableStrictMode={false} // Only if <StrictMode> is used and you are in dev mode
>
<ModuleLoader
module={AppPageModule}
// children = {}
// Component = {}
ErrorBoundary={ErrorBoundaryView}
LoadingComponent={LoadingSpinnerView}
ErrorComponent={ErrorViewView}
>
{/* children used */}
<App />
</ModuleLoader>
</RootModuleLoader>
)API Reference
ModuleManager
ModuleManager is a global service for managing modules. Here are its main public methods:
loadModule<T>(moduleClass: ModuleType, isRootModule: boolean = false): Promise<T>
Loads a module and all its dependencies. If isRootModule is set to true, the module will be loaded as the root module of the application.
// Loading the root module
await moduleManager.loadModule(AppModule, true)
// Loading a regular module
await moduleManager.loadModule(FeatureModule)getService<T>(moduleClass: ModuleType, token: InjectionToken<unknown>): T
Gets a service instance from a loaded module.
// Getting a service
const authService = moduleManager.getService<AuthService>(AppModule, AuthService)isModuleLoaded(moduleClass: ModuleType): boolean
Checks if a module is loaded.
if (moduleManager.isModuleLoaded(FeatureModule)) {
console.log('Module is already loaded')
}unloadModule(moduleClass: ModuleType): void
Unloads a module and all its unused dependencies.
// Unloading a module
moduleManager.unloadModule(FeatureModule)isRootModule(moduleClass: ModuleType): boolean
Checks if a module is the root module.
if (moduleManager.isRootModule(AppModule)) {
console.log('This is the root module')
}Testing with FlexDI
FlexDI is great for unit testing thanks to its ability to easily replace dependencies with mocks. Here's an example of testing using Vitest:
Mocking Services
import { Inject, Injectable, Module, ModuleManager, ModuleManagerFactory } from 'flexdi'
import { beforeEach, describe, expect, it, vi } from 'vitest'
abstract class DataService {
abstract getData(): string[]
}
// Mock service
@Injectable()
class MockDataServiceImpl extends DataService {
getData = vi.fn().mockReturnValue(['test', 'data'])
}
abstract class UserService {
abstract processData(): string[]
}
// Service being tested
@Injectable()
class UserServiceImpl {
constructor(@Inject(DataService) private dataService: DataService) {}
processData() {
const data = this.dataService.getData()
return data.map(item => item.toUpperCase())
}
}
// Test module with mock
@Module({
providers: [
{provide: DataService, useClass: MockDataServiceImpl},
{provide: UserService, useClass: UserServiceImpl}
],
exports: [UserService, DataService]
})
class TestModule {}
describe('UserService', () => {
let userService: UserService
let mockDataService: DataService
let testModuleManager: ModuleManager
beforeEach(async () => {
// Create a new ModuleManager instance for complete test isolation
ModuleManagerFactory.resetInstance()
testModuleManager = ModuleManagerFactory.getInstance()
// Load the test module with our isolated ModuleManager
await testModuleManager.loadModule(TestModule, true)
// Get services from the test module
userService = testModuleManager.getService<UserServiceImpl>(TestModule, UserService)
mockDataService = testModuleManager.getService<DataService>(TestModule, DataService)
})
it('should process data correctly', () => {
// Check mock service call
const result = userService.processData()
expect(mockDataService.getData).toHaveBeenCalled()
expect(result).toEqual(['TEST', 'DATA'])
})
})Testing Presenters
import { BasicPresenter, Inject, Injectable, Module, ModuleManager, ModuleManagerFactory } from 'flexdi'
import { BehaviorSubject, firstValueFrom, Observable } from 'rxjs'
import { beforeEach, describe, expect, it, vi } from 'vitest'
interface User {
name: string
}
abstract class AuthService {
abstract isAuthenticated(): boolean
abstract getUser(): User | null
}
// Mock service
@Injectable()
class MockAuthServiceImpl extends AuthService {
isAuthenticated = vi.fn().mockReturnValue(true)
getUser = vi.fn().mockReturnValue({id: 1, name: 'Test User'})
}
abstract class UserPresenter extends BasicPresenter<void> {
abstract getUser(): Observable<User | null>
}
// Presenter to test
@Injectable()
class UserPresenterImpl extends UserPresenter {
private user = new BehaviorSubject<User | null>(null)
constructor(@Inject(AuthService) private authService: AuthService) {
super()
}
ready() {
if (this.authService.isAuthenticated()) {
this.user.next(this.authService.getUser())
}
}
destroy() {
this.user.complete()
}
getUser(): Observable<User | null> {
return this.user.asObservable()
}
}
@Module({
providers: [
{provide: AuthService, useClass: MockAuthServiceImpl},
{provide: UserPresenter, useClass: UserPresenterImpl}
],
exports: [AuthService, UserPresenter]
})
class TestModule {}
describe('UserPresenter', () => {
let presenter: UserPresenter
let mockAuthService: AuthService
let testModuleManager: ModuleManager
beforeEach(async () => {
ModuleManagerFactory.resetInstance()
// Create a new ModuleManager instance for complete test isolation
testModuleManager = ModuleManagerFactory.getInstance()
// Load the test module with our isolated ModuleManager
await testModuleManager.loadModule(TestModule, true)
// Get services from the test module
presenter = testModuleManager.getService<UserPresenter>(TestModule, UserPresenter)
mockAuthService = testModuleManager.getService<MockAuthServiceImpl>(TestModule, AuthService)
// Manual init call, simulating lifecycle
presenter.init()
})
it('should load user when authenticated', async () => {
const user = await firstValueFrom(presenter.getUser())
expect(mockAuthService.isAuthenticated).toHaveBeenCalled()
expect(mockAuthService.getUser).toHaveBeenCalled()
expect(user?.name).toBe('Test User')
})
})Testing React hooks
import { render, screen, waitFor } from '@testing-library/react'
import React, { useLayoutEffect, useRef } from 'react'
import { BehaviorSubject, Observable, Subject, takeUntil } from 'rxjs'
import { beforeEach, describe, expect, it } from 'vitest'
import {
BasicPresenter,
Inject,
Injectable,
Module,
ModuleManager,
ModuleManagerFactory,
ModuleProvider,
} from 'flexdi'
import {useInject, useObservable, usePresenter} from 'flexdi/react'
import '@testing-library/jest-dom'
interface User {
id: number
name: string
email: string
}
abstract class UserService {
abstract getUsers(): Observable<User[]>
abstract updateUsers(users: User[]): void
}
@Injectable()
class MockUserServiceImpl extends UserService {
private users = new BehaviorSubject<User[]>([
{id: 1, name: 'John Doe', email: '[email protected]'},
{id: 2, name: 'Jane Smith', email: '[email protected]'}
])
getUsers(): Observable<User[]> {
return this.users.asObservable()
}
updateUsers(users: User[]): void {
this.users.next(users)
}
}
abstract class UserPresenter extends BasicPresenter<void> {
abstract getUsers(): Observable<User[]>
abstract filterUsersByName(query: string): void
}
@Injectable()
class UserPresenterImpl extends UserPresenter {
private filteredUsers = new BehaviorSubject<User[]>([])
constructor(@Inject(UserService) private userService: UserService) {
super()
}
ready(): void {
this.userService.getUsers().subscribe(users => {
this.filteredUsers.next(users)
})
}
destroy(): void {
this.filteredUsers.complete()
}
getUsers(): Observable<User[]> {
return this.filteredUsers.asObservable()
}
filterUsersByName(query: string): void {
this.userService.getUsers().subscribe(users => {
if (!query) {
this.filteredUsers.next(users)
return
}
const filtered = users.filter(user =>
user.name.toLowerCase().includes(query.toLowerCase())
)
this.filteredUsers.next(filtered)
})
}
}
@Module({
providers: [
{provide: UserService, useClass: MockUserServiceImpl},
{provide: UserPresenter, useClass: UserPresenterImpl}
],
exports: [UserService, UserPresenter]
})
class TestModule {}
function UserList() {
const presenter = usePresenter(UserPresenter)
const userService = useInject(UserService)
const users = useObservable(presenter.getUsers(), [])
const destroySubject = useRef(new Subject<void>())
const totalUsers = React.useMemo(() => {
let count = 0
userService.getUsers()
.pipe(takeUntil(destroySubject.current))
.subscribe(users => {
count = users.length
})
return count
}, [userService, destroySubject.current])
useLayoutEffect(() => {
return () => {
destroySubject.current.next()
destroySubject.current.complete()
}
}, [destroySubject.current])
return (
<div>
<h1>User List</h1>
<p data-testid='user-count-presenter'>Total users in presenter: {users.length}</p>
<p data-testid='user-count-service'>Total users in service: {totalUsers}</p>
{users.length === 0 ? (
<p data-testid='empty-message'>No users found</p>
) : (
<ul data-testid='user-list'>
{users.map(user => (
<li key={user.id} data-testid={`user-${user.id}`}>
<strong>{user.name}</strong> ({user.email})
</li>
))}
</ul>
)}
</div>
)
}
function TestApp() {
return (
<ModuleProvider module={TestModule}>
<UserList />
</ModuleProvider>
)
}
describe('UserList Component with DI', () => {
let testModuleManager: ModuleManager
let mockUserService: UserService
beforeEach(async () => {
ModuleManagerFactory.resetInstance()
testModuleManager = ModuleManagerFactory.getInstance()
await testModuleManager.loadModule(TestModule, true)
mockUserService = testModuleManager.getService<UserService>(TestModule, UserService)
})
it('renders the user list correctly', async () => {
render(<TestApp />)
expect(screen.getByText('User List')).toBeInTheDocument()
const userCountPresenter = screen.getByTestId('user-count-presenter')
expect(userCountPresenter).toBeInTheDocument()
expect(userCountPresenter).toHaveTextContent(/Total users in presenter/)
const userCountService = screen.getByTestId('user-count-service')
expect(userCountService).toBeInTheDocument()
expect(userCountService).toHaveTextContent(/Total users in service/)
await waitFor(() => {
expect(screen.getByTestId('user-list')).toBeInTheDocument()
})
expect(screen.getByTestId('user-1')).toBeInTheDocument()
expect(screen.getByTestId('user-2')).toBeInTheDocument()
expect(screen.getByText('John Doe')).toBeInTheDocument()
expect(screen.getByText(/[email protected]/)).toBeInTheDocument()
})
it('shows empty message when no users', async () => {
mockUserService.updateUsers([])
render(<TestApp />)
await waitFor(() => {
expect(screen.getByTestId('empty-message')).toBeInTheDocument()
})
expect(screen.getByText('No users found')).toBeInTheDocument()
})
it('updates when user service changes', async () => {
render(<TestApp />)
await waitFor(() => {
expect(screen.getByTestId('user-list')).toBeInTheDocument()
})
expect(screen.getByText('John Doe')).toBeInTheDocument()
const newUsers = [
{id: 3, name: 'Bob Johnson', email: '[email protected]'}
]
mockUserService.updateUsers(newUsers)
await waitFor(() => {
expect(screen.getByText('Bob Johnson')).toBeInTheDocument()
})
expect(screen.queryByText('John Doe')).not.toBeInTheDocument()
})
})Supporting the Project
If you like FlexDI and find it useful for your project, please support it:
- ⭐ Star it on GitHub
- 🍴 Fork it to contribute improvements
- 📢 Tell your colleagues about the library
Your support helps to develop the project and make it better!
